mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-24 08:43:11 -05:00
Compare commits
80 Commits
v1.0.0beta
...
v1.0.0beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13850cda1f | ||
|
|
483f789b8e | ||
|
|
1b83c82997 | ||
|
|
34f52c06a6 | ||
|
|
07fef8af9f | ||
|
|
703ee32653 | ||
|
|
3d4e5441dd | ||
|
|
f00280e32b | ||
|
|
9e6a720cf1 | ||
|
|
7f50071312 | ||
|
|
c64da1fdb7 | ||
|
|
2809cef3b1 | ||
|
|
2f7ff6d178 | ||
|
|
c05e048b65 | ||
|
|
157bad0e29 | ||
|
|
f96a584a5d | ||
|
|
151e20489a | ||
|
|
7dbb0858bd | ||
|
|
b921e95163 | ||
|
|
cb15db2d27 | ||
|
|
c158672d12 | ||
|
|
292bf7068a | ||
|
|
5db4dedc3f | ||
|
|
f122c382e9 | ||
|
|
c865bc7769 | ||
|
|
efffe26a19 | ||
|
|
8b054fd945 | ||
|
|
bb1fa52d10 | ||
|
|
d4b92a8ade | ||
|
|
85d514eb1a | ||
|
|
8878f78ab1 | ||
|
|
d315ad63d2 | ||
|
|
48053b55b9 | ||
|
|
78c7399ff7 | ||
|
|
f70fc18222 | ||
|
|
6f83b0f522 | ||
|
|
5a053cdcd6 | ||
|
|
b1256f4ad2 | ||
|
|
525842e9a1 | ||
|
|
9e261f5235 | ||
|
|
3f808f8f00 | ||
|
|
394df6c210 | ||
|
|
754e77c9cb | ||
|
|
3030e3e7f4 | ||
|
|
f6c18ec73d | ||
|
|
84dc60d7bf | ||
|
|
7541175b75 | ||
|
|
932f4a72df | ||
|
|
b904b161eb | ||
|
|
504bf41b9c | ||
|
|
92ccbae657 | ||
|
|
c0d59db83d | ||
|
|
511ce91630 | ||
|
|
5f5eb2c46d | ||
|
|
4662253d0e | ||
|
|
8836a258bd | ||
|
|
56eb0bca71 | ||
|
|
eca8a96509 | ||
|
|
7eb80d18d2 | ||
|
|
37a673b34d | ||
|
|
3e7b8d4b71 | ||
|
|
abb114c375 | ||
|
|
12f480eb75 | ||
|
|
bc175d4ca9 | ||
|
|
f78c5eb359 | ||
|
|
5a0c034391 | ||
|
|
52fbf6b833 | ||
|
|
592b1de39d | ||
|
|
f29d5f1dff | ||
|
|
738ef0aaa7 | ||
|
|
f1fdec5afe | ||
|
|
4c594a48dc | ||
|
|
00f144a622 | ||
|
|
d2a9f7ca24 | ||
|
|
f831791db2 | ||
|
|
c3bdfe7b3b | ||
|
|
3542bb0927 | ||
|
|
e898c80f59 | ||
|
|
27c5cfc56b | ||
|
|
369cda0a61 |
41
.github/workflows/frontend-lint.yml
vendored
41
.github/workflows/frontend-lint.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
- mealie-next
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
lint:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
@@ -47,3 +47,42 @@ jobs:
|
||||
- name: Run linter 👀
|
||||
run: yarn lint
|
||||
working-directory: "frontend"
|
||||
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
node: [16]
|
||||
|
||||
steps:
|
||||
- name: Checkout 🛎
|
||||
uses: actions/checkout@master
|
||||
|
||||
- name: Setup node env 🏗
|
||||
uses: actions/setup-node@v2.1.5
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
check-latest: true
|
||||
|
||||
- name: Get yarn cache directory path 🛠
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- name: Cache node_modules 📦
|
||||
uses: actions/cache@v2.1.4
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install dependencies 👨🏻💻
|
||||
run: yarn
|
||||
working-directory: "frontend"
|
||||
|
||||
- name: Run Build 🚚
|
||||
run: yarn build
|
||||
working-directory: "frontend"
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -52,6 +52,7 @@
|
||||
"package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig",
|
||||
"pyproject.toml": "poetry.lock, alembic.ini, .pylintrc, .flake8",
|
||||
"netlify.toml": "runtime.txt",
|
||||
"docker-compose.yml": "Dockerfile, .dockerignore, docker-compose.dev.yml, docker-compose.yml"
|
||||
"docker-compose.yml": "Dockerfile, .dockerignore, docker-compose.dev.yml, docker-compose.yml",
|
||||
"README.md": "LICENSE, SECURITY.md"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
Mealie is a self hosted recipe manager and meal planner with a RestAPI backend and a reactive frontend application built in Vue for a pleasant user experience for the whole family. Easily add recipes into your database by providing the url and Mealie will automatically import the relevant data or add a family recipe with the UI editor. Mealie also provides an API for interactions from 3rd party applications.
|
||||
|
||||
- [Remember to join the Discord](https://discord.gg/QuStdQGSGK)!
|
||||
- [Documentation](https://docs.mealie.io)
|
||||
- [Documentation](https://nightly.mealie.io)
|
||||
|
||||
|
||||
<!-- CONTRIBUTING -->
|
||||
@@ -87,7 +87,7 @@ Thanks to Linode for providing Hosting for the Demo, Beta, and Documentation sit
|
||||
[issues-shield]: https://img.shields.io/github/issues/hay-kot/mealie.svg?style=flat-square
|
||||
[issues-url]: https://github.com/hay-kot/mealie/issues
|
||||
[license-shield]: https://img.shields.io/github/license/hay-kot/mealie.svg?style=flat-square
|
||||
[license-url]: https://github.com/hay-kot/mealie/blob/master/LICENSE.txt
|
||||
[license-url]: https://github.com/hay-kot/mealie/blob/mealie-next/LICENSE
|
||||
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555
|
||||
[linkedin-url]: https://linkedin.com/in/hay-kot
|
||||
[product-screenshot]: docs/docs/assets/img/home_screenshot.png
|
||||
|
||||
9
SECURITY.md
Normal file
9
SECURITY.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Since this software is still considered beta/WIP support is always only given for the latest version. Security patches are only available for the latest version and not back-ported to older versions.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
For general security vulnerabilities you're welcome to open a GitHub issues or contribute a fix. If you feel the vulnerability should not be disclosed you can open a generic issue on GitHub and email to the details to [ob92oy0sl@mozmail.com](mailto:ob92oy0sl@mozmail.com) which is monitored by the maintainer.
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Add use_abbreviation column to ingredients
|
||||
|
||||
Revision ID: ab0bae02578f
|
||||
Revises: 09dfc897ad62
|
||||
Create Date: 2022-06-01 11:12:06.748383
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "ab0bae02578f"
|
||||
down_revision = "09dfc897ad62"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("ingredient_units", sa.Column("use_abbreviation", sa.Boolean(), nullable=True))
|
||||
|
||||
op.execute("UPDATE ingredient_units SET use_abbreviation = FALSE WHERE use_abbreviation IS NULL")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("ingredient_units", "use_abbreviation")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,31 @@
|
||||
"""add new webhook fields
|
||||
|
||||
|
||||
Revision ID: f30cf048c228
|
||||
Revises: ab0bae02578f
|
||||
Create Date: 2022-06-15 21:05:34.851857
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f30cf048c228"
|
||||
down_revision = "ab0bae02578f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("webhook_urls", sa.Column("webhook_type", sa.String(), nullable=True))
|
||||
op.add_column("webhook_urls", sa.Column("scheduled_time", sa.Time(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("webhook_urls", "scheduled_time")
|
||||
op.drop_column("webhook_urls", "webhook_type")
|
||||
# ### end Alembic commands ###
|
||||
64
cliff.toml
Normal file
64
cliff.toml
Normal file
@@ -0,0 +1,64 @@
|
||||
# configuration file for git-cliff (0.1.0)
|
||||
|
||||
[changelog]
|
||||
# changelog header
|
||||
header = """
|
||||
# Changelog\n
|
||||
All notable changes to this project will be documented in this file.\n
|
||||
"""
|
||||
# template for the changelog body
|
||||
# https://tera.netlify.app/docs/#introduction
|
||||
body = """
|
||||
{% if version %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [unreleased]
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
# remove the leading and trailing whitespace from the template
|
||||
trim = true
|
||||
# changelog footer
|
||||
footer = """
|
||||
<!-- generated by git-cliff -->
|
||||
"""
|
||||
|
||||
[git]
|
||||
# parse the commits based on https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# filter out the commits that are not conventional
|
||||
filter_unconventional = true
|
||||
# regex for preprocessing the commit messages
|
||||
commit_preprocessors = [
|
||||
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/hay-kot/mealie/issues/${2}))"},
|
||||
]
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features"},
|
||||
{ message = "^fix", group = "Bug Fixes"},
|
||||
{ message = "^doc", group = "Documentation"},
|
||||
{ message = "^perf", group = "Performance"},
|
||||
{ message = "^refactor", group = "Refactor"},
|
||||
{ message = "^style", group = "Styling"},
|
||||
{ message = "^test", group = "Testing"},
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true},
|
||||
{ message = "^chore", group = "Miscellaneous Tasks"},
|
||||
{ body = ".*security", group = "Security"},
|
||||
]
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
filter_commits = false
|
||||
# glob pattern for matching git tags
|
||||
tag_pattern = "v[0-9]*"
|
||||
# regex for skipping tags
|
||||
skip_tags = "v0.1.0-beta.1"
|
||||
# regex for ignoring tags
|
||||
ignore_tags = ""
|
||||
# sort the tags chronologically
|
||||
date_order = false
|
||||
# sort the commits inside sections by oldest/newest order
|
||||
sort_commits = "oldest"
|
||||
@@ -2,8 +2,6 @@ preserve_hierarchy: false
|
||||
files:
|
||||
- source: /frontend/lang/messages/en-US.json
|
||||
translation: /frontend/lang/messages/%locale%.json
|
||||
- source: /frontend/lang/dateTimeFormats/en-US.json
|
||||
translation: /frontend/lang/dateTimeFormats/%locale%.json
|
||||
- source: /mealie/lang/messages/en-US.json
|
||||
translation: /mealie/lang/messages/%locale%.json
|
||||
- source: /mealie/repos/seed/resources/foods/locales/en-US.json
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# vx.x.x COOL TITLE GOES HERE
|
||||
|
||||
**App Version: vx.x.x**
|
||||
|
||||
**Database Version: vx.x.x**
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
!!! error "Breaking Changes"
|
||||
|
||||
#### Database
|
||||
|
||||
#### ENV Variables
|
||||
|
||||
|
||||
## Bug Fixes
|
||||
- Fixed ...
|
||||
|
||||
## Features and Improvements
|
||||
|
||||
### General
|
||||
- New Thing 1
|
||||
|
||||
|
||||
### UI Improvements
|
||||
-
|
||||
|
||||
|
||||
### Behind the Scenes
|
||||
- Refactoring...
|
||||
@@ -1,29 +1,29 @@
|
||||
### Bug Fixes
|
||||
|
||||
- Bump isomorphic-dompurify from 0.18.0 to 0.19.0 in /frontend ([#1257](https://github.com/orhun/git-cliff/issues/1257))
|
||||
- Bump @nuxtjs/auth-next in /frontend ([#1265](https://github.com/orhun/git-cliff/issues/1265))
|
||||
- Bad dev dependency ([#1281](https://github.com/orhun/git-cliff/issues/1281))
|
||||
- Add touch support for mealplanner delete ([#1298](https://github.com/orhun/git-cliff/issues/1298))
|
||||
- Bump isomorphic-dompurify from 0.18.0 to 0.19.0 in /frontend ([#1257](https://github.com/hay-kot/mealie/issues/1257))
|
||||
- Bump @nuxtjs/auth-next in /frontend ([#1265](https://github.com/hay-kot/mealie/issues/1265))
|
||||
- Bad dev dependency ([#1281](https://github.com/hay-kot/mealie/issues/1281))
|
||||
- Add touch support for mealplanner delete ([#1298](https://github.com/hay-kot/mealie/issues/1298))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add references for VSCode dev containers ([#1299](https://github.com/orhun/git-cliff/issues/1299))
|
||||
- Docker-compose.dev.yml is currently not functional ([#1300](https://github.com/orhun/git-cliff/issues/1300))
|
||||
- Add references for VSCode dev containers ([#1299](https://github.com/hay-kot/mealie/issues/1299))
|
||||
- Docker-compose.dev.yml is currently not functional ([#1300](https://github.com/hay-kot/mealie/issues/1300))
|
||||
|
||||
### Features
|
||||
|
||||
- Add reports to bulk recipe import (url) ([#1294](https://github.com/orhun/git-cliff/issues/1294))
|
||||
- Rewrite print implementation to support new ing ([#1305](https://github.com/orhun/git-cliff/issues/1305))
|
||||
- Add reports to bulk recipe import (url) ([#1294](https://github.com/hay-kot/mealie/issues/1294))
|
||||
- Rewrite print implementation to support new ing ([#1305](https://github.com/hay-kot/mealie/issues/1305))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Github stalebot changes ([#1271](https://github.com/orhun/git-cliff/issues/1271))
|
||||
- Bump eslint-plugin-nuxt in /frontend ([#1258](https://github.com/orhun/git-cliff/issues/1258))
|
||||
- Bump @vue/runtime-dom in /frontend ([#1259](https://github.com/orhun/git-cliff/issues/1259))
|
||||
- Bump nuxt-vite from 0.1.3 to 0.3.5 in /frontend ([#1260](https://github.com/orhun/git-cliff/issues/1260))
|
||||
- Bump vue2-script-setup-transform in /frontend ([#1263](https://github.com/orhun/git-cliff/issues/1263))
|
||||
- Update dev dependencies ([#1282](https://github.com/orhun/git-cliff/issues/1282))
|
||||
- Github stalebot changes ([#1271](https://github.com/hay-kot/mealie/issues/1271))
|
||||
- Bump eslint-plugin-nuxt in /frontend ([#1258](https://github.com/hay-kot/mealie/issues/1258))
|
||||
- Bump @vue/runtime-dom in /frontend ([#1259](https://github.com/hay-kot/mealie/issues/1259))
|
||||
- Bump nuxt-vite from 0.1.3 to 0.3.5 in /frontend ([#1260](https://github.com/hay-kot/mealie/issues/1260))
|
||||
- Bump vue2-script-setup-transform in /frontend ([#1263](https://github.com/hay-kot/mealie/issues/1263))
|
||||
- Update dev dependencies ([#1282](https://github.com/hay-kot/mealie/issues/1282))
|
||||
|
||||
### Refactor
|
||||
|
||||
- Split up recipe create page ([#1283](https://github.com/orhun/git-cliff/issues/1283))
|
||||
- Split up recipe create page ([#1283](https://github.com/hay-kot/mealie/issues/1283))
|
||||
|
||||
36
docs/docs/changelog/v1.0.0beta-3.md
Normal file
36
docs/docs/changelog/v1.0.0beta-3.md
Normal file
@@ -0,0 +1,36 @@
|
||||
### Bug Fixes
|
||||
|
||||
- Update issue links in v1.0.0beta-2 changelog ([#1312](https://github.com/hay-kot/mealie/issues/1312))
|
||||
- Bad import path ([#1313](https://github.com/hay-kot/mealie/issues/1313))
|
||||
- Printer page refs ([#1314](https://github.com/hay-kot/mealie/issues/1314))
|
||||
- Consolidate stores to fix mismatched state
|
||||
- Bump @vue/composition-api from 1.6.1 to 1.6.2 in /frontend ([#1275](https://github.com/hay-kot/mealie/issues/1275))
|
||||
- Shopping list label editor ([#1333](https://github.com/hay-kot/mealie/issues/1333))
|
||||
|
||||
### Features
|
||||
|
||||
- Default unit fractions to True
|
||||
- Add unit abbreviation support ([#1332](https://github.com/hay-kot/mealie/issues/1332))
|
||||
- Attached images by drag and drop for recipe steps ([#1341](https://github.com/hay-kot/mealie/issues/1341))
|
||||
|
||||
### Docs
|
||||
|
||||
- Render homepage social media link images at 32x32 size ([#1310](https://github.com/hay-kot/mealie/issues/1310))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Init git-cliff config
|
||||
- Bump @types/sortablejs in /frontend ([#1287](https://github.com/hay-kot/mealie/issues/1287))
|
||||
- Bump @babel/eslint-parser in /frontend ([#1290](https://github.com/hay-kot/mealie/issues/1290))
|
||||
|
||||
### Refactor
|
||||
|
||||
- Unify recipe-organizer components ([#1340](https://github.com/hay-kot/mealie/issues/1340))
|
||||
|
||||
### Security
|
||||
|
||||
- Delay server response whenever username is non existing ([#1338](https://github.com/hay-kot/mealie/issues/1338))
|
||||
|
||||
### Wip
|
||||
|
||||
- Pagination-repository ([#1316](https://github.com/hay-kot/mealie/issues/1316))
|
||||
126
docs/docs/changelog/v1.0.0beta-4.md
Normal file
126
docs/docs/changelog/v1.0.0beta-4.md
Normal file
@@ -0,0 +1,126 @@
|
||||
### Security
|
||||
|
||||
#### v1.0.0beta-3 and Under - Recipe Scraper: Server Side Request Forgery Lead To Denial Of Service
|
||||
|
||||
!!! error "CWE-918: Server-Side Request Forgery (SSRF)"
|
||||
In this case if a attacker try to load a huge file then server will try to load the file and eventually server use its all memory which will dos the server
|
||||
|
||||
##### Mitigation
|
||||
|
||||
HTML is now scraped via a Stream and canceled after a 15 second timeout to prevent arbitrary data from being loaded into the server.
|
||||
|
||||
#### v1.0.0beta-3 and Under - Recipe Assets: Remote Code Execution
|
||||
|
||||
!!! error "CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine"
|
||||
As a low privileged user, Create a new recipe and click on the "+" to add a New Asset.
|
||||
Select a file, then proxy the request that will create the asset.
|
||||
|
||||
Since mealie/routes/recipe/recipe_crud_routes.py:306 is calling slugify on the name POST parameter, we use $ which slugify() will remove completely.
|
||||
|
||||
Since mealie/routes/recipe/recipe_crud_routes.py:306 is concatenating raw user input from the extension POST parameter into the variable file_name, which ultimately gets used when writing to disk, we can use a directory traversal attack in the extension (e.g. ./../../../tmp/pwn.txt) to write the file to arbitrary location on the server.
|
||||
|
||||
As an attacker, now that we have a strong attack primitive, we can start getting creative to get RCE. Since the files were being created by root, we could add an entry to /etc/passwd, create a crontab, etc. but since there was templating functionality in the application that peaked my interest. The PoC in the HTTP request above creates a Jinja2 template at /app/data/template/pwn.html. Since Jinja2 templates execute Python code when rendered, all we have to do now to get code execution is render the malicious template. This was easy enough.
|
||||
|
||||
##### Mitigation
|
||||
|
||||
We've added proper path sanitization to ensure that the user is not allowed to write to arbitrary locations on the server.
|
||||
|
||||
!!! warning "Breaking Change Incoming"
|
||||
As this has shown a significant area of exposure in the templates that Mealie was provided for exporting recipes, we'll be removing this feature in the next Beta release and will instead rely on the community to provide tooling around transforming recipes using templates. This will significantly limit the possible exposure of users injecting malicious templates into the application. The template functionality will be completely removed in the next beta release v1.0.0beta-5
|
||||
|
||||
#### All version Markdown Editor: Cross Site Scripting
|
||||
|
||||
!!! error "CWE-79: Cross-site Scripting (XSS) - Stored"
|
||||
A low privilege user can insert malicious JavaScript code into the Recipe Instructions which will execute in another person's browser that visits the recipe.
|
||||
|
||||
`<img src=x onerror=alert(document.domain)>`
|
||||
|
||||
##### Mitigation
|
||||
|
||||
This issues is present on all pages that allow markdown input. This error has been mitigated by wrapping the 3rd Party Markdown component and using the `domPurify` library to strip out the dangerous HTML.
|
||||
|
||||
#### v1.0.0beta-3 and Under - Image Scraper: Server-Side Request Forgery
|
||||
|
||||
!!! error "CWE-918: Server-Side Request Forgery (SSRF)"
|
||||
In the recipe edit page, is possible to upload an image directly or via an URL provided by the user. The function that handles the fetching and saving of the image via the URL doesn't have any URL verification, which allows to fetch internal services.
|
||||
|
||||
Furthermore, after the resource is fetch, there is no MIME type validation, which would ensure that the resource is indeed an image. After this, because there is no extension in the provided URL, the application will fallback to jpg, and original for the image name.
|
||||
|
||||
Then the result is saved to disk with the original.jpg name, that can be retrieved from the following URL: http://<domain>/api/media/recipes/<recipe-uid>/images/original.jpg. This file will contain the full response of the provided URL.
|
||||
|
||||
**Impact**
|
||||
|
||||
An attacker can get sensitive information of any internal-only services running. For example, if the application is hosted on Amazon Web Services (AWS) platform, its possible to fetch the AWS API endpoint, https://169.254.169.254, which returns API keys and other sensitive metadata.
|
||||
|
||||
##### Mitigation
|
||||
|
||||
Two actions were taken to reduce exposure to SSRF in this case.
|
||||
|
||||
1. The application will not prevent requests being made to local resources by checking for localhost or 127.0.0.1 domain names.
|
||||
2. The mime-type of the response is now checked prior to writing to disk.
|
||||
|
||||
If either of the above actions prevent the user from uploading images, the application will alert the user of what error occurred.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- For erroneously-translated datetime config ([#1362](https://github.com/hay-kot/mealie/issues/1362))
|
||||
- Fixed text color on RecipeCard in RecipePrintView and implemented ingredient sections ([#1351](https://github.com/hay-kot/mealie/issues/1351))
|
||||
- Ingredient sections lost after parsing ([#1368](https://github.com/hay-kot/mealie/issues/1368))
|
||||
- Increased float rounding precision for CRF parser ([#1369](https://github.com/hay-kot/mealie/issues/1369))
|
||||
- Infinite scroll bug on all recipes page ([#1393](https://github.com/hay-kot/mealie/issues/1393))
|
||||
- Fast fail of bulk importer ([#1394](https://github.com/hay-kot/mealie/issues/1394))
|
||||
- Bump @mdi/js from 5.9.55 to 6.7.96 in /frontend ([#1279](https://github.com/hay-kot/mealie/issues/1279))
|
||||
- Bump @nuxtjs/i18n from 7.0.3 to 7.2.2 in /frontend ([#1288](https://github.com/hay-kot/mealie/issues/1288))
|
||||
- Bump date-fns from 2.23.0 to 2.28.0 in /frontend ([#1293](https://github.com/hay-kot/mealie/issues/1293))
|
||||
- Bump fuse.js from 6.5.3 to 6.6.2 in /frontend ([#1325](https://github.com/hay-kot/mealie/issues/1325))
|
||||
- Bump core-js from 3.17.2 to 3.23.1 in /frontend ([#1383](https://github.com/hay-kot/mealie/issues/1383))
|
||||
- All-recipes page now sorts alphabetically ([#1405](https://github.com/hay-kot/mealie/issues/1405))
|
||||
- Sort recent recipes by created_at instead of date_added ([#1417](https://github.com/hay-kot/mealie/issues/1417))
|
||||
- Only show scaler when ingredients amounts enabled ([#1426](https://github.com/hay-kot/mealie/issues/1426))
|
||||
- Add missing types for API token deletion ([#1428](https://github.com/hay-kot/mealie/issues/1428))
|
||||
- Entry nutrition checker ([#1448](https://github.com/hay-kot/mealie/issues/1448))
|
||||
- Use == operator instead of is_ for sql queries ([#1453](https://github.com/hay-kot/mealie/issues/1453))
|
||||
- Use `mtime` instead of `ctime` for backup dates ([#1461](https://github.com/hay-kot/mealie/issues/1461))
|
||||
- Mealplan pagination ([#1464](https://github.com/hay-kot/mealie/issues/1464))
|
||||
- Properly use pagination for group event notifies ([#1512](https://github.com/hay-kot/mealie/pull/1512))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add go bulk import example ([#1388](https://github.com/hay-kot/mealie/issues/1388))
|
||||
- Fix old link
|
||||
- Pagination and filtering, and fixed a few broken links ([#1488](https://github.com/hay-kot/mealie/issues/1488))
|
||||
|
||||
### Features
|
||||
|
||||
- Toggle display of ingredient references in recipe instructions ([#1268](https://github.com/hay-kot/mealie/issues/1268))
|
||||
- Add custom scaling option ([#1345](https://github.com/hay-kot/mealie/issues/1345))
|
||||
- Implemented "order by" API parameters for recipe, food, and unit queries ([#1356](https://github.com/hay-kot/mealie/issues/1356))
|
||||
- Implement user favorites page ([#1376](https://github.com/hay-kot/mealie/issues/1376))
|
||||
- Extend Apprise JSON notification functionality with programmatic data ([#1355](https://github.com/hay-kot/mealie/issues/1355))
|
||||
- Mealplan-webhooks ([#1403](https://github.com/hay-kot/mealie/issues/1403))
|
||||
- Added "last-modified" header to supported record types ([#1379](https://github.com/hay-kot/mealie/issues/1379))
|
||||
- Re-write get all routes to use pagination ([#1424](https://github.com/hay-kot/mealie/issues/1424))
|
||||
- Advanced filtering API ([#1468](https://github.com/hay-kot/mealie/issues/1468))
|
||||
- Restore frontend sorting for all recipes ([#1497](https://github.com/hay-kot/mealie/issues/1497))
|
||||
- Implemented local storage for sorting and dynamic sort icons on the new recipe sort card ([1506](https://github.com/hay-kot/mealie/pull/1506))
|
||||
- create new foods and units from their Data Management pages ([#1511](https://github.com/hay-kot/mealie/pull/1511))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Bump dev deps ([#1418](https://github.com/hay-kot/mealie/issues/1418))
|
||||
- Bump @vue/runtime-dom in /frontend ([#1423](https://github.com/hay-kot/mealie/issues/1423))
|
||||
- Backend page_all route cleanup ([#1483](https://github.com/hay-kot/mealie/issues/1483))
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove depreciated repo call ([#1370](https://github.com/hay-kot/mealie/issues/1370))
|
||||
|
||||
### Hotfix
|
||||
|
||||
- Tame typescript beast
|
||||
|
||||
### UI
|
||||
|
||||
- Improve parser ui text display ([#1437](https://github.com/hay-kot/mealie/issues/1437))
|
||||
|
||||
<!-- generated by git-cliff -->
|
||||
@@ -43,6 +43,9 @@ import_from_file $input $token $mealie_url
|
||||
|
||||
```
|
||||
|
||||
#### Go
|
||||
See <a href="https://github.com/Jleagle/mealie-importer" target="_blank">Jleagle/mealie-importer</a>.
|
||||
|
||||
#### Python
|
||||
```python
|
||||
import requests
|
||||
|
||||
@@ -11,7 +11,67 @@ Mealie supports long-live api tokens in the user frontend. See [user settings pa
|
||||
On your local installation you can access interactive API documentation that provides `curl` examples and expected results. This allows you to easily test and interact with your API to identify places to include your own functionality. You can visit the documentation at `http://mealie.yourdomain.com/docs` or see the example at the [Demo Site](https://mealie-demo.hay-kot.dev/docs)
|
||||
|
||||
### Recipe Extras
|
||||
Recipes extras are a key feature of the Mealie API. They allow you to create custom json key/value pairs within a recipe to reference from 3rd part applications. You can use these keys to contain information to trigger automation or custom messages to relay to your desired device.
|
||||
Recipes extras are a key feature of the Mealie API. They allow you to create custom json key/value pairs within a recipe to reference from 3rd part applications. You can use these keys to contain information to trigger automation or custom messages to relay to your desired device.
|
||||
|
||||
For example you could add `{"message": "Remember to thaw the chicken"}` to a recipe and use the webhooks built into mealie to send that message payload to a destination to be processed.
|
||||
|
||||
### Pagination and Filtering
|
||||
Most document types share a uniform pagination and filtering API (e.g. `GET /api/recipes`). These allow you to filter by an arbitrary combination of criteria and return only a certain number of documents (i.e. a single "page" of documents).
|
||||
|
||||
#### Pagination
|
||||
The pagination API allows you to limit how many documents you return in each call. This is important when serving data to an application, as you don't want to wait for a huge payload every time you load a page. You may also not want to render all documents at once, opting to render only a few at a time.
|
||||
|
||||
The `perPage` parameter tells Mealie how many documents to return (this is similar to `LIMIT` in SQL). If you want to keep fetching more data in batches, first determine your batch size (in other words: how many documents you want per-page), then make additional calls by changing the `page` parameter. If your `perPage` size is 30, then page 1 will return the first 30 documents, page 2 will return the next 30 documents, etc.
|
||||
|
||||
Many applications will keep track of the query and adjust the page parameter appropriately, but some applications can't do this, or a particular implementation may make this difficult. The response includes pagination guides to help you find the next page and previous page. Here is a sample response:
|
||||
```json
|
||||
{
|
||||
"page": 2,
|
||||
"per_page": 5,
|
||||
"total": 23,
|
||||
"total_pages": 5,
|
||||
"data": [...],
|
||||
"next": "/recipes?page=3&per_page=5&order_by=name&order_direction=asc",
|
||||
"previous": "/recipes?page=1&per_page=5&order_by=name&order_direction=asc"
|
||||
}
|
||||
```
|
||||
Notice that the route does not contain the baseurl (e.g. `https://mymealieapplication.com/api`).
|
||||
|
||||
There are a few shorthands available to reduce the number of calls for certain common requests:
|
||||
- if you want to return _all_ results, effectively disabling pagination, set `perPage = -1` (and fetch the first page)
|
||||
- if you want to fetch the _last_ page, set `page = -1`
|
||||
|
||||
#### Filtering
|
||||
The `queryFilter` parameter enables fine-grained control over your query. You can filter by any combination of attributes connected by logical operators (`AND`, `OR`). You can also group attributes together using parenthesis. For string, date, or datetime literals, you should surround them in double quotes (e.g. `"Pasta Fagioli"`). If there are no spaces in your literal (such as dates) the API will probably parse it correctly, but it's recommended that you use quotes anyway.
|
||||
|
||||
Here are several examples of filters. These filter strings are not surrounded in quotes for ease of reading, but they are _strings_, so they will probably be in quotes in your language.
|
||||
|
||||
##### Simple Filters
|
||||
Here is an example of a filter to find a recipe with the name "Pasta Fagioli": <br>
|
||||
`name = "Pasta Fagioli"`
|
||||
|
||||
This filter will find all recipes created on or after a particular date: <br>
|
||||
`createdAt >= "2021-02-22"`
|
||||
|
||||
> **_NOTE:_** The API uses Python's [dateutil parser](https://dateutil.readthedocs.io/en/stable/parser.html), which parses many different date/datetime formats.
|
||||
|
||||
This filter will find all units that have `useAbbreviation` disabled: <br>
|
||||
`useAbbreviation = false`
|
||||
|
||||
##### Compound Filters
|
||||
You can combine multiple filter statements using logical operators (`AND`, `OR`).
|
||||
|
||||
This filter will only return recipes named "Pasta Fagioli" or "Grandma's Brisket": <br>
|
||||
`name = "Pasta Fagioli" OR name = "Grandma's Brisket"`
|
||||
|
||||
This filter will return all recipes created before a particular date, except for the one named "Ultimate Vegan Ramen Recipe With Miso Broth": <br>
|
||||
`createdAt < "January 2nd, 2014" AND name <> "Ultimate Vegan Ramen Recipe With Miso Broth"`
|
||||
|
||||
This filter will return three particular recipes: <br>
|
||||
`name = "Pasta Fagioli" OR name = "Grandma's Brisket" OR name = "Ultimate Vegan Ramen Recipe With Miso Broth"`
|
||||
|
||||
##### Advanced Filters
|
||||
You can have multiple filter groups combined by logical operators. You can define a filter group with parenthesis.
|
||||
|
||||
Here's a filter that will find all recipes updated between two particular times, but exclude the "Pasta Fagioli" recipe: <br>
|
||||
`(updatedAt > "2022-07-17T15:47:00Z" AND updatedAt < "2022-07-17T15:50:00Z") AND name <> "Pasta Fagioli"`
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
## Is it Safe to Upgrade Mealie?
|
||||
|
||||
Yes. If you are using the v1 branches (including beta), you can upgrade to the latest version of Mealie without performing a site Export/Restore. This process was required in previous versions of Mealie, however we've automated the database migration process to make it easier to upgrade. Not that if you were using the v0.5.x version, you CANNOT upgrade to the latest version automatically. You must follow the migration instructions in the documentation.
|
||||
|
||||
**Links**
|
||||
|
||||
- [Migration From v0.5.x](./migrating-to-mealie-v1.md)
|
||||
|
||||
## How can I change the theme?
|
||||
|
||||
You can change the theme by settings the environment variables on the frontend container.
|
||||
You can change the theme by settings the environment variables on the frontend container.
|
||||
|
||||
Links:
|
||||
|
||||
- [Frontend Theme](/mealie/documentation/getting-started/installation/frontend-config#themeing)
|
||||
- [Frontend Theme](./installation/frontend-config#themeing)
|
||||
|
||||
## How can I change the language?
|
||||
|
||||
@@ -14,29 +22,29 @@ Languages need to be set on the frontend and backend containers as ENV variables
|
||||
|
||||
Links
|
||||
|
||||
- [Frontend Config](/mealie/documentation/getting-started/installation/frontend-config/)
|
||||
- [Backend Config](/mealie/documentation/getting-started/installation/backend-config/)
|
||||
- [Frontend Config](./installation/frontend-config/)
|
||||
- [Backend Config](./installation/backend-config/)
|
||||
|
||||
## How can I change the Login Session Timeout?
|
||||
|
||||
Login session can be configured by setting the `TOKEN_TIME` variable on the backend container.
|
||||
|
||||
- [Backend Config](/mealie/documentation/getting-started/installation/backend-config/)
|
||||
- [Backend Config](./installation/backend-config/)
|
||||
|
||||
## Can I serve Mealie on a subpath?
|
||||
|
||||
No. Due to limitations from the Javascript Framework, mealie doesn't support serving Mealie on a subpath.
|
||||
|
||||
## Can I install Mealie without docker?
|
||||
## Can I install Mealie without docker?
|
||||
|
||||
Yes, you can install Mealie on your local machine. HOWEVER, it is recommended that you don't. Managing non-system versions of python, node, and npm is a pain. Moreover updating and upgrading your system with this configuration is unsupported and will likely require manual interventions. If you insist on installing Mealie on your local machine, you can use the links below to help guide your path.
|
||||
|
||||
- [Advanced Installation](/mealie/documentation/getting-started/installation/advanced/)
|
||||
- [Advanced Installation](./installation/advanced/)
|
||||
|
||||
## How I can attach an Image or Video to a Recipe?
|
||||
## How I can attach an Image or Video to a Recipe?
|
||||
|
||||
Yes. Mealie's Recipe Steps and other fields support the markdown syntax and therefor supports images and videos. To attach an image to the recipe, you can upload it as an asset and use the provided copy button to generate the html image tag required to render the image. For videos, Mealie provides no way to host videos. You'll need to host your videos with another provider and embed them in your recipe. Generally, the video provider will provide a link to the video and the html tag required to render the video. For example, youtube provides the following link that works inside a step. You can adjust the width and height attributes as necessary to ensure a fit.
|
||||
|
||||
```html
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/nAUwKeO93bY" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
```
|
||||
```
|
||||
|
||||
@@ -85,6 +85,17 @@ These backups are just plain .zip files that you can download from the UI or acc
|
||||
|
||||
## Appendix
|
||||
|
||||
### Docker Tags
|
||||
|
||||
`mealie:frontend-v1.0.0beta-x` **and** `mealie:api-v1.0.0beta-x`
|
||||
|
||||
These are the tags for the latest beta release of the frontend docker-container. These are currently considered the latest and most stable releases and the recommended way of using Mealie.
|
||||
|
||||
`mealie:frontend-nightly`**and** `mealie:api-nightly`
|
||||
|
||||
The nightly build are the latest and greatest builds that are built directly off of every commit to the `mealie-next` branch and as such may contain bugs. These are great to help the community catch bugs before they hit the stable release or if you like living on the edge.
|
||||
|
||||
|
||||
### Docker Diagram
|
||||
|
||||
While the docker-compose file should work without modification, some users want to tailor it to their installation. This diagram shows network and volume architecture for the default setup. You can use this to help you customize your configuration.
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
**For Environmental Variable Configuration See:**
|
||||
|
||||
- [Frontend Configuration](/mealie/documentation/getting-started/installation/frontend-config/)
|
||||
- [Backend Configuration](/mealie/documentation/getting-started/installation/backend-config/)
|
||||
- [Frontend Configuration](./frontend-config.md)
|
||||
- [Backend Configuration](./backend-config.md)
|
||||
|
||||
```yaml
|
||||
---
|
||||
version: "3.7"
|
||||
services:
|
||||
mealie-frontend:
|
||||
image: hkotel/mealie:frontend-v1.0.0-beta-1
|
||||
image: hkotel/mealie:frontend-v1.0.0beta-4
|
||||
container_name: mealie-frontend
|
||||
depends_on:
|
||||
- mealie-api
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
volumes:
|
||||
- mealie-data:/app/data/ # (3)
|
||||
mealie-api:
|
||||
image: hkotel/mealie:api-v1.0.0-beta-1
|
||||
image: hkotel/mealie:api-v1.0.0beta-4
|
||||
container_name: mealie-api
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
@@ -4,15 +4,15 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
|
||||
**For Environmental Variable Configuration See:**
|
||||
|
||||
- [Frontend Configuration](/mealie/documentation/getting-started/installation/frontend-config/)
|
||||
- [Backend Configuration](/mealie/documentation/getting-started/installation/backend-config/)
|
||||
- [Frontend Configuration](./frontend-config.md)
|
||||
- [Backend Configuration](./backend-config.md)
|
||||
|
||||
```yaml
|
||||
---
|
||||
version: "3.7"
|
||||
services:
|
||||
mealie-frontend:
|
||||
image: hkotel/mealie:frontend-v1.0.0-beta-1
|
||||
image: hkotel/mealie:frontend-v1.0.0beta-4
|
||||
container_name: mealie-frontend
|
||||
environment:
|
||||
# Set Frontend ENV Variables Here
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
volumes:
|
||||
- mealie-data:/app/data/ # (3)
|
||||
mealie-api:
|
||||
image: hkotel/mealie:api-v1.0.0-beta-1
|
||||
image: hkotel/mealie:api-v1.0.0beta-4
|
||||
container_name: mealie-api
|
||||
volumes:
|
||||
- mealie-data:/app/data/
|
||||
|
||||
@@ -5,16 +5,12 @@
|
||||
|
||||
You should likely find bugs, errors, and unfinished pages within the application. To find the current status of the release you can checkout the [project on github](https://github.com/hay-kot/mealie/projects/7) or reach out on discord.
|
||||
|
||||
You should also be aware that Mealie v1 Beta does not have the backup/export feature available. This is the next priority for Mealie v1
|
||||
and is currently being worked out.
|
||||
|
||||
Mealie is a self hosted recipe manager and meal planner with a RestAPI backend and a reactive frontend application built in Vue for a pleasant user experience for the whole family. Easily add recipes into your database by providing the url and Mealie will automatically import the relevant data or add a family recipe with the UI editor. Mealie also provides an API for interactions from 3rd party applications.
|
||||
Mealie is a self hosted recipe manager and meal planner with a RestAPI backend and a reactive frontend application built in Vue for a pleasant user experience for the whole family. Easily add recipes into your database by providing the url and Mealie will automatically import the relevant data or add a family recipe with the UI editor. Mealie also provides an API for interactions from 3rd party applications.
|
||||
|
||||
[Remember to join the Discord](https://discord.gg/QuStdQGSGK)
|
||||
|
||||
|
||||
|
||||
|
||||
## Key Features
|
||||
- 🔍 Fuzzy search
|
||||
- 🏷️ Tag recipes with categories or tags to flexible sorting
|
||||
@@ -40,15 +36,15 @@ Mealie is a self hosted recipe manager and meal planner with a RestAPI backend a
|
||||
## FAQ
|
||||
|
||||
### Why An API?
|
||||
An API allows integration into applications like [Home Assistant](https://www.home-assistant.io/) that can act as notification engines to provide custom notifications based of Meal Plan data to remind you to defrost the chicken, marinade the steak, or start the CrockPot. Additionally, you can access nearly any backend service via the API giving you total control to extend the application. To explore the API spin up your server and navigate to http://yourserver.com/docs for interactive API documentation.
|
||||
An API allows integration into applications like [Home Assistant](https://www.home-assistant.io/) that can act as notification engines to provide custom notifications based of Meal Plan data to remind you to defrost the chicken, marinade the steak, or start the CrockPot. Additionally, you can access nearly any backend service via the API giving you total control to extend the application. To explore the API spin up your server and navigate to http://yourserver.com/docs for interactive API documentation.
|
||||
|
||||
### Why a Database?
|
||||
Some users of static-site generator applications like ChowDown have expressed concerns about their data being stuck in a database. Considering this is a new project it is a valid concern to be worried about your data. Mealie specifically addresses this concern by provided automatic daily backups that export your data in json, plain-text markdown files, and/or custom Jinja2 templates. **This puts you in controls of how your data is represented** when exported from Mealie, which means you can easily migrate to any other service provided Mealie doesn't work for you.
|
||||
Some users of static-site generator applications like ChowDown have expressed concerns about their data being stuck in a database. Considering this is a new project it is a valid concern to be worried about your data. Mealie specifically addresses this concern by provided automatic daily backups that export your data in json, plain-text markdown files, and/or custom Jinja2 templates. **This puts you in controls of how your data is represented** when exported from Mealie, which means you can easily migrate to any other service provided Mealie doesn't work for you.
|
||||
|
||||
As to why we need a database?
|
||||
|
||||
- **Developer Experience:** Without a database a lot of the work to maintain your data is taken on by the developer instead of a battle tested platform for storing data.
|
||||
- **Multi User Support:** With a solid database as backend storage for your data Mealie can better support multi-user sites and avoid read/write access errors when multiple actions are taken at the same time.
|
||||
- **Developer Experience:** Without a database a lot of the work to maintain your data is taken on by the developer instead of a battle tested platform for storing data.
|
||||
- **Multi User Support:** With a solid database as backend storage for your data Mealie can better support multi-user sites and avoid read/write access errors when multiple actions are taken at the same time.
|
||||
|
||||
## Built With
|
||||
|
||||
@@ -68,7 +64,6 @@ As to why we need a database?
|
||||
|
||||
Contributions are what make the open source community such an amazing place to learn, develop, and create. Any contributions you make are **greatly appreciated**. See the [Contributors Guide](../../contributors/non-coders.md) for help getting started.
|
||||
|
||||
If you are not a coder, you can still contribute financially. Financial contributions help me prioritize working on this project over others and help me to know that there is a real demand for project development.
|
||||
If you are not a coder, you can still contribute financially. Financial contributions help me prioritize working on this project over others and help me to know that there is a real demand for project development.
|
||||
|
||||
<a href="https://www.buymeacoffee.com/haykot" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-green.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -382,7 +382,7 @@
|
||||
target="_blank"
|
||||
title="github.com"
|
||||
>
|
||||
<svg viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg style="width: 32px; height: 32px" viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M186.1 328.7c0 20.9-10.9 55.1-36.7 55.1s-36.7-34.2-36.7-55.1 10.9-55.1 36.7-55.1 36.7 34.2 36.7 55.1zM480 278.2c0 31.9-3.2 65.7-17.5 95-37.9 76.6-142.1 74.8-216.7 74.8-75.8 0-186.2 2.7-225.6-74.8-14.6-29-20.2-63.1-20.2-95 0-41.9 13.9-81.5 41.5-113.6-5.2-15.8-7.7-32.4-7.7-48.8 0-21.5 4.9-32.3 14.6-51.8 45.3 0 74.3 9 108.8 36 29-6.9 58.8-10 88.7-10 27 0 54.2 2.9 80.4 9.2 34-26.7 63-35.2 107.8-35.2 9.8 19.5 14.6 30.3 14.6 51.8 0 16.4-2.6 32.7-7.7 48.2 27.5 32.4 39 72.3 39 114.2zm-64.3 50.5c0-43.9-26.7-82.6-73.5-82.6-18.9 0-37 3.4-56 6-14.9 2.3-29.8 3.2-45.1 3.2-15.2 0-30.1-.9-45.1-3.2-18.7-2.6-37-6-56-6-46.8 0-73.5 38.7-73.5 82.6 0 87.8 80.4 101.3 150.4 101.3h48.2c70.3 0 150.6-13.4 150.6-101.3zm-82.6-55.1c-25.8 0-36.7 34.2-36.7 55.1s10.9 55.1 36.7 55.1 36.7-34.2 36.7-55.1-10.9-55.1-36.7-55.1z"
|
||||
></path>
|
||||
@@ -395,7 +395,7 @@
|
||||
target="_blank"
|
||||
title="twitter.com"
|
||||
>
|
||||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg style="width: 32px; height: 32px" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"
|
||||
></path>
|
||||
@@ -408,7 +408,7 @@
|
||||
target="_blank"
|
||||
title="www.linkedin.com"
|
||||
>
|
||||
<svg viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg style="width: 32px; height: 32px" viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z"
|
||||
></path>
|
||||
|
||||
@@ -88,6 +88,8 @@ nav:
|
||||
- Improving Ingredient Parser: "contributors/guides/ingredient-parser.md"
|
||||
|
||||
- Change Log:
|
||||
- v1.0.0beta-4: "changelog/v1.0.0beta-4.md"
|
||||
- v1.0.0beta-3: "changelog/v1.0.0beta-3.md"
|
||||
- v1.0.0beta-2: "changelog/v1.0.0beta-2.md"
|
||||
- v1.0.0 Beta: "changelog/v1.0.0.md"
|
||||
- v0.5.2 Misc Updates: "changelog/v0.5.2.md"
|
||||
|
||||
@@ -52,6 +52,8 @@ module.exports = {
|
||||
"ts-ignore": "allow-with-description",
|
||||
},
|
||||
],
|
||||
"no-restricted-imports": ["error", { paths: ["@vue/reactivity", "@vue/runtime-dom", "@vue/composition-api"] }],
|
||||
|
||||
// TODO Gradually activate all rules
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiRequestInstance } from "~/types/api";
|
||||
import { ApiRequestInstance, PaginationData } from "~/types/api";
|
||||
|
||||
export interface CrudAPIInterface {
|
||||
requests: ApiRequestInstance;
|
||||
@@ -18,13 +18,13 @@ export abstract class BaseAPI {
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType=CreateType> extends BaseAPI implements CrudAPIInterface {
|
||||
export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType> extends BaseAPI implements CrudAPIInterface {
|
||||
abstract baseRoute: string;
|
||||
abstract itemRoute(itemId: string | number): string;
|
||||
|
||||
async getAll(start = 0, limit = 9999, params = {} as any) {
|
||||
return await this.requests.get<ReadType[]>(this.baseRoute, {
|
||||
params: { start, limit, ...params },
|
||||
async getAll(page = 1, perPage = -1, params = {} as any) {
|
||||
return await this.requests.get<PaginationData<ReadType>>(this.baseRoute, {
|
||||
params: { page, perPage, ...params },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { BaseCRUDAPI } from "../_base";
|
||||
import { ChangePassword, DeleteTokenResponse, LongLiveTokenIn, LongLiveTokenOut, ResetPassword, UserBase, UserIn, UserOut } from "~/types/api-types/user";
|
||||
import {
|
||||
ChangePassword,
|
||||
DeleteTokenResponse,
|
||||
LongLiveTokenIn,
|
||||
LongLiveTokenOut,
|
||||
ResetPassword,
|
||||
UserBase,
|
||||
UserFavorites,
|
||||
UserIn,
|
||||
UserOut,
|
||||
} from "~/types/api-types/user";
|
||||
|
||||
const prefix = "/api";
|
||||
|
||||
@@ -32,7 +42,7 @@ export class UserApi extends BaseCRUDAPI<UserIn, UserOut, UserBase> {
|
||||
}
|
||||
|
||||
async getFavorites(id: string) {
|
||||
await this.requests.get(routes.usersIdFavorites(id));
|
||||
return await this.requests.get<UserFavorites>(routes.usersIdFavorites(id));
|
||||
}
|
||||
|
||||
async changePassword(id: string, changePassword: ChangePassword) {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<v-select v-model="inputEntryType" :items="MEAL_TYPE_OPTIONS" label="Meal Type"></v-select>
|
||||
</div>
|
||||
|
||||
<RecipeCategoryTagSelector v-model="inputCategories" />
|
||||
<RecipeCategoryTagSelector v-model="inputTags" :tag-selector="true" />
|
||||
<RecipeOrganizerSelector v-model="inputCategories" selector-type="categories" />
|
||||
<RecipeOrganizerSelector v-model="inputTags" selector-type="tags" />
|
||||
|
||||
{{ inputDay === "unset" ? "This rule will apply to all days" : `This rule applies on ${inputDay}s` }}
|
||||
{{ inputEntryType === "unset" ? "for all meal types" : ` and for ${inputEntryType} meal types` }}
|
||||
@@ -15,7 +15,8 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from "@nuxtjs/composition-api";
|
||||
import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { RecipeTag, RecipeCategory } from "~/types/api-types/group";
|
||||
|
||||
const MEAL_TYPE_OPTIONS = [
|
||||
{ text: "Breakfast", value: "breakfast" },
|
||||
@@ -38,7 +39,7 @@ const MEAL_DAY_OPTIONS = [
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeCategoryTagSelector,
|
||||
RecipeOrganizerSelector,
|
||||
},
|
||||
props: {
|
||||
day: {
|
||||
@@ -50,11 +51,11 @@ export default defineComponent({
|
||||
default: "unset",
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
type: Array as () => RecipeCategory[],
|
||||
default: () => [],
|
||||
},
|
||||
tags: {
|
||||
type: Array,
|
||||
type: Array as () => RecipeTag[],
|
||||
default: () => [],
|
||||
},
|
||||
showHelp: {
|
||||
|
||||
84
frontend/components/Domain/Group/GroupWebhookEditor.vue
Normal file
84
frontend/components/Domain/Group/GroupWebhookEditor.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card-text>
|
||||
<v-switch v-model="webhookCopy.enabled" label="Enabled"></v-switch>
|
||||
<v-text-field v-model="webhookCopy.name" label="Webhook Name"></v-text-field>
|
||||
<v-text-field v-model="webhookCopy.url" label="Webhook Url"></v-text-field>
|
||||
<v-time-picker v-model="scheduledTime" class="elevation-2" ampm-in-title format="ampm"></v-time-picker>
|
||||
</v-card-text>
|
||||
<v-card-actions class="py-0 justify-end">
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $tc('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.testTube,
|
||||
text: $tc('general.test'),
|
||||
event: 'test',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.save,
|
||||
text: $tc('general.save'),
|
||||
event: 'save',
|
||||
},
|
||||
]"
|
||||
@delete="$emit('delete', webhookCopy.id)"
|
||||
@save="handleSave"
|
||||
@test="$emit('test', webhookCopy.id)"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
|
||||
import { ReadWebhook } from "~/types/api-types/group";
|
||||
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
webhook: {
|
||||
type: Object as () => ReadWebhook,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["delete", "save", "test"],
|
||||
setup(props, { emit }) {
|
||||
const itemUTC = ref<string>(props.webhook.scheduledTime);
|
||||
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
|
||||
|
||||
const scheduledTime = computed({
|
||||
get() {
|
||||
return itemLocal.value;
|
||||
},
|
||||
set(v: string) {
|
||||
itemUTC.value = timeLocalToUTC(v);
|
||||
itemLocal.value = v;
|
||||
},
|
||||
});
|
||||
|
||||
const webhookCopy = ref({ ...props.webhook });
|
||||
|
||||
function handleSave() {
|
||||
webhookCopy.value.scheduledTime = itemLocal.value;
|
||||
emit("save", webhookCopy.value);
|
||||
}
|
||||
|
||||
return {
|
||||
webhookCopy,
|
||||
scheduledTime,
|
||||
handleSave,
|
||||
itemUTC,
|
||||
itemLocal,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$tc("settings.webhooks.webhooks"),
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -24,7 +24,7 @@
|
||||
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="slug" show-always />
|
||||
<v-tooltip v-if="!locked" bottom color="info">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on" @click="$emit('input', true)">
|
||||
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
|
||||
<v-icon> {{ $globals.icons.edit }} </v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
@submit="addAsset"
|
||||
>
|
||||
<template #activator>
|
||||
<BaseButton v-if="edit" small create @click="newAssetDialog = true" />
|
||||
<BaseButton v-if="edit" small create @click="state.newAssetDialog = true" />
|
||||
</template>
|
||||
<v-card-text class="pt-4">
|
||||
<v-text-field v-model="state.newAsset.name" dense :label="$t('general.name')"></v-text-field>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
</v-icon>
|
||||
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.random") }}
|
||||
</v-btn>
|
||||
|
||||
<v-menu v-if="$listeners.sort" offset-y left>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn text :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" :loading="sortLoading" v-on="on">
|
||||
@@ -23,6 +24,48 @@
|
||||
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.sort") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item @click="sortRecipesFrontend(EVENTS.az)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.orderAlphabeticalAscending }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="sortRecipesFrontend(EVENTS.rating)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.star }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="sortRecipesFrontend(EVENTS.created)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.newBox }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="sortRecipesFrontend(EVENTS.updated)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.update }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="sortRecipesFrontend(EVENTS.shuffle)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.shuffleVariant }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.shuffle") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-menu v-if="$listeners.sortRecipes" offset-y left>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn text :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" :loading="sortLoading" v-on="on">
|
||||
<v-icon :left="!$vuetify.breakpoint.xsOnly">
|
||||
{{ preferences.sortIcon }}
|
||||
</v-icon>
|
||||
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.sort") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item @click="sortRecipes(EVENTS.az)">
|
||||
<v-icon left>
|
||||
@@ -48,17 +91,22 @@
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="sortRecipes(EVENTS.shuffle)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.shuffleVariant }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.shuffle") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<ContextMenu
|
||||
v-if="!$vuetify.breakpoint.xsOnly"
|
||||
:items="[
|
||||
{
|
||||
title: 'Toggle View',
|
||||
icon: $globals.icons.eye,
|
||||
event: 'toggle-dense-view',
|
||||
},
|
||||
]"
|
||||
@toggle-dense-view="toggleMobileCards()"
|
||||
/>
|
||||
</v-app-bar>
|
||||
<div v-if="recipes" class="mt-2">
|
||||
<v-row v-if="!viewScale">
|
||||
<v-row v-if="!useMobileCards">
|
||||
<v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3">
|
||||
<v-lazy>
|
||||
<RecipeCard
|
||||
@@ -99,17 +147,38 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
<div v-if="usePagination">
|
||||
<v-card v-intersect="infiniteScroll"></v-card>
|
||||
<v-fade-transition>
|
||||
<AppLoader v-if="loading" :loading="loading" />
|
||||
</v-fade-transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
toRefs,
|
||||
useAsync,
|
||||
useContext,
|
||||
useRouter,
|
||||
} from "@nuxtjs/composition-api";
|
||||
import { useThrottleFn } from "@vueuse/core";
|
||||
import RecipeCard from "./RecipeCard.vue";
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import { useSorter } from "~/composables/recipes";
|
||||
import {Recipe} from "~/types/api-types/recipe";
|
||||
import { useAsyncKey } from "~/composables/use-utils";
|
||||
import { useLazyRecipes, useSorter } from "~/composables/recipes";
|
||||
import { Recipe } from "~/types/api-types/recipe";
|
||||
import { useUserSortPreferences } from "~/composables/use-users/preferences";
|
||||
|
||||
const SORT_EVENT = "sort";
|
||||
const REPLACE_RECIPES_EVENT = "replaceRecipes";
|
||||
const APPEND_RECIPES_EVENT = "appendRecipes";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -129,10 +198,6 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
mobileCards: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
singleColumn: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -141,8 +206,14 @@ export default defineComponent({
|
||||
type: Array as () => Recipe[],
|
||||
default: () => [],
|
||||
},
|
||||
usePagination: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const preferences = useUserSortPreferences();
|
||||
|
||||
const utils = useSorter();
|
||||
|
||||
const EVENTS = {
|
||||
@@ -154,8 +225,8 @@ export default defineComponent({
|
||||
};
|
||||
|
||||
const { $globals, $vuetify } = useContext();
|
||||
const viewScale = computed(() => {
|
||||
return props.mobileCards || $vuetify.breakpoint.smAndDown;
|
||||
const useMobileCards = computed(() => {
|
||||
return $vuetify.breakpoint.smAndDown || preferences.value.useMobileCards;
|
||||
});
|
||||
|
||||
const displayTitleIcon = computed(() => {
|
||||
@@ -164,7 +235,7 @@ export default defineComponent({
|
||||
|
||||
const state = reactive({
|
||||
sortLoading: false,
|
||||
})
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
function navigateRandom() {
|
||||
@@ -176,7 +247,114 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
const page = ref(1);
|
||||
const perPage = ref(30);
|
||||
const hasMore = ref(true);
|
||||
const ready = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
const { fetchMore } = useLazyRecipes();
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.usePagination) {
|
||||
const newRecipes = await fetchMore(
|
||||
page.value,
|
||||
perPage.value,
|
||||
preferences.value.orderBy,
|
||||
preferences.value.orderDirection
|
||||
);
|
||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
ready.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const infiniteScroll = useThrottleFn(() => {
|
||||
useAsync(async () => {
|
||||
if (!ready.value || !hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
page.value = page.value + 1;
|
||||
|
||||
const newRecipes = await fetchMore(
|
||||
page.value,
|
||||
perPage.value,
|
||||
preferences.value.orderBy,
|
||||
preferences.value.orderDirection
|
||||
);
|
||||
if (!newRecipes.length) {
|
||||
hasMore.value = false;
|
||||
} else {
|
||||
context.emit(APPEND_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}, useAsyncKey());
|
||||
}, 500);
|
||||
|
||||
/**
|
||||
* sortRecipes helps filter using the API. This will eventually replace the sortRecipesFrontend function which pulls all recipes
|
||||
* (without pagination) and does the sorting in the frontend.
|
||||
* TODO: remove sortRecipesFrontend and remove duplicate "sortRecipes" section in the template (above)
|
||||
* @param sortType
|
||||
*/
|
||||
function sortRecipes(sortType: string) {
|
||||
if (state.sortLoading || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
function setter(orderBy: string, ascIcon: string, descIcon: string) {
|
||||
if (preferences.value.orderBy !== orderBy) {
|
||||
preferences.value.orderBy = orderBy;
|
||||
preferences.value.orderDirection = "asc";
|
||||
} else {
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
}
|
||||
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
|
||||
}
|
||||
|
||||
switch (sortType) {
|
||||
case EVENTS.az:
|
||||
setter("name", $globals.icons.sortAlphabeticalAscending, $globals.icons.sortAlphabeticalDescending);
|
||||
break;
|
||||
case EVENTS.rating:
|
||||
setter("rating", $globals.icons.sortAscending, $globals.icons.sortDescending);
|
||||
break;
|
||||
case EVENTS.created:
|
||||
setter("created_at", $globals.icons.sortCalendarAscending, $globals.icons.sortCalendarDescending);
|
||||
break;
|
||||
case EVENTS.updated:
|
||||
setter("updated_at", $globals.icons.sortClockAscending, $globals.icons.sortClockDescending);
|
||||
break;
|
||||
default:
|
||||
console.log("Unknown Event", sortType);
|
||||
return;
|
||||
}
|
||||
|
||||
useAsync(async () => {
|
||||
// reset pagination
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
|
||||
state.sortLoading = true;
|
||||
loading.value = true;
|
||||
|
||||
// fetch new recipes
|
||||
const newRecipes = await fetchMore(
|
||||
page.value,
|
||||
perPage.value,
|
||||
preferences.value.orderBy,
|
||||
preferences.value.orderDirection
|
||||
);
|
||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
|
||||
state.sortLoading = false;
|
||||
loading.value = false;
|
||||
}, useAsyncKey());
|
||||
}
|
||||
|
||||
function sortRecipesFrontend(sortType: string) {
|
||||
state.sortLoading = true;
|
||||
const sortTarget = [...props.recipes];
|
||||
switch (sortType) {
|
||||
@@ -203,13 +381,22 @@ export default defineComponent({
|
||||
state.sortLoading = false;
|
||||
}
|
||||
|
||||
function toggleMobileCards() {
|
||||
preferences.value.useMobileCards = !preferences.value.useMobileCards;
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
EVENTS,
|
||||
viewScale,
|
||||
displayTitleIcon,
|
||||
EVENTS,
|
||||
infiniteScroll,
|
||||
loading,
|
||||
navigateRandom,
|
||||
preferences,
|
||||
sortRecipes,
|
||||
sortRecipesFrontend,
|
||||
toggleMobileCards,
|
||||
useMobileCards,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot>
|
||||
<v-btn icon class="mt-n1" @click="dialog = true">
|
||||
<v-icon :color="color">{{ $globals.icons.create }}</v-icon>
|
||||
</v-btn>
|
||||
</slot>
|
||||
<v-dialog v-model="dialog" width="500">
|
||||
<v-card>
|
||||
<v-app-bar dense dark color="primary mb-2">
|
||||
<v-icon large left class="mt-1">
|
||||
{{ $globals.icons.tags }}
|
||||
</v-icon>
|
||||
|
||||
<v-toolbar-title class="headline">
|
||||
{{ title }}
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
</v-app-bar>
|
||||
<v-card-title> </v-card-title>
|
||||
<v-form @submit.prevent="select">
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="itemName"
|
||||
dense
|
||||
:label="inputLabel"
|
||||
:rules="[rules.required]"
|
||||
autofocus
|
||||
></v-text-field>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<BaseButton cancel @click="dialog = false" />
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton type="submit" create :disabled="!itemName" />
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, toRefs, watch } from "@nuxtjs/composition-api";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
const CREATED_ITEM_EVENT = "created-item";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
tagDialog: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const title = computed(() => props.tagDialog ? "Create a Tag" : "Create a Category");
|
||||
const inputLabel = computed(() => props.tagDialog ? "Tag Name" : "Category Name");
|
||||
|
||||
const rules = {
|
||||
required: (val: string) => !!val || "A Name is Required",
|
||||
};
|
||||
|
||||
const state = reactive({
|
||||
dialog: false,
|
||||
itemName: "",
|
||||
});
|
||||
|
||||
watch(() => state.dialog, (val: boolean) => {
|
||||
if (!val) state.itemName = "";
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
async function select() {
|
||||
const newItem = await (async () => {
|
||||
if (props.tagDialog) {
|
||||
const { data } = await api.tags.createOne({ name: state.itemName });
|
||||
return data;
|
||||
} else {
|
||||
const { data } = await api.categories.createOne({ name: state.itemName });
|
||||
return data;
|
||||
}
|
||||
})();
|
||||
|
||||
console.log(newItem);
|
||||
|
||||
context.emit(CREATED_ITEM_EVENT, newItem);
|
||||
state.dialog = false;
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
title,
|
||||
inputLabel,
|
||||
rules,
|
||||
select,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -1,164 +0,0 @@
|
||||
//TODO: Prevent fetching Categories/Tags multiple time when selector is on page multiple times
|
||||
|
||||
<template>
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
:items="activeItems"
|
||||
:value="value"
|
||||
:label="inputLabel"
|
||||
chips
|
||||
deletable-chips
|
||||
:dense="dense"
|
||||
item-text="name"
|
||||
persistent-hint
|
||||
multiple
|
||||
:hide-details="hideDetails"
|
||||
:hint="hint"
|
||||
:solo="solo"
|
||||
:return-object="returnObject"
|
||||
:prepend-inner-icon="$globals.icons.tags"
|
||||
v-bind="$attrs"
|
||||
@input="emitChange"
|
||||
>
|
||||
<template #selection="data">
|
||||
<v-chip
|
||||
v-if="showSelected"
|
||||
:key="data.index"
|
||||
:small="dense"
|
||||
class="ma-1"
|
||||
:input-value="data.selected"
|
||||
close
|
||||
label
|
||||
color="accent"
|
||||
dark
|
||||
@click:close="removeByIndex(data.index)"
|
||||
>
|
||||
{{ data.item.name || data.item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #append-outer>
|
||||
<RecipeCategoryTagDialog v-if="showAdd" :tag-dialog="tagSelector" @created-item="pushToItem" />
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, reactive, toRefs, useContext, watch } from "@nuxtjs/composition-api";
|
||||
import RecipeCategoryTagDialog from "./RecipeCategoryTagDialog.vue";
|
||||
import { useTags, useCategories } from "~/composables/recipes";
|
||||
import { RecipeCategory, RecipeTag } from "~/types/api-types/user";
|
||||
|
||||
const MOUNTED_EVENT = "mounted";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeCategoryTagDialog,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array as () => (RecipeTag | RecipeCategory | string)[],
|
||||
required: true,
|
||||
},
|
||||
solo: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dense: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
returnObject: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
tagSelector: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hint: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
showAdd: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showLabel: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSelected: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hideDetails: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props, context) {
|
||||
const { allTags, useAsyncGetAll: getAllTags } = useTags();
|
||||
const { allCategories, useAsyncGetAll: getAllCategories } = useCategories();
|
||||
getAllCategories();
|
||||
getAllTags();
|
||||
|
||||
const state = reactive({
|
||||
selected: props.value,
|
||||
});
|
||||
watch(
|
||||
() => props.value,
|
||||
(val) => {
|
||||
state.selected = val;
|
||||
}
|
||||
);
|
||||
|
||||
const { i18n } = useContext();
|
||||
const inputLabel = computed(() => {
|
||||
if (!props.showLabel) return null;
|
||||
return props.tagSelector ? i18n.t("tag.tags") : i18n.t("recipe.categories");
|
||||
});
|
||||
|
||||
const activeItems = computed(() => {
|
||||
let itemObjects: RecipeTag[] | RecipeCategory[] | null;
|
||||
if (props.tagSelector) itemObjects = allTags.value;
|
||||
else {
|
||||
itemObjects = allCategories.value;
|
||||
}
|
||||
if (props.returnObject) return itemObjects;
|
||||
else {
|
||||
return itemObjects?.map((x: RecipeTag | RecipeCategory) => x.name);
|
||||
}
|
||||
});
|
||||
|
||||
function emitChange() {
|
||||
context.emit("input", state.selected);
|
||||
}
|
||||
|
||||
// TODO Is this needed?
|
||||
onMounted(() => {
|
||||
context.emit(MOUNTED_EVENT);
|
||||
});
|
||||
|
||||
function removeByIndex(index: number) {
|
||||
state.selected.splice(index, 1);
|
||||
}
|
||||
|
||||
function pushToItem(createdItem: RecipeTag | RecipeCategory) {
|
||||
// TODO: Remove excessive get calls
|
||||
getAllCategories();
|
||||
getAllTags();
|
||||
state.selected.push(createdItem);
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
inputLabel,
|
||||
activeItems,
|
||||
emitChange,
|
||||
removeByIndex,
|
||||
pushToItem,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,207 +0,0 @@
|
||||
<template>
|
||||
<div class="text-center">
|
||||
<BaseDialog
|
||||
v-model="ItemDeleteDialog"
|
||||
:title="`Delete ${itemName}`"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
@confirm="deleteItem()"
|
||||
>
|
||||
<v-card-text> Are you sure you want to delete this {{ itemName }}? </v-card-text>
|
||||
</BaseDialog>
|
||||
<v-menu
|
||||
offset-y
|
||||
left
|
||||
:bottom="!menuTop"
|
||||
:nudge-bottom="!menuTop ? '5' : '0'"
|
||||
:top="menuTop"
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
open-on-hover
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent>
|
||||
<v-icon>{{ icon }}</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list dense>
|
||||
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
|
||||
<v-list-item-icon>
|
||||
<v-icon :color="item.color">
|
||||
{{ item.icon }}
|
||||
</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||
import colors from "vuetify/lib/util/colors";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
export interface ContextMenuIncludes {
|
||||
delete: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
color: string | undefined;
|
||||
event: string;
|
||||
}
|
||||
|
||||
const ItemTypes = {
|
||||
tag: "tags",
|
||||
category: "categories",
|
||||
tool: "tools",
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
itemType: {
|
||||
type: String as () => string,
|
||||
required: true,
|
||||
},
|
||||
useItems: {
|
||||
type: Object as () => ContextMenuIncludes,
|
||||
default: () => ({
|
||||
delete: true,
|
||||
}),
|
||||
},
|
||||
// Append items are added at the end of the useItems list
|
||||
appendItems: {
|
||||
type: Array as () => ContextMenuItem[],
|
||||
default: () => [],
|
||||
},
|
||||
// Append items are added at the beginning of the useItems list
|
||||
leadingItems: {
|
||||
type: Array as () => ContextMenuItem[],
|
||||
default: () => [],
|
||||
},
|
||||
menuTop: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
fab: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: colors.grey.darken2,
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
menuIcon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
name: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
id: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const api = useUserApi();
|
||||
|
||||
const state = reactive({
|
||||
ItemDeleteDialog: false,
|
||||
loading: false,
|
||||
menuItems: [] as ContextMenuItem[],
|
||||
itemName: "tag",
|
||||
});
|
||||
|
||||
const { i18n, $globals } = useContext();
|
||||
|
||||
let apiRoute = "tags" as "tags" | "categories" | "tools";
|
||||
|
||||
switch (props.itemType) {
|
||||
case ItemTypes.tag:
|
||||
state.itemName = "tag";
|
||||
apiRoute = "tags";
|
||||
break;
|
||||
case ItemTypes.category:
|
||||
state.itemName = "category";
|
||||
apiRoute = "categories";
|
||||
break;
|
||||
case ItemTypes.tool:
|
||||
state.itemName = "tool";
|
||||
apiRoute = "tools";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Context Menu Setup
|
||||
|
||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||
delete: {
|
||||
title: i18n.t("general.delete") as string,
|
||||
icon: $globals.icons.delete,
|
||||
color: undefined,
|
||||
event: "delete",
|
||||
},
|
||||
};
|
||||
|
||||
// Get Default Menu Items Specified in Props
|
||||
for (const [key, value] of Object.entries(props.useItems)) {
|
||||
if (value) {
|
||||
const item = defaultItems[key];
|
||||
if (item) {
|
||||
state.menuItems.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add leading and Apppending Items
|
||||
state.menuItems = [...props.leadingItems, ...state.menuItems, ...props.appendItems];
|
||||
|
||||
const icon = props.menuIcon || $globals.icons.dotsVertical;
|
||||
|
||||
async function deleteItem() {
|
||||
await api[apiRoute].deleteOne(props.id);
|
||||
context.emit("delete", props.id);
|
||||
}
|
||||
|
||||
// Note: Print is handled as an event in the parent component
|
||||
const eventHandlers: { [key: string]: () => void } = {
|
||||
delete: () => {
|
||||
state.ItemDeleteDialog = true;
|
||||
},
|
||||
};
|
||||
|
||||
function contextMenuEventHandler(eventKey: string) {
|
||||
const handler = eventHandlers[eventKey];
|
||||
|
||||
if (handler && typeof handler === "function") {
|
||||
handler();
|
||||
state.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
context.emit(eventKey);
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
contextMenuEventHandler,
|
||||
deleteItem,
|
||||
icon,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,123 +0,0 @@
|
||||
<template>
|
||||
<div v-if="items">
|
||||
<v-app-bar color="transparent" flat class="mt-n1 rounded">
|
||||
<v-icon large left>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline"> {{ headline }} </v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
</v-app-bar>
|
||||
<section v-for="(itms, key, idx) in itemsSorted" :key="'header' + idx" :class="idx === 1 ? null : 'my-4'">
|
||||
<BaseCardSectionTitle :title="key"> </BaseCardSectionTitle>
|
||||
<v-row>
|
||||
<v-col v-for="(item, index) in itms" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3">
|
||||
<v-card class="left-border" hover :to="`/recipes/${itemType}/${item.slug}`">
|
||||
<v-card-actions>
|
||||
<v-icon>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
<v-card-title class="py-1">
|
||||
{{ item.name }}
|
||||
</v-card-title>
|
||||
<v-spacer></v-spacer>
|
||||
<RecipeCategoryTagToolContextMenu
|
||||
:id="item.id"
|
||||
:item-type="itemType"
|
||||
:slug="item.slug"
|
||||
:name="item.name"
|
||||
:use-items="{
|
||||
delete: true,
|
||||
}"
|
||||
@delete="$emit('delete', item.id)"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, toRefs, useContext, computed, useMeta } from "@nuxtjs/composition-api";
|
||||
import RecipeCategoryTagToolContextMenu from "./RecipeCategoryTagToolContextMenu.vue";
|
||||
|
||||
type ItemType = "tags" | "categories" | "tools";
|
||||
|
||||
const ItemTypes = {
|
||||
tag: "tags",
|
||||
category: "categories",
|
||||
tool: "tools",
|
||||
};
|
||||
|
||||
interface GenericItem {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: { RecipeCategoryTagToolContextMenu },
|
||||
props: {
|
||||
itemType: {
|
||||
type: String as () => ItemType,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array as () => GenericItem[],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n, $globals } = useContext();
|
||||
|
||||
const state = reactive({
|
||||
headline: "tags",
|
||||
icon: $globals.icons.tags,
|
||||
});
|
||||
|
||||
switch (props.itemType) {
|
||||
case ItemTypes.tag:
|
||||
state.headline = i18n.t("tag.tags") as string;
|
||||
break;
|
||||
case ItemTypes.category:
|
||||
state.headline = i18n.t("category.categories") as string;
|
||||
break;
|
||||
case ItemTypes.tool:
|
||||
state.headline = i18n.t("tool.tools") as string;
|
||||
state.icon = $globals.icons.potSteam;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
useMeta(() => ({
|
||||
title: state.headline,
|
||||
}));
|
||||
|
||||
const itemsSorted = computed(() => {
|
||||
const byLetter: { [key: string]: Array<GenericItem> } = {};
|
||||
|
||||
if (!props.items) return byLetter;
|
||||
|
||||
props.items.forEach((item) => {
|
||||
const letter = item.name[0].toUpperCase();
|
||||
if (!byLetter[letter]) {
|
||||
byLetter[letter] = [];
|
||||
}
|
||||
|
||||
byLetter[letter].push(item);
|
||||
});
|
||||
|
||||
return byLetter;
|
||||
});
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
itemsSorted,
|
||||
};
|
||||
},
|
||||
// Needed for useMeta
|
||||
head: {},
|
||||
});
|
||||
</script>
|
||||
@@ -261,7 +261,7 @@ export default defineComponent({
|
||||
async function getShoppingLists() {
|
||||
const { data } = await api.shopping.lists.getAll();
|
||||
if (data) {
|
||||
shoppingLists.value = data;
|
||||
shoppingLists.value = data.items ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,10 +131,10 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
async function refreshTokens() {
|
||||
const { data } = await userApi.recipes.share.getAll(0, 999, { recipe_id: props.recipeId });
|
||||
const { data } = await userApi.recipes.share.getAll(1, -1, { recipe_id: props.recipeId });
|
||||
|
||||
if (data) {
|
||||
state.tokens = data;
|
||||
state.tokens = data.items ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, ref, toRefs } from "@nuxtjs/composition-api";
|
||||
import { useFoods, useUnits } from "~/composables/recipes";
|
||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { RecipeIngredient } from "~/types/api-types/recipe";
|
||||
|
||||
@@ -136,24 +136,28 @@ export default defineComponent({
|
||||
setup(props) {
|
||||
// ==================================================
|
||||
// Foods
|
||||
const { foods, workingFoodData, actions: foodActions } = useFoods();
|
||||
const foodStore = useFoodStore();
|
||||
const foodData = useFoodData();
|
||||
const foodSearch = ref("");
|
||||
|
||||
async function createAssignFood() {
|
||||
workingFoodData.name = foodSearch.value;
|
||||
await foodActions.createOne();
|
||||
props.value.food = foods.value?.find((food) => food.name === foodSearch.value);
|
||||
foodData.data.name = foodSearch.value;
|
||||
await foodStore.actions.createOne(foodData.data);
|
||||
props.value.food = foodStore.foods.value?.find((food) => food.name === foodSearch.value);
|
||||
foodData.reset();
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// Units
|
||||
const { units, workingUnitData, actions: unitActions } = useUnits();
|
||||
const unitStore = useUnitStore();
|
||||
const unitsData = useUnitData();
|
||||
const unitSearch = ref("");
|
||||
|
||||
async function createAssignUnit() {
|
||||
workingUnitData.name = unitSearch.value;
|
||||
await unitActions.createOne();
|
||||
props.value.unit = units.value?.find((unit) => unit.name === unitSearch.value);
|
||||
unitsData.data.name = unitSearch.value;
|
||||
await unitStore.actions.createOne(unitsData.data);
|
||||
props.value.unit = unitStore.units.value?.find((unit) => unit.name === unitSearch.value);
|
||||
unitsData.reset();
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
@@ -226,22 +230,22 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
quantityFilter,
|
||||
toggleOriginalText,
|
||||
contextMenuOptions,
|
||||
handleUnitEnter,
|
||||
handleFoodEnter,
|
||||
...toRefs(state),
|
||||
createAssignFood,
|
||||
createAssignUnit,
|
||||
foods,
|
||||
foods: foodStore.foods,
|
||||
foodSearch,
|
||||
toggleTitle,
|
||||
unitActions,
|
||||
units,
|
||||
unitActions: unitStore.actions,
|
||||
units: unitStore.units,
|
||||
unitSearch,
|
||||
validators,
|
||||
workingUnitData,
|
||||
workingUnitData: unitsData.data,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<v-list-item dense @click="toggleChecked(index)">
|
||||
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" />
|
||||
<v-list-item-content :key="ingredient.quantity">
|
||||
<VueMarkdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="ingredientDisplay[index]" />
|
||||
<SafeMarkdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="ingredientDisplay[index]" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</div>
|
||||
@@ -22,14 +22,11 @@
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, toRefs } from "@nuxtjs/composition-api";
|
||||
// @ts-ignore vue-markdown has no types
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { RecipeIngredient } from "~/types/api-types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
VueMarkdown,
|
||||
},
|
||||
components: {},
|
||||
props: {
|
||||
value: {
|
||||
type: Array as () => RecipeIngredient[],
|
||||
|
||||
@@ -58,13 +58,13 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<div class="d-flex justify-space-between justify-start">
|
||||
<div v-if="showCookMode" class="d-flex justify-space-between justify-start">
|
||||
<h2 class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
|
||||
<BaseButton v-if="!public" minor :to="$router.currentRoute.path + '/cook'" cancel color="primary">
|
||||
<BaseButton v-if="!public && !edit" minor cancel color="primary" @click="toggleCookMode()">
|
||||
<template #icon>
|
||||
{{ $globals.icons.primary }}
|
||||
</template>
|
||||
Cook
|
||||
Cook Mode
|
||||
</BaseButton>
|
||||
</div>
|
||||
<draggable
|
||||
@@ -168,12 +168,26 @@
|
||||
</v-icon>
|
||||
</v-fade-transition>
|
||||
</v-card-title>
|
||||
<v-card-text v-if="edit">
|
||||
|
||||
<!-- Content -->
|
||||
<v-card-text
|
||||
v-if="edit"
|
||||
:class="{
|
||||
blur: imageUploadMode,
|
||||
}"
|
||||
@drop.stop.prevent="handleImageDrop(index, $event)"
|
||||
>
|
||||
<MarkdownEditor
|
||||
v-model="value[index]['text']"
|
||||
class="mb-2"
|
||||
:preview.sync="previewStates[index]"
|
||||
:display-preview="false"
|
||||
:textarea="{
|
||||
hint: 'Attach images by dragging & dropping them into the editor',
|
||||
persistentHint: true,
|
||||
}"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="ing in step.ingredientReferences"
|
||||
:key="ing.referenceId"
|
||||
@@ -183,7 +197,15 @@
|
||||
<v-expand-transition>
|
||||
<div v-show="!isChecked(index) && !edit" class="m-0 p-0">
|
||||
<v-card-text class="markdown">
|
||||
<VueMarkdown class="markdown" :source="step.text"> </VueMarkdown>
|
||||
<SafeMarkdown class="markdown" :source="step.text" />
|
||||
<div v-if="cookMode && step.ingredientReferences && step.ingredientReferences.length > 0">
|
||||
<v-divider class="mb-2"></v-divider>
|
||||
<div
|
||||
v-for="ing in step.ingredientReferences"
|
||||
:key="ing.referenceId"
|
||||
v-html="getIngredientByRefId(ing.referenceId)"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
@@ -197,12 +219,20 @@
|
||||
|
||||
<script lang="ts">
|
||||
import draggable from "vuedraggable";
|
||||
// @ts-ignore vue-markdown has no types
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
import { ref, toRefs, reactive, defineComponent, watch, onMounted } from "@nuxtjs/composition-api";
|
||||
import { RecipeStep, IngredientReferences, RecipeIngredient } from "~/types/api-types/recipe";
|
||||
import {
|
||||
ref,
|
||||
toRefs,
|
||||
reactive,
|
||||
defineComponent,
|
||||
watch,
|
||||
onMounted,
|
||||
useContext,
|
||||
computed,
|
||||
} from "@nuxtjs/composition-api";
|
||||
import { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset } from "~/types/api-types/recipe";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { uuid4 } from "~/composables/use-utils";
|
||||
import { uuid4, detectServerBaseUrl } from "~/composables/use-utils";
|
||||
import { useUserApi, useStaticRoutes } from "~/composables/api";
|
||||
|
||||
interface MergerHistory {
|
||||
target: number;
|
||||
@@ -213,7 +243,6 @@ interface MergerHistory {
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
VueMarkdown,
|
||||
draggable,
|
||||
},
|
||||
props: {
|
||||
@@ -237,9 +266,34 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
recipeId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
recipeSlug: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
assets: {
|
||||
type: Array as () => RecipeAsset[],
|
||||
required: true,
|
||||
},
|
||||
cookMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props, context) {
|
||||
const { req } = useContext();
|
||||
const BASE_URL = detectServerBaseUrl(req);
|
||||
|
||||
console.log("Base URL", BASE_URL);
|
||||
|
||||
const state = reactive({
|
||||
dialog: false,
|
||||
disabledSteps: [] as number[],
|
||||
@@ -281,13 +335,20 @@ export default defineComponent({
|
||||
});
|
||||
});
|
||||
|
||||
const showCookMode = ref(false);
|
||||
|
||||
// Eliminate state with an eager call to watcher?
|
||||
onMounted(() => {
|
||||
props.value.forEach((element) => {
|
||||
props.value.forEach((element: RecipeStep) => {
|
||||
if (element.id !== undefined) {
|
||||
showTitleEditor.value[element.id] = validateTitle(element.title);
|
||||
}
|
||||
|
||||
// showCookMode.value = false;
|
||||
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
|
||||
showCookMode.value = true;
|
||||
}
|
||||
|
||||
showTitleEditor.value = { ...showTitleEditor.value };
|
||||
});
|
||||
});
|
||||
@@ -344,6 +405,14 @@ export default defineComponent({
|
||||
referenceId: ref,
|
||||
};
|
||||
});
|
||||
|
||||
// Update the visibility of the cook mode button
|
||||
showCookMode.value = false;
|
||||
props.value.forEach((element) => {
|
||||
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
|
||||
showCookMode.value = true;
|
||||
}
|
||||
});
|
||||
state.dialog = false;
|
||||
}
|
||||
|
||||
@@ -368,7 +437,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
function autoSetReferences() {
|
||||
// Ingore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
|
||||
// Ignore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
|
||||
// other common phrases trigger false positives and I'm not sure how else to approach this. In the future I maybe look at looking directly
|
||||
// at the food variable and seeing if the food is in the instructions, but I still need to support those who don't want to provide the value
|
||||
// and only use the "notes" feature.
|
||||
@@ -414,12 +483,27 @@ export default defineComponent({
|
||||
});
|
||||
}
|
||||
|
||||
function getIngredientByRefId(refId: string) {
|
||||
const ing = props.ingredients.find((ing) => ing.referenceId === refId) || "";
|
||||
const ingredientLookup = computed(() => {
|
||||
const results: { [key: string]: RecipeIngredient } = {};
|
||||
return props.ingredients.reduce((prev, ing) => {
|
||||
if (ing.referenceId === undefined) {
|
||||
return prev;
|
||||
}
|
||||
prev[ing.referenceId] = ing;
|
||||
return prev;
|
||||
}, results);
|
||||
});
|
||||
|
||||
function getIngredientByRefId(refId: string | undefined) {
|
||||
if (refId === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const ing = ingredientLookup.value[refId] ?? "";
|
||||
if (ing === "") {
|
||||
return "";
|
||||
}
|
||||
return parseIngredientText(ing, props.disableAmount);
|
||||
return parseIngredientText(ing, props.disableAmount, props.scale);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
@@ -493,7 +577,63 @@ export default defineComponent({
|
||||
|
||||
const drag = ref(false);
|
||||
|
||||
// ===============================================================
|
||||
// Image Uploader
|
||||
const api = useUserApi();
|
||||
const { recipeAssetPath } = useStaticRoutes();
|
||||
|
||||
const imageUploadMode = ref(false);
|
||||
|
||||
function toggleDragMode() {
|
||||
console.log("Toggling Drag Mode");
|
||||
imageUploadMode.value = !imageUploadMode.value;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.assets === undefined) {
|
||||
context.emit("update:assets", []);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleImageDrop(index: number, e: DragEvent) {
|
||||
if (!e.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the file is an image
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file || !file.type.startsWith("image/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await api.recipes.createAsset(props.recipeSlug, {
|
||||
name: file.name,
|
||||
icon: "mdi-file-image",
|
||||
file,
|
||||
extension: file.name.split(".").pop() || "",
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return; // TODO: Handle error
|
||||
}
|
||||
|
||||
context.emit("update:assets", [...props.assets, data]);
|
||||
const assetUrl = BASE_URL + recipeAssetPath(props.recipeId, data.fileName as string);
|
||||
const text = `<img src="${assetUrl}" height="100%" width="100%"/>`;
|
||||
props.value[index].text += text;
|
||||
}
|
||||
|
||||
function toggleCookMode() {
|
||||
context.emit("cookModeToggle");
|
||||
}
|
||||
|
||||
return {
|
||||
// Image Uploader
|
||||
toggleDragMode,
|
||||
handleImageDrop,
|
||||
imageUploadMode,
|
||||
|
||||
// Rest
|
||||
drag,
|
||||
togglePreviewState,
|
||||
toggleCollapseSection,
|
||||
@@ -514,6 +654,8 @@ export default defineComponent({
|
||||
updateIndex,
|
||||
autoSetReferences,
|
||||
parseIngredientText,
|
||||
toggleCookMode,
|
||||
showCookMode,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -553,4 +695,21 @@ export default defineComponent({
|
||||
.list-group-item i {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.blur {
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.upload-overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{{ note.title }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<VueMarkdown :source="note.text"> </VueMarkdown>
|
||||
<SafeMarkdown :source="note.text" />
|
||||
</v-card-text>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,15 +30,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// @ts-ignore vue-markdown has no types
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import { RecipeNote } from "~/types/api-types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
VueMarkdown,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array as () => RecipeNote[],
|
||||
|
||||
@@ -81,9 +81,12 @@ export default defineComponent({
|
||||
},
|
||||
};
|
||||
const valueNotNull = computed(() => {
|
||||
Object.values(props.value).forEach((valueProperty) => {
|
||||
if (valueProperty && valueProperty !== "") return true;
|
||||
});
|
||||
let key: keyof Nutrition;
|
||||
for (key in props.value) {
|
||||
if (props.value[key] !== null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -97,8 +100,8 @@ export default defineComponent({
|
||||
labels,
|
||||
valueNotNull,
|
||||
showViewer,
|
||||
updateValue
|
||||
}
|
||||
updateValue,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
152
frontend/components/Domain/Recipe/RecipeOrganizerDialog.vue
Normal file
152
frontend/components/Domain/Recipe/RecipeOrganizerDialog.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-dialog v-model="dialog" width="500">
|
||||
<v-card>
|
||||
<v-app-bar dense dark color="primary mb-2">
|
||||
<v-icon large left class="mt-1">
|
||||
{{ itemType === Organizer.Tool ? $globals.icons.potSteam : $globals.icons.tags }}
|
||||
</v-icon>
|
||||
|
||||
<v-toolbar-title class="headline">
|
||||
{{ properties.title }}
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
</v-app-bar>
|
||||
<v-card-title> </v-card-title>
|
||||
<v-form @submit.prevent="select">
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="name"
|
||||
dense
|
||||
:label="properties.label"
|
||||
:rules="[rules.required]"
|
||||
autofocus
|
||||
></v-text-field>
|
||||
<v-checkbox v-if="itemType === Organizer.Tool" v-model="onHand" label="On Hand"></v-checkbox>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<BaseButton cancel @click="dialog = false" />
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton type="submit" create :disabled="!name" />
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, toRefs, watch } from "@nuxtjs/composition-api";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { RecipeOrganizer, Organizer } from "~/types/recipe/organizers";
|
||||
|
||||
const CREATED_ITEM_EVENT = "created-item";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
tagDialog: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
itemType: {
|
||||
type: String as () => RecipeOrganizer,
|
||||
default: "category",
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
name: "",
|
||||
onHand: false,
|
||||
});
|
||||
|
||||
const dialog = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
},
|
||||
set(value) {
|
||||
context.emit("input", value);
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(val: boolean) => {
|
||||
if (!val) state.name = "";
|
||||
}
|
||||
);
|
||||
|
||||
const userApi = useUserApi();
|
||||
|
||||
const store = (() => {
|
||||
switch (props.itemType) {
|
||||
case Organizer.Tag:
|
||||
return useTagStore();
|
||||
case Organizer.Tool:
|
||||
return useToolStore();
|
||||
default:
|
||||
return useCategoryStore();
|
||||
}
|
||||
})();
|
||||
|
||||
const properties = computed(() => {
|
||||
switch (props.itemType) {
|
||||
case Organizer.Tag:
|
||||
return {
|
||||
title: "Create a Tag",
|
||||
label: "Tag Name",
|
||||
api: userApi.tags,
|
||||
};
|
||||
case Organizer.Tool:
|
||||
return {
|
||||
title: "Create a Tool",
|
||||
label: "Tool Name",
|
||||
api: userApi.tools,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: "Create a Category",
|
||||
label: "Category Name",
|
||||
api: userApi.categories,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const rules = {
|
||||
required: (val: string) => !!val || "A Name is Required",
|
||||
};
|
||||
|
||||
async function select() {
|
||||
if (store) {
|
||||
// @ts-ignore - only property really required is the name
|
||||
await store.actions.createOne({ name: state.name });
|
||||
}
|
||||
|
||||
const newItem = store.items.value.find((item) => item.name === state.name);
|
||||
|
||||
context.emit(CREATED_ITEM_EVENT, newItem);
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
Organizer,
|
||||
...toRefs(state),
|
||||
dialog,
|
||||
properties,
|
||||
rules,
|
||||
select,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
133
frontend/components/Domain/Recipe/RecipeOrganizerPage.vue
Normal file
133
frontend/components/Domain/Recipe/RecipeOrganizerPage.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div v-if="items">
|
||||
<RecipeOrganizerDialog v-model="dialog" :item-type="itemType" />
|
||||
|
||||
<BaseDialog
|
||||
v-if="deleteTarget"
|
||||
v-model="deleteDialog"
|
||||
:title="`Delete ${deleteTarget.name}`"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
@confirm="deleteOne()"
|
||||
>
|
||||
<v-card-text> Are you sure you want to delete this {{ deleteTarget.name }}? </v-card-text>
|
||||
</BaseDialog>
|
||||
<v-app-bar color="transparent" flat class="mt-n1 rounded align-center">
|
||||
<v-icon large left>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline">
|
||||
<slot name="title">
|
||||
{{ headline }}
|
||||
</slot>
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton create @click="dialog = true" />
|
||||
</v-app-bar>
|
||||
<section v-for="(itms, key, idx) in itemsSorted" :key="'header' + idx" :class="idx === 1 ? null : 'my-4'">
|
||||
<BaseCardSectionTitle :title="key"> </BaseCardSectionTitle>
|
||||
<v-row>
|
||||
<v-col v-for="(item, index) in itms" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3">
|
||||
<v-card class="left-border" hover :to="`/recipes/${itemType}/${item.slug}`">
|
||||
<v-card-actions>
|
||||
<v-icon>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
<v-card-title class="py-1">
|
||||
{{ item.name }}
|
||||
</v-card-title>
|
||||
<v-spacer></v-spacer>
|
||||
<ContextMenu :items="[presets.delete]" @delete="confirmDelete(item)" />
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
|
||||
import { useContextPresets } from "~/composables/use-context-presents";
|
||||
import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue";
|
||||
import { RecipeOrganizer } from "~/types/recipe/organizers";
|
||||
|
||||
interface GenericItem {
|
||||
id?: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeOrganizerDialog,
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array as () => GenericItem[],
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
itemType: {
|
||||
type: String as () => RecipeOrganizer,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
// =================================================================
|
||||
// Sorted Items
|
||||
const itemsSorted = computed(() => {
|
||||
const byLetter: { [key: string]: Array<GenericItem> } = {};
|
||||
|
||||
if (!props.items) return byLetter;
|
||||
|
||||
props.items.sort((a, b) => a.name.localeCompare(b.name)).forEach((item) => {
|
||||
const letter = item.name[0].toUpperCase();
|
||||
if (!byLetter[letter]) {
|
||||
byLetter[letter] = [];
|
||||
}
|
||||
byLetter[letter].push(item);
|
||||
});
|
||||
|
||||
return byLetter;
|
||||
});
|
||||
|
||||
// =================================================================
|
||||
// Context Menu
|
||||
const presets = useContextPresets();
|
||||
|
||||
const deleteTarget = ref<GenericItem | null>(null);
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
function confirmDelete(item: GenericItem) {
|
||||
deleteTarget.value = item;
|
||||
deleteDialog.value = true;
|
||||
}
|
||||
|
||||
function deleteOne() {
|
||||
if (!deleteTarget.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("delete", deleteTarget.value.id);
|
||||
}
|
||||
|
||||
const dialog = ref(false);
|
||||
|
||||
return {
|
||||
dialog,
|
||||
confirmDelete,
|
||||
deleteOne,
|
||||
deleteDialog,
|
||||
deleteTarget,
|
||||
presets,
|
||||
itemsSorted,
|
||||
};
|
||||
},
|
||||
// Needed for useMeta
|
||||
head: {},
|
||||
});
|
||||
</script>
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
:items="items"
|
||||
:items="storeItem"
|
||||
:value="value"
|
||||
:label="label"
|
||||
chips
|
||||
deletable-chips
|
||||
item-text="name"
|
||||
multiple
|
||||
:prepend-inner-icon="$globals.icons.tags"
|
||||
:prepend-inner-icon="selectorType === Organizer.Tool ? $globals.icons.potSteam : $globals.icons.tags"
|
||||
return-object
|
||||
v-bind="inputAttrs"
|
||||
>
|
||||
@@ -17,6 +17,7 @@
|
||||
:key="data.index"
|
||||
class="ma-1"
|
||||
:input-value="data.selected"
|
||||
small
|
||||
close
|
||||
label
|
||||
color="accent"
|
||||
@@ -26,41 +27,55 @@
|
||||
{{ data.item.name || data.item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template v-if="showAdd" #append-outer>
|
||||
<v-btn icon @click="dialog = true">
|
||||
<v-icon>
|
||||
{{ $globals.icons.create }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<RecipeOrganizerDialog v-model="dialog" :item-type="selectorType" @created-item="appendCreated" />
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
||||
import { computed, onMounted } from "vue-demi";
|
||||
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
|
||||
import { RecipeCategory, RecipeTag } from "~/types/api-types/user";
|
||||
import { RecipeTool } from "~/types/api-types/admin";
|
||||
|
||||
type OrganizerType = "tag" | "category" | "tool";
|
||||
import { useTagStore } from "~/composables/store/use-tag-store";
|
||||
import { useCategoryStore, useToolStore } from "~/composables/store";
|
||||
import { Organizer, RecipeOrganizer } from "~/types/recipe/organizers";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeOrganizerDialog,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array as () => (RecipeTag | RecipeCategory | RecipeTool)[] | undefined,
|
||||
type: Array as () => (RecipeTag | RecipeCategory | RecipeTool | string)[] | undefined,
|
||||
required: true,
|
||||
},
|
||||
/**
|
||||
* The type of organizer to use.
|
||||
*/
|
||||
selectorType: {
|
||||
type: String as () => OrganizerType,
|
||||
required: true,
|
||||
},
|
||||
/**
|
||||
* List of items that are available to be chosen from
|
||||
*/
|
||||
items: {
|
||||
type: Array as () => (RecipeTag | RecipeCategory | RecipeTool)[],
|
||||
type: String as () => RecipeOrganizer,
|
||||
required: true,
|
||||
},
|
||||
inputAttrs: {
|
||||
type: Object as () => Record<string, any>,
|
||||
default: () => ({}),
|
||||
},
|
||||
returnObject: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showAdd: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props, context) {
|
||||
@@ -81,27 +96,62 @@ export default defineComponent({
|
||||
|
||||
const label = computed(() => {
|
||||
switch (props.selectorType) {
|
||||
case "tag":
|
||||
case Organizer.Tag:
|
||||
return i18n.t("tag.tags");
|
||||
case "category":
|
||||
case Organizer.Category:
|
||||
return i18n.t("category.categories");
|
||||
case "tool":
|
||||
return "Tools";
|
||||
case Organizer.Tool:
|
||||
return i18n.t("tool.tools");
|
||||
default:
|
||||
return "Organizer";
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Store & Items Setup
|
||||
|
||||
const store = (() => {
|
||||
switch (props.selectorType) {
|
||||
case Organizer.Tag:
|
||||
return useTagStore();
|
||||
case Organizer.Tool:
|
||||
return useToolStore();
|
||||
default:
|
||||
return useCategoryStore();
|
||||
}
|
||||
})();
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.returnObject) {
|
||||
return store.items.value.map((item) => item.name);
|
||||
}
|
||||
return store.items.value;
|
||||
});
|
||||
|
||||
function removeByIndex(index: number) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSelected = selected.value.filter((_, i) => i !== index);
|
||||
selected.value = [...newSelected];
|
||||
}
|
||||
|
||||
function appendCreated(item: RecipeTag | RecipeCategory | RecipeTool) {
|
||||
console.log(item);
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
selected.value = [...selected.value, item];
|
||||
}
|
||||
|
||||
const dialog = ref(false);
|
||||
|
||||
return {
|
||||
Organizer,
|
||||
appendCreated,
|
||||
dialog,
|
||||
storeItem: items,
|
||||
label,
|
||||
selected,
|
||||
removeByIndex,
|
||||
|
||||
@@ -11,69 +11,82 @@
|
||||
</section>
|
||||
|
||||
<v-card-text class="px-0">
|
||||
<VueMarkdown :source="recipe.description" />
|
||||
<SafeMarkdown :source="recipe.description" />
|
||||
</v-card-text>
|
||||
|
||||
<!-- Ingredients -->
|
||||
<section>
|
||||
<v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title>
|
||||
<div class="ingredient-grid">
|
||||
<div class="ingredient-col-1">
|
||||
<ul>
|
||||
<li v-for="(text, index) in splitIngredients.value.firstHalf" :key="index">
|
||||
{{ text }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="ingredient-col-2">
|
||||
<ul>
|
||||
<li v-for="(text, index) in splitIngredients.value.secondHalf" :key="index">
|
||||
{{ text }}
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
||||
:key="`ingredient-section-${sectionIndex}`"
|
||||
class="print-section"
|
||||
>
|
||||
<div class="ingredient-grid">
|
||||
<template v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients">
|
||||
<h4 v-if="ingredient.title" :key="`ingredient-title-${ingredientIndex}`" class="ingredient-title mt-2">
|
||||
{{ ingredient.title }}
|
||||
</h4>
|
||||
<p :key="`ingredient-${ingredientIndex}`" class="ingredient-body" v-html="parseText(ingredient)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Instructions -->
|
||||
<section>
|
||||
<v-card-title class="headline pl-0">{{ $t("recipe.instructions") }}</v-card-title>
|
||||
<div v-for="(step, index) in recipe.recipeInstructions" :key="index">
|
||||
<h3 v-if="step.title" class="mb-2">{{ step.title }}</h3>
|
||||
<div class="ml-5">
|
||||
<h4>{{ $t("recipe.step-index", { step: index + 1 }) }}</h4>
|
||||
<VueMarkdown :source="step.text" />
|
||||
<div
|
||||
v-for="(instructionSection, sectionIndex) in instructionSections"
|
||||
:key="`instruction-section-${sectionIndex}`"
|
||||
:class="{ 'print-section': instructionSection.sectionName }"
|
||||
>
|
||||
<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">
|
||||
{{ step.title }}
|
||||
</h4>
|
||||
<h5>{{ $t("recipe.step-index", { step: stepIndex + instructionSection.stepOffset + 1 }) }}</h5>
|
||||
<SafeMarkdown :source="step.text" class="recipe-step-body" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notes -->
|
||||
<v-divider v-if="hasNotes" class="grey my-4"></v-divider>
|
||||
|
||||
<section>
|
||||
<div v-for="(note, index) in recipe.notes" :key="index + 'note'">
|
||||
<h4>{{ note.title }}</h4>
|
||||
<VueMarkdown :source="note.text" />
|
||||
<div class="print-section">
|
||||
<h4>{{ note.title }}</h4>
|
||||
<SafeMarkdown :source="note.text" class="note-body" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
// @ts-ignore vue-markdown has no types
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
import { computed } from "@vue/reactivity";
|
||||
import { defineComponent, computed } from "@nuxtjs/composition-api";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import { Recipe } from "~/types/api-types/recipe";
|
||||
import { Recipe, RecipeIngredient, RecipeStep } from "~/types/api-types/recipe";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
|
||||
type SplitIngredients = {
|
||||
firstHalf: string[];
|
||||
secondHalf: string[];
|
||||
type IngredientSection = {
|
||||
sectionName: string;
|
||||
ingredients: RecipeIngredient[];
|
||||
};
|
||||
|
||||
type InstructionSection = {
|
||||
sectionName: string;
|
||||
stepOffset: number;
|
||||
instructions: RecipeStep[];
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeTimeCard,
|
||||
VueMarkdown,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
@@ -82,33 +95,98 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const splitIngredients = computed<SplitIngredients>(() => {
|
||||
const firstHalf = props.recipe.recipeIngredient
|
||||
?.slice(0, Math.ceil(props.recipe.recipeIngredient.length / 2))
|
||||
.map((ingredient) => {
|
||||
return parseIngredientText(ingredient, props.recipe?.settings?.disableAmount || false);
|
||||
});
|
||||
// Group ingredients by section so we can style them independently
|
||||
const ingredientSections = computed<IngredientSection[]>(() => {
|
||||
if (!props.recipe.recipeIngredient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const secondHalf = props.recipe.recipeIngredient
|
||||
?.slice(Math.ceil(props.recipe.recipeIngredient.length / 2))
|
||||
.map((ingredient) => {
|
||||
return parseIngredientText(ingredient, props.recipe?.settings?.disableAmount || false);
|
||||
});
|
||||
return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
|
||||
// if title append new section to the end of the array
|
||||
if (ingredient.title) {
|
||||
sections.push({
|
||||
sectionName: ingredient.title,
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
|
||||
return {
|
||||
firstHalf: firstHalf || [],
|
||||
secondHalf: secondHalf || [],
|
||||
};
|
||||
return sections;
|
||||
}
|
||||
|
||||
// append new section if first
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// otherwise add ingredient to last section in the array
|
||||
sections[sections.length - 1].ingredients.push(ingredient);
|
||||
return sections;
|
||||
}, [] as IngredientSection[]);
|
||||
});
|
||||
|
||||
// Group instructions by section so we can style them independently
|
||||
const instructionSections = computed<InstructionSection[]>(() => {
|
||||
if (!props.recipe.recipeInstructions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.recipe.recipeInstructions.reduce((sections, step) => {
|
||||
const offset = (() => {
|
||||
if (sections.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lastOffset = sections[sections.length - 1].stepOffset;
|
||||
const lastNumSteps = sections[sections.length - 1].instructions.length;
|
||||
return lastOffset + lastNumSteps;
|
||||
})();
|
||||
|
||||
// if title append new section to the end of the array
|
||||
if (step.title) {
|
||||
sections.push({
|
||||
sectionName: step.title,
|
||||
stepOffset: offset,
|
||||
instructions: [step],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// append if first element
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
stepOffset: offset,
|
||||
instructions: [step],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// otherwise add step to last section in the array
|
||||
sections[sections.length - 1].instructions.push(step);
|
||||
return sections;
|
||||
}, [] as InstructionSection[]);
|
||||
});
|
||||
|
||||
const hasNotes = computed(() => {
|
||||
return props.recipe.notes && props.recipe.notes.length > 0;
|
||||
});
|
||||
|
||||
function parseText(ingredient: RecipeIngredient) {
|
||||
return parseIngredientText(ingredient, props.recipe.settings?.disableAmount || false);
|
||||
}
|
||||
|
||||
return {
|
||||
hasNotes,
|
||||
splitIngredients,
|
||||
parseText,
|
||||
parseIngredientText,
|
||||
ingredientSections,
|
||||
instructionSections,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -138,12 +216,23 @@ export default defineComponent({
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
/* Makes all text solid black */
|
||||
.print-container {
|
||||
display: none;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.print-container,
|
||||
.print-container >>> * {
|
||||
opacity: 1 !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
/* Prevents sections from being broken up between pages */
|
||||
.print-section {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
p {
|
||||
padding-bottom: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
@@ -157,7 +246,20 @@ p {
|
||||
.ingredient-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 1rem;
|
||||
grid-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ingredient-title,
|
||||
.instruction-title {
|
||||
grid-column: 1 / span 2;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.ingredient-body,
|
||||
.recipe-step-body,
|
||||
.note-body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
ul {
|
||||
|
||||
103
frontend/components/Domain/Recipe/RecipeScaleEditButton.vue
Normal file
103
frontend/components/Domain/Recipe/RecipeScaleEditButton.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-center d-flex align-center">
|
||||
<div>
|
||||
<v-menu v-model="menu" :disabled="!editScale" offset-y top nudge-top="6" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-card class="pa-1 px-2" dark color="secondary darken-1" small v-bind="attrs" v-on="on">
|
||||
<span v-if="recipeYield"> {{ scaledYield }} </span>
|
||||
<span v-if="!recipeYield"> x {{ scale }} </span>
|
||||
</v-card>
|
||||
</template>
|
||||
<v-card min-width="300px">
|
||||
<v-card-title class="mb-0">
|
||||
{{ $t("recipe.edit-scale") }}
|
||||
</v-card-title>
|
||||
<v-card-text class="mt-n5">
|
||||
<div class="mt-4 d-flex align-center">
|
||||
<v-text-field v-model.number="scale" type="number" :min="0" :label="$t('recipe.edit-scale')" />
|
||||
<v-tooltip right color="secondary darken-1">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn v-bind="attrs" icon class="mx-1" small v-on="on" @click="scale = 1">
|
||||
<v-icon>
|
||||
{{ $globals.icons.undo }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span> Reset Scale </span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
<BaseButtonGroup
|
||||
v-if="editScale"
|
||||
class="pl-2"
|
||||
:large="false"
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.minus,
|
||||
text: 'Decrease Scale by 1',
|
||||
event: 'decrement',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.createAlt,
|
||||
text: 'Increase Scale by 1',
|
||||
event: 'increment',
|
||||
},
|
||||
]"
|
||||
@decrement="scale > 1 ? scale-- : null"
|
||||
@increment="scale++"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, toRefs, computed } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
recipeYield: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
basicYield: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
scaledYield: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
editScale: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const state = reactive({
|
||||
tempScale: 1,
|
||||
menu: false,
|
||||
});
|
||||
|
||||
const scale = computed({
|
||||
get: () => props.value,
|
||||
set: (value) => {
|
||||
const newScaleNumber = parseFloat(`${value}`);
|
||||
emit("input", isNaN(newScaleNumber) ? 0 : newScaleNumber);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
scale,
|
||||
...toRefs(state),
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -122,8 +122,5 @@ export default defineComponent({
|
||||
listItem,
|
||||
};
|
||||
},
|
||||
head: {
|
||||
title: "vbase-nuxt",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title> {{ $auth.user.fullName }}</v-list-item-title>
|
||||
<v-list-item-subtitle> {{ $auth.user.admin ? $t("user.admin") : $t("user.user") }}</v-list-item-subtitle>
|
||||
<v-list-item-subtitle>
|
||||
<NuxtLink class="favorites-link" :to="`/user/${$auth.user.id}/favorites`"> Favorite Recipes </NuxtLink>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-divider></v-divider>
|
||||
@@ -200,4 +202,12 @@ export default defineComponent({
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.favorites-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.favorites-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
56
frontend/components/global/ContextMenu.vue
Normal file
56
frontend/components/global/ContextMenu.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<v-menu
|
||||
offset-y
|
||||
left
|
||||
:bottom="!menuTop"
|
||||
:nudge-bottom="!menuTop ? '5' : '0'"
|
||||
:top="menuTop"
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
open-on-hover
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent>
|
||||
<v-icon>{{ $globals.icons.dotsVertical }}</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list dense>
|
||||
<v-list-item v-for="(item, index) in items" :key="index" @click="$emit(item.event)">
|
||||
<v-list-item-icon>
|
||||
<v-icon :color="item.color ? item.color : undefined">
|
||||
{{ item.icon }}
|
||||
</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import { ContextMenuItem } from "~/composables/use-context-presents";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as () => ContextMenuItem[],
|
||||
required: true,
|
||||
},
|
||||
menuTop: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
fab: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "grey darken-2",
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<v-card-actions>
|
||||
<v-menu v-if="tableConfig.hideColumns" offset-y bottom nudge-bottom="6" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn color="accent" class="mr-1" dark v-bind="attrs" v-on="on">
|
||||
<v-btn color="accent" class="mr-2" dark v-bind="attrs" v-on="on">
|
||||
<v-icon>
|
||||
{{ $globals.icons.cog }}
|
||||
</v-icon>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:buttons="[
|
||||
{
|
||||
icon: previewState ? $globals.icons.edit : $globals.icons.eye,
|
||||
text: previewState ? $t('general.edit') : 'Preview Markdown',
|
||||
text: previewState ? $tc('general.edit') : 'Preview Markdown',
|
||||
event: 'toggle',
|
||||
},
|
||||
]"
|
||||
@@ -14,28 +14,23 @@
|
||||
</div>
|
||||
<v-textarea
|
||||
v-if="!previewState"
|
||||
v-bind="textarea"
|
||||
v-model="inputVal"
|
||||
:class="label == '' ? '' : 'mt-5'"
|
||||
:label="label"
|
||||
auto-grow
|
||||
dense
|
||||
rows="4"
|
||||
></v-textarea>
|
||||
<VueMarkdown v-else :source="value"> </VueMarkdown>
|
||||
/>
|
||||
<SafeMarkdown v-else :source="value" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// @ts-ignore vue-markdown has no types
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
|
||||
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MarkdownEditor",
|
||||
components: {
|
||||
VueMarkdown,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
@@ -53,6 +48,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
textarea: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const fallbackPreview = ref(false);
|
||||
@@ -84,5 +83,3 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
42
frontend/components/global/SafeMarkdown.vue
Normal file
42
frontend/components/global/SafeMarkdown.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<VueMarkdown :source="sanitizeMarkdown(source)"></VueMarkdown>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// @ts-ignore vue-markdown has no types
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
VueMarkdown,
|
||||
},
|
||||
props: {
|
||||
source: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
function sanitizeMarkdown(rawHtml: string | null | undefined): string {
|
||||
if (!rawHtml) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const sanitized = DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
// TODO: some more thought could be put into what is allowed and what isn't
|
||||
ALLOWED_TAGS: ["img", "div", "p"],
|
||||
ADD_ATTR: ["src", "alt", "height", "width", "class"],
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return {
|
||||
sanitizeMarkdown,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -11,15 +11,15 @@ export const useStaticRoutes = () => {
|
||||
|
||||
// Methods to Generate reference urls for assets/images *
|
||||
function recipeImage(recipeId: string, version = "", key = 1) {
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/original.webp?&rnd=${key}&version=${version}`;
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/original.webp?rnd=${key}&version=${version}`;
|
||||
}
|
||||
|
||||
function recipeSmallImage(recipeId: string, version = "", key = 1) {
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/min-original.webp?&rnd=${key}&version=${version}`;
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/min-original.webp?rnd=${key}&version=${version}`;
|
||||
}
|
||||
|
||||
function recipeTinyImage(recipeId: string, version = "", key = 1) {
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/tiny-original.webp?&rnd=${key}&version=${version}`;
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/tiny-original.webp?rnd=${key}&version=${version}`;
|
||||
}
|
||||
|
||||
function recipeAssetPath(recipeId: string, assetName: string) {
|
||||
|
||||
99
frontend/composables/partials/use-actions-factory.ts
Normal file
99
frontend/composables/partials/use-actions-factory.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Ref, useAsync } from "@nuxtjs/composition-api";
|
||||
import { useAsyncKey } from "../use-utils";
|
||||
import { BaseCRUDAPI } from "~/api/_base";
|
||||
|
||||
type BoundT = {
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
interface StoreActions<T extends BoundT> {
|
||||
getAll(): Ref<T[] | null>;
|
||||
refresh(): Promise<void>;
|
||||
createOne(createData: T): Promise<void>;
|
||||
updateOne(updateData: T): Promise<void>;
|
||||
deleteOne(id: string | number): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* useStoreActions is a factory function that returns a set of methods
|
||||
* that can be reused to manage the state of a data store without using
|
||||
* Vuex. This is primarily used for basic CRUD operations that required
|
||||
* a lot of refreshing hooks to be called on operations
|
||||
*/
|
||||
export function useStoreActions<T extends BoundT>(
|
||||
api: BaseCRUDAPI<unknown, T, unknown>,
|
||||
allRef: Ref<T[] | null> | null,
|
||||
loading: Ref<boolean>
|
||||
): StoreActions<T> {
|
||||
function getAll() {
|
||||
loading.value = true;
|
||||
const allItems = useAsync(async () => {
|
||||
const { data } = await api.getAll();
|
||||
|
||||
if (data && allRef) {
|
||||
allRef.value = data.items;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return data.items ?? [];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, useAsyncKey());
|
||||
|
||||
loading.value = false;
|
||||
return allItems;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
const { data } = await api.getAll();
|
||||
|
||||
if (data && data.items && allRef) {
|
||||
allRef.value = data.items;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function createOne(createData: T) {
|
||||
loading.value = true;
|
||||
const { data } = await api.createOne(createData);
|
||||
if (data && allRef?.value) {
|
||||
allRef.value.push(data);
|
||||
} else {
|
||||
refresh();
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function updateOne(updateData: T) {
|
||||
if (!updateData.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const { data } = await api.updateOne(updateData.id, updateData);
|
||||
if (data && allRef?.value) {
|
||||
refresh();
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function deleteOne(id: string | number) {
|
||||
loading.value = true;
|
||||
const { response } = await api.deleteOne(id);
|
||||
if (response && allRef?.value) {
|
||||
refresh();
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
getAll,
|
||||
refresh,
|
||||
createOne,
|
||||
updateOne,
|
||||
deleteOne,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
export { useFraction } from "./use-fraction";
|
||||
export { useRecipe } from "./use-recipe";
|
||||
export { useFoods } from "./use-recipe-foods";
|
||||
export { useUnits } from "./use-recipe-units";
|
||||
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes, useSorter } from "./use-recipes";
|
||||
export { useTags, useCategories, allCategories, allTags } from "./use-tags-categories";
|
||||
export { parseIngredientText } from "./use-recipe-ingredients";
|
||||
export { useRecipeSearch } from "./use-recipe-search";
|
||||
export { useTools } from "./use-recipe-tools";
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useAsync, ref, reactive, Ref } from "@nuxtjs/composition-api";
|
||||
import { useAsyncKey } from "../use-utils";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { VForm } from "~/types/vuetify";
|
||||
import { IngredientFood } from "~/types/api-types/recipe";
|
||||
|
||||
let foodStore: Ref<IngredientFood[] | null> | null = null;
|
||||
|
||||
export const useFoods = function () {
|
||||
const api = useUserApi();
|
||||
const loading = ref(false);
|
||||
const deleteTargetId = ref(0);
|
||||
const validForm = ref(true);
|
||||
|
||||
const workingFoodData = reactive<IngredientFood>({
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
labelId: undefined,
|
||||
});
|
||||
|
||||
const actions = {
|
||||
getAll() {
|
||||
loading.value = true;
|
||||
const units = useAsync(async () => {
|
||||
const { data } = await api.foods.getAll();
|
||||
return data;
|
||||
}, useAsyncKey());
|
||||
|
||||
loading.value = false;
|
||||
return units;
|
||||
},
|
||||
async refreshAll() {
|
||||
loading.value = true;
|
||||
const { data } = await api.foods.getAll();
|
||||
|
||||
if (data && foodStore) {
|
||||
foodStore.value = data;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
},
|
||||
async createOne(domForm: VForm | null = null) {
|
||||
if (domForm && !domForm.validate()) {
|
||||
validForm.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const { data } = await api.foods.createOne(workingFoodData);
|
||||
if (data && foodStore?.value) {
|
||||
foodStore.value.push(data);
|
||||
return data;
|
||||
} else {
|
||||
this.refreshAll();
|
||||
}
|
||||
domForm?.reset();
|
||||
validForm.value = true;
|
||||
this.resetWorking();
|
||||
loading.value = false;
|
||||
},
|
||||
async updateOne() {
|
||||
if (!workingFoodData.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
console.log(workingFoodData);
|
||||
const { data } = await api.foods.updateOne(workingFoodData.id, workingFoodData);
|
||||
if (data && foodStore?.value) {
|
||||
this.refreshAll();
|
||||
}
|
||||
loading.value = false;
|
||||
},
|
||||
async deleteOne(id: string | number) {
|
||||
loading.value = true;
|
||||
const { data } = await api.foods.deleteOne(id);
|
||||
if (data && foodStore?.value) {
|
||||
this.refreshAll();
|
||||
}
|
||||
},
|
||||
resetWorking() {
|
||||
workingFoodData.id = "";
|
||||
workingFoodData.name = "";
|
||||
workingFoodData.description = "";
|
||||
workingFoodData.labelId = undefined;
|
||||
},
|
||||
setWorking(item: IngredientFood) {
|
||||
workingFoodData.id = item.id;
|
||||
workingFoodData.name = item.name;
|
||||
workingFoodData.description = item.description || "";
|
||||
workingFoodData.labelId = item.labelId;
|
||||
},
|
||||
flushStore() {
|
||||
foodStore = null;
|
||||
},
|
||||
};
|
||||
|
||||
if (!foodStore) {
|
||||
foodStore = actions.getAll();
|
||||
}
|
||||
|
||||
return { foods: foodStore, workingFoodData, deleteTargetId, actions, validForm };
|
||||
};
|
||||
@@ -19,9 +19,10 @@ export function parseIngredientText(ingredient: RecipeIngredient, disableAmount:
|
||||
|
||||
let returnQty = "";
|
||||
|
||||
let unitDisplay = unit?.name;
|
||||
|
||||
// casting to number is required as sometimes quantity is a string
|
||||
if (quantity && Number(quantity) !== 0) {
|
||||
console.log("Using Quantity", quantity, typeof quantity);
|
||||
if (unit?.fraction) {
|
||||
const fraction = frac(quantity * scale, 10, true);
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
@@ -34,8 +35,12 @@ export function parseIngredientText(ingredient: RecipeIngredient, disableAmount:
|
||||
} else {
|
||||
returnQty = (quantity * scale).toString();
|
||||
}
|
||||
|
||||
if (unit?.useAbbreviation && unit.abbreviation) {
|
||||
unitDisplay = unit.abbreviation;
|
||||
}
|
||||
}
|
||||
|
||||
const text = `${returnQty} ${unit?.name || " "} ${food?.name || " "} ${note || " "}`.replace(/ {2,}/g, " ");
|
||||
const text = `${returnQty} ${unitDisplay || " "} ${food?.name || " "} ${note || " "}`.replace(/ {2,}/g, " ");
|
||||
return sanitizeIngredientHTML(text);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,12 @@ export const useTools = function (eager = true) {
|
||||
loading.value = true;
|
||||
const units = useAsync(async () => {
|
||||
const { data } = await api.tools.getAll();
|
||||
return data;
|
||||
|
||||
if (data) {
|
||||
return data.items;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, useAsyncKey());
|
||||
|
||||
loading.value = false;
|
||||
@@ -33,7 +38,7 @@ export const useTools = function (eager = true) {
|
||||
const { data } = await api.tools.getAll();
|
||||
|
||||
if (data) {
|
||||
tools.value = data;
|
||||
tools.value = data.items;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useAsync, ref, reactive, Ref } from "@nuxtjs/composition-api";
|
||||
import { useAsyncKey } from "../use-utils";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { VForm } from "~/types/vuetify";
|
||||
import { IngredientUnit } from "~/types/api-types/recipe";
|
||||
|
||||
let unitStore: Ref<IngredientUnit[] | null> | null = null;
|
||||
|
||||
export const useUnits = function () {
|
||||
const api = useUserApi();
|
||||
const loading = ref(false);
|
||||
const deleteTargetId = ref(0);
|
||||
const validForm = ref(true);
|
||||
|
||||
const workingUnitData: IngredientUnit = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
fraction: true,
|
||||
abbreviation: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
const actions = {
|
||||
getAll() {
|
||||
loading.value = true;
|
||||
const units = useAsync(async () => {
|
||||
const { data } = await api.units.getAll();
|
||||
return data;
|
||||
}, useAsyncKey());
|
||||
|
||||
loading.value = false;
|
||||
return units;
|
||||
},
|
||||
async refreshAll() {
|
||||
loading.value = true;
|
||||
const { data } = await api.units.getAll();
|
||||
|
||||
if (data && unitStore) {
|
||||
unitStore.value = data;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
},
|
||||
async createOne(domForm: VForm | null = null) {
|
||||
if (domForm && !domForm.validate()) {
|
||||
validForm.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const { data } = await api.units.createOne(workingUnitData);
|
||||
if (data && unitStore?.value) {
|
||||
unitStore.value.push(data);
|
||||
} else {
|
||||
this.refreshAll();
|
||||
}
|
||||
domForm?.reset();
|
||||
validForm.value = true;
|
||||
this.resetWorking();
|
||||
loading.value = false;
|
||||
},
|
||||
async updateOne() {
|
||||
if (!workingUnitData.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const { data } = await api.units.updateOne(workingUnitData.id, workingUnitData);
|
||||
if (data && unitStore?.value) {
|
||||
this.refreshAll();
|
||||
}
|
||||
loading.value = false;
|
||||
},
|
||||
async deleteOne(id: string | number) {
|
||||
loading.value = true;
|
||||
const { data } = await api.units.deleteOne(id);
|
||||
if (data && unitStore?.value) {
|
||||
this.refreshAll();
|
||||
}
|
||||
},
|
||||
resetWorking() {
|
||||
workingUnitData.id = "";
|
||||
workingUnitData.name = "";
|
||||
workingUnitData.abbreviation = "";
|
||||
workingUnitData.description = "";
|
||||
},
|
||||
setWorking(item: IngredientUnit) {
|
||||
workingUnitData.id = item.id;
|
||||
workingUnitData.name = item.name;
|
||||
workingUnitData.fraction = item.fraction;
|
||||
workingUnitData.abbreviation = item.abbreviation;
|
||||
workingUnitData.description = item.description;
|
||||
},
|
||||
flushStore() {
|
||||
unitStore = null;
|
||||
},
|
||||
};
|
||||
|
||||
if (!unitStore) {
|
||||
unitStore = actions.getAll();
|
||||
}
|
||||
|
||||
return { units: unitStore, workingUnitData, deleteTargetId, actions, validForm };
|
||||
};
|
||||
@@ -18,8 +18,8 @@ function swap(t: Array<unknown>, i: number, j: number) {
|
||||
export const useSorter = () => {
|
||||
function sortAToZ(list: Array<Recipe>) {
|
||||
list.sort((a, b) => {
|
||||
const textA = a.name?.toUpperCase() ?? "";
|
||||
const textB = b.name?.toUpperCase() ?? "";
|
||||
const textA: string = a.name?.toUpperCase() ?? "";
|
||||
const textB: string = b.name?.toUpperCase() ?? "";
|
||||
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
||||
});
|
||||
}
|
||||
@@ -61,13 +61,9 @@ export const useLazyRecipes = function () {
|
||||
|
||||
const recipes = ref<Recipe[]>([]);
|
||||
|
||||
async function fetchMore(start: number, limit: number) {
|
||||
const { data } = await api.recipes.getAll(start, limit);
|
||||
if (data) {
|
||||
data.forEach((recipe) => {
|
||||
recipes.value?.push(recipe);
|
||||
});
|
||||
}
|
||||
async function fetchMore(page: number, perPage: number, orderBy: string | null = null, orderDirection = "desc") {
|
||||
const { data } = await api.recipes.getAll(page, perPage, { orderBy, orderDirection });
|
||||
return data ? data.items : [];
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -80,26 +76,26 @@ export const useRecipes = (all = false, fetchRecipes = true) => {
|
||||
const api = useUserApi();
|
||||
|
||||
// recipes is non-reactive!!
|
||||
const { recipes, start, end } = (() => {
|
||||
const { recipes, page, perPage } = (() => {
|
||||
if (all) {
|
||||
return {
|
||||
recipes: allRecipes,
|
||||
start: 0,
|
||||
end: 9999,
|
||||
page: 1,
|
||||
perPage: -1,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
recipes: recentRecipes,
|
||||
start: 0,
|
||||
end: 30,
|
||||
page: 1,
|
||||
perPage: 30,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
async function refreshRecipes() {
|
||||
const { data } = await api.recipes.getAll(start, end, { loadFood: true });
|
||||
const { data } = await api.recipes.getAll(page, perPage, { loadFood: true, orderBy: "created_at" });
|
||||
if (data) {
|
||||
recipes.value = data;
|
||||
recipes.value = data.items;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Ref, ref, useAsync } from "@nuxtjs/composition-api";
|
||||
import { useUserApi } from "../api";
|
||||
import { useAsyncKey } from "../use-utils";
|
||||
import { CategoriesAPI } from "~/api/class-interfaces/organizer-categories";
|
||||
import { TagsAPI } from "~/api/class-interfaces/organizer-tags";
|
||||
import { RecipeTag, RecipeCategory } from "~/types/api-types/recipe";
|
||||
|
||||
export const allCategories = ref<RecipeCategory[] | null>([]);
|
||||
export const allTags = ref<RecipeTag[] | null>([]);
|
||||
|
||||
function baseTagsCategories(
|
||||
reference: Ref<RecipeCategory[] | null> | Ref<RecipeTag[] | null>,
|
||||
api: TagsAPI | CategoriesAPI
|
||||
) {
|
||||
function useAsyncGetAll() {
|
||||
useAsync(async () => {
|
||||
await refreshItems();
|
||||
}, useAsyncKey());
|
||||
}
|
||||
|
||||
async function refreshItems() {
|
||||
const { data } = await api.getAll();
|
||||
// @ts-ignore hotfix
|
||||
reference.value = data;
|
||||
}
|
||||
|
||||
async function createOne(payload: { name: string }) {
|
||||
const { data } = await api.createOne(payload);
|
||||
if (data) {
|
||||
refreshItems();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOne(slug: string) {
|
||||
const { data } = await api.deleteOne(slug);
|
||||
if (data) {
|
||||
refreshItems();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateOne(slug: string, payload: { name: string }) {
|
||||
// @ts-ignore // TODO: Fix Typescript Issue - Unsure how to fix this while also keeping mixins
|
||||
const { data } = await api.updateOne(slug, payload);
|
||||
if (data) {
|
||||
refreshItems();
|
||||
}
|
||||
}
|
||||
|
||||
return { useAsyncGetAll, refreshItems, createOne, deleteOne, updateOne };
|
||||
}
|
||||
|
||||
export const useTags = function () {
|
||||
const api = useUserApi();
|
||||
return {
|
||||
allTags,
|
||||
...baseTagsCategories(allTags, api.tags),
|
||||
};
|
||||
};
|
||||
export const useCategories = function () {
|
||||
const api = useUserApi();
|
||||
return {
|
||||
allCategories,
|
||||
...baseTagsCategories(allCategories, api.categories),
|
||||
};
|
||||
};
|
||||
6
frontend/composables/store/index.ts
Normal file
6
frontend/composables/store/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { useFoodStore, useFoodData } from "./use-food-store";
|
||||
export { useUnitStore, useUnitData } from "./use-unit-store";
|
||||
export { useLabelStore, useLabelData } from "./use-label-store";
|
||||
export { useToolStore, useToolData } from "./use-tool-store";
|
||||
export { useCategoryStore, useCategoryData } from "./use-category-store";
|
||||
export { useTagStore, useTagData } from "./use-tag-store";
|
||||
47
frontend/composables/store/use-category-store.ts
Normal file
47
frontend/composables/store/use-category-store.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
|
||||
import { useStoreActions } from "../partials/use-actions-factory";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { RecipeCategory } from "~/types/api-types/admin";
|
||||
|
||||
const categoryStore: Ref<RecipeCategory[]> = ref([]);
|
||||
|
||||
export function useCategoryData() {
|
||||
const data = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
slug: undefined,
|
||||
});
|
||||
|
||||
function reset() {
|
||||
data.id = "";
|
||||
data.name = "";
|
||||
data.slug = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCategoryStore() {
|
||||
const api = useUserApi();
|
||||
const loading = ref(false);
|
||||
|
||||
const actions = {
|
||||
...useStoreActions<RecipeCategory>(api.categories, categoryStore, loading),
|
||||
flushStore() {
|
||||
categoryStore.value = [];
|
||||
},
|
||||
};
|
||||
|
||||
if (!categoryStore.value || categoryStore.value?.length === 0) {
|
||||
actions.getAll();
|
||||
}
|
||||
|
||||
return {
|
||||
items: categoryStore,
|
||||
actions,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
50
frontend/composables/store/use-food-store.ts
Normal file
50
frontend/composables/store/use-food-store.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ref, reactive, Ref } from "@nuxtjs/composition-api";
|
||||
import { useStoreActions } from "../partials/use-actions-factory";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { IngredientFood } from "~/types/api-types/recipe";
|
||||
|
||||
let foodStore: Ref<IngredientFood[] | null> | null = null;
|
||||
|
||||
/**
|
||||
* useFoodData returns a template reactive object
|
||||
* for managing the creation of units. It also provides a
|
||||
* function to reset the data back to the initial state.
|
||||
*/
|
||||
export const useFoodData = function () {
|
||||
const data: IngredientFood = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
labelId: undefined,
|
||||
});
|
||||
|
||||
function reset() {
|
||||
data.id = "";
|
||||
data.name = "";
|
||||
data.description = "";
|
||||
data.labelId = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
|
||||
export const useFoodStore = function () {
|
||||
const api = useUserApi();
|
||||
const loading = ref(false);
|
||||
|
||||
const actions = {
|
||||
...useStoreActions(api.foods, foodStore, loading),
|
||||
flushStore() {
|
||||
foodStore = null;
|
||||
},
|
||||
};
|
||||
|
||||
if (!foodStore) {
|
||||
foodStore = actions.getAll();
|
||||
}
|
||||
|
||||
return { foods: foodStore, actions };
|
||||
};
|
||||
49
frontend/composables/store/use-label-store.ts
Normal file
49
frontend/composables/store/use-label-store.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
|
||||
import { useStoreActions } from "../partials/use-actions-factory";
|
||||
import { MultiPurposeLabelOut } from "~/types/api-types/labels";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
let labelStore: Ref<MultiPurposeLabelOut[] | null> | null = null;
|
||||
|
||||
export function useLabelData() {
|
||||
const data = reactive({
|
||||
groupId: "",
|
||||
id: "",
|
||||
name: "",
|
||||
color: "",
|
||||
});
|
||||
|
||||
function reset() {
|
||||
data.groupId = "";
|
||||
data.id = "";
|
||||
data.name = "";
|
||||
data.color = "";
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export function useLabelStore() {
|
||||
const api = useUserApi();
|
||||
const loading = ref(false);
|
||||
|
||||
const actions = {
|
||||
...useStoreActions<MultiPurposeLabelOut>(api.multiPurposeLabels, labelStore, loading),
|
||||
flushStore() {
|
||||
labelStore = null;
|
||||
},
|
||||
};
|
||||
|
||||
if (!labelStore) {
|
||||
labelStore = actions.getAll();
|
||||
}
|
||||
|
||||
return {
|
||||
labels: labelStore,
|
||||
actions,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
47
frontend/composables/store/use-tag-store.ts
Normal file
47
frontend/composables/store/use-tag-store.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
|
||||
import { useStoreActions } from "../partials/use-actions-factory";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { RecipeTag } from "~/types/api-types/admin";
|
||||
|
||||
const items: Ref<RecipeTag[]> = ref([]);
|
||||
|
||||
export function useTagData() {
|
||||
const data = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
slug: undefined,
|
||||
});
|
||||
|
||||
function reset() {
|
||||
data.id = "";
|
||||
data.name = "";
|
||||
data.slug = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTagStore() {
|
||||
const api = useUserApi();
|
||||
const loading = ref(false);
|
||||
|
||||
const actions = {
|
||||
...useStoreActions<RecipeTag>(api.tags, items, loading),
|
||||
flushStore() {
|
||||
items.value = [];
|
||||
},
|
||||
};
|
||||
|
||||
if (!items.value || items.value?.length === 0) {
|
||||
actions.getAll();
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
actions,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
49
frontend/composables/store/use-tool-store.ts
Normal file
49
frontend/composables/store/use-tool-store.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
|
||||
import { useStoreActions } from "../partials/use-actions-factory";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { RecipeTool } from "~/types/api-types/recipe";
|
||||
|
||||
const toolStore: Ref<RecipeTool[]> = ref([]);
|
||||
|
||||
export function useToolData() {
|
||||
const data = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
slug: undefined,
|
||||
onHand: false,
|
||||
});
|
||||
|
||||
function reset() {
|
||||
data.id = "";
|
||||
data.name = "";
|
||||
data.slug = undefined;
|
||||
data.onHand = false;
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export function useToolStore() {
|
||||
const api = useUserApi();
|
||||
const loading = ref(false);
|
||||
|
||||
const actions = {
|
||||
...useStoreActions<RecipeTool>(api.tools, toolStore, loading),
|
||||
flushStore() {
|
||||
toolStore.value = [];
|
||||
},
|
||||
};
|
||||
|
||||
if (!toolStore.value || toolStore.value?.length === 0) {
|
||||
actions.getAll();
|
||||
}
|
||||
|
||||
return {
|
||||
items: toolStore,
|
||||
actions,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
52
frontend/composables/store/use-unit-store.ts
Normal file
52
frontend/composables/store/use-unit-store.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ref, reactive, Ref } from "@nuxtjs/composition-api";
|
||||
import { useStoreActions } from "../partials/use-actions-factory";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { IngredientUnit } from "~/types/api-types/recipe";
|
||||
|
||||
let unitStore: Ref<IngredientUnit[] | null> | null = null;
|
||||
|
||||
/**
|
||||
* useUnitData returns a template reactive object
|
||||
* for managing the creation of units. It also provides a
|
||||
* function to reset the data back to the initial state.
|
||||
*/
|
||||
export const useUnitData = function () {
|
||||
const data: IngredientUnit = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
fraction: true,
|
||||
abbreviation: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
function reset() {
|
||||
data.id = "";
|
||||
data.name = "";
|
||||
data.fraction = true;
|
||||
data.abbreviation = "";
|
||||
data.description = "";
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
|
||||
export const useUnitStore = function () {
|
||||
const api = useUserApi();
|
||||
const loading = ref(false);
|
||||
|
||||
const actions = {
|
||||
...useStoreActions<IngredientUnit>(api.units, unitStore, loading),
|
||||
flushStore() {
|
||||
unitStore = null;
|
||||
},
|
||||
};
|
||||
|
||||
if (!unitStore) {
|
||||
unitStore = actions.getAll();
|
||||
}
|
||||
|
||||
return { units: unitStore, actions };
|
||||
};
|
||||
30
frontend/composables/use-context-presents.ts
Normal file
30
frontend/composables/use-context-presents.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useContext } from "@nuxtjs/composition-api";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
event: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function useContextPresets(): { [key: string]: ContextMenuItem } {
|
||||
const { $globals, i18n } = useContext();
|
||||
|
||||
return {
|
||||
delete: {
|
||||
title: i18n.tc("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
event: "delete",
|
||||
},
|
||||
edit: {
|
||||
title: i18n.tc("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
event: "edit",
|
||||
},
|
||||
save: {
|
||||
title: i18n.tc("general.save"),
|
||||
icon: $globals.icons.save,
|
||||
event: "save",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -31,7 +31,11 @@ export const useCookbooks = function () {
|
||||
const units = useAsync(async () => {
|
||||
const { data } = await api.cookbooks.getAll();
|
||||
|
||||
return data;
|
||||
if (data) {
|
||||
return data.items;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, useAsyncKey());
|
||||
|
||||
loading.value = false;
|
||||
@@ -41,8 +45,8 @@ export const useCookbooks = function () {
|
||||
loading.value = true;
|
||||
const { data } = await api.cookbooks.getAll();
|
||||
|
||||
if (data && cookbookStore) {
|
||||
cookbookStore.value = data;
|
||||
if (data && data.items && cookbookStore) {
|
||||
cookbookStore.value = data.items;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
|
||||
@@ -26,13 +26,17 @@ export const useMealplans = function (range: Ref<DateRange>) {
|
||||
loading.value = true;
|
||||
const units = useAsync(async () => {
|
||||
const query = {
|
||||
start: format(range.value.start, "yyyy-MM-dd"),
|
||||
limit: format(range.value.end, "yyyy-MM-dd"),
|
||||
start_date: format(range.value.start, "yyyy-MM-dd"),
|
||||
end_date: format(range.value.end, "yyyy-MM-dd"),
|
||||
};
|
||||
// @ts-ignore TODO Modify typing to allow for string start+limit for mealplans
|
||||
const { data } = await api.mealplans.getAll(query.start, query.limit);
|
||||
const { data } = await api.mealplans.getAll(1, -1, { start_date: query.start_date, end_date: query.end_date });
|
||||
|
||||
return data;
|
||||
if (data) {
|
||||
return data.items;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, useAsyncKey());
|
||||
|
||||
loading.value = false;
|
||||
@@ -41,14 +45,14 @@ export const useMealplans = function (range: Ref<DateRange>) {
|
||||
async refreshAll(this: void) {
|
||||
loading.value = true;
|
||||
const query = {
|
||||
start: format(range.value.start, "yyyy-MM-dd"),
|
||||
limit: format(range.value.end, "yyyy-MM-dd"),
|
||||
start_date: format(range.value.start, "yyyy-MM-dd"),
|
||||
end_date: format(range.value.end, "yyyy-MM-dd"),
|
||||
};
|
||||
// @ts-ignore TODO Modify typing to allow for string start+limit for mealplans
|
||||
const { data } = await api.mealplans.getAll(query.start, query.limit);
|
||||
const { data } = await api.mealplans.getAll(1, -1, { start_date: query.start_date, end_date: query.end_date });
|
||||
|
||||
if (data) {
|
||||
mealplans.value = data;
|
||||
if (data && data.items) {
|
||||
mealplans.value = data.items;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
|
||||
@@ -14,7 +14,11 @@ export const useGroupWebhooks = function () {
|
||||
const units = useAsync(async () => {
|
||||
const { data } = await api.groupWebhooks.getAll();
|
||||
|
||||
return data;
|
||||
if (data) {
|
||||
return data.items;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, useAsyncKey());
|
||||
|
||||
loading.value = false;
|
||||
@@ -24,8 +28,8 @@ export const useGroupWebhooks = function () {
|
||||
loading.value = true;
|
||||
const { data } = await api.groupWebhooks.getAll();
|
||||
|
||||
if (data) {
|
||||
webhooks.value = data;
|
||||
if (data && data.items) {
|
||||
webhooks.value = data.items;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
@@ -37,7 +41,7 @@ export const useGroupWebhooks = function () {
|
||||
enabled: false,
|
||||
name: "New Webhook",
|
||||
url: "",
|
||||
time: "00:00",
|
||||
scheduledTime: "00:00",
|
||||
};
|
||||
|
||||
const { data } = await api.groupWebhooks.createOne(payload);
|
||||
@@ -52,8 +56,23 @@ export const useGroupWebhooks = function () {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to UTC time
|
||||
const [hours, minutes] = updateData.scheduledTime.split(":");
|
||||
|
||||
const newDt = new Date();
|
||||
newDt.setHours(Number(hours));
|
||||
newDt.setMinutes(Number(minutes));
|
||||
|
||||
updateData.scheduledTime = `${pad(newDt.getUTCHours(), 2)}:${pad(newDt.getUTCMinutes(), 2)}`;
|
||||
console.log(updateData.scheduledTime);
|
||||
|
||||
const payload = {
|
||||
...updateData,
|
||||
scheduledTime: updateData.scheduledTime,
|
||||
};
|
||||
|
||||
loading.value = true;
|
||||
const { data } = await api.groupWebhooks.updateOne(updateData.id, updateData);
|
||||
const { data } = await api.groupWebhooks.updateOne(updateData.id, payload);
|
||||
if (data) {
|
||||
this.refreshAll();
|
||||
}
|
||||
@@ -73,3 +92,25 @@ export const useGroupWebhooks = function () {
|
||||
|
||||
return { webhooks, actions, validForm };
|
||||
};
|
||||
|
||||
function pad(num: number, size: number) {
|
||||
let numStr = num.toString();
|
||||
while (numStr.length < size) numStr = "0" + numStr;
|
||||
return numStr;
|
||||
}
|
||||
|
||||
export function timeUTCToLocal(time: string): string {
|
||||
const [hours, minutes] = time.split(":");
|
||||
const dt = new Date();
|
||||
dt.setUTCMinutes(Number(minutes));
|
||||
dt.setUTCHours(Number(hours));
|
||||
return `${pad(dt.getHours(), 2)}:${pad(dt.getMinutes(), 2)}`;
|
||||
}
|
||||
|
||||
export function timeLocalToUTC(time: string) {
|
||||
const [hours, minutes] = time.split(":");
|
||||
const dt = new Date();
|
||||
dt.setHours(Number(hours));
|
||||
dt.setMinutes(Number(minutes));
|
||||
return `${pad(dt.getUTCHours(), 2)}:${pad(dt.getUTCMinutes(), 2)}`;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,12 @@ export const useGroups = function () {
|
||||
const asyncKey = String(Date.now());
|
||||
const groups = useAsync(async () => {
|
||||
const { data } = await api.groups.getAll();
|
||||
return data;
|
||||
|
||||
if (data) {
|
||||
return data.items;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, asyncKey);
|
||||
|
||||
loading.value = false;
|
||||
@@ -53,7 +58,13 @@ export const useGroups = function () {
|
||||
async function refreshAllGroups() {
|
||||
loading.value = true;
|
||||
const { data } = await api.groups.getAll();
|
||||
groups.value = data;
|
||||
|
||||
if (data) {
|
||||
groups.value = data.items;
|
||||
} else {
|
||||
groups.value = null;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,11 @@ export const useAllUsers = function () {
|
||||
const asyncKey = String(Date.now());
|
||||
const allUsers = useAsync(async () => {
|
||||
const { data } = await api.users.getAll();
|
||||
return data;
|
||||
if (data) {
|
||||
return data.items;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, asyncKey);
|
||||
|
||||
loading.value = false;
|
||||
@@ -27,7 +31,13 @@ export const useAllUsers = function () {
|
||||
async function refreshAllUsers() {
|
||||
loading.value = true;
|
||||
const { data } = await api.users.getAll();
|
||||
users.value = data;
|
||||
|
||||
if (data) {
|
||||
users.value = data.items;
|
||||
} else {
|
||||
users.value = null;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
|
||||
28
frontend/composables/use-users/preferences.ts
Normal file
28
frontend/composables/use-users/preferences.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Ref, useContext } from "@nuxtjs/composition-api";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
|
||||
export interface UserRecipePreferences {
|
||||
orderBy: string;
|
||||
orderDirection: string;
|
||||
sortIcon: string;
|
||||
useMobileCards: boolean;
|
||||
}
|
||||
|
||||
export function useUserSortPreferences(): Ref<UserRecipePreferences> {
|
||||
const { $globals } = useContext();
|
||||
|
||||
const fromStorage = useLocalStorage(
|
||||
"recipe-section-preferences",
|
||||
{
|
||||
orderBy: "name",
|
||||
orderDirection: "asc",
|
||||
sortIcon: $globals.icons.sortAlphabeticalAscending,
|
||||
useMobileCards: false,
|
||||
},
|
||||
{ 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 Ref<UserRecipePreferences>;
|
||||
|
||||
return fromStorage;
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"short": {
|
||||
"month": "kort",
|
||||
"day": "numerisk",
|
||||
"weekday": "lang"
|
||||
"month": "short",
|
||||
"day": "numeric",
|
||||
"weekday": "long"
|
||||
},
|
||||
"medium": {
|
||||
"month": "lang",
|
||||
"day": "numerisk",
|
||||
"weekday": "lang",
|
||||
"year": "numerisk"
|
||||
"month": "long",
|
||||
"day": "numeric",
|
||||
"weekday": "long",
|
||||
"year": "numeric"
|
||||
},
|
||||
"long": {
|
||||
"year": "numerisk",
|
||||
"month": "lang",
|
||||
"day": "numerisk",
|
||||
"weekday": "lang",
|
||||
"hour": "numerisk",
|
||||
"minute": "numerisk"
|
||||
"year": "numeric",
|
||||
"month": "long",
|
||||
"day": "numeric",
|
||||
"weekday": "long",
|
||||
"hour": "numeric",
|
||||
"minute": "numeric"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
"hour": "numeric",
|
||||
"minute": "numeric"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"short": {
|
||||
"month": "breve",
|
||||
"day": "numerico",
|
||||
"weekday": "lungo"
|
||||
"month": "short",
|
||||
"day": "numeric",
|
||||
"weekday": "long"
|
||||
},
|
||||
"medium": {
|
||||
"month": "lungo",
|
||||
"day": "numerico",
|
||||
"weekday": "lungo",
|
||||
"year": "numerico"
|
||||
"month": "long",
|
||||
"day": "numeric",
|
||||
"weekday": "long",
|
||||
"year": "numeric"
|
||||
},
|
||||
"long": {
|
||||
"year": "numeric",
|
||||
"month": "lungo",
|
||||
"day": "numerico",
|
||||
"weekday": "lungo",
|
||||
"hour": "numerico",
|
||||
"minute": "numerico"
|
||||
"month": "long",
|
||||
"day": "numeric",
|
||||
"weekday": "long",
|
||||
"hour": "numeric",
|
||||
"minute": "numeric"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"short": {
|
||||
"month": "short",
|
||||
"day": "числовий",
|
||||
"day": "numeric",
|
||||
"weekday": "long"
|
||||
},
|
||||
"medium": {
|
||||
"month": "long",
|
||||
"day": "числовий",
|
||||
"day": "numeric",
|
||||
"weekday": "long",
|
||||
"year": "числовий"
|
||||
"year": "numeric"
|
||||
},
|
||||
"long": {
|
||||
"year": "числовий",
|
||||
"year": "numeric",
|
||||
"month": "long",
|
||||
"day": "числовий",
|
||||
"day": "numeric",
|
||||
"weekday": "long",
|
||||
"hour": "числовий",
|
||||
"minute": "числовий"
|
||||
"hour": "numeric",
|
||||
"minute": "numeric"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Description",
|
||||
"disable-amount": "Disable Ingredient Amounts",
|
||||
"disable-comments": "Disable Comments",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Fat",
|
||||
"fiber-content": "Fiber",
|
||||
"grams": "grams",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Description",
|
||||
"disable-amount": "Disable Ingredient Amounts",
|
||||
"disable-comments": "Disable Comments",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Fat",
|
||||
"fiber-content": "Fiber",
|
||||
"grams": "grams",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Description",
|
||||
"disable-amount": "Disable Ingredient Amounts",
|
||||
"disable-comments": "Disable Comments",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Fat",
|
||||
"fiber-content": "Fiber",
|
||||
"grams": "grams",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Descripció",
|
||||
"disable-amount": "Oculta les quantitats",
|
||||
"disable-comments": "Oculta els comentaris",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Greixos",
|
||||
"fiber-content": "Fibra",
|
||||
"grams": "grams",
|
||||
|
||||
@@ -1,63 +1,63 @@
|
||||
{
|
||||
"about": {
|
||||
"about": "About",
|
||||
"about-mealie": "About Mealie",
|
||||
"api-docs": "API Docs",
|
||||
"api-port": "API Port",
|
||||
"application-mode": "Application Mode",
|
||||
"database-type": "Database Type",
|
||||
"database-url": "Database URL",
|
||||
"default-group": "Default Group",
|
||||
"about": "O aplikaci",
|
||||
"about-mealie": "O Mealie",
|
||||
"api-docs": "Dokumentace API",
|
||||
"api-port": "Dokumentace portu",
|
||||
"application-mode": "Režim aplikace",
|
||||
"database-type": "Typ databáze",
|
||||
"database-url": "URL databáze",
|
||||
"default-group": "Výchozí skupina",
|
||||
"demo": "Demo",
|
||||
"demo-status": "Demo Status",
|
||||
"development": "Development",
|
||||
"docs": "Docs",
|
||||
"download-log": "Download Log",
|
||||
"download-recipe-json": "Last Scraped JSON",
|
||||
"demo-status": "Stav dema",
|
||||
"development": "Vývoj",
|
||||
"docs": "Dokumentace",
|
||||
"download-log": "Stáhnout log",
|
||||
"download-recipe-json": "Poslední scrapovaný JSON",
|
||||
"github": "Github",
|
||||
"log-lines": "Log Lines",
|
||||
"not-demo": "Not Demo",
|
||||
"log-lines": "Řádky logů",
|
||||
"not-demo": "Není demo",
|
||||
"portfolio": "Portfolio",
|
||||
"production": "Production",
|
||||
"support": "Support",
|
||||
"version": "Version"
|
||||
"production": "Produkce",
|
||||
"support": "Podpora",
|
||||
"version": "Verze"
|
||||
},
|
||||
"asset": {
|
||||
"assets": "Assets",
|
||||
"code": "Code",
|
||||
"file": "File",
|
||||
"image": "Image",
|
||||
"new-asset": "New Asset",
|
||||
"assets": "Zdroje",
|
||||
"code": "Kód",
|
||||
"file": "Soubor",
|
||||
"image": "Obrázek",
|
||||
"new-asset": "Nový zdroj",
|
||||
"pdf": "PDF",
|
||||
"recipe": "Recipe",
|
||||
"show-assets": "Show Assets"
|
||||
"recipe": "Recept",
|
||||
"show-assets": "Zobrazit zdroje"
|
||||
},
|
||||
"category": {
|
||||
"categories": "Categories",
|
||||
"category-created": "Category created",
|
||||
"category-creation-failed": "Category creation failed",
|
||||
"category-deleted": "Category Deleted",
|
||||
"category-deletion-failed": "Category deletion failed",
|
||||
"category-filter": "Category Filter",
|
||||
"category-update-failed": "Category update failed",
|
||||
"category-updated": "Category updated",
|
||||
"uncategorized-count": "Uncategorized {count}"
|
||||
"categories": "Kategorie",
|
||||
"category-created": "Kategorie vytvořena",
|
||||
"category-creation-failed": "Vytvoření kategorie selhalo",
|
||||
"category-deleted": "Kategorie smazána",
|
||||
"category-deletion-failed": "Smazání kategorie se nezdařilo",
|
||||
"category-filter": "Filtr kategorií",
|
||||
"category-update-failed": "Aktualizace kategorie selhala",
|
||||
"category-updated": "Kategorie byla aktualizována",
|
||||
"uncategorized-count": "Nezařazené {count}"
|
||||
},
|
||||
"events": {
|
||||
"apprise-url": "Apprise URL",
|
||||
"database": "Database",
|
||||
"delete-event": "Delete Event",
|
||||
"new-notification-form-description": "Mealie uses the Apprise library to generate notifications. They offer many options for services to use for notifications. Refer to their wiki for a comprehensive guide on how to create the URL for your service. If available, selecting the type of your notification may include extra features.",
|
||||
"new-version": "New version available!",
|
||||
"notification": "Notification",
|
||||
"refresh": "Refresh",
|
||||
"scheduled": "Scheduled",
|
||||
"something-went-wrong": "Something Went Wrong!",
|
||||
"subscribed-events": "Subscribed Events",
|
||||
"test-message-sent": "Test Message Sent"
|
||||
"database": "Databáze",
|
||||
"delete-event": "Smazat Událost",
|
||||
"new-notification-form-description": "Mealie používá knihovnu Apprise pro generování notifikací. Nabízí spousty služeb pro zasílání oznámení. Podívejte se do jejich wiki pro komplexní návod jak vytvářet URL pro vaši službu. Výběr typu oznámení může obsahovat další extra funkce.",
|
||||
"new-version": "Je dostupná nová verze!",
|
||||
"notification": "Oznámení",
|
||||
"refresh": "Obnovit",
|
||||
"scheduled": "Naplánováno",
|
||||
"something-went-wrong": "Něco se nepovedlo!",
|
||||
"subscribed-events": "Odebírané události",
|
||||
"test-message-sent": "Testovací zpráva odeslána"
|
||||
},
|
||||
"general": {
|
||||
"cancel": "Cancel",
|
||||
"cancel": "Zrušit",
|
||||
"clear": "Clear",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
@@ -108,62 +108,62 @@
|
||||
"reset": "Reset",
|
||||
"saturday": "Saturday",
|
||||
"save": "Save",
|
||||
"settings": "Settings",
|
||||
"share": "Share",
|
||||
"shuffle": "Shuffle",
|
||||
"sort": "Sort",
|
||||
"sort-alphabetically": "Alphabetical",
|
||||
"status": "Status",
|
||||
"submit": "Submit",
|
||||
"success-count": "Success: {count}",
|
||||
"sunday": "Sunday",
|
||||
"templates": "Templates:",
|
||||
"settings": "Nastavení",
|
||||
"share": "Sdílet",
|
||||
"shuffle": "Náhodně",
|
||||
"sort": "Seřadit",
|
||||
"sort-alphabetically": "Abecedně",
|
||||
"status": "Stav",
|
||||
"submit": "Odeslat",
|
||||
"success-count": "Úspěšné: {count}",
|
||||
"sunday": "Neděle",
|
||||
"templates": "Šablony:",
|
||||
"test": "Test",
|
||||
"themes": "Themes",
|
||||
"thursday": "Thursday",
|
||||
"themes": "Motivy",
|
||||
"thursday": "Čtvrtek",
|
||||
"token": "Token",
|
||||
"tuesday": "Tuesday",
|
||||
"type": "Type",
|
||||
"update": "Update",
|
||||
"updated": "Updated",
|
||||
"upload": "Upload",
|
||||
"tuesday": "Úterý",
|
||||
"type": "Typ",
|
||||
"update": "Aktualizace",
|
||||
"updated": "Aktualizováno",
|
||||
"upload": "Nahrát",
|
||||
"url": "URL",
|
||||
"view": "View",
|
||||
"wednesday": "Wednesday",
|
||||
"yes": "Yes",
|
||||
"foods": "Foods",
|
||||
"units": "Units",
|
||||
"back": "Back",
|
||||
"next": "Next"
|
||||
"view": "Zobrazit",
|
||||
"wednesday": "Středa",
|
||||
"yes": "Ano",
|
||||
"foods": "Potraviny",
|
||||
"units": "Jednotky",
|
||||
"back": "Zpět",
|
||||
"next": "Další"
|
||||
},
|
||||
"group": {
|
||||
"are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete <b>{groupName}<b/>?",
|
||||
"cannot-delete-default-group": "Cannot delete default group",
|
||||
"cannot-delete-group-with-users": "Cannot delete group with users",
|
||||
"confirm-group-deletion": "Confirm Group Deletion",
|
||||
"create-group": "Create Group",
|
||||
"error-updating-group": "Error updating group",
|
||||
"group": "Group",
|
||||
"group-deleted": "Group deleted",
|
||||
"group-deletion-failed": "Group deletion failed",
|
||||
"group-id-with-value": "Group ID: {groupID}",
|
||||
"group-name": "Group Name",
|
||||
"group-not-found": "Group not found",
|
||||
"group-with-value": "Group: {groupID}",
|
||||
"groups": "Groups",
|
||||
"manage-groups": "Manage Groups",
|
||||
"user-group": "User Group",
|
||||
"user-group-created": "User Group Created",
|
||||
"user-group-creation-failed": "User Group Creation Failed",
|
||||
"are-you-sure-you-want-to-delete-the-group": "Jste si jisti, že chcete smazat <b>{groupName}<b/>?",
|
||||
"cannot-delete-default-group": "Nelze smazat výchozí skupinu",
|
||||
"cannot-delete-group-with-users": "Nelze smazat skupinu obsahující uživatele",
|
||||
"confirm-group-deletion": "Potvrdit smazání skupiny",
|
||||
"create-group": "Vytvořit skupinu",
|
||||
"error-updating-group": "Chyba při aktualizaci skupiny",
|
||||
"group": "Skupina",
|
||||
"group-deleted": "Skupina smazána",
|
||||
"group-deletion-failed": "Smazání skupiny se nezdařilo",
|
||||
"group-id-with-value": "ID skupiny: {groupID}",
|
||||
"group-name": "Název skupiny",
|
||||
"group-not-found": "Skupina nenalezena",
|
||||
"group-with-value": "Skupina: {groupID}",
|
||||
"groups": "Skupiny",
|
||||
"manage-groups": "Spravovat skupiny",
|
||||
"user-group": "Skupina uživatelů",
|
||||
"user-group-created": "Uživatelská skupina vytvořena",
|
||||
"user-group-creation-failed": "Vytvoření uživatelské skupiny se nezdařilo",
|
||||
"settings": {
|
||||
"keep-my-recipes-private": "Keep My Recipes Private",
|
||||
"keep-my-recipes-private-description": "Sets your group and all recipes defaults to private. You can always change this later."
|
||||
"keep-my-recipes-private": "Ponechat mé recepty soukromé",
|
||||
"keep-my-recipes-private-description": "Nastaví vaši skupinu a všechny recepty jako soukromé. Později to můžete změnit."
|
||||
}
|
||||
},
|
||||
"meal-plan": {
|
||||
"create-a-new-meal-plan": "Create a New Meal Plan",
|
||||
"dinner-this-week": "Dinner This Week",
|
||||
"dinner-today": "Dinner Today",
|
||||
"create-a-new-meal-plan": "Vytvořit nový jídelníček",
|
||||
"dinner-this-week": "Večeře na tento týden",
|
||||
"dinner-today": "Dnešní večeře",
|
||||
"dinner-tonight": "DINNER TONIGHT",
|
||||
"edit-meal-plan": "Edit Meal Plan",
|
||||
"end-date": "End Date",
|
||||
@@ -224,58 +224,59 @@
|
||||
"404-page-not-found": "404 Page not found",
|
||||
"all-recipes": "All Recipes",
|
||||
"new-page-created": "New page created",
|
||||
"page": "Page",
|
||||
"page-creation-failed": "Page creation failed",
|
||||
"page-deleted": "Page deleted",
|
||||
"page-deletion-failed": "Page deletion failed",
|
||||
"page-update-failed": "Page update failed",
|
||||
"page-updated": "Page updated",
|
||||
"pages-update-failed": "Pages update failed",
|
||||
"pages-updated": "Pages updated"
|
||||
"page": "Stránka",
|
||||
"page-creation-failed": "Vytvoření stránky se nezdařilo",
|
||||
"page-deleted": "Stránka smazána",
|
||||
"page-deletion-failed": "Smazání stránky se nezdařilo",
|
||||
"page-update-failed": "Aktualizace stránky se nezdařila",
|
||||
"page-updated": "Stránka aktualizována",
|
||||
"pages-update-failed": "Aktualizace stránek se nezdařila",
|
||||
"pages-updated": "Stránky aktualizovány"
|
||||
},
|
||||
"recipe": {
|
||||
"add-key": "Add Key",
|
||||
"add-to-favorites": "Add to Favorites",
|
||||
"add-key": "Přidat klíč",
|
||||
"add-to-favorites": "Přidat do oblíbených",
|
||||
"api-extras": "API Extras",
|
||||
"calories": "Calories",
|
||||
"calories-suffix": "calories",
|
||||
"carbohydrate-content": "Carbohydrate",
|
||||
"categories": "Categories",
|
||||
"comment-action": "Comment",
|
||||
"comments": "Comments",
|
||||
"delete-confirmation": "Are you sure you want to delete this recipe?",
|
||||
"delete-recipe": "Delete Recipe",
|
||||
"description": "Description",
|
||||
"disable-amount": "Disable Ingredient Amounts",
|
||||
"disable-comments": "Disable Comments",
|
||||
"fat-content": "Fat",
|
||||
"fiber-content": "Fiber",
|
||||
"grams": "grams",
|
||||
"ingredient": "Ingredient",
|
||||
"ingredients": "Ingredients",
|
||||
"insert-section": "Insert Section",
|
||||
"instructions": "Instructions",
|
||||
"key-name-required": "Key Name Required",
|
||||
"calories": "Kalorie",
|
||||
"calories-suffix": "kalorie",
|
||||
"carbohydrate-content": "Sacharidy",
|
||||
"categories": "Kategorie",
|
||||
"comment-action": "Komentář",
|
||||
"comments": "Komentáře",
|
||||
"delete-confirmation": "Opravdu chcete smazat tento recept?",
|
||||
"delete-recipe": "Smazat recept",
|
||||
"description": "Popis",
|
||||
"disable-amount": "Nezobrazovat množství ingrediencí",
|
||||
"disable-comments": "Zakázat komentáře",
|
||||
"edit-scale": "Upravit měřítko",
|
||||
"fat-content": "Tuky",
|
||||
"fiber-content": "Vláknina",
|
||||
"grams": "gramy",
|
||||
"ingredient": "Ingredience",
|
||||
"ingredients": "Ingredience",
|
||||
"insert-section": "Vložit sekci",
|
||||
"instructions": "Postup",
|
||||
"key-name-required": "Je vyžadován název klíče",
|
||||
"landscape-view-coming-soon": "Landscape View (Coming Soon)",
|
||||
"milligrams": "milligrams",
|
||||
"new-key-name": "New Key Name",
|
||||
"no-white-space-allowed": "No White Space Allowed",
|
||||
"note": "Note",
|
||||
"nutrition": "Nutrition",
|
||||
"object-key": "Object Key",
|
||||
"object-value": "Object Value",
|
||||
"original-url": "Original URL",
|
||||
"perform-time": "Cook Time",
|
||||
"prep-time": "Prep Time",
|
||||
"protein-content": "Protein",
|
||||
"public-recipe": "Public Recipe",
|
||||
"recipe-created": "Recipe created",
|
||||
"recipe-creation-failed": "Recipe creation failed",
|
||||
"recipe-deleted": "Recipe deleted",
|
||||
"recipe-image": "Recipe Image",
|
||||
"recipe-image-updated": "Recipe image updated",
|
||||
"recipe-name": "Recipe Name",
|
||||
"recipe-settings": "Recipe Settings",
|
||||
"milligrams": "miligramy",
|
||||
"new-key-name": "Nový název klíče",
|
||||
"no-white-space-allowed": "Prázdná místa nejsou povolena",
|
||||
"note": "Poznámka",
|
||||
"nutrition": "Výživové hodnoty",
|
||||
"object-key": "Klíč objektu",
|
||||
"object-value": "Hodnota objektu",
|
||||
"original-url": "Původní URL",
|
||||
"perform-time": "Doba vaření",
|
||||
"prep-time": "Doba přípravy",
|
||||
"protein-content": "Bílkoviny",
|
||||
"public-recipe": "Veřejný recept",
|
||||
"recipe-created": "Recept vytvořen",
|
||||
"recipe-creation-failed": "Vytvoření receptu selhalo",
|
||||
"recipe-deleted": "Recept smazán",
|
||||
"recipe-image": "Obrázek receptu",
|
||||
"recipe-image-updated": "Obrázek receptu aktualizován",
|
||||
"recipe-name": "Název receptu",
|
||||
"recipe-settings": "Nastavení receptu",
|
||||
"recipe-update-failed": "Recipe update failed",
|
||||
"recipe-updated": "Recipe updated",
|
||||
"remove-from-favorites": "Remove from Favorites",
|
||||
@@ -334,62 +335,62 @@
|
||||
"card-per-section": "Card Per Section",
|
||||
"home-page": "Home Page",
|
||||
"home-page-sections": "Home Page Sections",
|
||||
"show-recent": "Show Recent"
|
||||
"show-recent": "Zobrazit poslední"
|
||||
},
|
||||
"language": "Language",
|
||||
"latest": "Latest",
|
||||
"local-api": "Local API",
|
||||
"locale-settings": "Locale settings",
|
||||
"migrations": "Migrations",
|
||||
"new-page": "New Page",
|
||||
"notify": "Notify",
|
||||
"organize": "Organize",
|
||||
"page-name": "Page Name",
|
||||
"pages": "Pages",
|
||||
"profile": "Profile",
|
||||
"remove-existing-entries-matching-imported-entries": "Remove existing entries matching imported entries",
|
||||
"set-new-time": "Set New Time",
|
||||
"settings-update-failed": "Settings update failed",
|
||||
"settings-updated": "Settings updated",
|
||||
"site-settings": "Site Settings",
|
||||
"language": "Jazyk",
|
||||
"latest": "Poslední",
|
||||
"local-api": "Lokální API",
|
||||
"locale-settings": "Nastavení locale",
|
||||
"migrations": "Migrace",
|
||||
"new-page": "Nová stránka",
|
||||
"notify": "Upozornit",
|
||||
"organize": "Organizovat",
|
||||
"page-name": "Název stránky",
|
||||
"pages": "Stránky",
|
||||
"profile": "Profil",
|
||||
"remove-existing-entries-matching-imported-entries": "Odstranit existující položky odpovídající importovaným položkám",
|
||||
"set-new-time": "Nastavit nový čas",
|
||||
"settings-update-failed": "Aktualizace nastavení se nezdařila",
|
||||
"settings-updated": "Nastavení aktualizováno",
|
||||
"site-settings": "Nastavení webu",
|
||||
"theme": {
|
||||
"accent": "Accent",
|
||||
"dark": "Dark",
|
||||
"default-to-system": "Default to system",
|
||||
"error": "Error",
|
||||
"error-creating-theme-see-log-file": "Error creating theme. See log file.",
|
||||
"error-deleting-theme": "Error deleting theme",
|
||||
"error-updating-theme": "Error updating theme",
|
||||
"info": "Info",
|
||||
"light": "Light",
|
||||
"primary": "Primary",
|
||||
"secondary": "Secondary",
|
||||
"success": "Success",
|
||||
"switch-to-dark-mode": "Switch to dark mode",
|
||||
"switch-to-light-mode": "Switch to light mode",
|
||||
"theme-deleted": "Theme deleted",
|
||||
"theme-name": "Theme Name",
|
||||
"theme-name-is-required": "Theme Name is required.",
|
||||
"theme-saved": "Theme Saved",
|
||||
"theme-updated": "Theme updated",
|
||||
"warning": "Warning"
|
||||
"accent": "Odstín",
|
||||
"dark": "Tmavý",
|
||||
"default-to-system": "Výchozí nastavení systému",
|
||||
"error": "Chyba",
|
||||
"error-creating-theme-see-log-file": "Chyba při vytváření motivu. Viz log soubor.",
|
||||
"error-deleting-theme": "Chyba při mazání motivu",
|
||||
"error-updating-theme": "Chyba při aktualizaci motivu",
|
||||
"info": "Informace",
|
||||
"light": "Světlý",
|
||||
"primary": "Primární",
|
||||
"secondary": "Sekundární",
|
||||
"success": "Úspěšně dokončeno",
|
||||
"switch-to-dark-mode": "Přepnout do tmavého režimu",
|
||||
"switch-to-light-mode": "Přepnout do světlého režimu",
|
||||
"theme-deleted": "Motiv odstraněn",
|
||||
"theme-name": "Název motivu",
|
||||
"theme-name-is-required": "Název motivu je povinný.",
|
||||
"theme-saved": "Motiv uložen",
|
||||
"theme-updated": "Motiv aktualizován",
|
||||
"warning": "Upozornění"
|
||||
},
|
||||
"token": {
|
||||
"active-tokens": "ACTIVE TOKENS",
|
||||
"active-tokens": "AKTIVNÍ TOKENY",
|
||||
"api-token": "API Token",
|
||||
"api-tokens": "API Tokens",
|
||||
"copy-this-token-for-use-with-an-external-application-this-token-will-not-be-viewable-again": "Copy this token for use with an external application. This token will not be viewable again.",
|
||||
"create-an-api-token": "Create an API Token",
|
||||
"token-name": "Token Name"
|
||||
"api-tokens": "API Tokeny",
|
||||
"copy-this-token-for-use-with-an-external-application-this-token-will-not-be-viewable-again": "Zkopírujte tento token pro použití v externí aplikaci. Tento token nebude znovu zobrazen.",
|
||||
"create-an-api-token": "Vytvořit nový API token",
|
||||
"token-name": "Název tokenu"
|
||||
},
|
||||
"toolbox": {
|
||||
"assign-all": "Assign All",
|
||||
"bulk-assign": "Bulk Assign",
|
||||
"new-name": "New Name",
|
||||
"no-unused-items": "No Unused Items",
|
||||
"recipes-affected": "No Recipes Affected|One Recipe Affected|{count} Recipes Affected",
|
||||
"remove-unused": "Remove Unused",
|
||||
"title-case-all": "Title Case All",
|
||||
"assign-all": "Přiřadit vše",
|
||||
"bulk-assign": "Hromadné přiřazení",
|
||||
"new-name": "Nový název",
|
||||
"no-unused-items": "Žádné nepoužité položky",
|
||||
"recipes-affected": "Žádné recepty neovlivněny|Jeden recept ovlivněn|{count} receptů ovlivněno",
|
||||
"remove-unused": "Odstranit nepoužívané",
|
||||
"title-case-all": "Změnit první písmena slov na kapitálky",
|
||||
"toolbox": "Toolbox",
|
||||
"unorganized": "Unorganized"
|
||||
},
|
||||
@@ -455,26 +456,26 @@
|
||||
"are-you-sure-you-want-to-delete-the-link": "Are you sure you want to delete the link <b>{link}<b/>?",
|
||||
"are-you-sure-you-want-to-delete-the-user": "Are you sure you want to delete the user <b>{activeName} ID: {activeId}<b/>?",
|
||||
"confirm-link-deletion": "Confirm Link Deletion",
|
||||
"confirm-password": "Confirm Password",
|
||||
"confirm-user-deletion": "Confirm User Deletion",
|
||||
"could-not-validate-credentials": "Could Not Validate Credentials",
|
||||
"create-link": "Create Link",
|
||||
"create-user": "Create User",
|
||||
"current-password": "Current Password",
|
||||
"e-mail-must-be-valid": "E-mail must be valid",
|
||||
"edit-user": "Edit User",
|
||||
"confirm-password": "Potvrdit heslo",
|
||||
"confirm-user-deletion": "Potvrdit smazání uživatele",
|
||||
"could-not-validate-credentials": "Nelze ověřit přihlašovací údaje",
|
||||
"create-link": "Vytvořit odkaz",
|
||||
"create-user": "Vytvořit uživatele",
|
||||
"current-password": "Současné heslo",
|
||||
"e-mail-must-be-valid": "E-mail musí být platný",
|
||||
"edit-user": "Upravit uživatele",
|
||||
"email": "Email",
|
||||
"error-cannot-delete-super-user": "Error! Cannot Delete Super User",
|
||||
"existing-password-does-not-match": "Existing password does not match",
|
||||
"full-name": "Full Name",
|
||||
"invite-only": "Invite Only",
|
||||
"link-id": "Link ID",
|
||||
"link-name": "Link Name",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"manage-users": "Manage Users",
|
||||
"new-password": "New Password",
|
||||
"new-user": "New User",
|
||||
"error-cannot-delete-super-user": "Chyba! Nelze odstranit superuživatele",
|
||||
"existing-password-does-not-match": "Hesla se neshodují",
|
||||
"full-name": "Jméno a příjmení",
|
||||
"invite-only": "Jen na pozvání",
|
||||
"link-id": "ID odkazu",
|
||||
"link-name": "Název odkazu",
|
||||
"login": "Přihlášení",
|
||||
"logout": "Odhlášení",
|
||||
"manage-users": "Spravovat uživatele",
|
||||
"new-password": "Nové heslo",
|
||||
"new-user": "Nový uživatel",
|
||||
"password-has-been-reset-to-the-default-password": "Password has been reset to the default password",
|
||||
"password-must-match": "Password must match",
|
||||
"password-reset-failed": "Password reset failed",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Beskrivelse",
|
||||
"disable-amount": "Slå ingrediensmængder fra",
|
||||
"disable-comments": "Slå kommentarer fra",
|
||||
"edit-scale": "Rediger skalering",
|
||||
"fat-content": "Fedt",
|
||||
"fiber-content": "Kostfibre",
|
||||
"grams": "gram",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Beschreibung",
|
||||
"disable-amount": "Zutatenmenge deaktivieren",
|
||||
"disable-comments": "Kommentare deaktivieren",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Fett",
|
||||
"fiber-content": "Ballaststoffe",
|
||||
"grams": "g",
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
"about": {
|
||||
"about": "Σχετικά με",
|
||||
"about-mealie": "Σχετικά με το Mealie",
|
||||
"api-docs": "API Docs",
|
||||
"api-port": "API Port",
|
||||
"api-docs": "Έγγραφα API",
|
||||
"api-port": "Θύρα API",
|
||||
"application-mode": "Κατάσταση εφαρμογής",
|
||||
"database-type": "Τύπος βάσης δεδομένων",
|
||||
"database-url": "Database URL",
|
||||
"database-url": "URL Βάσης Δεδομένων",
|
||||
"default-group": "Προεπιλεγμένη ομάδα",
|
||||
"demo": "Επίδειξη",
|
||||
"demo-status": "Κατάσταση επίδειξης",
|
||||
"development": "Ανάπτυξη",
|
||||
"docs": "Docs",
|
||||
"docs": "Έγγραφα",
|
||||
"download-log": "Λήψη αρχείου καταγραφής",
|
||||
"download-recipe-json": "Τελευταίο Scraped JSON",
|
||||
"github": "Github",
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"asset": {
|
||||
"assets": "Στοιχεία",
|
||||
"code": "Code",
|
||||
"code": "Κώδικας",
|
||||
"file": "Αρχείο",
|
||||
"image": "Εικόνα",
|
||||
"new-asset": "Νέο Στοιχείο",
|
||||
@@ -33,7 +33,7 @@
|
||||
"show-assets": "Εμφάνιση Στοιχείων"
|
||||
},
|
||||
"category": {
|
||||
"categories": "Categories",
|
||||
"categories": "Κατηγορίες",
|
||||
"category-created": "Δημιουργήθηκε η κατηγορία",
|
||||
"category-creation-failed": "Η δημιουργία κατηγορίας απέτυχε",
|
||||
"category-deleted": "Κατηγορία Διαγράφηκε",
|
||||
@@ -69,7 +69,7 @@
|
||||
"dashboard": "Ταμπλό",
|
||||
"delete": "Διαγραφή",
|
||||
"disabled": "Ανενεργό",
|
||||
"download": "Download",
|
||||
"download": "Λήψη",
|
||||
"edit": "Επεξεργασία",
|
||||
"enabled": "Ενεργό",
|
||||
"exception": "Εξαίρεση",
|
||||
@@ -131,10 +131,10 @@
|
||||
"view": "Προβολη",
|
||||
"wednesday": "Τετάρτη",
|
||||
"yes": "Ναι",
|
||||
"foods": "Foods",
|
||||
"units": "Units",
|
||||
"back": "Back",
|
||||
"next": "Next"
|
||||
"foods": "Φαγητά",
|
||||
"units": "Μονάδες",
|
||||
"back": "Πίσω",
|
||||
"next": "Επόμενο"
|
||||
},
|
||||
"group": {
|
||||
"are-you-sure-you-want-to-delete-the-group": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό τον ασφαλή σύνδεσμο <b>{groupName}<b/>;",
|
||||
@@ -156,8 +156,8 @@
|
||||
"user-group-created": "Η Ομάδα Χρηστών Δημιουργήθηκε",
|
||||
"user-group-creation-failed": "Αποτυχία Δημιουργίας Ομάδας Χρηστών",
|
||||
"settings": {
|
||||
"keep-my-recipes-private": "Keep My Recipes Private",
|
||||
"keep-my-recipes-private-description": "Sets your group and all recipes defaults to private. You can always change this later."
|
||||
"keep-my-recipes-private": "Κρατήστε Τις Συνταγές Μου Ιδιωτικές",
|
||||
"keep-my-recipes-private-description": "Ορίζει την ομάδα σας και όλες τις συνταγές ιδιωτικές από προεπιλογή. Μπορείτε πάντα να το αλλάξετε αργότερα."
|
||||
}
|
||||
},
|
||||
"meal-plan": {
|
||||
@@ -206,7 +206,7 @@
|
||||
"error-details": "Μόνο ιστοσελίδες που περιέχουν ld+json ή μικροδεδομένα μπορούν να εισαχθούν από την Mealie. Οι πιο σημαντικές ιστοσελίδες συνταγών υποστηρίζουν αυτή τη δομή δεδομένων. Αν το site σας δεν μπορεί να εισαχθεί, αλλά υπάρχουν δεδομένα json στο αρχείο καταγραφής, παρακαλούμε να υποβάλετε ένα github πρόβλημα με το URL και τα δεδομένα.",
|
||||
"error-title": "Φαίνεται Όπως Δεν Μπορούσαμε Να βρούμε Οτιδήποτε",
|
||||
"from-url": "Εισαγωγή συνταγής",
|
||||
"github-issues": "GitHub Issues",
|
||||
"github-issues": "Σφάλματα GitHub",
|
||||
"google-ld-json-info": "Google ld+json Info",
|
||||
"must-be-a-valid-url": "Πρέπει να είναι ένα έγκυρο URL",
|
||||
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Επικόλληση δεδομένων συνταγών σας. Κάθε γραμμή θα αντιμετωπίζεται ως αντικείμενο σε μια λίστα",
|
||||
@@ -216,9 +216,9 @@
|
||||
"upload-individual-zip-file": "Ανεβάστε ένα μεμονωμένο αρχείο .zip που εξάγεται από μια άλλη περίπτωση Mealie.",
|
||||
"url-form-hint": "Αντιγράψτε και επικολλήστε έναν σύνδεσμο από την αγαπημένη σας ιστοσελίδα συνταγών",
|
||||
"view-scraped-data": "Προβολή Παραγόμενων Δεδομένων",
|
||||
"trim-whitespace-description": "Trim leading and trailing whitespace as well as blank lines",
|
||||
"trim-prefix-description": "Trim first character from each line",
|
||||
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns"
|
||||
"trim-whitespace-description": "Περικοπή αιχμής και διαδρομής κενών καθώς και κενών γραμμών",
|
||||
"trim-prefix-description": "Περικοπή πρώτου χαρακτήρα από κάθε γραμμή",
|
||||
"split-by-numbered-line-description": "Προσπάθεια για χωρισμό μιας παραγράφου ταιριάζοντας μοτίβα '1)' ή '1'"
|
||||
},
|
||||
"page": {
|
||||
"404-page-not-found": "404. η σελίδα δεν βρέθηκε",
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Περιγραφή",
|
||||
"disable-amount": "Απενεργοποίηση Ποσών Συστατικών",
|
||||
"disable-comments": "Απενεργοποιηση σχολιων",
|
||||
"edit-scale": "Επεξεργασία Κλίμακας",
|
||||
"fat-content": "Λιπαρά",
|
||||
"fiber-content": "Ίνα",
|
||||
"grams": "γραμμάρια",
|
||||
@@ -291,7 +292,7 @@
|
||||
"title": "Τίτλος",
|
||||
"total-time": "Συνολικός Χρόνος",
|
||||
"unable-to-delete-recipe": "Αδυναμία διαγραφής συνταγής",
|
||||
"no-recipe": "No Recipe"
|
||||
"no-recipe": "Καμία Συνταγή"
|
||||
},
|
||||
"search": {
|
||||
"advanced-search": "Σύνθετη Αναζήτηση",
|
||||
@@ -413,9 +414,9 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"all-recipes": "Συνταγές όλες",
|
||||
"backups": "Backups",
|
||||
"backups": "Αντίγραφα ασφαλείας",
|
||||
"categories": "Κατηγορίες",
|
||||
"cookbooks": "Cookbooks",
|
||||
"cookbooks": "Μαγειρικά Βιβλία",
|
||||
"dashboard": "Ταμπλό",
|
||||
"home-page": "Αρχική Σελίδα",
|
||||
"manage-users": "Διαχ. χρηστών",
|
||||
@@ -425,7 +426,7 @@
|
||||
"site-settings": "Ρυθμ. site",
|
||||
"tags": "Ετικέτα",
|
||||
"toolbox": "Εργαλειοθήκη",
|
||||
"language": "Language"
|
||||
"language": "Γλώσσα"
|
||||
},
|
||||
"signup": {
|
||||
"error-signing-up": "Σφάλμα Στην Υπογραφή",
|
||||
@@ -448,7 +449,7 @@
|
||||
"untagged-count": "Χωρίς ετικέτα {count}"
|
||||
},
|
||||
"tool": {
|
||||
"tools": "Tools"
|
||||
"tools": "Εργαλεία"
|
||||
},
|
||||
"user": {
|
||||
"admin": "Διαχειριστής",
|
||||
@@ -467,7 +468,7 @@
|
||||
"error-cannot-delete-super-user": "Σφάλμα! Αδυναμία Διαγραφής Υπερχρήστη",
|
||||
"existing-password-does-not-match": "Ο υπάρχων κωδικός πρόσβασης δεν ταιριάζει",
|
||||
"full-name": "Πλήρες όνομα",
|
||||
"invite-only": "Invite Only",
|
||||
"invite-only": "Μόνο με πρόσκληση",
|
||||
"link-id": "Σύνδεσμος ID",
|
||||
"link-name": "Όνομα συνδέσμου",
|
||||
"login": "Σύνδεση",
|
||||
@@ -480,8 +481,8 @@
|
||||
"password-reset-failed": "Αποτυχία επαναφοράς κωδικού πρόσβασης",
|
||||
"password-updated": "Ο κωδικός πρόσβασης ενημερώθηκε",
|
||||
"password": "Κωδικός",
|
||||
"password-strength": "Password is {strength}",
|
||||
"register": "Register",
|
||||
"password-strength": "Ο κωδικός είναι {strength}",
|
||||
"register": "Εγγραφή",
|
||||
"reset-password": "Επαναφορά Κωδικού",
|
||||
"sign-in": "Είσοδος",
|
||||
"total-mealplans": "Σύνολο Σχεδίων Γεύματος",
|
||||
@@ -505,21 +506,21 @@
|
||||
"webhooks-enabled": "Το webhook είναι ενεργό",
|
||||
"you-are-not-allowed-to-create-a-user": "Δεν επιτρέπεται να δημιουργήσετε ένα χρήστη",
|
||||
"you-are-not-allowed-to-delete-this-user": "Δεν επιτρέπεται να διαγράψετε αυτόν τον χρήστη",
|
||||
"enable-advanced-content": "Enable Advanced Content",
|
||||
"enable-advanced-content-description": "Enables advanced features like Recipe Scaling, API keys, Webhooks, and Data Management. Don't worry, you can always change this later"
|
||||
"enable-advanced-content": "Ενεργοποίηση Προηγμένου Περιεχομένου",
|
||||
"enable-advanced-content-description": "Ενεργοποιεί προηγμένες λειτουργίες όπως κλιμάκωση συνταγής, κλειδιά API, Webhooks και διαχείριση δεδομένων. Μην ανησυχείτε, μπορείτε πάντα να το αλλάξετε αργότερα"
|
||||
},
|
||||
"language-dialog": {
|
||||
"translated": "translated",
|
||||
"choose-language": "Choose Language",
|
||||
"select-description": "Choose the language for the Mealie UI. The setting only applies to you, not other users.",
|
||||
"how-to-contribute-description": "Is something not translated yet, mistranslated, or your language missing from the list? {read-the-docs-link} on how to contribute!",
|
||||
"read-the-docs": "Read the docs"
|
||||
"translated": "μεταφρασμένο",
|
||||
"choose-language": "Επιλογή γλώσσας",
|
||||
"select-description": "Επιλέξτε τη γλώσσα για το περιβάλλον εργασίας εργασίας Mealie. Η ρύθμιση ισχύει μόνο για εσάς, όχι για άλλους χρήστες.",
|
||||
"how-to-contribute-description": "Δεν είναι κάτι μεταφρασμένο ακόμα, λανθασμένο, ή η γλώσσα σας λείπει από τη λίστα? {read-the-docs-link} για το πώς να συνεισφέρει!",
|
||||
"read-the-docs": "Διαβάστε τα έγγραφα"
|
||||
},
|
||||
"data-pages": {
|
||||
"seed-data": "Seed Data",
|
||||
"foods": {
|
||||
"merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.",
|
||||
"merge-food-example": "Merging {food1} into {food2}",
|
||||
"merge-dialog-text": "Ο συνδυασμός των επιλεγμένων τροφίμων θα συγχωνεύσει την πηγή τροφίμων και θα στοχεύσει τα τρόφιμα σε ένα μόνο φαγητό. Η πηγή τροφίμων θα διαγραφεί και όλες οι αναφορές στην πηγή τροφίμων θα ενημερωθούν ώστε να υποδείξουν το τρόφιμο-στόχο.",
|
||||
"merge-food-example": "Συγχώνευση {food1} στο {food2}",
|
||||
"seed-dialog-text": "Seed the database with foods based on your local language. This will create 200+ common foods that can be used to organize your database. Foods are translated via a community effort.",
|
||||
"seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually."
|
||||
},
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Description",
|
||||
"disable-amount": "Disable Ingredient Amounts",
|
||||
"disable-comments": "Disable Comments",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Fat",
|
||||
"fiber-content": "Fibre",
|
||||
"grams": "grams",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Description",
|
||||
"disable-amount": "Disable Ingredient Amounts",
|
||||
"disable-comments": "Disable Comments",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Fat",
|
||||
"fiber-content": "Fiber",
|
||||
"grams": "grams",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Descripción",
|
||||
"disable-amount": "Desactivar cantidades de ingredientes",
|
||||
"disable-comments": "Desactivar comentarios",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Grasa",
|
||||
"fiber-content": "Fibra",
|
||||
"grams": "gramos",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Description",
|
||||
"disable-amount": "Disable Ingredient Amounts",
|
||||
"disable-comments": "Disable Comments",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Fat",
|
||||
"fiber-content": "Fiber",
|
||||
"grams": "grams",
|
||||
|
||||
@@ -216,9 +216,9 @@
|
||||
"upload-individual-zip-file": "Chargez un fichier .zip exporté depuis une autre instance Mealie.",
|
||||
"url-form-hint": "Copiez et collez un lien depuis votre site de recettes favori",
|
||||
"view-scraped-data": "Voir les données récupérées",
|
||||
"trim-whitespace-description": "Trim leading and trailing whitespace as well as blank lines",
|
||||
"trim-prefix-description": "Trim first character from each line",
|
||||
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns"
|
||||
"trim-whitespace-description": "Ajuster les espaces de début et de fin ainsi que les lignes vides",
|
||||
"trim-prefix-description": "Couper le premier caractère de chaque ligne",
|
||||
"split-by-numbered-line-description": "Tenter de découper un paragraphe par correspondance de motifs : '1) ou '1.'"
|
||||
},
|
||||
"page": {
|
||||
"404-page-not-found": "404 Page introuvable",
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Description",
|
||||
"disable-amount": "Désactiver les quantités d'ingrédients",
|
||||
"disable-comments": "Désactiver les commentaires",
|
||||
"edit-scale": "Modifier l'échelle",
|
||||
"fat-content": "Matières grasses",
|
||||
"fiber-content": "Fibres",
|
||||
"grams": "grammes",
|
||||
@@ -415,7 +416,7 @@
|
||||
"all-recipes": "Les recettes",
|
||||
"backups": "Sauvegardes",
|
||||
"categories": "Catégories",
|
||||
"cookbooks": "Cookbooks",
|
||||
"cookbooks": "Livre de recettes",
|
||||
"dashboard": "Console",
|
||||
"home-page": "Accueil",
|
||||
"manage-users": "Utilisateurs",
|
||||
@@ -467,7 +468,7 @@
|
||||
"error-cannot-delete-super-user": "Erreur! Impossible de supprimer le super utilisateur",
|
||||
"existing-password-does-not-match": "Le mot de passe actuel ne correspond pas",
|
||||
"full-name": "Nom",
|
||||
"invite-only": "Invite Only",
|
||||
"invite-only": "Invités uniquement",
|
||||
"link-id": "ID du lien",
|
||||
"link-name": "Nom du lien",
|
||||
"login": "Connexion",
|
||||
@@ -480,7 +481,7 @@
|
||||
"password-reset-failed": "Échec de la réinitialisation du mot de passe",
|
||||
"password-updated": "Mot de passe mis à jour",
|
||||
"password": "Mot de passe",
|
||||
"password-strength": "Password is {strength}",
|
||||
"password-strength": "Robustesse du mot de passe : {strength}",
|
||||
"register": "S'inscrire",
|
||||
"reset-password": "Réinitialiser le mot de passe",
|
||||
"sign-in": "Se connecter",
|
||||
@@ -505,45 +506,45 @@
|
||||
"webhooks-enabled": "Webhooks activés",
|
||||
"you-are-not-allowed-to-create-a-user": "Vous n'avez pas le droit de créer un utilisateur",
|
||||
"you-are-not-allowed-to-delete-this-user": "Vous n'avez pas le droit de supprimer cet utilisateur",
|
||||
"enable-advanced-content": "Enable Advanced Content",
|
||||
"enable-advanced-content-description": "Enables advanced features like Recipe Scaling, API keys, Webhooks, and Data Management. Don't worry, you can always change this later"
|
||||
"enable-advanced-content": "Activer le contenu avancé",
|
||||
"enable-advanced-content-description": "Active les fonctionnalités avancées comme la mise à l'échelle des recettes, les clés API, les Webhooks, et la gestion des données. Ne vous inquiétez pas, vous pouvez toujours modifier cela plus tard"
|
||||
},
|
||||
"language-dialog": {
|
||||
"translated": "translated",
|
||||
"choose-language": "Choose Language",
|
||||
"select-description": "Choose the language for the Mealie UI. The setting only applies to you, not other users.",
|
||||
"how-to-contribute-description": "Is something not translated yet, mistranslated, or your language missing from the list? {read-the-docs-link} on how to contribute!",
|
||||
"read-the-docs": "Read the docs"
|
||||
"translated": "traduit",
|
||||
"choose-language": "Choisir la langue",
|
||||
"select-description": "Choisissez la langue de l'interface utilisateur de Mealie. Ce paramètre s'applique uniquement à vous, pas aux autres utilisateurs.",
|
||||
"how-to-contribute-description": "Quelque chose n'est pas encore traduit, mal traduit, ou votre langue est manquante dans la liste ? {read-the-docs-link} sur la façon de contribuer !",
|
||||
"read-the-docs": "Lire la documentation"
|
||||
},
|
||||
"data-pages": {
|
||||
"seed-data": "Seed Data",
|
||||
"seed-data": "Génération de données",
|
||||
"foods": {
|
||||
"merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.",
|
||||
"merge-food-example": "Merging {food1} into {food2}",
|
||||
"seed-dialog-text": "Seed the database with foods based on your local language. This will create 200+ common foods that can be used to organize your database. Foods are translated via a community effort.",
|
||||
"seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually."
|
||||
"merge-dialog-text": "La combinaison des aliments sélectionnés fusionnera l'aliment source et l'aliment cible en un seul aliment. L'aliment source sera supprimé et toutes les références à l'aliment source seront mises à jour pour pointer vers l'aliment cible.",
|
||||
"merge-food-example": "Fusion de {food1} dans {food2}",
|
||||
"seed-dialog-text": "Ensemencez la base de données avec des aliments basés sur votre langue locale. Cela permettra de créer plus de 200 aliments communs qui pourront être utilisés pour organiser votre base de données. Les aliments sont traduits grâce à un effort communautaire.",
|
||||
"seed-dialog-warning": "Vous avez déjà des éléments dans votre base de données. Cette action ne réconciliera pas les doublons, vous devrez les gérer manuellement."
|
||||
},
|
||||
"units": {
|
||||
"seed-dialog-text": "Seed the database with common units based on your local language."
|
||||
"seed-dialog-text": "Introduisez dans la base de données des unités communes basées sur votre langue locale."
|
||||
},
|
||||
"labels": {
|
||||
"seed-dialog-text": "Seed the database with common labels based on your local language."
|
||||
"seed-dialog-text": "Introduisez dans la base de données des unités communes basées sur votre langue locale."
|
||||
}
|
||||
},
|
||||
"user-registration": {
|
||||
"user-registration": "User Registration",
|
||||
"join-a-group": "Join a Group",
|
||||
"create-a-new-group": "Create a New Group",
|
||||
"provide-registration-token-description": "Please provide the registration token associated with the group that you'd like to join. You'll need to obtain this from an existing group member.",
|
||||
"group-details": "Group Details",
|
||||
"group-details-description": "Before you create an account you'll need to create a group. Your group will only contain you, but you'll be able to invite others later. Members in your group can share meal plans, shopping lists, recipes, and more!",
|
||||
"use-seed-data": "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.",
|
||||
"account-details": "Account Details"
|
||||
"user-registration": "Inscription d'utilisateur",
|
||||
"join-a-group": "Rejoindre un groupe",
|
||||
"create-a-new-group": "Créer un nouveau groupe",
|
||||
"provide-registration-token-description": "Veuillez fournir le jeton d'enregistrement associé au groupe que vous souhaitez rejoindre. Vous devrez l'obtenir auprès d'un membre existant du groupe.",
|
||||
"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 planification de repas, leurs listes d'achat, leurs recettes et plus encore !",
|
||||
"use-seed-data": "Utiliser la génération de données",
|
||||
"use-seed-data-description": "Mealie est livré avec une collection d'aliments, d'unités et d'étiquettes qui peuvent être utilisés pour alimenter votre groupe avec des données utiles pour organiser vos recettes.",
|
||||
"account-details": "Détails du compte"
|
||||
},
|
||||
"validation": {
|
||||
"group-name-is-taken": "Group name is taken",
|
||||
"username-is-taken": "Username is taken",
|
||||
"email-is-taken": "Email is taken"
|
||||
"group-name-is-taken": "Le nom du groupe est déjà pris",
|
||||
"username-is-taken": "Nom d'utilisateur déjà utilisé",
|
||||
"email-is-taken": "Cet e-mail est déjà pris"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"log-lines": "Lignes de log",
|
||||
"not-demo": "Non",
|
||||
"portfolio": "Portfolio",
|
||||
"production": "Production",
|
||||
"production": "Réalisation",
|
||||
"support": "Soutenir",
|
||||
"version": "Version"
|
||||
},
|
||||
@@ -216,9 +216,9 @@
|
||||
"upload-individual-zip-file": "Chargez un fichier .zip exporté depuis une autre instance Mealie.",
|
||||
"url-form-hint": "Copiez et collez un lien depuis votre site de recettes favori",
|
||||
"view-scraped-data": "Voir les données récupérées",
|
||||
"trim-whitespace-description": "Trim leading and trailing whitespace as well as blank lines",
|
||||
"trim-prefix-description": "Trim first character from each line",
|
||||
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns"
|
||||
"trim-whitespace-description": "Ajuster les espaces de début et de fin ainsi que les lignes vides",
|
||||
"trim-prefix-description": "Couper le premier caractère de chaque ligne",
|
||||
"split-by-numbered-line-description": "Tente de découper un paragraphe par correspondance de motifs : '1) ou '1.'"
|
||||
},
|
||||
"page": {
|
||||
"404-page-not-found": "404 Page introuvable",
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Description",
|
||||
"disable-amount": "Désactiver les quantités des ingrédients",
|
||||
"disable-comments": "Désactiver les commentaires",
|
||||
"edit-scale": "Modifier l'échelle",
|
||||
"fat-content": "Matières grasses",
|
||||
"fiber-content": "Fibres",
|
||||
"grams": "grammes",
|
||||
@@ -260,7 +261,7 @@
|
||||
"milligrams": "milligrammes",
|
||||
"new-key-name": "Nouveau nom de clé",
|
||||
"no-white-space-allowed": "Aucun espace blanc autorisé",
|
||||
"note": "Note",
|
||||
"note": "Remarque",
|
||||
"nutrition": "Valeurs nutritionnelles",
|
||||
"object-key": "Clé d'objet",
|
||||
"object-value": "Valeur d'objet",
|
||||
@@ -415,7 +416,7 @@
|
||||
"all-recipes": "Les recettes",
|
||||
"backups": "Sauvegardes",
|
||||
"categories": "Catégories",
|
||||
"cookbooks": "Cookbooks",
|
||||
"cookbooks": "Livre de recettes",
|
||||
"dashboard": "Tableau de bord",
|
||||
"home-page": "Accueil",
|
||||
"manage-users": "Utilisateurs",
|
||||
@@ -451,7 +452,7 @@
|
||||
"tools": "Outils"
|
||||
},
|
||||
"user": {
|
||||
"admin": "Admin",
|
||||
"admin": "Administrateur",
|
||||
"are-you-sure-you-want-to-delete-the-link": "Êtes-vous sûr de vouloir supprimer le lien <b>{link}<b/> ?",
|
||||
"are-you-sure-you-want-to-delete-the-user": "Êtes-vous sûr de vouloir supprimer l'utilisateur <b>{activeName} ID : {activeId}<b/> ?",
|
||||
"confirm-link-deletion": "Confirmer la suppression du lien",
|
||||
@@ -467,7 +468,7 @@
|
||||
"error-cannot-delete-super-user": "Erreur ! Impossible de supprimer le super utilisateur",
|
||||
"existing-password-does-not-match": "Le mot de passe actuel ne correspond pas",
|
||||
"full-name": "Nom",
|
||||
"invite-only": "Invite Only",
|
||||
"invite-only": "Invités uniquement",
|
||||
"link-id": "ID du lien",
|
||||
"link-name": "Nom du lien",
|
||||
"login": "Connexion",
|
||||
@@ -480,7 +481,7 @@
|
||||
"password-reset-failed": "Échec de la réinitialisation du mot de passe",
|
||||
"password-updated": "Mot de passe mis à jour",
|
||||
"password": "Mot de passe",
|
||||
"password-strength": "Password is {strength}",
|
||||
"password-strength": "Robustesse du mot de passe : {strength}",
|
||||
"register": "S'inscrire",
|
||||
"reset-password": "Réinitialiser le mot de passe",
|
||||
"sign-in": "Se connecter",
|
||||
@@ -505,45 +506,45 @@
|
||||
"webhooks-enabled": "Webhooks activés",
|
||||
"you-are-not-allowed-to-create-a-user": "Vous n'avez pas le droit de créer un utilisateur",
|
||||
"you-are-not-allowed-to-delete-this-user": "Vous n'avez pas le droit de supprimer cet utilisateur",
|
||||
"enable-advanced-content": "Enable Advanced Content",
|
||||
"enable-advanced-content-description": "Enables advanced features like Recipe Scaling, API keys, Webhooks, and Data Management. Don't worry, you can always change this later"
|
||||
"enable-advanced-content": "Activer le contenu avancé",
|
||||
"enable-advanced-content-description": "Active les fonctionnalités avancées comme la mise à l'échelle des recettes, les clés API, les Webhooks, et la gestion des données. Ne vous inquiétez pas, vous pouvez toujours modifier cela plus tard"
|
||||
},
|
||||
"language-dialog": {
|
||||
"translated": "traduit",
|
||||
"choose-language": "Choisir la langue",
|
||||
"select-description": "Choose the language for the Mealie UI. The setting only applies to you, not other users.",
|
||||
"how-to-contribute-description": "Is something not translated yet, mistranslated, or your language missing from the list? {read-the-docs-link} on how to contribute!",
|
||||
"read-the-docs": "Read the docs"
|
||||
"select-description": "Choisissez la langue de l'interface utilisateur de Mealie. Ce paramètre s'applique uniquement à vous, pas aux autres utilisateurs.",
|
||||
"how-to-contribute-description": "Quelque chose n'est pas encore traduit, mal traduit, ou votre langue est manquante dans la liste ? {read-the-docs-link} sur la façon de contribuer !",
|
||||
"read-the-docs": "Lire la documentation"
|
||||
},
|
||||
"data-pages": {
|
||||
"seed-data": "Seed Data",
|
||||
"seed-data": "Génération de données",
|
||||
"foods": {
|
||||
"merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.",
|
||||
"merge-food-example": "Merging {food1} into {food2}",
|
||||
"seed-dialog-text": "Seed the database with foods based on your local language. This will create 200+ common foods that can be used to organize your database. Foods are translated via a community effort.",
|
||||
"seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually."
|
||||
"merge-dialog-text": "La combinaison des aliments sélectionnés fusionnera l'aliment source et l'aliment cible en un seul aliment. L'aliment source sera supprimé et toutes les références à l'aliment source seront mises à jour pour pointer vers l'aliment cible.",
|
||||
"merge-food-example": "Fusion de {food1} dans {food2}",
|
||||
"seed-dialog-text": "Ensemencez la base de données avec des aliments basés sur votre langue locale. Cela permettra de créer plus de 200 aliments communs qui pourront être utilisés pour organiser votre base de données. Les aliments sont traduits grâce à un effort communautaire.",
|
||||
"seed-dialog-warning": "Vous avez déjà des éléments dans votre base de données. Cette action ne réconciliera pas les doublons, vous devrez les gérer manuellement."
|
||||
},
|
||||
"units": {
|
||||
"seed-dialog-text": "Seed the database with common units based on your local language."
|
||||
"seed-dialog-text": "Introduisez dans la base de données des unités communes basées sur votre langue locale."
|
||||
},
|
||||
"labels": {
|
||||
"seed-dialog-text": "Seed the database with common labels based on your local language."
|
||||
"seed-dialog-text": "Introduisez dans la base de données des unités communes basées sur votre langue locale."
|
||||
}
|
||||
},
|
||||
"user-registration": {
|
||||
"user-registration": "User Registration",
|
||||
"user-registration": "Inscription d'utilisateur",
|
||||
"join-a-group": "Rejoindre un groupe",
|
||||
"create-a-new-group": "Créer un nouveau groupe",
|
||||
"provide-registration-token-description": "Please provide the registration token associated with the group that you'd like to join. You'll need to obtain this from an existing group member.",
|
||||
"provide-registration-token-description": "Veuillez fournir le jeton d'enregistrement associé au groupe que vous souhaitez rejoindre. Vous devrez l'obtenir auprès d'un membre existant du groupe.",
|
||||
"group-details": "Détails du groupe",
|
||||
"group-details-description": "Before you create an account you'll need to create a group. Your group will only contain you, but you'll be able to invite others later. Members in your group can share meal plans, shopping lists, recipes, and more!",
|
||||
"use-seed-data": "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.",
|
||||
"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 planification de repas, leurs listes d'achat, leurs recettes et plus encore !",
|
||||
"use-seed-data": "Utiliser la génération de données",
|
||||
"use-seed-data-description": "Mealie est livré avec une collection d'aliments, d'unités et d'étiquettes qui peuvent être utilisés pour alimenter votre groupe avec des données utiles pour organiser vos recettes.",
|
||||
"account-details": "Détails du compte"
|
||||
},
|
||||
"validation": {
|
||||
"group-name-is-taken": "Group name is taken",
|
||||
"group-name-is-taken": "Le nom du groupe est déjà pris",
|
||||
"username-is-taken": "Nom d'utilisateur déjà utilisé",
|
||||
"email-is-taken": "Email is taken"
|
||||
"email-is-taken": "Cet e-mail est déjà pris"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Description",
|
||||
"disable-amount": "Disable Ingredient Amounts",
|
||||
"disable-comments": "Disable Comments",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Fat",
|
||||
"fiber-content": "Fiber",
|
||||
"grams": "grams",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Leírás",
|
||||
"disable-amount": "Hozzávalók mennyiségének letiltása",
|
||||
"disable-comments": "Megjegyzések letiltása",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Zsír",
|
||||
"fiber-content": "Rostok",
|
||||
"grams": "gramm",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Descrizione",
|
||||
"disable-amount": "Disabilita Quantità Ingredienti",
|
||||
"disable-comments": "Disattiva Commenti",
|
||||
"edit-scale": "Modifica Scala",
|
||||
"fat-content": "Grassi",
|
||||
"fiber-content": "Fibre",
|
||||
"grams": "grammi",
|
||||
@@ -521,7 +522,7 @@
|
||||
"merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.",
|
||||
"merge-food-example": "Unione di {food1} in {food2}",
|
||||
"seed-dialog-text": "Inizializza il database con alimenti in base alla tua lingua locale. Questo creerà oltre 200 alimenti comuni che possono essere utilizzati per organizzare il tuo database. Gli alimenti sono tradotti grazie al contributo della comunità di utenti.",
|
||||
"seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually."
|
||||
"seed-dialog-warning": "Hai già alcuni elementi nel tuo database. Questa azione non riconcilierà i duplicati, dovrai gestirli manualmente."
|
||||
},
|
||||
"units": {
|
||||
"seed-dialog-text": "Seed the database with common units based on your local language."
|
||||
@@ -534,11 +535,11 @@
|
||||
"user-registration": "Registrazione Utente",
|
||||
"join-a-group": "Unisciti a un Gruppo",
|
||||
"create-a-new-group": "Crea un Nuovo Gruppo",
|
||||
"provide-registration-token-description": "Please provide the registration token associated with the group that you'd like to join. You'll need to obtain this from an existing group member.",
|
||||
"provide-registration-token-description": "Fornisci il token di registrazione associato al gruppo a cui desideri partecipare. Dovrai ottenerlo da un membro di gruppo esistente.",
|
||||
"group-details": "Dettagli del Guppo",
|
||||
"group-details-description": "Before you create an account you'll need to create a group. Your group will only contain you, but you'll be able to invite others later. Members in your group can share meal plans, shopping lists, recipes, and more!",
|
||||
"use-seed-data": "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.",
|
||||
"use-seed-data-description": "Mealie viene fornito con una raccolta di alimenti, unità ed etichette che possono essere utilizzate per popolare il tuo gruppo con dati utili per organizzare le tue ricette.",
|
||||
"account-details": "Dettagli dell'Account"
|
||||
},
|
||||
"validation": {
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Description",
|
||||
"disable-amount": "Disable Ingredient Amounts",
|
||||
"disable-comments": "Disable Comments",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Fat",
|
||||
"fiber-content": "Fiber",
|
||||
"grams": "grams",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Description",
|
||||
"disable-amount": "Disable Ingredient Amounts",
|
||||
"disable-comments": "Disable Comments",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Fat",
|
||||
"fiber-content": "Fiber",
|
||||
"grams": "grams",
|
||||
|
||||
550
frontend/lang/messages/lt-LT.json
Normal file
550
frontend/lang/messages/lt-LT.json
Normal file
@@ -0,0 +1,550 @@
|
||||
{
|
||||
"about": {
|
||||
"about": "About",
|
||||
"about-mealie": "About Mealie",
|
||||
"api-docs": "API Docs",
|
||||
"api-port": "API Port",
|
||||
"application-mode": "Application Mode",
|
||||
"database-type": "Database Type",
|
||||
"database-url": "Database URL",
|
||||
"default-group": "Default Group",
|
||||
"demo": "Demo",
|
||||
"demo-status": "Demo Status",
|
||||
"development": "Development",
|
||||
"docs": "Docs",
|
||||
"download-log": "Download Log",
|
||||
"download-recipe-json": "Last Scraped JSON",
|
||||
"github": "Github",
|
||||
"log-lines": "Log Lines",
|
||||
"not-demo": "Not Demo",
|
||||
"portfolio": "Portfolio",
|
||||
"production": "Production",
|
||||
"support": "Support",
|
||||
"version": "Version"
|
||||
},
|
||||
"asset": {
|
||||
"assets": "Assets",
|
||||
"code": "Code",
|
||||
"file": "File",
|
||||
"image": "Image",
|
||||
"new-asset": "New Asset",
|
||||
"pdf": "PDF",
|
||||
"recipe": "Recipe",
|
||||
"show-assets": "Show Assets"
|
||||
},
|
||||
"category": {
|
||||
"categories": "Categories",
|
||||
"category-created": "Category created",
|
||||
"category-creation-failed": "Category creation failed",
|
||||
"category-deleted": "Category Deleted",
|
||||
"category-deletion-failed": "Category deletion failed",
|
||||
"category-filter": "Category Filter",
|
||||
"category-update-failed": "Category update failed",
|
||||
"category-updated": "Category updated",
|
||||
"uncategorized-count": "Uncategorized {count}"
|
||||
},
|
||||
"events": {
|
||||
"apprise-url": "Apprise URL",
|
||||
"database": "Database",
|
||||
"delete-event": "Delete Event",
|
||||
"new-notification-form-description": "Mealie uses the Apprise library to generate notifications. They offer many options for services to use for notifications. Refer to their wiki for a comprehensive guide on how to create the URL for your service. If available, selecting the type of your notification may include extra features.",
|
||||
"new-version": "New version available!",
|
||||
"notification": "Notification",
|
||||
"refresh": "Refresh",
|
||||
"scheduled": "Scheduled",
|
||||
"something-went-wrong": "Something Went Wrong!",
|
||||
"subscribed-events": "Subscribed Events",
|
||||
"test-message-sent": "Test Message Sent"
|
||||
},
|
||||
"general": {
|
||||
"cancel": "Cancel",
|
||||
"clear": "Clear",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"confirm-delete-generic": "Are you sure you want to delete this?",
|
||||
"copied": "Copied",
|
||||
"create": "Create",
|
||||
"created": "Created",
|
||||
"custom": "Custom",
|
||||
"dashboard": "Dashboard",
|
||||
"delete": "Delete",
|
||||
"disabled": "Disabled",
|
||||
"download": "Download",
|
||||
"edit": "Edit",
|
||||
"enabled": "Enabled",
|
||||
"exception": "Exception",
|
||||
"failed-count": "Failed: {count}",
|
||||
"failure-uploading-file": "Failure uploading file",
|
||||
"favorites": "Favorites",
|
||||
"field-required": "Field Required",
|
||||
"file-folder-not-found": "File/folder not found",
|
||||
"file-uploaded": "File uploaded",
|
||||
"filter": "Filter",
|
||||
"friday": "Friday",
|
||||
"general": "General",
|
||||
"get": "Get",
|
||||
"home": "Home",
|
||||
"image": "Image",
|
||||
"image-upload-failed": "Image upload failed",
|
||||
"import": "Import",
|
||||
"json": "JSON",
|
||||
"keyword": "Keyword",
|
||||
"link-copied": "Link Copied",
|
||||
"loading-recipes": "Loading Recipes",
|
||||
"monday": "Monday",
|
||||
"name": "Name",
|
||||
"new": "New",
|
||||
"no": "No",
|
||||
"no-recipe-found": "No Recipe Found",
|
||||
"ok": "OK",
|
||||
"options": "Options:",
|
||||
"print": "Print",
|
||||
"random": "Random",
|
||||
"rating": "Rating",
|
||||
"recent": "Recent",
|
||||
"recipe": "Recipe",
|
||||
"recipes": "Recipes",
|
||||
"rename-object": "Rename {0}",
|
||||
"reset": "Reset",
|
||||
"saturday": "Saturday",
|
||||
"save": "Save",
|
||||
"settings": "Settings",
|
||||
"share": "Share",
|
||||
"shuffle": "Shuffle",
|
||||
"sort": "Sort",
|
||||
"sort-alphabetically": "Alphabetical",
|
||||
"status": "Status",
|
||||
"submit": "Submit",
|
||||
"success-count": "Success: {count}",
|
||||
"sunday": "Sunday",
|
||||
"templates": "Templates:",
|
||||
"test": "Test",
|
||||
"themes": "Themes",
|
||||
"thursday": "Thursday",
|
||||
"token": "Token",
|
||||
"tuesday": "Tuesday",
|
||||
"type": "Type",
|
||||
"update": "Update",
|
||||
"updated": "Updated",
|
||||
"upload": "Upload",
|
||||
"url": "URL",
|
||||
"view": "View",
|
||||
"wednesday": "Wednesday",
|
||||
"yes": "Yes",
|
||||
"foods": "Foods",
|
||||
"units": "Units",
|
||||
"back": "Back",
|
||||
"next": "Next"
|
||||
},
|
||||
"group": {
|
||||
"are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete <b>{groupName}<b/>?",
|
||||
"cannot-delete-default-group": "Cannot delete default group",
|
||||
"cannot-delete-group-with-users": "Cannot delete group with users",
|
||||
"confirm-group-deletion": "Confirm Group Deletion",
|
||||
"create-group": "Create Group",
|
||||
"error-updating-group": "Error updating group",
|
||||
"group": "Group",
|
||||
"group-deleted": "Group deleted",
|
||||
"group-deletion-failed": "Group deletion failed",
|
||||
"group-id-with-value": "Group ID: {groupID}",
|
||||
"group-name": "Group Name",
|
||||
"group-not-found": "Group not found",
|
||||
"group-with-value": "Group: {groupID}",
|
||||
"groups": "Groups",
|
||||
"manage-groups": "Manage Groups",
|
||||
"user-group": "User Group",
|
||||
"user-group-created": "User Group Created",
|
||||
"user-group-creation-failed": "User Group Creation Failed",
|
||||
"settings": {
|
||||
"keep-my-recipes-private": "Keep My Recipes Private",
|
||||
"keep-my-recipes-private-description": "Sets your group and all recipes defaults to private. You can always change this later."
|
||||
}
|
||||
},
|
||||
"meal-plan": {
|
||||
"create-a-new-meal-plan": "Create a New Meal Plan",
|
||||
"dinner-this-week": "Dinner This Week",
|
||||
"dinner-today": "Dinner Today",
|
||||
"dinner-tonight": "DINNER TONIGHT",
|
||||
"edit-meal-plan": "Edit Meal Plan",
|
||||
"end-date": "End Date",
|
||||
"group": "Group (Beta)",
|
||||
"main": "Main",
|
||||
"meal-planner": "Meal Planner",
|
||||
"meal-plans": "Meal Plans",
|
||||
"mealplan-categories": "MEALPLAN CATEGORIES",
|
||||
"mealplan-created": "Mealplan created",
|
||||
"mealplan-creation-failed": "Mealplan creation failed",
|
||||
"mealplan-deleted": "Mealplan Deleted",
|
||||
"mealplan-deletion-failed": "Mealplan deletion failed",
|
||||
"mealplan-settings": "Mealplan Settings",
|
||||
"mealplan-update-failed": "Mealplan update failed",
|
||||
"mealplan-updated": "Mealplan Updated",
|
||||
"no-meal-plan-defined-yet": "No meal plan defined yet",
|
||||
"no-meal-planned-for-today": "No meal planned for today",
|
||||
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Only recipes with these categories will be used in Meal Plans",
|
||||
"planner": "Planner",
|
||||
"quick-week": "Quick Week",
|
||||
"side": "Side",
|
||||
"sides": "Sides",
|
||||
"start-date": "Start Date"
|
||||
},
|
||||
"migration": {
|
||||
"chowdown": {
|
||||
"description": "Migrate data from Chowdown",
|
||||
"title": "Chowdown"
|
||||
},
|
||||
"migration-data-removed": "Migration data removed",
|
||||
"nextcloud": {
|
||||
"description": "Migrate data from a Nextcloud Cookbook instance",
|
||||
"title": "Nextcloud Cookbook"
|
||||
},
|
||||
"no-migration-data-available": "No Migration Data Available",
|
||||
"recipe-migration": "Recipe Migration"
|
||||
},
|
||||
"new-recipe": {
|
||||
"bulk-add": "Bulk Add",
|
||||
"error-details": "Only websites containing ld+json or microdata can be imported by Mealie. Most major recipe websites support this data structure. If your site cannot be imported but there is json data in the log, please submit a github issue with the URL and data.",
|
||||
"error-title": "Looks Like We Couldn't Find Anything",
|
||||
"from-url": "Import a Recipe",
|
||||
"github-issues": "GitHub Issues",
|
||||
"google-ld-json-info": "Google ld+json Info",
|
||||
"must-be-a-valid-url": "Must be a Valid URL",
|
||||
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Paste in your recipe data. Each line will be treated as an item in a list",
|
||||
"recipe-markup-specification": "Recipe Markup Specification",
|
||||
"recipe-url": "Recipe URL",
|
||||
"upload-a-recipe": "Upload a Recipe",
|
||||
"upload-individual-zip-file": "Upload an individual .zip file exported from another Mealie instance.",
|
||||
"url-form-hint": "Copy and paste a link from your favorite recipe website",
|
||||
"view-scraped-data": "View Scraped Data",
|
||||
"trim-whitespace-description": "Trim leading and trailing whitespace as well as blank lines",
|
||||
"trim-prefix-description": "Trim first character from each line",
|
||||
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns"
|
||||
},
|
||||
"page": {
|
||||
"404-page-not-found": "404 Page not found",
|
||||
"all-recipes": "All Recipes",
|
||||
"new-page-created": "New page created",
|
||||
"page": "Page",
|
||||
"page-creation-failed": "Page creation failed",
|
||||
"page-deleted": "Page deleted",
|
||||
"page-deletion-failed": "Page deletion failed",
|
||||
"page-update-failed": "Page update failed",
|
||||
"page-updated": "Page updated",
|
||||
"pages-update-failed": "Pages update failed",
|
||||
"pages-updated": "Pages updated"
|
||||
},
|
||||
"recipe": {
|
||||
"add-key": "Add Key",
|
||||
"add-to-favorites": "Add to Favorites",
|
||||
"api-extras": "API Extras",
|
||||
"calories": "Calories",
|
||||
"calories-suffix": "calories",
|
||||
"carbohydrate-content": "Carbohydrate",
|
||||
"categories": "Categories",
|
||||
"comment-action": "Comment",
|
||||
"comments": "Comments",
|
||||
"delete-confirmation": "Are you sure you want to delete this recipe?",
|
||||
"delete-recipe": "Delete Recipe",
|
||||
"description": "Description",
|
||||
"disable-amount": "Disable Ingredient Amounts",
|
||||
"disable-comments": "Disable Comments",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Fat",
|
||||
"fiber-content": "Fiber",
|
||||
"grams": "grams",
|
||||
"ingredient": "Ingredient",
|
||||
"ingredients": "Ingredients",
|
||||
"insert-section": "Insert Section",
|
||||
"instructions": "Instructions",
|
||||
"key-name-required": "Key Name Required",
|
||||
"landscape-view-coming-soon": "Landscape View",
|
||||
"milligrams": "milligrams",
|
||||
"new-key-name": "New Key Name",
|
||||
"no-white-space-allowed": "No White Space Allowed",
|
||||
"note": "Note",
|
||||
"nutrition": "Nutrition",
|
||||
"object-key": "Object Key",
|
||||
"object-value": "Object Value",
|
||||
"original-url": "Original URL",
|
||||
"perform-time": "Cook Time",
|
||||
"prep-time": "Prep Time",
|
||||
"protein-content": "Protein",
|
||||
"public-recipe": "Public Recipe",
|
||||
"recipe-created": "Recipe created",
|
||||
"recipe-creation-failed": "Recipe creation failed",
|
||||
"recipe-deleted": "Recipe deleted",
|
||||
"recipe-image": "Recipe Image",
|
||||
"recipe-image-updated": "Recipe image updated",
|
||||
"recipe-name": "Recipe Name",
|
||||
"recipe-settings": "Recipe Settings",
|
||||
"recipe-update-failed": "Recipe update failed",
|
||||
"recipe-updated": "Recipe updated",
|
||||
"remove-from-favorites": "Remove from Favorites",
|
||||
"remove-section": "Remove Section",
|
||||
"save-recipe-before-use": "Save recipe before use",
|
||||
"section-title": "Section Title",
|
||||
"servings": "Servings",
|
||||
"share-recipe-message": "I wanted to share my {0} recipe with you.",
|
||||
"show-nutrition-values": "Show Nutrition Values",
|
||||
"sodium-content": "Sodium",
|
||||
"step-index": "Step: {step}",
|
||||
"sugar-content": "Sugar",
|
||||
"title": "Title",
|
||||
"total-time": "Total Time",
|
||||
"unable-to-delete-recipe": "Unable to Delete Recipe",
|
||||
"no-recipe": "No Recipe"
|
||||
},
|
||||
"search": {
|
||||
"advanced-search": "Advanced Search",
|
||||
"and": "and",
|
||||
"exclude": "Exclude",
|
||||
"include": "Include",
|
||||
"max-results": "Max Results",
|
||||
"or": "Or",
|
||||
"results": "Results",
|
||||
"search": "Search",
|
||||
"search-mealie": "Search Mealie (press /)",
|
||||
"search-placeholder": "Search...",
|
||||
"tag-filter": "Tag Filter"
|
||||
},
|
||||
"settings": {
|
||||
"add-a-new-theme": "Add a New Theme",
|
||||
"admin-settings": "Admin Settings",
|
||||
"backup": {
|
||||
"backup-created-at-response-export_path": "Backup Created at {path}",
|
||||
"backup-deleted": "Backup deleted",
|
||||
"backup-tag": "Backup Tag",
|
||||
"create-heading": "Create A Backup",
|
||||
"delete-backup": "Delete Backup",
|
||||
"error-creating-backup-see-log-file": "Error Creating Backup. See Log File",
|
||||
"full-backup": "Full Backup",
|
||||
"import-summary": "Import Summary",
|
||||
"partial-backup": "Partial Backup",
|
||||
"unable-to-delete-backup": "Unable to Delete Backup."
|
||||
},
|
||||
"backup-and-exports": "Backups",
|
||||
"change-password": "Change Password",
|
||||
"current": "Version:",
|
||||
"custom-pages": "Custom Pages",
|
||||
"edit-page": "Edit Page",
|
||||
"events": "Events",
|
||||
"first-day-of-week": "First day of the week",
|
||||
"group-settings-updated": "Group Settings Updated",
|
||||
"homepage": {
|
||||
"all-categories": "All Categories",
|
||||
"card-per-section": "Card Per Section",
|
||||
"home-page": "Home Page",
|
||||
"home-page-sections": "Home Page Sections",
|
||||
"show-recent": "Show Recent"
|
||||
},
|
||||
"language": "Language",
|
||||
"latest": "Latest",
|
||||
"local-api": "Local API",
|
||||
"locale-settings": "Locale settings",
|
||||
"migrations": "Migrations",
|
||||
"new-page": "New Page",
|
||||
"notify": "Notify",
|
||||
"organize": "Organize",
|
||||
"page-name": "Page Name",
|
||||
"pages": "Pages",
|
||||
"profile": "Profile",
|
||||
"remove-existing-entries-matching-imported-entries": "Remove existing entries matching imported entries",
|
||||
"set-new-time": "Set New Time",
|
||||
"settings-update-failed": "Settings update failed",
|
||||
"settings-updated": "Settings updated",
|
||||
"site-settings": "Site Settings",
|
||||
"theme": {
|
||||
"accent": "Accent",
|
||||
"dark": "Dark",
|
||||
"default-to-system": "Default to system",
|
||||
"error": "Error",
|
||||
"error-creating-theme-see-log-file": "Error creating theme. See log file.",
|
||||
"error-deleting-theme": "Error deleting theme",
|
||||
"error-updating-theme": "Error updating theme",
|
||||
"info": "Info",
|
||||
"light": "Light",
|
||||
"primary": "Primary",
|
||||
"secondary": "Secondary",
|
||||
"success": "Success",
|
||||
"switch-to-dark-mode": "Switch to dark mode",
|
||||
"switch-to-light-mode": "Switch to light mode",
|
||||
"theme-deleted": "Theme deleted",
|
||||
"theme-name": "Theme Name",
|
||||
"theme-name-is-required": "Theme Name is required.",
|
||||
"theme-saved": "Theme Saved",
|
||||
"theme-updated": "Theme updated",
|
||||
"warning": "Warning"
|
||||
},
|
||||
"token": {
|
||||
"active-tokens": "ACTIVE TOKENS",
|
||||
"api-token": "API Token",
|
||||
"api-tokens": "API Tokens",
|
||||
"copy-this-token-for-use-with-an-external-application-this-token-will-not-be-viewable-again": "Copy this token for use with an external application. This token will not be viewable again.",
|
||||
"create-an-api-token": "Create an API Token",
|
||||
"token-name": "Token Name"
|
||||
},
|
||||
"toolbox": {
|
||||
"assign-all": "Assign All",
|
||||
"bulk-assign": "Bulk Assign",
|
||||
"new-name": "New Name",
|
||||
"no-unused-items": "No Unused Items",
|
||||
"recipes-affected": "No Recipes Affected|One Recipe Affected|{count} Recipes Affected",
|
||||
"remove-unused": "Remove Unused",
|
||||
"title-case-all": "Title Case All",
|
||||
"toolbox": "Toolbox",
|
||||
"unorganized": "Unorganized"
|
||||
},
|
||||
"webhooks": {
|
||||
"test-webhooks": "Test Webhooks",
|
||||
"the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "The URLs listed below will receive webhooks containing the recipe data for the meal plan on it's scheduled day. Currently Webhooks will execute at",
|
||||
"webhook-url": "Webhook URL",
|
||||
"webhooks-caps": "WEBHOOKS",
|
||||
"webhooks": "Webhooks"
|
||||
}
|
||||
},
|
||||
"shopping-list": {
|
||||
"all-lists": "All Lists",
|
||||
"create-shopping-list": "Create Shopping List",
|
||||
"from-recipe": "From Recipe",
|
||||
"list-name": "List Name",
|
||||
"new-list": "New List",
|
||||
"quantity": "Quantity: {0}",
|
||||
"shopping-list": "Shopping List",
|
||||
"shopping-lists": "Shopping Lists"
|
||||
},
|
||||
"sidebar": {
|
||||
"all-recipes": "All Recipes",
|
||||
"backups": "Backups",
|
||||
"categories": "Categories",
|
||||
"cookbooks": "Cookbooks",
|
||||
"dashboard": "Dashboard",
|
||||
"home-page": "Home Page",
|
||||
"manage-users": "Manage Users",
|
||||
"migrations": "Migrations",
|
||||
"profile": "Profile",
|
||||
"search": "Search",
|
||||
"site-settings": "Site Settings",
|
||||
"tags": "Tags",
|
||||
"toolbox": "Toolbox",
|
||||
"language": "Language"
|
||||
},
|
||||
"signup": {
|
||||
"error-signing-up": "Error Signing Up",
|
||||
"sign-up": "Sign Up",
|
||||
"sign-up-link-created": "Sign up link created",
|
||||
"sign-up-link-creation-failed": "Sign up link creation failed",
|
||||
"sign-up-links": "Sign Up Links",
|
||||
"sign-up-token-deleted": "Sign Up Token Deleted",
|
||||
"sign-up-token-deletion-failed": "Sign up token deletion failed",
|
||||
"welcome-to-mealie": "Welcome to Mealie! To become a user of this instance you are required to have a valid invitation link. If you haven't recieved an invitation you are unable to sign-up. To recieve a link, contact the sites administrator."
|
||||
},
|
||||
"tag": {
|
||||
"tag-created": "Tag created",
|
||||
"tag-creation-failed": "Tag creation failed",
|
||||
"tag-deleted": "Tag deleted",
|
||||
"tag-deletion-failed": "Tag deletion failed",
|
||||
"tag-update-failed": "Tag update failed",
|
||||
"tag-updated": "Tag updated",
|
||||
"tags": "Tags",
|
||||
"untagged-count": "Untagged {count}"
|
||||
},
|
||||
"tool": {
|
||||
"tools": "Tools"
|
||||
},
|
||||
"user": {
|
||||
"admin": "Admin",
|
||||
"are-you-sure-you-want-to-delete-the-link": "Are you sure you want to delete the link <b>{link}<b/>?",
|
||||
"are-you-sure-you-want-to-delete-the-user": "Are you sure you want to delete the user <b>{activeName} ID: {activeId}<b/>?",
|
||||
"confirm-link-deletion": "Confirm Link Deletion",
|
||||
"confirm-password": "Confirm Password",
|
||||
"confirm-user-deletion": "Confirm User Deletion",
|
||||
"could-not-validate-credentials": "Could Not Validate Credentials",
|
||||
"create-link": "Create Link",
|
||||
"create-user": "Create User",
|
||||
"current-password": "Current Password",
|
||||
"e-mail-must-be-valid": "E-mail must be valid",
|
||||
"edit-user": "Edit User",
|
||||
"email": "Email",
|
||||
"error-cannot-delete-super-user": "Error! Cannot Delete Super User",
|
||||
"existing-password-does-not-match": "Existing password does not match",
|
||||
"full-name": "Full Name",
|
||||
"invite-only": "Invite Only",
|
||||
"link-id": "Link ID",
|
||||
"link-name": "Link Name",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"manage-users": "Manage Users",
|
||||
"new-password": "New Password",
|
||||
"new-user": "New User",
|
||||
"password-has-been-reset-to-the-default-password": "Password has been reset to the default password",
|
||||
"password-must-match": "Password must match",
|
||||
"password-reset-failed": "Password reset failed",
|
||||
"password-updated": "Password updated",
|
||||
"password": "Password",
|
||||
"password-strength": "Password is {strength}",
|
||||
"register": "Register",
|
||||
"reset-password": "Reset Password",
|
||||
"sign-in": "Sign in",
|
||||
"total-mealplans": "Total MealPlans",
|
||||
"total-users": "Total Users",
|
||||
"upload-photo": "Upload Photo",
|
||||
"use-8-characters-or-more-for-your-password": "Use 8 characters or more for your password",
|
||||
"user-created": "User created",
|
||||
"user-creation-failed": "User creation failed",
|
||||
"user-deleted": "User deleted",
|
||||
"user-id-with-value": "User ID: {id}",
|
||||
"user-id": "User ID",
|
||||
"user-password": "User Password",
|
||||
"user-successfully-logged-in": "User Successfully Logged In",
|
||||
"user-update-failed": "User update failed",
|
||||
"user-updated": "User updated",
|
||||
"user": "User",
|
||||
"username": "Username",
|
||||
"users-header": "USERS",
|
||||
"users": "Users",
|
||||
"webhook-time": "Webhook Time",
|
||||
"webhooks-enabled": "Webhooks Enabled",
|
||||
"you-are-not-allowed-to-create-a-user": "You are not allowed to create a user",
|
||||
"you-are-not-allowed-to-delete-this-user": "You are not allowed to delete this user",
|
||||
"enable-advanced-content": "Enable Advanced Content",
|
||||
"enable-advanced-content-description": "Enables advanced features like Recipe Scaling, API keys, Webhooks, and Data Management. Don't worry, you can always change this later"
|
||||
},
|
||||
"language-dialog": {
|
||||
"translated": "translated",
|
||||
"choose-language": "Choose Language",
|
||||
"select-description": "Choose the language for the Mealie UI. The setting only applies to you, not other users.",
|
||||
"how-to-contribute-description": "Is something not translated yet, mistranslated, or your language missing from the list? {read-the-docs-link} on how to contribute!",
|
||||
"read-the-docs": "Read the docs"
|
||||
},
|
||||
"data-pages": {
|
||||
"seed-data": "Seed Data",
|
||||
"foods": {
|
||||
"merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.",
|
||||
"merge-food-example": "Merging {food1} into {food2}",
|
||||
"seed-dialog-text": "Seed the database with foods based on your local language. This will create 200+ common foods that can be used to organize your database. Foods are translated via a community effort.",
|
||||
"seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually."
|
||||
},
|
||||
"units": {
|
||||
"seed-dialog-text": "Seed the database with common units based on your local language."
|
||||
},
|
||||
"labels": {
|
||||
"seed-dialog-text": "Seed the database with common labels based on your local language."
|
||||
}
|
||||
},
|
||||
"user-registration": {
|
||||
"user-registration": "User Registration",
|
||||
"join-a-group": "Join a Group",
|
||||
"create-a-new-group": "Create a New Group",
|
||||
"provide-registration-token-description": "Please provide the registration token associated with the group that you'd like to join. You'll need to obtain this from an existing group member.",
|
||||
"group-details": "Group Details",
|
||||
"group-details-description": "Before you create an account you'll need to create a group. Your group will only contain you, but you'll be able to invite others later. Members in your group can share meal plans, shopping lists, recipes, and more!",
|
||||
"use-seed-data": "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.",
|
||||
"account-details": "Account Details"
|
||||
},
|
||||
"validation": {
|
||||
"group-name-is-taken": "Group name is taken",
|
||||
"username-is-taken": "Username is taken",
|
||||
"email-is-taken": "Email is taken"
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"database-type": "Databasetype",
|
||||
"database-url": "Database URL",
|
||||
"default-group": "Standaardgroep",
|
||||
"demo": "Demo",
|
||||
"demo": "Voorbeeld",
|
||||
"demo-status": "Demo status",
|
||||
"development": "Versies in ontwikkeling",
|
||||
"docs": "Documentatie",
|
||||
@@ -33,7 +33,7 @@
|
||||
"show-assets": "Toon Bijlagen"
|
||||
},
|
||||
"category": {
|
||||
"categories": "Categories",
|
||||
"categories": "Categorieën",
|
||||
"category-created": "Categorie aangemaakt",
|
||||
"category-creation-failed": "Categorie aanmaken mislukt",
|
||||
"category-deleted": "Categorie Verwijderd",
|
||||
@@ -69,7 +69,7 @@
|
||||
"dashboard": "Dashboard",
|
||||
"delete": "Verwijderen",
|
||||
"disabled": "Uitgeschakeld",
|
||||
"download": "Download",
|
||||
"download": "Downloaden",
|
||||
"edit": "Bewerken",
|
||||
"enabled": "Ingeschakeld",
|
||||
"exception": "Uitzondering",
|
||||
@@ -83,7 +83,7 @@
|
||||
"friday": "Vrijdag",
|
||||
"general": "Algemeen",
|
||||
"get": "Haal op",
|
||||
"home": "Home",
|
||||
"home": "Startpagina",
|
||||
"image": "Afbeelding",
|
||||
"image-upload-failed": "Afbeelding uploaden mislukt",
|
||||
"import": "Importeren",
|
||||
@@ -131,10 +131,10 @@
|
||||
"view": "Weergave",
|
||||
"wednesday": "Woensdag",
|
||||
"yes": "Ja",
|
||||
"foods": "Foods",
|
||||
"units": "Units",
|
||||
"back": "Back",
|
||||
"next": "Next"
|
||||
"foods": "Voedsel",
|
||||
"units": "Eenheden",
|
||||
"back": "Terug",
|
||||
"next": "Volgende"
|
||||
},
|
||||
"group": {
|
||||
"are-you-sure-you-want-to-delete-the-group": "Weet je zeker dat je <b>{groupName}<b/> wilt verwijderen?",
|
||||
@@ -156,8 +156,8 @@
|
||||
"user-group-created": "Gebruikersgroep aangemaakt",
|
||||
"user-group-creation-failed": "Aanmaken gebruikersgroep is mislukt",
|
||||
"settings": {
|
||||
"keep-my-recipes-private": "Keep My Recipes Private",
|
||||
"keep-my-recipes-private-description": "Sets your group and all recipes defaults to private. You can always change this later."
|
||||
"keep-my-recipes-private": "Houd mijn recepten privé",
|
||||
"keep-my-recipes-private-description": "Stelt je groep en alle recepten standaard privé in. Je kunt dit later nog wijzigen."
|
||||
}
|
||||
},
|
||||
"meal-plan": {
|
||||
@@ -217,8 +217,8 @@
|
||||
"url-form-hint": "Kopieer en plak een link vanuit jouw favoriete receptwebsite",
|
||||
"view-scraped-data": "Bekijk Opgehaalde Data",
|
||||
"trim-whitespace-description": "Trim leading and trailing whitespace as well as blank lines",
|
||||
"trim-prefix-description": "Trim first character from each line",
|
||||
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns"
|
||||
"trim-prefix-description": "Knip het eerste teken van elke regel bij",
|
||||
"split-by-numbered-line-description": "Pogingen om een alinea te splitsen door de '1)' of '1.' patronen te gebruiken"
|
||||
},
|
||||
"page": {
|
||||
"404-page-not-found": "404 Pagina niet gevonden",
|
||||
@@ -248,6 +248,7 @@
|
||||
"description": "Omschrijving",
|
||||
"disable-amount": "Ingrediënt Hoeveelheden Uitschakelen",
|
||||
"disable-comments": "Reacties Uitschakelen",
|
||||
"edit-scale": "Bewerk Schaal",
|
||||
"fat-content": "Vet",
|
||||
"fiber-content": "Vezels",
|
||||
"grams": "gram",
|
||||
@@ -291,7 +292,7 @@
|
||||
"title": "Titel",
|
||||
"total-time": "Totale Tijd",
|
||||
"unable-to-delete-recipe": "Kan recept niet verwijderen",
|
||||
"no-recipe": "No Recipe"
|
||||
"no-recipe": "Geen Recept"
|
||||
},
|
||||
"search": {
|
||||
"advanced-search": "Geavanceerd Zoeken",
|
||||
@@ -415,7 +416,7 @@
|
||||
"all-recipes": "Recepten",
|
||||
"backups": "Backups",
|
||||
"categories": "Categorieën",
|
||||
"cookbooks": "Cookbooks",
|
||||
"cookbooks": "Koekboeken",
|
||||
"dashboard": "Dashboard",
|
||||
"home-page": "Home Pagina",
|
||||
"manage-users": "Gebruikers",
|
||||
@@ -424,8 +425,8 @@
|
||||
"search": "Zoeken",
|
||||
"site-settings": "Instellingen",
|
||||
"tags": "Labels",
|
||||
"toolbox": "Toolbox",
|
||||
"language": "Language"
|
||||
"toolbox": "Gereedschapskist",
|
||||
"language": "Taal"
|
||||
},
|
||||
"signup": {
|
||||
"error-signing-up": "Fout bij registreren",
|
||||
@@ -448,7 +449,7 @@
|
||||
"untagged-count": "Niet gelabeld {count}"
|
||||
},
|
||||
"tool": {
|
||||
"tools": "Tools"
|
||||
"tools": "Hulpmiddelen"
|
||||
},
|
||||
"user": {
|
||||
"admin": "Beheerder",
|
||||
@@ -467,7 +468,7 @@
|
||||
"error-cannot-delete-super-user": "Fout! Kan supergebruiker niet verwijderen",
|
||||
"existing-password-does-not-match": "Bestaande wachtwoord komt niet overeen",
|
||||
"full-name": "Voor- en achternaam",
|
||||
"invite-only": "Invite Only",
|
||||
"invite-only": "Alleen op uitnodiging",
|
||||
"link-id": "Koppeling ID",
|
||||
"link-name": "Koppeling Naam",
|
||||
"login": "Inloggen",
|
||||
@@ -480,8 +481,8 @@
|
||||
"password-reset-failed": "Wachtwoord resetten is mislukt",
|
||||
"password-updated": "Wachtwoord bijgewerkt",
|
||||
"password": "Wachtwoord",
|
||||
"password-strength": "Password is {strength}",
|
||||
"register": "Register",
|
||||
"password-strength": "Wachtwoord is {strength}",
|
||||
"register": "Registreren",
|
||||
"reset-password": "Wachtwoord Herstellen",
|
||||
"sign-in": "Inloggen",
|
||||
"total-mealplans": "Totaal maaltijdplannen",
|
||||
@@ -505,7 +506,7 @@
|
||||
"webhooks-enabled": "Webhooks ingeschakeld",
|
||||
"you-are-not-allowed-to-create-a-user": "Je hebt geen toestemming om een gebruiker aan te maken",
|
||||
"you-are-not-allowed-to-delete-this-user": "Je hebt geen toestemming om deze gebruiker te verwijderen",
|
||||
"enable-advanced-content": "Enable Advanced Content",
|
||||
"enable-advanced-content": "Schakel geavanceerde instellingen in",
|
||||
"enable-advanced-content-description": "Enables advanced features like Recipe Scaling, API keys, Webhooks, and Data Management. Don't worry, you can always change this later"
|
||||
},
|
||||
"language-dialog": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user