mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-01-03 11:58:28 -05:00
Compare commits
101 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 |
@@ -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
|
||||
|
||||
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.12
|
||||
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,
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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:f3e50c7689a1b6982fab45b1b23ba5adf1fd725e233dc640918fb59f7a57b174 \
|
||||
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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 |
|
||||
@@ -138,6 +138,13 @@ For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values
|
||||
|
||||
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.1.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
|
||||
|
||||
|
||||
@@ -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.1.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.1.2 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.3.1 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
<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>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<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 && 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>
|
||||
<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">
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
@deleted="$emit('delete', slug)"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</slot>
|
||||
</slot>
|
||||
</v-list-item>
|
||||
<slot />
|
||||
</v-card>
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<RecipeContextMenuContent
|
||||
v-if="isMenuContentLoaded"
|
||||
v-bind="contentProps"
|
||||
@print="$emit('print')"
|
||||
@deleted="$emit('deleted', $event)"
|
||||
/>
|
||||
</v-menu>
|
||||
@@ -108,6 +109,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
defineEmits<{
|
||||
[key: string]: any;
|
||||
print: [];
|
||||
deleted: [slug: string];
|
||||
}>();
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
hide-header
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@update:model-value="pickerMenu = false"
|
||||
@update:model-value="pickerMenu = false"
|
||||
/>
|
||||
</v-menu>
|
||||
<v-select
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import RecipeExplorerPageSearchFilters from "./RecipeExplorerPageSearchFilters.vue";
|
||||
import { useRecipeExplorerSearch } from "~/composables/use-recipe-explorer-search";
|
||||
import { useRecipeExplorerSearch, clearRecipeExplorerSearchState } from "~/composables/use-recipe-explorer-search";
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: [];
|
||||
@@ -155,6 +155,11 @@ onMounted(async () => {
|
||||
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 "";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<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
|
||||
@@ -106,9 +113,13 @@
|
||||
/>
|
||||
<v-divider />
|
||||
</v-col>
|
||||
<v-col class="overflow-y-auto"
|
||||
:class="$vuetify.display.smAndDown ? '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,12 +190,13 @@ 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 });
|
||||
@@ -192,12 +205,13 @@ 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) {
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -34,21 +34,21 @@
|
||||
{{ $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 :markup="parseIngredientText(ing)" />
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
<template v-if="Object.keys(groupedUsedIngredients).length > 0">
|
||||
@@ -67,7 +67,7 @@
|
||||
class="ml-4"
|
||||
>
|
||||
<template #label>
|
||||
<RecipeIngredientHtml :markup="parseIngredientText(ing)" />
|
||||
<RecipeIngredientHtml :ingredient="ing" :scale="scale" />
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
</template>
|
||||
@@ -184,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"
|
||||
@@ -202,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
|
||||
@@ -314,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>
|
||||
@@ -373,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";
|
||||
@@ -383,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;
|
||||
@@ -500,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 ?? "");
|
||||
}
|
||||
@@ -544,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(() => {
|
||||
@@ -603,8 +622,8 @@ const ingredientSectionTitles = computed(() => {
|
||||
return titleMap;
|
||||
});
|
||||
|
||||
const groupedUnusedIngredients = computed(() => {
|
||||
const groups: { [key: string]: RecipeIngredient[] } = {};
|
||||
const groupedUnusedIngredients = computed((): Record<string, RecipeIngredient[]> => {
|
||||
const groups: Record<string, RecipeIngredient[]> = {};
|
||||
|
||||
// Group ingredients by section title
|
||||
unusedIngredients.value.forEach((ingredient) => {
|
||||
@@ -614,20 +633,14 @@ const groupedUnusedIngredients = computed(() => {
|
||||
|
||||
// Use the section title from the mapping, or fallback to the ingredient's own title
|
||||
const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || "";
|
||||
|
||||
if (!groups[title]) {
|
||||
groups[title] = [];
|
||||
}
|
||||
groups[title].push(ingredient);
|
||||
(groups[title] ||= []).push(ingredient);
|
||||
});
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
const groupedUsedIngredients = computed(() => {
|
||||
const groups: { [key: string]: RecipeIngredient[] } = {};
|
||||
|
||||
// Group ingredients by section title
|
||||
const groupedUsedIngredients = computed((): Record<string, RecipeIngredient[]> => {
|
||||
const groups: Record<string, RecipeIngredient[]> = {};
|
||||
usedIngredients.value.forEach((ingredient) => {
|
||||
if (ingredient.referenceId === undefined) {
|
||||
return;
|
||||
@@ -635,26 +648,12 @@ const groupedUsedIngredients = computed(() => {
|
||||
|
||||
// Use the section title from the mapping, or fallback to the ingredient's own title
|
||||
const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || "";
|
||||
|
||||
if (!groups[title]) {
|
||||
groups[title] = [];
|
||||
}
|
||||
groups[title].push(ingredient);
|
||||
(groups[title] ||= []).push(ingredient);
|
||||
});
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
function getIngredientByRefId(refId: string | undefined) {
|
||||
if (refId === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const ing = ingredientLookup.value[refId];
|
||||
if (!ing) return "";
|
||||
return parseIngredientText(ing, props.scale);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// Instruction Merger
|
||||
const mergeHistory = ref<MergerHistory[]>([]);
|
||||
@@ -847,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<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>
|
||||
@@ -100,9 +100,7 @@ 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 type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
setup() {
|
||||
@@ -116,12 +114,8 @@ export default defineNuxtComponent({
|
||||
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]) {
|
||||
@@ -130,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;
|
||||
@@ -148,24 +135,6 @@ 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);
|
||||
|
||||
@@ -197,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[]>);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -33,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>
|
||||
@@ -60,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 -->
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { size } = withDefaults(defineProps<{ size?: number }>(), { size: 75 });
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 75 });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
:hide-details="!inputField.hint"
|
||||
:persistent-hint="!!inputField.hint"
|
||||
density="comfortable"
|
||||
@change="emitBlur">
|
||||
@change="emitBlur"
|
||||
>
|
||||
<template #label>
|
||||
<span class="ml-4">
|
||||
{{ inputField.label }}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ const publicLoading = ref(false);
|
||||
|
||||
export const useCookbookStore = function (i18n?: Composer) {
|
||||
const api = useUserApi(i18n);
|
||||
const store = useStore<ReadCookBook>(cookbooks, loading, api.cookbooks);
|
||||
const store = useStore<ReadCookBook>("cookbook", cookbooks, loading, api.cookbooks);
|
||||
|
||||
const updateAll = async function (updateData: UpdateCookBook[]) {
|
||||
loading.value = true;
|
||||
@@ -25,5 +25,5 @@ export const useCookbookStore = function (i18n?: Composer) {
|
||||
|
||||
export const usePublicCookbookStore = function (groupSlug: string, i18n?: Composer) {
|
||||
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
||||
return useReadOnlyStore<ReadCookBook>(cookbooks, 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" });
|
||||
};
|
||||
|
||||
@@ -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 = [];
|
||||
},
|
||||
|
||||
@@ -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,7 +45,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Slovenščina (Slovenian)",
|
||||
value: "sl-SI",
|
||||
progress: 39,
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -57,7 +57,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Pусский (Russian)",
|
||||
value: "ru-RU",
|
||||
progress: 40,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -75,7 +75,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Português do Brasil (Brazilian Portuguese)",
|
||||
value: "pt-BR",
|
||||
progress: 41,
|
||||
progress: 46,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -87,13 +87,13 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Norsk (Norwegian)",
|
||||
value: "no-NO",
|
||||
progress: 39,
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Nederlands (Dutch)",
|
||||
value: "nl-NL",
|
||||
progress: 49,
|
||||
progress: 52,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -105,7 +105,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Lietuvių (Lithuanian)",
|
||||
value: "lt-LT",
|
||||
progress: 26,
|
||||
progress: 27,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -123,19 +123,19 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Italiano (Italian)",
|
||||
value: "it-IT",
|
||||
progress: 40,
|
||||
progress: 43,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Íslenska (Icelandic)",
|
||||
value: "is-IS",
|
||||
progress: 3,
|
||||
progress: 10,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Magyar (Hungarian)",
|
||||
value: "hu-HU",
|
||||
progress: 44,
|
||||
progress: 45,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -159,7 +159,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Français (French)",
|
||||
value: "fr-FR",
|
||||
progress: 64,
|
||||
progress: 67,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -189,7 +189,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Español (Spanish)",
|
||||
value: "es-ES",
|
||||
progress: 42,
|
||||
progress: 45,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -207,19 +207,19 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Ελληνικά (Greek)",
|
||||
value: "el-GR",
|
||||
progress: 40,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Deutsch (German)",
|
||||
value: "de-DE",
|
||||
progress: 72,
|
||||
progress: 80,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Dansk (Danish)",
|
||||
value: "da-DK",
|
||||
progress: 40,
|
||||
progress: 43,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -231,13 +231,13 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Català (Catalan)",
|
||||
value: "ca-ES",
|
||||
progress: 38,
|
||||
progress: 37,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -17,19 +17,19 @@ export interface OrganizerBase {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type FieldType =
|
||||
| "string"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| RecipeOrganizer;
|
||||
export type FieldType
|
||||
= | "string"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| RecipeOrganizer;
|
||||
|
||||
export type FieldValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Date
|
||||
| Organizer;
|
||||
export type FieldValue
|
||||
= | string
|
||||
| number
|
||||
| boolean
|
||||
| Date
|
||||
| Organizer;
|
||||
|
||||
export interface SelectableItem {
|
||||
label: string;
|
||||
|
||||
@@ -177,8 +177,8 @@ export function useShoppingListItemActions(shoppingListId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the queue items and returns whether the processing was successful.
|
||||
*/
|
||||
* Processes the queue items and returns whether the processing was successful.
|
||||
*/
|
||||
async function processQueueItems(
|
||||
action: (items: ShoppingListItemOut[]) => Promise<RequestResponse<any>>,
|
||||
itemQueueType: ItemQueueType,
|
||||
|
||||
@@ -59,6 +59,12 @@ export interface UserRecipeFinderPreferences {
|
||||
includeToolsOnHand: boolean;
|
||||
}
|
||||
|
||||
export interface UserRecipeCreatePreferences {
|
||||
importKeywordsAsTags: boolean;
|
||||
stayInEditMode: boolean;
|
||||
parseRecipe: boolean;
|
||||
}
|
||||
|
||||
export function useUserMealPlanPreferences(): Ref<UserMealPlanPreferences> {
|
||||
const fromStorage = useLocalStorage(
|
||||
"meal-planner-preferences",
|
||||
@@ -200,3 +206,19 @@ export function useRecipeFinderPreferences(): Ref<UserRecipeFinderPreferences> {
|
||||
|
||||
return fromStorage;
|
||||
}
|
||||
|
||||
export function useRecipeCreatePreferences(): Ref<UserRecipeCreatePreferences> {
|
||||
const fromStorage = useLocalStorage(
|
||||
"recipe-create-preferences",
|
||||
{
|
||||
importKeywordsAsTags: false,
|
||||
stayInEditMode: false,
|
||||
parseRecipe: true,
|
||||
},
|
||||
{ mergeDefaults: true },
|
||||
// we cast to a Ref because by default it will return an optional type ref
|
||||
// but since we pass defaults we know all properties are set.
|
||||
) as unknown as Ref<UserRecipeCreatePreferences>;
|
||||
|
||||
return fromStorage;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const useToggleDarkMode = () => {
|
||||
};
|
||||
|
||||
export const useAsyncKey = function () {
|
||||
return String(Date.now());
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
};
|
||||
|
||||
export const titleCase = function (str: string) {
|
||||
|
||||
151
frontend/composables/useAuthBackend.ts
Normal file
151
frontend/composables/useAuthBackend.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { ref, computed } from "vue";
|
||||
import type { UserOut } from "~/lib/api/types/user";
|
||||
|
||||
interface AuthData {
|
||||
value: UserOut | null;
|
||||
}
|
||||
|
||||
interface AuthStatus {
|
||||
value: "loading" | "authenticated" | "unauthenticated";
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
data: AuthData;
|
||||
status: AuthStatus;
|
||||
signIn: (credentials: FormData, options?: { redirect?: boolean }) => Promise<void>;
|
||||
signOut: (callbackUrl?: string) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
getSession: () => Promise<void>;
|
||||
setToken: (token: string | null) => void;
|
||||
}
|
||||
|
||||
const authUser = ref<UserOut | null>(null);
|
||||
const authStatus = ref<"loading" | "authenticated" | "unauthenticated">("loading");
|
||||
|
||||
export const useAuthBackend = function (): AuthState {
|
||||
const { $axios } = useNuxtApp();
|
||||
const router = useRouter();
|
||||
const tokenName = useRuntimeConfig().public.AUTH_TOKEN;
|
||||
const tokenCookie = useCookie(tokenName);
|
||||
|
||||
function setToken(token: string | null) {
|
||||
tokenCookie.value = token;
|
||||
}
|
||||
|
||||
function handleAuthError(error: any, redirect = false) {
|
||||
// Only clear token on auth errors, not network errors
|
||||
if (error?.response?.status === 401) {
|
||||
setToken(null);
|
||||
authUser.value = null;
|
||||
authStatus.value = "unauthenticated";
|
||||
if (redirect) {
|
||||
router.push("/login");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getSession(): Promise<void> {
|
||||
if (!tokenCookie.value) {
|
||||
authUser.value = null;
|
||||
authStatus.value = "unauthenticated";
|
||||
return;
|
||||
}
|
||||
|
||||
authStatus.value = "loading";
|
||||
try {
|
||||
const { data } = await $axios.get<UserOut>("/api/users/self");
|
||||
authUser.value = data;
|
||||
authStatus.value = "authenticated";
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error("Failed to fetch user session:", error);
|
||||
handleAuthError(error);
|
||||
authStatus.value = "unauthenticated";
|
||||
}
|
||||
}
|
||||
|
||||
async function signIn(credentials: FormData): Promise<void> {
|
||||
authStatus.value = "loading";
|
||||
|
||||
try {
|
||||
const response = await $axios.post("/api/auth/token", credentials, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
|
||||
const { access_token } = response.data;
|
||||
setToken(access_token);
|
||||
await getSession();
|
||||
}
|
||||
catch (error) {
|
||||
authStatus.value = "unauthenticated";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut(callbackUrl: string = ""): Promise<void> {
|
||||
try {
|
||||
await $axios.post("/api/auth/logout");
|
||||
}
|
||||
catch (error) {
|
||||
// Continue with logout even if API call fails
|
||||
console.warn("Logout API call failed:", error);
|
||||
}
|
||||
finally {
|
||||
setToken(null);
|
||||
authUser.value = null;
|
||||
authStatus.value = "unauthenticated";
|
||||
await router.push(callbackUrl || "/login");
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (!tokenCookie.value) return;
|
||||
|
||||
try {
|
||||
const response = await $axios.get("/api/auth/refresh");
|
||||
const { access_token } = response.data;
|
||||
setToken(access_token);
|
||||
await getSession();
|
||||
}
|
||||
catch (error: any) {
|
||||
handleAuthError(error, true);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh user data periodically when authenticated
|
||||
if (import.meta.client) {
|
||||
let refreshInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
watch(() => authStatus.value, (status) => {
|
||||
if (status === "authenticated") {
|
||||
refreshInterval = setInterval(() => {
|
||||
if (tokenCookie.value) {
|
||||
getSession().catch(() => {
|
||||
// Ignore errors in background refresh
|
||||
});
|
||||
}
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
}
|
||||
else {
|
||||
// Clear interval when not authenticated
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
}
|
||||
}, { immediate: true });
|
||||
}
|
||||
|
||||
return {
|
||||
data: computed(() => authUser.value),
|
||||
status: computed(() => authStatus.value),
|
||||
signIn,
|
||||
signOut,
|
||||
refresh,
|
||||
getSession,
|
||||
setToken,
|
||||
};
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { useAuthBackend } from "~/composables/useAuthBackend";
|
||||
import type { UserOut } from "~/lib/api/types/user";
|
||||
|
||||
export const useMealieAuth = function () {
|
||||
const auth = useAuth();
|
||||
const { setToken } = useAuthState();
|
||||
const auth = useAuthBackend();
|
||||
const { $axios } = useNuxtApp();
|
||||
|
||||
// User Management
|
||||
@@ -37,24 +37,18 @@ export const useMealieAuth = function () {
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function signIn(...params: Parameters<typeof auth.signIn>) {
|
||||
await auth.signIn(...params);
|
||||
refreshCookie(useRuntimeConfig().public.AUTH_TOKEN);
|
||||
}
|
||||
|
||||
async function oauthSignIn() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const { data: token } = await $axios.get<{ access_token: string; token_type: "bearer" }>("/api/auth/oauth/callback", { params });
|
||||
setToken(token.access_token);
|
||||
auth.setToken(token.access_token);
|
||||
await auth.getSession();
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loggedIn,
|
||||
signIn,
|
||||
signIn: auth.signIn,
|
||||
signOut: auth.signOut,
|
||||
signUp: auth.signUp,
|
||||
refresh: auth.refresh,
|
||||
oauthSignIn,
|
||||
};
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
// @ts-check
|
||||
import stylisticJs from "@stylistic/eslint-plugin-js";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||
|
||||
export default withNuxt({
|
||||
plugins: {
|
||||
"@stylistic/js": stylisticJs,
|
||||
"@stylistic": stylistic,
|
||||
},
|
||||
// Your custom configs here
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"vue/no-mutating-props": "warn",
|
||||
"vue/no-v-html": "warn",
|
||||
"object-curly-newline": "off",
|
||||
"consistent-list-newline": "off",
|
||||
"vue/first-attribute-linebreak": "off",
|
||||
"@stylistic/js/no-tabs": ["error", { allowIndentationTabs: true }],
|
||||
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
|
||||
"@stylistic/no-tabs": ["error"],
|
||||
"@stylistic/no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
|
||||
"vue/max-attributes-per-line": "off",
|
||||
"vue/html-indent": "off",
|
||||
"vue/html-closing-bracket-newline": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"vue/first-attribute-linebreak": "error",
|
||||
"vue/html-closing-bracket-newline": "error",
|
||||
"vue/max-attributes-per-line": [
|
||||
"error",
|
||||
{
|
||||
singleline: 5,
|
||||
multiline: 1,
|
||||
},
|
||||
],
|
||||
"vue/no-mutating-props": "error",
|
||||
"vue/no-v-html": "error",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
|
||||
"import-original-keywords-as-tags": "Voer oorspronklike sleutelwoorde as merkers in",
|
||||
"stay-in-edit-mode": "Bly in redigeer modus",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Voer vanaf zip in",
|
||||
"import-from-zip-description": "Voer 'n enkele resep in wat vanaf 'n ander Mealie-instansie uitgevoer is.",
|
||||
"import-from-html-or-json": "Import from HTML or JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Create missing food: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
|
||||
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
|
||||
"no-food": "No Food"
|
||||
"no-food": "No Food",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "Reset Servings Count",
|
||||
"not-linked-ingredients": "Additional Ingredients",
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "يمكنك الإضافة مباشرة باستخدام بيانات خام",
|
||||
"import-original-keywords-as-tags": "استيراد الكلمات المفتاحية الأصلية كوسوم",
|
||||
"stay-in-edit-mode": "البقاء في وضع التعديل",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "استيراد من ملف Zip",
|
||||
"import-from-zip-description": "استيراد وصفة واحدة تم تصديرها من حساب \"ميلي\" آخر",
|
||||
"import-from-html-or-json": "Import from HTML or JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "إنشاء طعام مفقود: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
|
||||
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
|
||||
"no-food": "لا يوجد طعام"
|
||||
"no-food": "لا يوجد طعام",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "إعادة تعيين عدد الحصص",
|
||||
"not-linked-ingredients": "مكونات إضافية",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"about-mealie": "Относно Mealie",
|
||||
"api-docs": "API Документация",
|
||||
"api-port": "API Порт",
|
||||
"application-mode": "Приложението",
|
||||
"application-mode": "Режим на приложение",
|
||||
"database-type": "Тип на база данни",
|
||||
"database-url": "URL адрес база данни",
|
||||
"default-group": "Група по подразбиране",
|
||||
@@ -69,7 +69,7 @@
|
||||
"new-notification": "Ново известие",
|
||||
"event-notifiers": "Известия за събитие",
|
||||
"apprise-url-skipped-if-blank": "URL за известяване (пропуска се ако е празно)",
|
||||
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
|
||||
"apprise-url-is-left-intentionally-blank": "Тъй като URL адресите на Apprise обикновено съдържат чувствителна информация, това поле е оставено умишлено празно по време на редактиране. Ако желаете да актуализирате URL адреса, моля, въведете новия тук, в противен случай го оставете празно, за да запазите текущия URL адрес.",
|
||||
"enable-notifier": "Включи известията",
|
||||
"what-events": "За кои събития трябва да се получават известия?",
|
||||
"user-events": "Потребителски събития",
|
||||
@@ -81,7 +81,7 @@
|
||||
"category-events": "Събития за категория",
|
||||
"when-a-new-user-joins-your-group": "Когато потребител се присъедини към твоята потребителска група",
|
||||
"recipe-events": "Събития на рецептата",
|
||||
"label-events": "Label Events"
|
||||
"label-events": "Събития с етикети"
|
||||
},
|
||||
"general": {
|
||||
"add": "Добави",
|
||||
@@ -93,13 +93,13 @@
|
||||
"confirm-delete-generic": "Сигурни ли сте, че желаете да изтриете това?",
|
||||
"copied_message": "Копирано!",
|
||||
"create": "Добави",
|
||||
"created": "Създадено",
|
||||
"created": "Последно добавени",
|
||||
"custom": "Персонализиран",
|
||||
"dashboard": "Табло",
|
||||
"delete": "Изтриване",
|
||||
"disabled": "Деактивирано",
|
||||
"download": "Изтегли",
|
||||
"duplicate": "Дублирай",
|
||||
"duplicate": "Дублиране",
|
||||
"edit": "Редактирай",
|
||||
"enabled": "Активиран",
|
||||
"exception": "Грешка",
|
||||
@@ -135,15 +135,15 @@
|
||||
"ok": "Добре",
|
||||
"options": "Опции:",
|
||||
"plural-name": "Име в множествено число",
|
||||
"print": "Принтирай",
|
||||
"print-preferences": "Настройки на принтиране",
|
||||
"random": "Произволно",
|
||||
"print": "Отпечатване",
|
||||
"print-preferences": "Настройки на печата",
|
||||
"random": "Произволна рецепта",
|
||||
"rating": "Оценка",
|
||||
"recent": "Скорошни",
|
||||
"recipe": "Рецепта",
|
||||
"recipes": "Рецепти",
|
||||
"rename-object": "Преименувай {0}",
|
||||
"reset": "Нулирай",
|
||||
"reset": "По подразбиране",
|
||||
"saturday": "Събота",
|
||||
"save": "Запази",
|
||||
"settings": "Настройки",
|
||||
@@ -169,7 +169,7 @@
|
||||
"tuesday": "Вторник",
|
||||
"type": "Тип",
|
||||
"update": "Актуализация",
|
||||
"updated": "Обновено",
|
||||
"updated": "Последно обновени",
|
||||
"upload": "Качи",
|
||||
"url": "URL",
|
||||
"view": "Преглед",
|
||||
@@ -198,7 +198,7 @@
|
||||
"copy": "Копиране",
|
||||
"color": "Цвят",
|
||||
"timestamp": "Времева отметка",
|
||||
"last-made": "Последно приготвена на",
|
||||
"last-made": "Дата на последно приготвяне",
|
||||
"learn-more": "Научи повече",
|
||||
"this-feature-is-currently-inactive": "Тази функционалност в момента е неактивна",
|
||||
"clipboard-not-supported": "Не се поддържа клипборд",
|
||||
@@ -210,7 +210,7 @@
|
||||
"export-all": "Експортиране на всички",
|
||||
"refresh": "Опресняване",
|
||||
"upload-file": "Качване на файл",
|
||||
"created-on-date": "Създадено на {0}",
|
||||
"created-on-date": "Добавена на {0}",
|
||||
"unsaved-changes": "Имате незапазени промени. Желаете ли да ги запазите преди да излезете? Натиснете Ок за запазване и Отказ за отхвърляне на промените.",
|
||||
"clipboard-copy-failure": "Линкът към рецептата е копиран в клипборда.",
|
||||
"confirm-delete-generic-items": "Сигурни ли сте, че желаете да изтриете следните елементи?",
|
||||
@@ -279,7 +279,7 @@
|
||||
"admin-group-management-text": "Промените по тази група ще бъдат отразени моментално.",
|
||||
"group-id-value": "ID на Групата: {0}",
|
||||
"total-households": "Общ брой домакинства",
|
||||
"you-must-select-a-group-before-selecting-a-household": "You must select a group before selecting a household"
|
||||
"you-must-select-a-group-before-selecting-a-household": "Трябва да изберете група, преди да изберете домакинство"
|
||||
},
|
||||
"household": {
|
||||
"household": "Домакинство",
|
||||
@@ -300,8 +300,8 @@
|
||||
"household-recipe-preferences": "Предпочитания за рецептите на домакинството",
|
||||
"default-recipe-preferences-description": "Това са настройките по подразбиране когато нова рецепта е създадена в домакинството ви. Тези настройки могат да бъдат променени за всяка рецепта в менюто за настройки на рецептата.",
|
||||
"allow-users-outside-of-your-household-to-see-your-recipes": "Разреши на потребители от други домакинства да виждат рецептите ми",
|
||||
"allow-users-outside-of-your-household-to-see-your-recipes-description": "When enabled you can use a public share link to share specific recipes without authorizing the user. When disabled, you can only share recipes with users who are in your household or with a pre-generated private link",
|
||||
"household-preferences": "Household Preferences"
|
||||
"allow-users-outside-of-your-household-to-see-your-recipes-description": "Когато е активирано, можете да използвате публичен линк за споделяне, за да споделяте конкретни рецепти, без да оторизирате потребителя. Когато е деактивирано, можете да споделяте рецепти само с потребители, които са във вашето домакинство, или с предварително генериран личен линк",
|
||||
"household-preferences": "Предпочитания на домакинството"
|
||||
},
|
||||
"meal-plan": {
|
||||
"create-a-new-meal-plan": "Създаване на нов хранителен план",
|
||||
@@ -323,15 +323,15 @@
|
||||
"mealplan-settings": "Настройки на менюто",
|
||||
"mealplan-update-failed": "Неуспешно обновяване на седмичното меню",
|
||||
"mealplan-updated": "Седмичното меню бе обновено",
|
||||
"mealplan-households-description": "If no household is selected, recipes can be added from any household",
|
||||
"mealplan-households-description": "Ако не е избрано домакинство, могат да се добавят рецепти от всяко домакинство",
|
||||
"any-category": "Всяка категория",
|
||||
"any-tag": "Всеки етикет",
|
||||
"any-household": "Any Household",
|
||||
"any-household": "Всяко домакинство",
|
||||
"no-meal-plan-defined-yet": "Все още няма създадено седмично меню",
|
||||
"no-meal-planned-for-today": "За днес няма планирано меню",
|
||||
"numberOfDays-hint": "Number of days on page load",
|
||||
"numberOfDays-label": "Default Days",
|
||||
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Само рецептите от тези категории ще бъдат използвани в хранителните планове",
|
||||
"numberOfDays-hint": "Брой дни за зареждане на страницата",
|
||||
"numberOfDays-label": "Дни по подразбиране",
|
||||
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Само рецепти от тези категории ще бъдат използвани в седмичното меню",
|
||||
"planner": "Планьор",
|
||||
"quick-week": "Бърза седмица",
|
||||
"side": "Предястие",
|
||||
@@ -359,7 +359,7 @@
|
||||
"for-type-meal-types": "за {0}",
|
||||
"meal-plan-rules": "Правила за съставяне на седмично меню",
|
||||
"new-rule": "Ново правило",
|
||||
"meal-plan-rules-description": "You can create rules for auto selecting recipes for your meal plans. These rules are used by the server to determine the random pool of recipes to select from when creating meal plans. Note that if rules have the same day/type constraints then the rule filters will be merged. In practice, it's unnecessary to create duplicate rules, but it's possible to do so.",
|
||||
"meal-plan-rules-description": "Можете да създавате правила за автоматично избиране на рецепти за вашите хранителни планове. Тези правила се използват от сървъра, за да определи произволния набор от рецепти, от които да се избира при създаването на хранителни планове. Обърнете внимание, че ако правилата имат едни и същи ограничения за ден/тип, филтрите на правилата ще бъдат обединени. На практика не е необходимо да се създават дублиращи се правила, но е възможно да се направи.",
|
||||
"new-rule-description": "Когато създавате ново правило за създаване на седмично меню, може да зададете ограничение правилото да бъде приложено за определен ден от седмицата и/или специфичен вид ястие. За да добавите правило за всички дни или всички типове ястия, Вие може да изберете \"Всички\", което ще го приложи за всички дни и/или видове ястия.",
|
||||
"recipe-rules": "Правила на рецептата",
|
||||
"applies-to-all-days": "Прилага се за всички дни",
|
||||
@@ -420,7 +420,7 @@
|
||||
},
|
||||
"recipekeeper": {
|
||||
"title": "Recipe Keeper",
|
||||
"description-long": "Mealie can import recipes from Recipe Keeper. Export your recipes in zip format, then upload the .zip file below."
|
||||
"description-long": "Mealie може да импортира рецепти от Recipe Keeper. Експортирайте рецептите си в zip формат, след което качете .zip файла по-долу."
|
||||
}
|
||||
},
|
||||
"new-recipe": {
|
||||
@@ -434,7 +434,7 @@
|
||||
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Поставете в данните на рецептата си. Всеки ред ще бъде третиран като елемент от списъка.",
|
||||
"recipe-markup-specification": "Спецификации за маркиране на рецептата",
|
||||
"recipe-url": "URL на рецептата",
|
||||
"recipe-html-or-json": "Recipe HTML or JSON",
|
||||
"recipe-html-or-json": "HTML или JSON на рецептата",
|
||||
"upload-a-recipe": "Качи рецепта",
|
||||
"upload-individual-zip-file": "Качи като индивидуален .zip файлов формат от друга инстанция на Mealie.",
|
||||
"url-form-hint": "Копирай и постави линк от твоя любим сайт за рецепти",
|
||||
@@ -474,23 +474,23 @@
|
||||
"comment": "Коментар",
|
||||
"comments": "Коментари",
|
||||
"delete-confirmation": "Сигурни ли сте, че желаете да изтриете тази рецепта?",
|
||||
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
|
||||
"admin-delete-confirmation": "Ще изтриете рецепта, която не е ваша, използвайки администраторски права. Сигурни ли сте?",
|
||||
"delete-recipe": "Изтрий рецептата",
|
||||
"description": "Описание",
|
||||
"disable-amount": "Изключи количествата за съставките",
|
||||
"disable-comments": "Изключи коментарите",
|
||||
"duplicate": "Дублирай рецептата",
|
||||
"duplicate": "Дублиране на рецептата",
|
||||
"duplicate-name": "Име на новата рецепта",
|
||||
"edit-scale": "Редактиране на размера",
|
||||
"fat-content": "Мазнини",
|
||||
"fiber-content": "Влакна",
|
||||
"grams": "грама",
|
||||
"ingredient": "Съставка",
|
||||
"ingredients": "Съставки",
|
||||
"ingredients": "Необходими продукти",
|
||||
"insert-ingredient": "Въведете съставка",
|
||||
"insert-section": "Въведете раздел",
|
||||
"insert-above": "Insert Above",
|
||||
"insert-below": "Insert Below",
|
||||
"insert-above": "Вмъкни отгоре",
|
||||
"insert-below": "Вмъкни по-долу",
|
||||
"instructions": "Инструкции",
|
||||
"key-name-required": "Ключовото име е задължително",
|
||||
"landscape-view-coming-soon": "Пейзажен изглед",
|
||||
@@ -503,7 +503,7 @@
|
||||
"object-value": "Стойност на обект",
|
||||
"original-url": "Оригинален линк",
|
||||
"perform-time": "Време за готвене",
|
||||
"prep-time": "Време за приготвяне",
|
||||
"prep-time": "Време за подготовка",
|
||||
"protein-content": "Белтъци",
|
||||
"public-recipe": "Публична рецепта",
|
||||
"recipe-created": "Рецептата е създадена",
|
||||
@@ -511,27 +511,27 @@
|
||||
"recipe-deleted": "Рецептата е изтрита",
|
||||
"recipe-image": "Изображение на рецептата",
|
||||
"recipe-image-updated": "Изображението на рецептата беше обновено",
|
||||
"recipe-name": "Име на рецептата",
|
||||
"recipe-name": "Наименование",
|
||||
"recipe-settings": "Настройки на рецептата",
|
||||
"recipe-update-failed": "Обновяването на рецептата беше неуспешно",
|
||||
"recipe-updated": "Рецептата е обновена",
|
||||
"remove-from-favorites": "Премахни от любими",
|
||||
"remove-section": "Премахни раздел",
|
||||
"saturated-fat-content": "Saturated fat",
|
||||
"saturated-fat-content": "Наситени мазнини",
|
||||
"save-recipe-before-use": "Запази рецептата преди да я използваш",
|
||||
"section-title": "Заглавие на раздела",
|
||||
"servings": "Порция|порции",
|
||||
"serves-amount": "Serves {amount}",
|
||||
"servings": "Порции",
|
||||
"serves-amount": "Количествата са за {amount} порции",
|
||||
"share-recipe-message": "Искам да споделя моята рецепта {0} с теб.",
|
||||
"show-nutrition-values": "Покажи хранителните стойности",
|
||||
"sodium-content": "Натрий",
|
||||
"step-index": "Стъпка: {step}",
|
||||
"sugar-content": "Захар",
|
||||
"title": "Заглавие",
|
||||
"total-time": "Общо време",
|
||||
"trans-fat-content": "Trans-fat",
|
||||
"total-time": "Общо време за приготвяне",
|
||||
"trans-fat-content": "Транс мазнини",
|
||||
"unable-to-delete-recipe": "Изтриването на рецептата е невъзможно",
|
||||
"unsaturated-fat-content": "Unsaturated fat",
|
||||
"unsaturated-fat-content": "Ненаситени мазнини",
|
||||
"no-recipe": "Няма рецепта",
|
||||
"locked-by-owner": "Заключена от собственика",
|
||||
"join-the-conversation": "Присъедини се към разговора",
|
||||
@@ -539,9 +539,9 @@
|
||||
"entry-type": "Тип на записа",
|
||||
"date-format-hint": "MM/DD/YYYY формат",
|
||||
"date-format-hint-yyyy-mm-dd": "YYYY-MM-DD формат",
|
||||
"add-to-list": "Добави към списък",
|
||||
"add-to-plan": "Добави към план",
|
||||
"add-to-timeline": "Добави към историята на събитията",
|
||||
"add-to-list": "Добавяне към списък",
|
||||
"add-to-plan": "Добавяне към план",
|
||||
"add-to-timeline": "Добавяне към историята на събитията",
|
||||
"recipe-added-to-list": "Рецептата е добавена към списъка",
|
||||
"recipes-added-to-list": "Рецептите са добавени към списъка",
|
||||
"successfully-added-to-list": "Успешно добавено в списъка",
|
||||
@@ -549,9 +549,9 @@
|
||||
"failed-to-add-recipes-to-list": "Неуспешно добавяне на рецепта към списъка",
|
||||
"failed-to-add-recipe-to-mealplan": "Рецептата не беше добавена към хранителния план",
|
||||
"failed-to-add-to-list": "Неуспешно добавяне към списъка",
|
||||
"yield": "Добив",
|
||||
"yields-amount-with-text": "Yields {amount} {text}",
|
||||
"yield-text": "Yield Text",
|
||||
"yield": "Количество",
|
||||
"yields-amount-with-text": "Порции {amount} {text}",
|
||||
"yield-text": "Забележка",
|
||||
"quantity": "Количество",
|
||||
"choose-unit": "Избери единица",
|
||||
"press-enter-to-create": "Натисните Enter за да създадете",
|
||||
@@ -561,10 +561,10 @@
|
||||
"see-original-text": "Виж оригиналния текст",
|
||||
"original-text-with-value": "Оригинален текст: {originalText}",
|
||||
"ingredient-linker": "Инструмент за свързване на съставки",
|
||||
"unlinked": "Not linked yet",
|
||||
"unlinked": "Все още не е свързано",
|
||||
"linked-to-other-step": "Свързано към друга стъпка",
|
||||
"auto": "Автоматично",
|
||||
"cook-mode": "Режим на готвене",
|
||||
"cook-mode": "Начин на приготвяне",
|
||||
"link-ingredients": "Свържи съставките",
|
||||
"merge-above": "Обедини с по-горната",
|
||||
"move-to-bottom": "Премести най-долу",
|
||||
@@ -579,18 +579,18 @@
|
||||
"timeline-is-empty": "Няма история на събитията. Опитайте да приготвите рецептата!",
|
||||
"timeline-no-events-found-try-adjusting-filters": "Няма намерени събития. Опитайте да промените филтрите си за търсене.",
|
||||
"group-global-timeline": "{groupName} История на събитията",
|
||||
"open-timeline": "Отвори историята на събитията",
|
||||
"open-timeline": "История на събитията",
|
||||
"made-this": "Сготвих рецептата",
|
||||
"how-did-it-turn-out": "Как се получи?",
|
||||
"user-made-this": "{user} направи това",
|
||||
"added-to-timeline": "Added to timeline",
|
||||
"failed-to-add-to-timeline": "Failed to add to timeline",
|
||||
"failed-to-update-recipe": "Failed to update recipe",
|
||||
"added-to-timeline-but-failed-to-add-image": "Added to timeline, but failed to add image",
|
||||
"added-to-timeline": "Добавено към историята на събитията",
|
||||
"failed-to-add-to-timeline": "Неуспешно добавяне към историята на събитията",
|
||||
"failed-to-update-recipe": "Неуспешно актуализиране на рецептата",
|
||||
"added-to-timeline-but-failed-to-add-image": "Добавено към хронологията, но добавянето на изображение бе неуспешно",
|
||||
"api-extras-description": "Екстрите за рецепти са ключова характеристика на Mealie API. Те Ви позволяват да създавате персонализирани JSON двойки ключ/стойност в рамките на рецепта, за да ги препращате към други приложения. Можете да използвате тези ключове, за да предоставите информация за задействане на автоматизация или персонализирани съобщения, за препращане към желаното от Вас устройство.",
|
||||
"message-key": "Ключ на съобщението",
|
||||
"parse": "Анализирай",
|
||||
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
|
||||
"ingredients-not-parsed-description": "Изглежда, че съставките ви все още не са анализирани. Кликнете върху бутона „{parse}“ по-долу, за да анализирате съставките си в структурирани храни.",
|
||||
"attach-images-hint": "Прикачете снимки като ги влачете и пуснете в редактора",
|
||||
"drop-image": "Премахване на изображение",
|
||||
"enable-ingredient-amounts-to-use-this-feature": "Пуснете количествата на съставките за да използвате функционалността",
|
||||
@@ -601,17 +601,17 @@
|
||||
"select-one-of-the-various-ways-to-create-a-recipe": "Изберете един от разнообразните начини за създаване на рецепта",
|
||||
"looking-for-migrations": "Миграция на данни",
|
||||
"import-with-url": "Импортирай от линк",
|
||||
"create-recipe": "Добави рецепта",
|
||||
"create-recipe": "Добавяне на рецепта",
|
||||
"create-recipe-description": "Създайте нова рецепта от чернова.",
|
||||
"create-recipes": "Създайте рецепти",
|
||||
"import-with-zip": "Импортирай от .zip",
|
||||
"create-recipe-from-an-image": "Create Recipe from an Image",
|
||||
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
||||
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
|
||||
"create-from-images": "Create from Images",
|
||||
"should-translate-description": "Translate the recipe into my language",
|
||||
"please-wait-image-procesing": "Please wait, the image is processing. This may take some time.",
|
||||
"please-wait-images-processing": "Please wait, the images are processing. This may take some time.",
|
||||
"crop-and-rotate-the-image": "Изрежете и завъртете изображението, така че да се вижда само текстът и той да е в правилната ориентация.",
|
||||
"create-from-images": "Създаване от изображения",
|
||||
"should-translate-description": "Преведете рецептата на моя език",
|
||||
"please-wait-image-procesing": "Моля, изчакайте, изображението се обработва. Това може да отнеме известно време.",
|
||||
"please-wait-images-processing": "Моля, изчакайте, изображенията се обработват. Това може да отнеме известно време.",
|
||||
"bulk-url-import": "Импортиране на рецепти от линк",
|
||||
"debug-scraper": "Отстраняване на грешки на скрейпъра",
|
||||
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Добави рецепта като предоставиш име. Всички рецепти трябва да имат уникални имена.",
|
||||
@@ -620,16 +620,17 @@
|
||||
"scrape-recipe-description": "Обходи рецепта по линк. Предоставете линк за сайт, който искате да бъде обходен. Mealie ще опита да обходи рецептата от този сайт и да я добави във Вашата колекция.",
|
||||
"scrape-recipe-have-a-lot-of-recipes": "Имате много рецепти, които искате да обходите наведнъж?",
|
||||
"scrape-recipe-suggest-bulk-importer": "Пробвайте масовото импорторане",
|
||||
"scrape-recipe-have-raw-html-or-json-data": "Have raw HTML or JSON data?",
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
|
||||
"scrape-recipe-have-raw-html-or-json-data": "Имате ли сурови HTML или JSON данни?",
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Можете да импортирате директно от сурови данни",
|
||||
"import-original-keywords-as-tags": "Добави оригиналните ключови думи като етикети",
|
||||
"stay-in-edit-mode": "Остани в режим на редакция",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Импортирай от Zip",
|
||||
"import-from-zip-description": "Импортирай рецепта, която е била експортирана от друга инстанция на Mealie.",
|
||||
"import-from-html-or-json": "Import from HTML or JSON",
|
||||
"import-from-html-or-json-description": "Import a single recipe from raw HTML or JSON. This is useful if you have a recipe from a site that Mealie can't scrape normally, or from some other external source.",
|
||||
"json-import-format-description-colon": "To import via JSON, it must be in valid format:",
|
||||
"json-editor": "JSON Editor",
|
||||
"import-from-html-or-json": "Импортиране от HTML или JSON",
|
||||
"import-from-html-or-json-description": "Импортирайте една рецепта от суров HTML или JSON. Това е полезно, ако имате рецепта от сайт, който Mealie не може да извлече нормално, или от друг външен източник.",
|
||||
"json-import-format-description-colon": "За да импортирате чрез JSON, той трябва да бъде във валиден формат:",
|
||||
"json-editor": "JSON редактор",
|
||||
"zip-files-must-have-been-exported-from-mealie": ".zip файловете трябва да бъдат експортирани от Mealie",
|
||||
"create-a-recipe-by-uploading-a-scan": "Добави рецепта като качиш сканирано копие.",
|
||||
"upload-a-png-image-from-a-recipe-book": "Качи png изображение от книга с рецепти",
|
||||
@@ -642,59 +643,65 @@
|
||||
"report-deletion-failed": "Неуспешно изтриване на доклад",
|
||||
"recipe-debugger": "Debugger на рецепти",
|
||||
"recipe-debugger-description": "Вземете URL на рецептата, която желаете да проверите за грешки и го поставете тук. URL ще бъде обходен и резултатите ще бъдат визуализирани. Ако не виждате върнати данни, сайтът който се опитвате да обходите не се поддържа от Mealie или библиотеката за обхождане.",
|
||||
"use-openai": "Use OpenAI",
|
||||
"recipe-debugger-use-openai-description": "Use OpenAI to parse the results instead of relying on the scraper library. When creating a recipe via URL, this is done automatically if the scraper library fails, but you may test it manually here.",
|
||||
"use-openai": "Използвайте OpenAI",
|
||||
"recipe-debugger-use-openai-description": "Използвайте OpenAI за анализиране на резултатите, вместо да разчитате на библиотеката за скрепер. Когато създавате рецепта чрез URL адрес, това се прави автоматично, ако библиотеката за скрепер се провали, но можете да я тествате ръчно тук.",
|
||||
"debug": "Отстраняване на грешки",
|
||||
"tree-view": "Дървовиден изглед",
|
||||
"recipe-servings": "Recipe Servings",
|
||||
"recipe-servings": "Порции рецепта",
|
||||
"recipe-yield": "Добиване от рецепта",
|
||||
"recipe-yield-text": "Recipe Yield Text",
|
||||
"recipe-yield-text": "Текст за порции на рецепта",
|
||||
"unit": "Единица",
|
||||
"upload-image": "Качване на изображение",
|
||||
"screen-awake": "Запази екрана активен",
|
||||
"remove-image": "Премахване на изображение",
|
||||
"nextStep": "Следваща стъпка",
|
||||
"recipe-actions": "Recipe Actions",
|
||||
"recipe-actions": "Действия с рецепти",
|
||||
"parser": {
|
||||
"ingredient-parser": "Ingredient Parser",
|
||||
"explanation": "To use the ingredient parser, click the 'Parse All' button to start the process. Once the processed ingredients are available, you can review the items and verify that they were parsed correctly. The model's confidence score is displayed on the right of the item title. This score is an average of all the individual scores and may not always be completely accurate.",
|
||||
"alerts-explainer": "Alerts will be displayed if a matching foods or unit is found but does not exists in the database.",
|
||||
"select-parser": "Select Parser",
|
||||
"natural-language-processor": "Natural Language Processor",
|
||||
"brute-parser": "Brute Parser",
|
||||
"openai-parser": "OpenAI Parser",
|
||||
"parse-all": "Parse All",
|
||||
"no-unit": "No unit",
|
||||
"missing-unit": "Create missing unit: {unit}",
|
||||
"missing-food": "Create missing food: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
|
||||
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
|
||||
"no-food": "No Food"
|
||||
"ingredient-parser": "Анализатор на съставки",
|
||||
"explanation": "За да използвате анализатора на съставките, щракнете върху бутона „Анализ на всички“, за да стартирате процеса. След като обработените съставки са налични, можете да прегледате елементите и да проверите дали са били анализирани правилно. Коефициентът на достоверност на модела се показва отдясно на заглавието на елемента. Този резултат е средна стойност на всички отделни оценки и не винаги може да бъде напълно точен.",
|
||||
"alerts-explainer": "Ще се показват предупреждения, ако бъдат намерени съответстващи храни или единици, но не съществуват в базата данни.",
|
||||
"select-parser": "Изберете парсер",
|
||||
"natural-language-processor": "Процесор за естествен език",
|
||||
"brute-parser": "Груб анализатор",
|
||||
"openai-parser": "OpenAI парсер",
|
||||
"parse-all": "Разбор на всички",
|
||||
"no-unit": "Няма зададена мерна единица",
|
||||
"missing-unit": "Създаване на липсваща мерна единица: {unit}",
|
||||
"missing-food": "Създаване на липсваща храна: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "Тази мерна единица не може да бъде анализирана автоматично",
|
||||
"this-food-could-not-be-parsed-automatically": "Тази храна не може да бъде анализирана автоматично",
|
||||
"no-food": "Не е зададен вид храна",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "Reset Servings Count",
|
||||
"not-linked-ingredients": "Additional Ingredients",
|
||||
"upload-another-image": "Upload another image",
|
||||
"upload-images": "Upload images",
|
||||
"upload-more-images": "Upload more images",
|
||||
"set-as-cover-image": "Set as recipe cover image",
|
||||
"cover-image": "Cover image"
|
||||
"reset-servings-count": "Нулиране на броя на порциите",
|
||||
"not-linked-ingredients": "Допълнителни съставки",
|
||||
"upload-another-image": "Качете друго изображение",
|
||||
"upload-images": "Качване на изображения",
|
||||
"upload-more-images": "Качете още изображения",
|
||||
"set-as-cover-image": "Задай като изображение на корицата на рецептата",
|
||||
"cover-image": "Изображение на корицата"
|
||||
},
|
||||
"recipe-finder": {
|
||||
"recipe-finder": "Recipe Finder",
|
||||
"recipe-finder-description": "Search for recipes based on ingredients you have on hand. You can also filter by tools you have available, and set a maximum number of missing ingredients or tools.",
|
||||
"selected-ingredients": "Selected Ingredients",
|
||||
"no-ingredients-selected": "No ingredients selected",
|
||||
"missing": "Missing",
|
||||
"no-recipes-found": "No recipes found",
|
||||
"no-recipes-found-description": "Try adding more ingredients to your search or adjusting your filters",
|
||||
"include-ingredients-on-hand": "Include Ingredients On Hand",
|
||||
"include-tools-on-hand": "Include Tools On Hand",
|
||||
"max-missing-ingredients": "Max Missing Ingredients",
|
||||
"max-missing-tools": "Max Missing Tools",
|
||||
"selected-tools": "Selected Tools",
|
||||
"other-filters": "Other Filters",
|
||||
"ready-to-make": "Ready to Make",
|
||||
"almost-ready-to-make": "Almost Ready to Make"
|
||||
"recipe-finder": "Търсачка на рецепти",
|
||||
"recipe-finder-description": "Търсете рецепти въз основа на съставките, които имате под ръка. Можете също да филтрирате по налични инструменти и да зададете максимален брой липсващи съставки или инструменти.",
|
||||
"selected-ingredients": "Избрани съставки",
|
||||
"no-ingredients-selected": "Няма избрани съставки",
|
||||
"missing": "Липсващ",
|
||||
"no-recipes-found": "Няма намерени рецепти",
|
||||
"no-recipes-found-description": "Опитайте да добавите още съставки към търсенето си или да коригирате филтрите си",
|
||||
"include-ingredients-on-hand": "Включете наличните съставки",
|
||||
"include-tools-on-hand": "Включете наличните инструменти",
|
||||
"max-missing-ingredients": "Максимален брой липсващи съставки",
|
||||
"max-missing-tools": "Максимален брой липсващи инструменти",
|
||||
"selected-tools": "Избрани инструменти",
|
||||
"other-filters": "Други филтри",
|
||||
"ready-to-make": "Готови за приготвяне",
|
||||
"almost-ready-to-make": "Почти готови за приготвяне"
|
||||
},
|
||||
"search": {
|
||||
"advanced-search": "Разширено търсене",
|
||||
@@ -705,7 +712,7 @@
|
||||
"or": "Или",
|
||||
"has-any": "Има някое",
|
||||
"has-all": "Има всички",
|
||||
"clear-selection": "Clear Selection",
|
||||
"clear-selection": "Изчистване на избора",
|
||||
"results": "Резултати",
|
||||
"search": "Търсене",
|
||||
"search-mealie": "Търсене в Mealie (Натисни /)",
|
||||
@@ -721,10 +728,10 @@
|
||||
"admin-settings": "Административни настройки",
|
||||
"backup": {
|
||||
"backup-created": "Архивът е създаден успешно",
|
||||
"backup-created-at-response-export_path": "Резервно копие е създадено на {path}",
|
||||
"backup-created-at-response-export_path": "Резервно копие е създадено в {path}",
|
||||
"backup-deleted": "Резервното копие е изтрито",
|
||||
"restore-success": "Успешно възстановяване",
|
||||
"restore-fail": "Restore failed. Check your server logs for more details",
|
||||
"restore-fail": "Възстановяването не бе успешно. Проверете лог файловете на сървъра си за повече подробности",
|
||||
"backup-tag": "Етикет на резервното копие",
|
||||
"create-heading": "Създай резервно копие",
|
||||
"delete-backup": "Изтрий резервно копие",
|
||||
@@ -737,7 +744,7 @@
|
||||
"backup-restore": "Възстановяване на резервно копие",
|
||||
"back-restore-description": "Възстановяването на това резервно копие ще презапише цялата текуща информация във Вашата база данни и директорията с данни, и ще ги замени със съдържанието от резервното копие. {cannot-be-undone} Ако възстановяването е успешно ще бъдете отписан от системата.",
|
||||
"cannot-be-undone": "Това действие не може да бъде отменено - използвайте с внимание.",
|
||||
"postgresql-note": "If you are using PostgreSQL, please review the {backup-restore-process} prior to restoring.",
|
||||
"postgresql-note": "Ако използвате PostgreSQL, моля, прегледайте {backup-restore-process} преди възстановяване.",
|
||||
"backup-restore-process-in-the-documentation": "процес за резервно копие/възстановяване в документацията",
|
||||
"irreversible-acknowledgment": "Разбирам, че това действие е невъзвращаемо, разрушително и може да доведе до загуба на данни",
|
||||
"restore-backup": "Възстановяване на резервно копие"
|
||||
@@ -841,7 +848,7 @@
|
||||
"email-configured": "Email е конфигуриран",
|
||||
"email-test-results": "Резултати от тест на email",
|
||||
"ready": "Готов",
|
||||
"not-ready": "Не е готово - Проверете променливите на средата",
|
||||
"not-ready": "Не е завършена - Проверете променливите на средата.",
|
||||
"succeeded": "Успешно",
|
||||
"failed": "Неуспешно",
|
||||
"general-about": "Основни настройки",
|
||||
@@ -862,9 +869,9 @@
|
||||
"oidc-ready": "Готов за OIDC",
|
||||
"oidc-ready-error-text": "Не всички OIDC стойности са конфигурирани. Това може да бъде игнорирано, ако не използвате OIDC удостоверяване.",
|
||||
"oidc-ready-success-text": "Задължителните OIDC променливи са зададени.",
|
||||
"openai-ready": "OpenAI Ready",
|
||||
"openai-ready-error-text": "Not all OpenAI Values are configured. This can be ignored if you are not using OpenAI features.",
|
||||
"openai-ready-success-text": "Required OpenAI variables are all set."
|
||||
"openai-ready": "Готов за OpenAI",
|
||||
"openai-ready-error-text": "Не всички стойности на OpenAI са конфигурирани. Това може да се игнорира, ако не използвате функции на OpenAI.",
|
||||
"openai-ready-success-text": "Всички необходими променливи на OpenAI са зададени."
|
||||
},
|
||||
"shopping-list": {
|
||||
"all-lists": "Всички списъци",
|
||||
@@ -878,7 +885,7 @@
|
||||
"food": "Продукт",
|
||||
"note": "Бележка",
|
||||
"label": "Етикет",
|
||||
"save-label": "Save Label",
|
||||
"save-label": "Запазване на етикета",
|
||||
"linked-item-warning": "Елементът е добавен към една или повече рецепти. Редактиране на единиците или храните ще се отрази с непредвидими резултати когато добавяте или премахвате рецепта от списъка.",
|
||||
"toggle-food": "Превключване на храна",
|
||||
"manage-labels": "Управление на етикети",
|
||||
@@ -894,12 +901,12 @@
|
||||
"items-checked-count": "Няма отбелязани етикети|Един елемент е отбелязан|{count} елементи са отбелязани",
|
||||
"no-label": "Няма етикет",
|
||||
"completed-on": "Приключена на {date}",
|
||||
"you-are-offline": "You are offline",
|
||||
"you-are-offline-description": "Not all features are available while offline. You can still add, modify, and remove items, but you will not be able to sync your changes to the server until you are back online.",
|
||||
"are-you-sure-you-want-to-check-all-items": "Are you sure you want to check all items?",
|
||||
"are-you-sure-you-want-to-uncheck-all-items": "Are you sure you want to uncheck all items?",
|
||||
"are-you-sure-you-want-to-delete-checked-items": "Are you sure you want to delete all checked items?",
|
||||
"no-shopping-lists-found": "No Shopping Lists Found"
|
||||
"you-are-offline": "Вие сте в офлайн режим",
|
||||
"you-are-offline-description": "Не всички функции са достъпни, докато сте офлайн. Все още можете да добавяте, променяте и премахвате елементи, но няма да можете да синхронизирате промените си със сървъра, докато не се свържете отново онлайн.",
|
||||
"are-you-sure-you-want-to-check-all-items": "Сигурни ли сте, че искате да изберете всички елементи?",
|
||||
"are-you-sure-you-want-to-uncheck-all-items": "Сигурни ли сте, че искате да премахнете отметката от всички елементи?",
|
||||
"are-you-sure-you-want-to-delete-checked-items": "Сигурни ли сте, че искате да изтриете всички отметнати елементи?",
|
||||
"no-shopping-lists-found": "Не са намерени списъци за пазаруване"
|
||||
},
|
||||
"sidebar": {
|
||||
"all-recipes": "Всички рецепти",
|
||||
@@ -1043,9 +1050,9 @@
|
||||
"authentication-method-hint": "This specifies how a user will authenticate with Mealie. If you're not sure, choose 'Mealie",
|
||||
"permissions": "Права",
|
||||
"administrator": "Администратор",
|
||||
"user-can-invite-other-to-group": "User can invite others to group",
|
||||
"user-can-invite-other-to-group": "Потребителя може да добавя други в групата",
|
||||
"user-can-manage-group": "Потребителя може да управлява групата",
|
||||
"user-can-manage-household": "User can manage household",
|
||||
"user-can-manage-household": "Потребителят може да управлява домакинството",
|
||||
"user-can-organize-group-data": "Потребителя може да организира данните на групата",
|
||||
"enable-advanced-features": "Включване на разширени функции",
|
||||
"it-looks-like-this-is-your-first-time-logging-in": "Изглежда това е първият път, в който влизате.",
|
||||
@@ -1076,8 +1083,8 @@
|
||||
"food-data": "Данни за храните",
|
||||
"example-food-singular": "пример: Домат",
|
||||
"example-food-plural": "пример: Домати",
|
||||
"label-overwrite-warning": "This will assign the chosen label to all selected foods and potentially overwrite your existing labels.",
|
||||
"on-hand-checkbox-label": "Setting this flag will make this food unchecked by default when adding a recipe to a shopping list."
|
||||
"label-overwrite-warning": "Това ще присвои избрания етикет на всички избрани храни и евентуално ще презапише съществуващите ви етикети.",
|
||||
"on-hand-checkbox-label": "Задаването на този флаг ще направи тази храна неотметната по подразбиране при добавяне на рецепта към списък за пазаруване."
|
||||
},
|
||||
"units": {
|
||||
"seed-dialog-text": "Заредете базата данни с мерни единици на Вашия местен език.",
|
||||
@@ -1106,7 +1113,7 @@
|
||||
"edit-label": "Редактиране на етикет",
|
||||
"new-label": "Нов етикет",
|
||||
"labels": "Етикети",
|
||||
"assign-label": "Assign Label"
|
||||
"assign-label": "Присвояване на етикет"
|
||||
},
|
||||
"recipes": {
|
||||
"purge-exports": "Изчистване на експортите",
|
||||
@@ -1130,10 +1137,10 @@
|
||||
"source-unit-will-be-deleted": "Изходната мерна единица ще бъде изтрита"
|
||||
},
|
||||
"recipe-actions": {
|
||||
"recipe-actions-data": "Recipe Actions Data",
|
||||
"new-recipe-action": "New Recipe Action",
|
||||
"edit-recipe-action": "Edit Recipe Action",
|
||||
"action-type": "Action Type"
|
||||
"recipe-actions-data": "Данни за действия с рецепти",
|
||||
"new-recipe-action": "Ново действие с рецепта",
|
||||
"edit-recipe-action": "Редактиране на действието с рецепта",
|
||||
"action-type": "Вид действие"
|
||||
},
|
||||
"create-alias": "Създаване на псевдоним",
|
||||
"manage-aliases": "Управление на псевдоними",
|
||||
@@ -1170,7 +1177,7 @@
|
||||
"group-details": "Подробности за групата",
|
||||
"group-details-description": "Преди да създадете акаунт, ще трябва да създадете група. Вашата група ще съдържа само Вас, но ще можете да поканите други по-късно. Членовете във вашата група могат да споделят планове за хранене, списъци за пазаруване, рецепти и други!",
|
||||
"use-seed-data": "Използвай предварителни данни",
|
||||
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes. These are translated into the language you currently have selected. You can always add to or modify this data later.",
|
||||
"use-seed-data-description": "Mealie се доставя с колекция от храни, единици и етикети, които могат да се използват за попълване на вашата група с полезни данни за организиране на вашите рецепти. Те са преведени на езика, който сте избрали в момента. Винаги можете да добавяте или променяте тези данни по-късно.",
|
||||
"account-details": "Подробни данни за акаунта"
|
||||
},
|
||||
"validation": {
|
||||
@@ -1301,25 +1308,25 @@
|
||||
"restore-from-v1-backup": "Имате резервно копие от предишна инстанция на Mealie v1? Можете да го възстановите тук.",
|
||||
"manage-profile-or-get-invite-link": "Управлявайте собствения си профил или вземете връзка за покана, която да споделите с други."
|
||||
},
|
||||
"debug-openai-services": "Debug OpenAI Services",
|
||||
"debug-openai-services-description": "Use this page to debug OpenAI services. You can test your OpenAI connection and see the results here. If you have image services enabled, you can also provide an image.",
|
||||
"run-test": "Run Test",
|
||||
"test-results": "Test Results",
|
||||
"debug-openai-services": "Отстраняване на грешки в услугите на OpenAI",
|
||||
"debug-openai-services-description": "Използвайте тази страница за отстраняване на грешки в услугите на OpenAI. Можете да тествате връзката си с OpenAI и да видите резултатите тук. Ако имате активирани услуги за изображения, можете също да предоставите изображение.",
|
||||
"run-test": "Изпълнение на теста",
|
||||
"test-results": "Резултати от теста",
|
||||
"group-delete-note": "Групите от потребители или домакинства немогат да бъдат изтривани",
|
||||
"household-delete-note": "Домакинства с потребители не могат да бъдат изтривани"
|
||||
},
|
||||
"profile": {
|
||||
"welcome-user": "👋 Добре дошъл(а), {0}!",
|
||||
"description": "Настройки на профил, рецепти и настройки на групата.",
|
||||
"invite-link": "Invite Link",
|
||||
"invite-link": "Линк за Покана",
|
||||
"get-invite-link": "Вземи линк за покана",
|
||||
"get-public-link": "Вземи публичен линк",
|
||||
"account-summary": "Обобщение на акаунта",
|
||||
"account-summary-description": "Обобщение на информацията за Вашата група.",
|
||||
"group-statistics": "Статистики на групата",
|
||||
"group-statistics-description": "Вашата статистика на групата дава известна представа как използвате Mealie.",
|
||||
"household-statistics": "Household Statistics",
|
||||
"household-statistics-description": "Your Household Statistics provide some insight how you're using Mealie.",
|
||||
"household-statistics": "Статистика на домакинствата",
|
||||
"household-statistics-description": "Статистиката на вашето домакинство дава известна представа за това как използвате Mealie.",
|
||||
"storage-capacity": "Капацитет за съхранение",
|
||||
"storage-capacity-description": "Вашият капацитет за съхранение е изчисление на изображенията и активите, които сте качили.",
|
||||
"personal": "Лични",
|
||||
@@ -1329,13 +1336,13 @@
|
||||
"api-tokens-description": "Управление на API токени за достъп от външни приложения.",
|
||||
"group-description": "Тези елементи се споделят във вашата група. Редактирането на един от тях ще го промени за цялата група!",
|
||||
"group-settings": "Настройки на групата",
|
||||
"group-settings-description": "Manage your common group settings, like privacy settings.",
|
||||
"household-description": "These items are shared within your household. Editing one of them will change it for the whole household!",
|
||||
"household-settings": "Household Settings",
|
||||
"household-settings-description": "Manage your household settings, like mealplan and privacy settings.",
|
||||
"group-settings-description": "Управлявайте общите настройки на групата си, като например настройките за поверителност.",
|
||||
"household-description": "Тези елементи се споделят в рамките на вашето домакинство. Редактирането на един от тях ще го промени за цялото домакинство!",
|
||||
"household-settings": "Настройки на домакинството",
|
||||
"household-settings-description": "Управлявайте настройките на домакинството си, като например план за хранене и настройки за поверителност.",
|
||||
"cookbooks-description": "Управление на категории на рецепти и генериране на съответните страници.",
|
||||
"members": "Участници",
|
||||
"members-description": "See who's in your household and manage their permissions.",
|
||||
"members-description": "Вижте кой е във вашето домакинство и управлявайте техните разрешения.",
|
||||
"webhooks-description": "Настройте webhooks, които се задействат в дните, в които имате планиран план за хранене.",
|
||||
"notifiers": "Уведомители",
|
||||
"notifiers-description": "Настройте имейл и push известия, които се задействат при конкретни събития.",
|
||||
@@ -1360,9 +1367,9 @@
|
||||
},
|
||||
"cookbook": {
|
||||
"cookbooks": "Готварски книги",
|
||||
"description": "Cookbooks are another way to organize recipes by creating cross sections of recipes, organizers, and other filters. Creating a cookbook will add an entry to the side-bar and all the recipes with the filters chosen will be displayed in the cookbook.",
|
||||
"hide-cookbooks-from-other-households": "Hide Cookbooks from Other Households",
|
||||
"hide-cookbooks-from-other-households-description": "When enabled, only cookbooks from your household will appear on the sidebar",
|
||||
"description": "Готварските книги са друг начин за организиране на рецепти чрез създаване на напречни секции от рецепти, органайзери и други филтри. Създаването на готварска книга ще добави запис към страничната лента и всички рецепти с избраните филтри ще бъдат показани в готварската книга.",
|
||||
"hide-cookbooks-from-other-households": "Скриване на готварски книги от други домакинства",
|
||||
"hide-cookbooks-from-other-households-description": "Когато е активирано, в страничната лента ще се показват само готварски книги от вашето домакинство",
|
||||
"public-cookbook": "Публична книга с рецепти",
|
||||
"public-cookbook-description": "Публичните готварски книги могат да се споделят с потребители, които не са в Mealie, и ще се показват на страницата на вашите групи.",
|
||||
"filter-options": "Опции на филтъра",
|
||||
@@ -1372,31 +1379,31 @@
|
||||
"require-all-tools": "Изискване на всички инструменти",
|
||||
"cookbook-name": "Име на книгата с рецепти",
|
||||
"cookbook-with-name": "Книга с рецепти {0}",
|
||||
"household-cookbook-name": "{0} Cookbook {1}",
|
||||
"household-cookbook-name": "{0} Готварска книга {1}",
|
||||
"create-a-cookbook": "Създай Готварска книга",
|
||||
"cookbook": "Готварска книга"
|
||||
},
|
||||
"query-filter": {
|
||||
"logical-operators": {
|
||||
"and": "AND",
|
||||
"or": "OR"
|
||||
"and": "И",
|
||||
"or": "ИЛИ"
|
||||
},
|
||||
"relational-operators": {
|
||||
"equals": "equals",
|
||||
"does-not-equal": "does not equal",
|
||||
"is-greater-than": "is greater than",
|
||||
"is-greater-than-or-equal-to": "is greater than or equal to",
|
||||
"is-less-than": "is less than",
|
||||
"is-less-than-or-equal-to": "is less than or equal to"
|
||||
"equals": "е равно на",
|
||||
"does-not-equal": "не е равно на",
|
||||
"is-greater-than": "е по-голямо от",
|
||||
"is-greater-than-or-equal-to": "е по-голямо от или равно на",
|
||||
"is-less-than": "е по-малко от",
|
||||
"is-less-than-or-equal-to": "e по-малко или равно на"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "is",
|
||||
"is-not": "is not",
|
||||
"is-one-of": "is one of",
|
||||
"is-not-one-of": "is not one of",
|
||||
"contains-all-of": "contains all of",
|
||||
"is-like": "is like",
|
||||
"is-not-like": "is not like"
|
||||
"is": "е",
|
||||
"is-not": "не е",
|
||||
"is-one-of": "е едно от",
|
||||
"is-not-one-of": "не е едно от",
|
||||
"contains-all-of": "съдържа всички от",
|
||||
"is-like": "е като",
|
||||
"is-not-like": "не е като"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Podeu importar directament des de les dades planes",
|
||||
"import-original-keywords-as-tags": "Importa les paraules clau originals com a tags",
|
||||
"stay-in-edit-mode": "Segueix en el mode d'edició",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Importa des d'un ZIP",
|
||||
"import-from-zip-description": "Importa una sola recepta que ha estat importada d'una altra instància de Mealie.",
|
||||
"import-from-html-or-json": "Importar des d'un HTML o JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Crear menjar que manca: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
|
||||
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
|
||||
"no-food": "Sense menjar"
|
||||
"no-food": "Sense menjar",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "Reiniciar racions servides",
|
||||
"not-linked-ingredients": "Ingredients addicionals",
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Můžete importovat přímo ze surových dat",
|
||||
"import-original-keywords-as-tags": "Importovat původní klíčová slova jako štítky",
|
||||
"stay-in-edit-mode": "Zůstat v režimu úprav",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Importovat ze zipu",
|
||||
"import-from-zip-description": "Importovat jeden recept, který byl exportován z jiné instance Mealie.",
|
||||
"import-from-html-or-json": "Importovat z HTML nebo JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Vytvořit chybějící jídlo: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "Tuto jednotku nelze analyzovat automaticky",
|
||||
"this-food-could-not-be-parsed-automatically": "Toto jídlo nelze analyzovat automaticky",
|
||||
"no-food": "Žádné jídlo"
|
||||
"no-food": "Žádné jídlo",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "Resetovat počet porcí",
|
||||
"not-linked-ingredients": "Další ingredience",
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Du kan importere direkte fra rå data",
|
||||
"import-original-keywords-as-tags": "Importér originale nøgleord som mærker",
|
||||
"stay-in-edit-mode": "Bliv i redigeringstilstand",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Importer fra zip-fil",
|
||||
"import-from-zip-description": "Importer en enkelt opskrift, der blev eksporteret fra en anden Mealie instans.",
|
||||
"import-from-html-or-json": "Importer fra HTML eller JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Opret manglende fødevare: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "Denne enhed kunne ikke fortolkes automatisk",
|
||||
"this-food-could-not-be-parsed-automatically": "Denne fødevare kunne ikke fortolkes automatisk",
|
||||
"no-food": "Ingen fødevarer"
|
||||
"no-food": "Ingen fødevarer",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "Nulstil antal serveringer",
|
||||
"not-linked-ingredients": "Yderligere ingredienser",
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
"organizers": "Organisieren",
|
||||
"caution": "Vorsicht",
|
||||
"show-advanced": "Erweiterte Optionen anzeigen",
|
||||
"add-field": "Bedingung hinzufügen",
|
||||
"add-field": "Feld Hinzufügen",
|
||||
"date-created": "Erstellungsdatum",
|
||||
"date-updated": "Aktualisiert am"
|
||||
},
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Du kannst direkt von Rohdaten importieren",
|
||||
"import-original-keywords-as-tags": "Importiere ursprüngliche Stichwörter als Schlagwörter",
|
||||
"stay-in-edit-mode": "Im Bearbeitungsmodus bleiben",
|
||||
"parse-recipe-ingredients-after-import": "Zutaten nach dem Import parsen",
|
||||
"import-from-zip": "Von Zip importieren",
|
||||
"import-from-zip-description": "Importiere ein einzelnes Rezept, das von einer anderen Mealie-Instanz exportiert wurde.",
|
||||
"import-from-html-or-json": "Aus HTML oder JSON importieren",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Fehlendes Lebensmittel erstellen: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "Diese Einheit konnte nicht automatisch analysiert werden",
|
||||
"this-food-could-not-be-parsed-automatically": "Dieses Lebensmittel konnte nicht automatisch analysiert werden",
|
||||
"no-food": "Kein Lebensmittel"
|
||||
"no-food": "Kein Lebensmittel",
|
||||
"review-parsed-ingredients": "Geparste Zutaten überprüfen",
|
||||
"confidence-score": "Zuverlässigkeitswert",
|
||||
"ingredient-parser-description": "Deine Zutaten wurden erfolgreich geparst. Bitte überprüfe die Zutaten, bei denen wir uns nicht sicher sind.",
|
||||
"ingredient-parser-final-review-description": "Sobald alle Zutaten überprüft wurden, kannst du nochmal alle Zutaten kontrollieren, bevor die Änderungen ins Rezept übernommen werden.",
|
||||
"add-text-as-alias-for-item": "Füge \"{text}\" als Alias für {item} hinzu",
|
||||
"delete-item": "Element löschen"
|
||||
},
|
||||
"reset-servings-count": "Portionen zurücksetzen",
|
||||
"not-linked-ingredients": "Zusätzliche Zutaten",
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Μπορείτε να κάνετε εισαγωγή απευθείας από ακατέργαστα δεδομένα",
|
||||
"import-original-keywords-as-tags": "Εισαγωγή αρχικών λέξεων-κλειδιών ως ετικέτες",
|
||||
"stay-in-edit-mode": "Παραμονή σε λειτουργία επεξεργασίας",
|
||||
"parse-recipe-ingredients-after-import": "Ανάλυση συστατικών συνταγής μετά την εισαγωγή",
|
||||
"import-from-zip": "Εισαγωγή μέσω zip",
|
||||
"import-from-zip-description": "Εισαγωγή μιας μόνο συνταγής που εξάχθηκε από μια άλλη υπόσταση Mealie.",
|
||||
"import-from-html-or-json": "Εισαγωγή από HTML ή JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Δημιουργία τροφίμου που λείπει: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "Δεν ήταν δυνατή η αυτόματη ανάλυση αυτής της μονάδας",
|
||||
"this-food-could-not-be-parsed-automatically": "Δεν ήταν δυνατή η αυτόματη ανάλυση αυτού του φαγητού",
|
||||
"no-food": "Χωρίς Τρόφιμο"
|
||||
"no-food": "Χωρίς Τρόφιμο",
|
||||
"review-parsed-ingredients": "Επανεξέταση αναλυμένων συστατικών",
|
||||
"confidence-score": "Βαθμολογία εμπιστοσύνης",
|
||||
"ingredient-parser-description": "Τα συστατικά σας έχουν αναλυθεί επιτυχώς. Παρακαλούμε ελέγξτε τα συστατικά για τα οποία δεν είμαστε σίγουροι.",
|
||||
"ingredient-parser-final-review-description": "Μόλις εξεταστούν όλα τα συστατικά, θα έχετε μία ακόμη ευκαιρία να επανεξετάσετε όλα τα συστατικά πριν εφαρμόσετε τις αλλαγές στη συνταγή σας.",
|
||||
"add-text-as-alias-for-item": "Προσθήκη \"{text}\" ως ψευδώνυμο για το {item}",
|
||||
"delete-item": "Διαγραφή αντικειμένου"
|
||||
},
|
||||
"reset-servings-count": "Επαναφορά μέτρησης μερίδων",
|
||||
"not-linked-ingredients": "Πρόσθετα συστατικά",
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
|
||||
"import-original-keywords-as-tags": "Import original keywords as tags",
|
||||
"stay-in-edit-mode": "Stay in Edit mode",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Import from Zip",
|
||||
"import-from-zip-description": "Import a single recipe that was exported from another Mealie instance.",
|
||||
"import-from-html-or-json": "Import from HTML or JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Create missing food: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
|
||||
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
|
||||
"no-food": "No Food"
|
||||
"no-food": "No Food",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "Reset Servings Count",
|
||||
"not-linked-ingredients": "Additional Ingredients",
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
|
||||
"import-original-keywords-as-tags": "Import original keywords as tags",
|
||||
"stay-in-edit-mode": "Stay in Edit mode",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Import from Zip",
|
||||
"import-from-zip-description": "Import a single recipe that was exported from another Mealie instance.",
|
||||
"import-from-html-or-json": "Import from HTML or JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Create missing food: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
|
||||
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
|
||||
"no-food": "No Food"
|
||||
"no-food": "No Food",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "Reset Servings Count",
|
||||
"not-linked-ingredients": "Additional Ingredients",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"github": "GitHub",
|
||||
"log-lines": "Líneas de registro",
|
||||
"not-demo": "No Demo",
|
||||
"portfolio": "Portfolio",
|
||||
"portfolio": "Portafolio",
|
||||
"production": "Producción",
|
||||
"support": "Soporte",
|
||||
"version": "Versión",
|
||||
@@ -45,7 +45,7 @@
|
||||
"category-filter": "Filtros de Categorías",
|
||||
"category-update-failed": "Error al actualizar categoría",
|
||||
"category-updated": "Categoría actualizada",
|
||||
"uncategorized-count": "{count} no categorizado",
|
||||
"uncategorized-count": "{count} sin categorizar",
|
||||
"create-a-category": "Crear una categoría",
|
||||
"category-name": "Nombre de la categoría",
|
||||
"category": "Categoría"
|
||||
@@ -561,7 +561,7 @@
|
||||
"see-original-text": "Mostrar Texto Original",
|
||||
"original-text-with-value": "Texto original: {originalText}",
|
||||
"ingredient-linker": "Vincular ingredientes",
|
||||
"unlinked": "Not linked yet",
|
||||
"unlinked": "Aún no vinculado",
|
||||
"linked-to-other-step": "Enlazado a otro paso",
|
||||
"auto": "Auto",
|
||||
"cook-mode": "Modo Cocinar",
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Puede importar directamente desde datos brutos",
|
||||
"import-original-keywords-as-tags": "Importar palabras clave originales como etiquetas",
|
||||
"stay-in-edit-mode": "Permanecer en modo edición",
|
||||
"parse-recipe-ingredients-after-import": "Analizar los ingredientes de la receta después de importarla",
|
||||
"import-from-zip": "Importar desde zip",
|
||||
"import-from-zip-description": "Importa una receta única que fue exportada desde otra instancia de Mealie.",
|
||||
"import-from-html-or-json": "Importar desde HTML o JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Crear comida faltante: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "Esta unidad no pudo ser procesada automáticamente",
|
||||
"this-food-could-not-be-parsed-automatically": "Esta comida no pudo ser procesada automáticamente",
|
||||
"no-food": "Sin Comida"
|
||||
"no-food": "Sin Comida",
|
||||
"review-parsed-ingredients": "Revisar los ingredientes analizados",
|
||||
"confidence-score": "Puntuación de confianza",
|
||||
"ingredient-parser-description": "Tus ingredientes se han analizado correctamente. Revisa los ingredientes que no nos convencen.",
|
||||
"ingredient-parser-final-review-description": "Una vez que se hayan revisado todos los ingredientes, tendrás una oportunidad más de revisarlos todos antes de aplicar los cambios a tu receta.",
|
||||
"add-text-as-alias-for-item": "Añadir \"{text}\" como alias para {item}",
|
||||
"delete-item": "Borrar elemento"
|
||||
},
|
||||
"reset-servings-count": "Restablecer contador de porciones",
|
||||
"not-linked-ingredients": "Ingredientes adicionales",
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Sa võid otse importida töötlemata andmetest",
|
||||
"import-original-keywords-as-tags": "Impordi originaal võtmesõnad siltidena",
|
||||
"stay-in-edit-mode": "Püsige redigeerimisrežiimis",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Impordi .zip-st",
|
||||
"import-from-zip-description": "Impordi üks retsept, mis oli eksporditud teisest Mealie paigaldusest.",
|
||||
"import-from-html-or-json": "Impordi HTMLst või JSONist",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Loo puuduv toit: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
|
||||
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
|
||||
"no-food": "Toit puudub"
|
||||
"no-food": "Toit puudub",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "Lähtesta portsionite arv",
|
||||
"not-linked-ingredients": "Lisa-koostisosad",
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Voit tuoda raakadatan suoraan",
|
||||
"import-original-keywords-as-tags": "Tuo alkuperäiset avainsanat tunnisteiksi",
|
||||
"stay-in-edit-mode": "Pysy muokkaustilassa",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Tuo zip-arkistosta",
|
||||
"import-from-zip-description": "Tuo yksi resepti, joka on viety toisesta Mealie-asennuksesta.",
|
||||
"import-from-html-or-json": "Tuo HTML- tai JSON-tiedostosta",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Luo puuttuva ruoka: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
|
||||
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
|
||||
"no-food": "Ei ruokaa"
|
||||
"no-food": "Ei ruokaa",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "Palauta Annoksien Määrä",
|
||||
"not-linked-ingredients": "Muut ainesosat",
|
||||
|
||||
@@ -561,7 +561,7 @@
|
||||
"see-original-text": "Afficher le texte original",
|
||||
"original-text-with-value": "Texte original : {originalText}",
|
||||
"ingredient-linker": "Liaison d’ingrédients",
|
||||
"unlinked": "Not linked yet",
|
||||
"unlinked": "Pas encore associée",
|
||||
"linked-to-other-step": "Déjà associé à une autre étape",
|
||||
"auto": "Auto",
|
||||
"cook-mode": "Mode Cuisine",
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Vous pouvez directement importer des données brutes",
|
||||
"import-original-keywords-as-tags": "Importer les mots-clés d'origine en tant que tags",
|
||||
"stay-in-edit-mode": "Rester en mode édition",
|
||||
"parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import",
|
||||
"import-from-zip": "Importer depuis un zip",
|
||||
"import-from-zip-description": "Importer une recette qui a été exportée depuis une autre instance de Mealie.",
|
||||
"import-from-html-or-json": "Importer depuis HTML ou JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Créer un aliment manquant : {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "Cette unité n'a pas pu être analysée automatiquement",
|
||||
"this-food-could-not-be-parsed-automatically": "Cet aliment n'a pas pu être analysé automatiquement",
|
||||
"no-food": "Aucun aliment"
|
||||
"no-food": "Aucun aliment",
|
||||
"review-parsed-ingredients": "Vérifier les ingrédients analysés",
|
||||
"confidence-score": "Score de confiance",
|
||||
"ingredient-parser-description": "Vos ingrédients ont été analysés avec succès. Veuillez vérifier les ingrédients dont nous ne sommes pas certains.",
|
||||
"ingredient-parser-final-review-description": "Une fois que tous les ingrédients ont été analysés, vous aurez encore une chance de vérifier tous les ingrédients avant de les appliquer à votre recette.",
|
||||
"add-text-as-alias-for-item": "Ajouter \"{text}\" comme alias pour {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "Réinitialiser le nombre de portions",
|
||||
"not-linked-ingredients": "Ingrédients supplémentaires",
|
||||
|
||||
@@ -561,7 +561,7 @@
|
||||
"see-original-text": "Afficher le texte original",
|
||||
"original-text-with-value": "Texte original: {originalText}",
|
||||
"ingredient-linker": "Association d’ingrédients",
|
||||
"unlinked": "Not linked yet",
|
||||
"unlinked": "Pas encore associée",
|
||||
"linked-to-other-step": "Lié à une autre étape",
|
||||
"auto": "Auto",
|
||||
"cook-mode": "Mode Cuisine",
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Vous pouvez directement importer des données brutes",
|
||||
"import-original-keywords-as-tags": "Importer les mots-clés d'origine en tant que tags",
|
||||
"stay-in-edit-mode": "Rester en mode édition",
|
||||
"parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import",
|
||||
"import-from-zip": "Importer depuis un zip",
|
||||
"import-from-zip-description": "Importer une recette qui a été exportée depuis une autre instance de Mealie.",
|
||||
"import-from-html-or-json": "Importer depuis HTML ou JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Créer un aliment manquant : {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "Cette unité n'a pas pu être analysée automatiquement",
|
||||
"this-food-could-not-be-parsed-automatically": "Cet aliment n'a pas pu être analysé automatiquement",
|
||||
"no-food": "Aucun aliment"
|
||||
"no-food": "Aucun aliment",
|
||||
"review-parsed-ingredients": "Vérifier les ingrédients analysés",
|
||||
"confidence-score": "Score de confiance",
|
||||
"ingredient-parser-description": "Vos ingrédients ont été analysés avec succès. Veuillez vérifier les ingrédients dont nous ne sommes pas certains.",
|
||||
"ingredient-parser-final-review-description": "Une fois que tous les ingrédients ont été analysés, vous aurez encore une chance de vérifier tous les ingrédients avant de les appliquer à votre recette.",
|
||||
"add-text-as-alias-for-item": "Ajouter \"{text}\" comme alias pour {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "Réinitialiser le nombre de portions",
|
||||
"not-linked-ingredients": "Ingrédients supplémentaires",
|
||||
@@ -1170,7 +1177,7 @@
|
||||
"group-details": "Détails du groupe",
|
||||
"group-details-description": "Avant de créer un compte, vous devrez créer un groupe. Votre groupe ne contiendra que vous, mais vous pourrez inviter d’autres personnes plus tard. Les membres de votre groupe peuvent partager leur menu de la semaine, leurs listes d’achat, leurs recettes et plus encore !",
|
||||
"use-seed-data": "Utiliser l'initialisation de données",
|
||||
"use-seed-data-description": "Mealie est livrée avec une collection d'aliments, d'unités et d'étiquettes qui peuvent être utilisés pour remplir votre groupe avec des données utiles pour organiser vos recettes. Ceux-ci sont traduits dans la langue que vous avez sélectionnée. Vous pouvez toujours ajouter ou modifier ces données plus tard.",
|
||||
"use-seed-data-description": "Mealie est livrée avec une collection d'aliments, d'unités et d'étiquettes qui peuvent être utilisés pour peupler votre groupe avec des données utiles pour organiser vos recettes. Ceux-ci sont traduits dans la langue que vous avez sélectionnée. Vous pouvez toujours ajouter ou modifier ces données plus tard.",
|
||||
"account-details": "Détails du compte"
|
||||
},
|
||||
"validation": {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"support": "Soutenir",
|
||||
"version": "Version",
|
||||
"unknown-version": "inconnu",
|
||||
"sponsor": "Sponsoriser"
|
||||
"sponsor": "Soutenir"
|
||||
},
|
||||
"asset": {
|
||||
"assets": "Ressources",
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Vous pouvez directement importer des données brutes",
|
||||
"import-original-keywords-as-tags": "Importer les mots-clés d'origine en tant que tags",
|
||||
"stay-in-edit-mode": "Rester en mode édition",
|
||||
"parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import",
|
||||
"import-from-zip": "Importer depuis un zip",
|
||||
"import-from-zip-description": "Importer une recette qui a été exportée depuis une autre instance de Mealie.",
|
||||
"import-from-html-or-json": "Importer depuis HTML ou JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Créer un aliment manquant : {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "Cette unité n'a pas pu être analysée automatiquement",
|
||||
"this-food-could-not-be-parsed-automatically": "Cet aliment n'a pas pu être analysé automatiquement",
|
||||
"no-food": "Aucun aliment"
|
||||
"no-food": "Aucun aliment",
|
||||
"review-parsed-ingredients": "Vérifier les ingrédients analysés",
|
||||
"confidence-score": "Score de confiance",
|
||||
"ingredient-parser-description": "Vos ingrédients ont été analysés avec succès. Veuillez vérifier les ingrédients dont nous ne sommes pas certains.",
|
||||
"ingredient-parser-final-review-description": "Une fois que tous les ingrédients ont été analysés, vous aurez encore une chance de vérifier tous les ingrédients avant de les appliquer à votre recette.",
|
||||
"add-text-as-alias-for-item": "Ajouter \"{text}\" comme alias pour {item}",
|
||||
"delete-item": "Supprimer l'élément"
|
||||
},
|
||||
"reset-servings-count": "Réinitialiser le nombre de portions",
|
||||
"not-linked-ingredients": "Ingrédients supplémentaires",
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "É posível importar diretamente a partir de datos en bruto",
|
||||
"import-original-keywords-as-tags": "Importar palavras-chave orixinais como etiquetas",
|
||||
"stay-in-edit-mode": "Permanecer no modo de edición",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Importar de Zip",
|
||||
"import-from-zip-description": "Importar unha única receita exportada de outra instancia Mealie.",
|
||||
"import-from-html-or-json": "Importar a partir de HTML ou JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Crear a comida que falta: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "Non foi posíbel procesar automaticamente esta unidade",
|
||||
"this-food-could-not-be-parsed-automatically": "Non foi posíbel procesar automaticamente este alimento",
|
||||
"no-food": "Sen Comida"
|
||||
"no-food": "Sen Comida",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "Reiniciar Contador de Porcións",
|
||||
"not-linked-ingredients": "Ingredientes Adicionais",
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"new-notification": "התראה חדשה",
|
||||
"event-notifiers": "מנגנוני התרעה על אירועים",
|
||||
"apprise-url-skipped-if-blank": "כתובת Apprise (דלג אם ריק)",
|
||||
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
|
||||
"apprise-url-is-left-intentionally-blank": "מאחר שכתובות URL של Apprise לרוב מכילות מידע רגיש, שזה זה נותר ריק במכוון בעת העריכה. אם ברצונך לעדכן את כתובת ה URL, אנא הזן כאן את ה-URL החדש, אחרת השאר אותו ריק כדי לשמור את כתובת ה-URL הנוכחית.",
|
||||
"enable-notifier": "הפעלת מתריע",
|
||||
"what-events": "לאילו אירועים לרשום את מתריע זה?",
|
||||
"user-events": "אירועי משתמש",
|
||||
@@ -81,7 +81,7 @@
|
||||
"category-events": "אירועי קטגוריות",
|
||||
"when-a-new-user-joins-your-group": "כאשר משתמש חדש מצטרף לקבוצה",
|
||||
"recipe-events": "אירועי מתכון",
|
||||
"label-events": "Label Events"
|
||||
"label-events": "הוסף תוויות לאירועים"
|
||||
},
|
||||
"general": {
|
||||
"add": "הוספה",
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "ניתן לייבא ישירות ממידע גולמי",
|
||||
"import-original-keywords-as-tags": "ייבוא שמות מפתח מקוריות כתגיות",
|
||||
"stay-in-edit-mode": "השאר במצב עריכה",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "ייבא מקובץ",
|
||||
"import-from-zip-description": "ייבוא מתכון בודד שיוצא ממילי אחרת.",
|
||||
"import-from-html-or-json": "ייבוא מ-HTML או JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "יצירת אוכל חסר: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
|
||||
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
|
||||
"no-food": "אין אוכל"
|
||||
"no-food": "אין אוכל",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "איפוס מספר המנות",
|
||||
"not-linked-ingredients": "מרכיבים נוספים",
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
|
||||
"import-original-keywords-as-tags": "Uvezi originalne ključne riječi kao oznake",
|
||||
"stay-in-edit-mode": "Ostanite u načinu uređivanja",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Uvoz iz Zip-a",
|
||||
"import-from-zip-description": "Uvezi pojedinačni recept koji je izvezen iz druge instance Mealie aplikacije.",
|
||||
"import-from-html-or-json": "Import from HTML or JSON",
|
||||
@@ -669,7 +670,13 @@
|
||||
"missing-food": "Create missing food: {food}",
|
||||
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
|
||||
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
|
||||
"no-food": "No Food"
|
||||
"no-food": "No Food",
|
||||
"review-parsed-ingredients": "Review parsed ingredients",
|
||||
"confidence-score": "Confidence Score",
|
||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
||||
"delete-item": "Delete Item"
|
||||
},
|
||||
"reset-servings-count": "Reset Servings Count",
|
||||
"not-linked-ingredients": "Additional Ingredients",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user