Compare commits

..

105 Commits

Author SHA1 Message Date
Michael Genson
db2c14093d fix: Explorer Page State Not Working On Hitting Back (#6171) 2025-09-14 22:28:17 -05:00
github-actions[bot]
9a0525c3a0 docs(auto): Update image tag, for release v3.2.0 (#6164)
Co-authored-by: michael-genson <71845777+michael-genson@users.noreply.github.com>
2025-09-13 22:05:25 +00:00
renovate[bot]
a2e5826da0 fix(deps): update dependency ingredient-parser-nlp to v2.3.0 (#6163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-13 16:54:11 -05:00
Michael Genson
d4f4ba0c8d fix: Ingredient Parser Drops Units Sometimes (#6150) 2025-09-13 15:49:08 -05:00
Michael Genson
8cd5835dd8 fix: Can't Edit Timeline Events (#6160) 2025-09-13 15:36:18 -05:00
renovate[bot]
7aa131b326 fix(deps): update dependency axios to v1.12.0 [security] (#6158)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-13 15:02:46 -05:00
Sören
af264bd288 fix: add breaks option to markdown rendering, to get old linebreak behaviour (#6156) 2025-09-13 17:29:23 +00:00
Hayden
72388e8bcf chore(l10n): New Crowdin updates (#6143) 2025-09-10 10:28:17 +02:00
Helge
c0afef46d6 docs: fix typo starting-dev-server.md (#6142) 2025-09-09 18:43:48 +00:00
Arsène Reymond
f90665cce9 feat: Improve first time setup ux (#6106) 2025-09-09 12:21:58 -05:00
renovate[bot]
942ac741cd fix(deps): update dependency next-auth to ~4.24.0 [security] (#6133)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 14:43:48 +00:00
Hayden
1d3a7e8d62 chore(l10n): New Crowdin updates (#6139) 2025-09-09 12:43:16 +00:00
renovate[bot]
5e85fc409e fix(deps): update dependency openai to v1.107.0 (#6129)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 08:35:08 +00:00
Michael Genson
2c20e96ede fix: Refactor and Optimize Explore Page Search (#6070)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-09-09 08:16:37 +00:00
renovate[bot]
608fc39747 chore(deps): update node.js to f3e50c7 (#6136)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 07:51:17 +00:00
renovate[bot]
ed2f40cd6a fix(deps): update dependency vite to v6.2.7 [security] (#6132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-09-09 07:37:46 +00:00
Michael Genson
a080cdb432 chore: Update GitHub Configs (#6135) 2025-09-09 07:21:06 +00:00
renovate[bot]
83101e3ed5 fix(deps): update dependency rapidfuzz to v3.14.1 (#6137)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 03:31:57 +00:00
renovate[bot]
5d90997ace chore(config): migrate renovate config (#6134)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 20:56:09 -05:00
Kuchenpirat
c78c6cf926 dev: list availlable frontend updates on renovate dependency dashboard (#6130) 2025-09-08 21:19:24 +00:00
Michael Genson
e26191d116 fix: Upgrade Vuetify, fix Dev Dependencies, and fix Migration Tree View (#6127) 2025-09-08 22:49:28 +02:00
Xavier L.
3774f68393 feat: Add option to switch sqlite to WAL (#6050)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-09-08 11:23:37 -05:00
Nico Hirsch
c46c412bf5 fix: Don't open the sidebar drawer by default on medium screens (#6107) 2025-09-08 14:58:39 +00:00
github-actions[bot]
aa9e61a16f chore(auto): Update pre-commit hooks (#6125)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-09-08 10:24:15 +00:00
Michael Genson
b2f8d63f33 fix: Missing Locale Dates (#6116) 2025-09-08 09:47:37 +00:00
Hayden
72b47a1103 chore(l10n): New Crowdin updates (#6123) 2025-09-08 02:50:03 +00:00
renovate[bot]
29e150d547 chore(deps): update dependency mkdocs-material to v9.6.19 (#6121)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-07 21:39:06 -05:00
Zach Wolf
e9ae6d86a4 docs: link to GitHub Release Notes (#6122)
Co-authored-by: TheMerinoWolf <zwolf@zwolf-mbp-16-m4.localdomain>
2025-09-08 02:08:43 +00:00
Hayden
f799938373 chore(l10n): New Crowdin updates (#6113) 2025-09-07 19:02:20 +00:00
github-actions[bot]
e5fff4ec5c chore: automatic locale sync (#6117)
Co-authored-by: GitHub Action <action@github.com>
2025-09-07 18:51:21 +00:00
Carl
192e531c1f Docs: Fix install grammar (#6118)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-09-07 18:31:32 +00:00
Michael Genson
45e710ee72 fix: Context Menu Dialogs Not Working (#6108) 2025-09-05 17:41:43 +02:00
Hayden
be579ed664 chore(l10n): New Crowdin updates (#6105) 2025-09-04 22:37:57 -05:00
renovate[bot]
fe953896f8 fix(deps): update dependency openai to v1.106.1 (#6103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 22:27:09 +00:00
renovate[bot]
decf7cb307 chore(deps): update dependency ruff to v0.12.12 (#6102)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 17:15:17 -05:00
Arsène Reymond
d396a8fdc2 fix: Cookboks page padding (#6097) 2025-09-04 19:59:54 +00:00
renovate[bot]
a3ef49f559 chore(deps): update dependency pytest to v8.4.2 (#6101)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 21:48:31 +02:00
Michael Genson
41e8458389 fix: Optimize Recipe Context Menu (#6071) 2025-09-04 16:19:47 +00:00
Hayden
18dc2fc6a8 chore(l10n): New Crowdin updates (#6100) 2025-09-04 18:08:58 +02:00
renovate[bot]
6355b3c8db fix(deps): update dependency openai to v1.106.0 (#6099)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 17:17:40 +02:00
renovate[bot]
3ac8af138f fix(deps): update dependency openai to v1.105.0 (#6094)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 13:35:58 +02:00
renovate[bot]
2b3803fb2e chore(deps): update node.js to d22c0ce (#6096)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 08:17:06 +02:00
renovate[bot]
6a80e70486 chore(deps): update node.js to bfee10f (#6095)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-03 22:09:22 +00:00
Hayden
f1dc854770 chore(l10n): New Crowdin updates (#6093) 2025-09-03 15:18:24 +00:00
Kuchenpirat
581aa929bd feat: consolidate settings gui (#6043)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-09-03 15:07:06 +00:00
Michael Genson
461e51bd22 fix: Optimize Recipe Favorites/Ratings (#6075) 2025-09-03 16:56:38 +02:00
Patrick Lehner (he/him)
1cdf43c599 fix: Shopping list top buttons layout (margin and row wrapping) (#6091) 2025-09-03 09:26:25 +00:00
Arsène Reymond
6bfbc7ca0a fix: set touchless on AppSidebar (#6092) 2025-09-03 09:11:36 +00:00
Michael Genson
608dbaa4c1 fix: Incorrect Usage of $vuetify.display (#6066) 2025-09-03 08:36:42 +00:00
renovate[bot]
89c1e007cb fix(deps): update dependency openai to v1.104.2 (#6086)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-03 08:27:44 +02:00
Hayden
fb5db583d2 chore(l10n): New Crowdin updates (#6088) 2025-09-03 06:09:31 +00:00
Michael Genson
bef3045e65 fix: Make Frontend Respect TOKEN_TIME (#6089) 2025-09-03 05:56:54 +00:00
Michael Genson
ff958a5015 fix: Fix PWA (#6090) 2025-09-03 07:44:52 +02:00
Hayden
37789c342e chore(l10n): New Crowdin updates (#6080)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-09-02 16:46:31 +00:00
renovate[bot]
b6b8bea925 fix(deps): update dependency openai to v1.103.0 (#6083)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 18:33:01 +02:00
Patrick Lehner (he/him)
60834178ba docs: Fix list formatting on 'Features' docs page (#6082) 2025-09-02 10:16:36 -05:00
github-actions[bot]
0375a0bd5a chore(auto): Update pre-commit hooks (#6077)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-09-01 15:54:52 +00:00
Patrick Lehner (he/him)
3361f9a7c3 fix: Fix RecipeLastMade dialog date picker being off by a day (#6079) 2025-09-01 10:44:30 -05:00
Hayden
0883ef05ab chore(l10n): New Crowdin updates (#6076) 2025-08-31 22:13:27 -05:00
Hayden
c4eb020a66 chore(l10n): New Crowdin updates (#6073) 2025-08-31 11:25:57 -05:00
github-actions[bot]
600f407b4f chore: automatic locale sync (#6069)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-31 02:51:20 +00:00
Hayden
6f92a829d6 chore(l10n): New Crowdin updates (#6067) 2025-08-30 21:41:32 -05:00
Hayden
6b11ff5128 chore(l10n): New Crowdin updates (#6063) 2025-08-30 15:48:37 +00:00
renovate[bot]
29fdad1574 chore(deps): update dependency coverage to v7.10.6 (#6062)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-29 23:52:17 -05:00
Hayden
54b3df105c chore(l10n): New Crowdin updates (#6058) 2025-08-29 22:00:47 +00:00
Richard vL
9a3303b06c fix: re-ordering of cookbooks (#5975)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-29 21:50:09 +00:00
Andrew Brock
c17accd82b fix: import from Paprika not importing some images (#5911)
Co-authored-by: brokeh <git@brocky.net>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-29 21:39:37 +00:00
Felix Schneider
18f7e8d935 feat: group recipe ingredients by section titles (#5864)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-29 21:25:25 +00:00
Xavier L.
6d2936cab6 fix: Handle missing OIDC groups claim (#6054) 2025-08-29 21:07:00 +00:00
renovate[bot]
cc2e33a254 chore(deps): update dependency ruff to v0.12.11 (#6056)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-28 17:03:16 +00:00
renovate[bot]
eee6f8113c fix(deps): update dependency alembic to v1.16.5 (#6048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-28 09:42:40 +02:00
Hayden
bd10cb8cd8 chore(l10n): New Crowdin updates (#6049) 2025-08-28 07:24:34 +02:00
renovate[bot]
d03081c4e6 fix(deps): update dependency authlib to v1.6.3 (#6018)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 17:56:03 +00:00
renovate[bot]
64d865bf7e chore(deps): update dependency coverage to v7.10.5 (#6021)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 17:44:26 +00:00
renovate[bot]
27efda2772 fix(deps): update dependency rapidfuzz to v3.14.0 (#6044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 19:33:05 +02:00
renovate[bot]
81986e63b8 fix(deps): update dependency beautifulsoup4 to v4.13.5 (#6026)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 17:46:07 +02:00
Michael Genson
42eef17cfb fix: Make String Cleaner More Robust (#6032) 2025-08-27 14:19:43 +00:00
renovate[bot]
1f724856b1 fix(deps): update dependency typing-extensions to v4.15.0 (#6035)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 16:06:53 +02:00
renovate[bot]
618ea06b7a fix(deps): update dependency orjson to v3.11.3 (#6041)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 14:52:29 +02:00
Hayden
ca2039ae35 chore(l10n): New Crowdin updates (#6034)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-27 10:47:56 +00:00
renovate[bot]
15ecab86d1 fix(deps): update dependency openai to v1.102.0 (#6042)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 12:36:20 +02:00
github-actions[bot]
aa164424d3 docs(auto): Update image tag, for release v3.1.2 (#6037)
Co-authored-by: michael-genson <71845777+michael-genson@users.noreply.github.com>
2025-08-25 18:25:01 +00:00
renovate[bot]
99acb349bd fix(deps): update dependency lxml to v6.0.1 (#6011)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 13:14:05 -05:00
Michael Genson
894162a669 fix: Remove Frontend Timeout (#6033) 2025-08-25 12:28:43 -05:00
Michael Genson
347af7d417 fix: Can't add first shopping list item to shopping list (#6013) 2025-08-25 11:53:36 -05:00
Michael Genson
cac1699aeb fix: Light Mode Using Dark Mode Background Color (#6014) 2025-08-25 13:34:00 +00:00
Hayden
d577966bfb chore(l10n): New Crowdin updates (#6017)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-25 11:21:11 +00:00
github-actions[bot]
c663efde09 chore(auto): Update pre-commit hooks (#6029)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-25 07:31:11 +00:00
Michael Genson
9e568a1182 fix: Simplify AutoForm and fix select (#6022)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-25 07:19:46 +00:00
github-actions[bot]
fc38ef2ba9 chore: automatic locale sync (#6024) 2025-08-25 06:46:24 +00:00
Michael Genson
323a8100db fix: Remove Temperature from OpenAI Integration (#6023) 2025-08-25 08:36:15 +02:00
DrDonoso
01d3d5d325 fix: theme dark/light are swapped (#6001)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-22 18:27:57 +00:00
renovate[bot]
3f52c66f02 chore(deps): update dependency mkdocs-material to v9.6.18 (#6008)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 19:20:09 +02:00
Hayden
566f744220 chore(l10n): New Crowdin updates (#6009) 2025-08-22 17:33:12 +02:00
renovate[bot]
561b50ba45 chore(deps): update dependency ruff to v0.12.10 (#6004)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 11:21:46 +02:00
renovate[bot]
4228c9e753 fix(deps): update dependency openai to v1.101.0 (#6005)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 08:59:19 +02:00
Hayden
2a5c3f6457 chore(l10n): New Crowdin updates (#6006) 2025-08-22 08:30:41 +02:00
Hayden
389f8b4279 chore(l10n): New Crowdin updates (#5999) 2025-08-21 17:42:31 +02:00
Hayden
f2b71e981e chore(l10n): New Crowdin updates (#5995) 2025-08-20 08:05:17 +00:00
github-actions[bot]
ec7e3a5103 docs(auto): Update image tag, for release v3.1.1 (#5994)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-20 05:57:49 +00:00
eMerzh
6f0183cc4b feat: Allow env_nested config with __ (#5616) 2025-08-19 21:00:53 +00:00
renovate[bot]
12d38c89ea fix(deps): update dependency requests to v2.32.5 (#5987)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-19 12:38:30 -05:00
renovate[bot]
492c9a948d fix(deps): update dependency openai to v1.100.2 (#5993)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 19:23:24 +02:00
github-actions[bot]
a808c8a18b docs(auto): Update image tag, for release 3.1.0 (#5992)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-19 18:49:54 +02:00
Hayden
0c6483aefa chore(l10n): New Crowdin updates (#5991) 2025-08-19 18:21:45 +02:00
179 changed files with 9021 additions and 8195 deletions

View File

@@ -86,7 +86,7 @@ jobs:
# Add and commit changes
git add .
git commit -m "chore: automatic locale sync"
git commit -m "chore: crowdin locale sync"
# Push the branch
git push origin "$BRANCH_NAME"
@@ -96,9 +96,10 @@ jobs:
# Create PR using GitHub CLI with explicit repository
gh pr create \
--repo "${{ github.repository }}" \
--title "chore: automatic locale sync" \
--title "chore(l10n): Crowdin locale sync" \
--base "$BASE_BRANCH" \
--head "$BRANCH_NAME" \
--label "l10n" \
--body "## Summary
Automatically generated locale updates from the weekly sync job.

View File

@@ -31,6 +31,7 @@ jobs:
deps
auto
l10n
config
# Configure that a scope must always be provided.
requireScope: false
# If the PR contains one of these newline-delimited labels, the

View File

@@ -12,7 +12,7 @@ repos:
exclude: ^tests/data/
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.9
rev: v0.12.12
hooks:
- id: ruff
- id: ruff-format

View File

@@ -88,6 +88,8 @@ tasks:
- rm -r ./dev/data/recipes/
- rm -r ./dev/data/users/
- rm -f ./dev/data/mealie*.db
- rm -f ./dev/data/mealie*.db-shm
- rm -f ./dev/data/mealie*.db-wal
- rm -f ./dev/data/mealie.log
- rm -f ./dev/data/.secret

View File

@@ -173,9 +173,25 @@ the code generation ID is hardcoded into the script and required in the nuxt con
def inject_nuxt_values():
all_date_locales = [
f'"{match.stem}": require("./lang/dateTimeFormats/{match.name}"),' for match in datetime_dir.glob("*.json")
]
datetime_files = list(datetime_dir.glob("*.json"))
datetime_files.sort()
datetime_imports = []
datetime_object_entries = []
for match in datetime_files:
# Convert locale name to camelCase variable name (e.g., "en-US" -> "enUS")
var_name = match.stem.replace("-", "")
# Generate import statement
import_line = f'import * as {var_name} from "./lang/dateTimeFormats/{match.name}";'
datetime_imports.append(import_line)
# Generate object entry
object_entry = f' "{match.stem}": {var_name},'
datetime_object_entries.append(object_entry)
all_date_locales = datetime_imports + ["", "const datetimeFormats = {"] + datetime_object_entries + ["};"]
all_langs = []
for match in locales_dir.glob("*.json"):
@@ -186,7 +202,6 @@ def inject_nuxt_values():
all_langs.append(lang_string)
all_langs.sort()
all_date_locales.sort()
log.debug(f"injecting locales into nuxt config -> {nuxt_config}")
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)

View File

@@ -1,7 +1,7 @@
###############################################
# Frontend Build
###############################################
FROM node:20@sha256:572a90df10a58ebb7d3f223d661d964a6c2383a9c2b5763162b4f631c53dc56a \
FROM node:20@sha256:f3e50c7689a1b6982fab45b1b23ba5adf1fd725e233dc640918fb59f7a57b174 \
AS frontend-builder
WORKDIR /frontend

View File

@@ -45,7 +45,7 @@ Once the prerequisites are installed you can cd into the project base directory
=== "Linux / macOS"
```bash
# Naviate To The Root Directory
# Navigate To The Root Directory
cd /path/to/project
# Utilize the Taskfile to Install Dependencies

View File

@@ -87,6 +87,7 @@ The shopping lists feature is a great way to keep track of what you need to buy
Managing shopping lists can be done from the Sidebar > Shopping Lists.
Here you will be able to:
- See items already on the Shopping List
- See linked recipes with ingredients
- Toggling via the 'Pot' icon will show you the linked recipe, allowing you to click to access it.
@@ -117,6 +118,7 @@ Mealie is designed to integrate with many different external services. There are
### Notifiers
Notifiers are event-driven notifications sent when specific actions are performed within Mealie. Some actions include:
- Creating / Updating a recipe
- Adding items to a shopping list
- Creating a new mealplan
@@ -198,6 +200,7 @@ Mealie lets you fully customize how you organize your users. You can use Groups
Groups are fully isolated instances of Mealie. Think of a goup as a completely separate, fully self-contained site. There is no data shared between groups. Each group has its own users, recipes, tags, categories, etc. A user logged-in to one group cannot make any changes to another.
Common use cases for groups include:
- Hosting multiple instances of Mealie for others who want to keep their data private and secure
- Creating completely isolated recipe pools
@@ -206,6 +209,7 @@ Common use cases for groups include:
Households are subdivisions within a single Group. Households maintain their own users and settings, while sharing their recipes with other households. Households also share organizers (tags, categories, etc.) with the entire group. Meal Plans, Shopping Lists, and Integrations are only accessible within a household.
Common use cases for households include:
- Sharing a common recipe pool amongst families
- Maintaining separate meal plans and shopping lists from other households
- Maintaining separate integrations and customizations from other households

View File

@@ -32,15 +32,16 @@
### Database
| Variables | Default | Description |
| ------------------------------------------------------- | :------: | ----------------------------------------------------------------------- |
| DB_ENGINE | sqlite | Optional: 'sqlite', 'postgres' |
| POSTGRES_USER<super>[&dagger;][secrets]</super> | mealie | Postgres database user |
| POSTGRES_PASSWORD<super>[&dagger;][secrets]</super> | mealie | Postgres database password |
| POSTGRES_SERVER<super>[&dagger;][secrets]</super> | postgres | Postgres database server address |
| POSTGRES_PORT<super>[&dagger;][secrets]</super> | 5432 | Postgres database port |
| POSTGRES_DB<super>[&dagger;][secrets]</super> | mealie | Postgres database name |
| POSTGRES_URL_OVERRIDE<super>[&dagger;][secrets]</super> | None | Optional Postgres URL override to use instead of POSTGRES\_\* variables |
| Variables | Default | Description |
|---------------------------------------------------------|:--------:|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DB_ENGINE | sqlite | Optional: 'sqlite', 'postgres' |
| SQLITE_MIGRATE_JOURNAL_WAL | False | If set to true, switches SQLite's journal mode to WAL, which allows for multiple concurrent accesses. This can be useful when you have a decent amount of concurrency or when using certain remote storage systems such as Ceph. |
| POSTGRES_USER<super>[&dagger;][secrets]</super> | mealie | Postgres database user |
| POSTGRES_PASSWORD<super>[&dagger;][secrets]</super> | mealie | Postgres database password |
| POSTGRES_SERVER<super>[&dagger;][secrets]</super> | postgres | Postgres database server address |
| POSTGRES_PORT<super>[&dagger;][secrets]</super> | 5432 | Postgres database port |
| POSTGRES_DB<super>[&dagger;][secrets]</super> | mealie | Postgres database name |
| POSTGRES_URL_OVERRIDE<super>[&dagger;][secrets]</super> | None | Optional Postgres URL override to use instead of POSTGRES\_\* variables |
### Email
@@ -131,7 +132,7 @@ For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
| OPENAI_REQUEST_TIMEOUT | 60 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
### Theming

View File

@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
1. Take a backup just in case!
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.0.2`
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.2.0`
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
4. Restart the container
@@ -60,7 +60,7 @@ The following steps were tested on a Ubuntu 20.04 server, but should work for mo
## Step 3: Customizing The `docker-compose.yaml` files.
After you've decided setup the files it's important to set a few ENV variables to ensure that you can use all the features of Mealie. I recommend that you verify and check that:
After you've decided how to set up your files, it's important to set a few ENV variables to ensure that you can use all the features of Mealie. Verify that:
- [x] You've configured the relevant ENV variables for your database selection in the `docker-compose.yaml` files.
- [x] You've configured the [SMTP server settings](./backend-config.md#email) (used for invitations, password resets, etc). You can setup a [google app password](https://support.google.com/accounts/answer/185833?hl=en) if you want to send email via gmail.
@@ -117,7 +117,7 @@ The latest tag provides the latest released image of Mealie.
---
**These tags no are long updated**
**These tags are no longer updated**
`mealie:frontend-v1.0.0beta-x` **and** `mealie:api-v1.0.0beta-x`

View File

@@ -10,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
```yaml
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.0.2 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.2.0 # (3)
container_name: mealie
restart: always
ports:

View File

@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
```yaml
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.0.2 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.2.0 # (3)
container_name: mealie
restart: always
ports:

View File

@@ -4,7 +4,7 @@
You MUST read the release notes prior to upgrading your container. Mealie has a robust backup and restore system for managing your data. Pre-v1.0.0 versions of Mealie use a different database structure, so if you are upgrading from pre-v1.0.0 to v1.0.0, you MUST backup your data and then re-import it. Even if you are already on v1.0.0, it is strongly recommended to backup all data before updating.
### Before Upgrading
- Read The Release Notes
- [Read The Release Notes](https://github.com/mealie-recipes/mealie/releases)
- Identify Breaking Changes
- Create a Backup and Download from the UI
- Upgrade

View File

@@ -87,7 +87,7 @@
</template>
<script setup lang="ts">
import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeContextMenu from "./RecipeContextMenu/RecipeContextMenu.vue";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
import type { Recipe } from "~/lib/api/types/recipe";

View File

@@ -55,12 +55,9 @@
/>
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
<RecipeRating
class="ml-n2"
<RecipeCardRating
:model-value="rating"
:recipe-id="recipeId"
:slug="slug"
small
/>
<v-spacer />
<RecipeChips
@@ -75,9 +72,10 @@
<!-- If we're not logged-in, no items display, so we hide this menu -->
<RecipeContextMenu
v-if="isOwnGroup"
v-if="isOwnGroup && showRecipeContent"
color="grey-darken-2"
:slug="slug"
:menu-icon="$globals.icons.dotsVertical"
:name="name"
:recipe-id="recipeId"
:use-items="{
@@ -90,7 +88,7 @@
printPreferences: false,
share: true,
}"
@delete="$emit('delete', slug)"
@deleted="$emit('delete', slug)"
/>
</v-card-actions>
</slot>
@@ -103,9 +101,9 @@
<script setup lang="ts">
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeChips from "./RecipeChips.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeContextMenu from "./RecipeContextMenu/RecipeContextMenu.vue";
import RecipeCardImage from "./RecipeCardImage.vue";
import RecipeRating from "./RecipeRating.vue";
import RecipeCardRating from "./RecipeCardRating.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
interface Props {

View File

@@ -87,13 +87,11 @@
class="ma-0 pa-0"
/>
<div v-else class="my-0 px-1 py-0" /> <!-- Empty div to keep the layout consistent -->
<RecipeRating
<RecipeCardRating
v-if="showRecipeContent"
:class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']"
:value="rating"
:model-value="rating"
:recipe-id="recipeId"
:slug="slug"
small
/>
<!-- If we're not logged-in, no items display, so we hide this menu -->
@@ -128,9 +126,9 @@
<script setup lang="ts">
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeContextMenu from "./RecipeContextMenu/RecipeContextMenu.vue";
import RecipeCardImage from "./RecipeCardImage.vue";
import RecipeRating from "./RecipeRating.vue";
import RecipeCardRating from "./RecipeCardRating.vue";
import RecipeChips from "./RecipeChips.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";

View File

@@ -0,0 +1,101 @@
<template>
<div class="rating-display">
<span
v-for="(star, index) in ratingDisplay"
:key="index"
class="star"
:class="{
'star-half': star === 'half',
'text-secondary': !useGroupStyle,
'text-grey-darken-1': useGroupStyle,
}"
>
<!-- We render both the full and empty stars for "half" stars because they're layered over each other -->
<span
v-if="star === 'empty' || star === 'half'"
class="star-empty"
>
</span>
<span
v-if="star === 'full' || star === 'half'"
class="star-full"
>
</span>
</span>
</div>
</template>
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useUserSelfRatings } from "~/composables/use-users";
type Star = "full" | "half" | "empty";
const props = defineProps({
modelValue: {
type: Number,
default: 0,
},
recipeId: {
type: String,
default: "",
},
});
const { isOwnGroup } = useLoggedInState();
const { userRatings } = useUserSelfRatings();
const userRating = computed(() => {
return userRatings.value.find(r => r.recipeId === props.recipeId)?.rating ?? undefined;
});
const ratingValue = computed(() => userRating.value || props.modelValue || 0);
const useGroupStyle = computed(() => isOwnGroup.value && !userRating.value && props.modelValue);
const ratingDisplay = computed<Star[]>(
() => {
const stars: Star[] = [];
for (let i = 0; i < 5; i++) {
const diff = ratingValue.value - i;
if (diff >= 1) {
stars.push("full");
}
else if (diff >= 0.25) { // round to half star if rating is at least 0.25 but not quite a full star
stars.push("half");
}
else {
stars.push("empty");
}
}
return stars;
},
);
</script>
<style lang="scss" scoped>
.rating-display {
display: inline-flex;
align-items: center;
gap: 1px;
.star {
font-size: 18px;
transition: color 0.2s ease;
user-select: none;
position: relative;
display: inline-block;
&.star-half {
.star-full {
position: absolute;
left: 0;
top: 0;
width: 50%;
overflow: hidden;
}
}
}
}
</style>

View File

@@ -199,7 +199,7 @@ const emit = defineEmits<{
appendRecipes: [recipes: Recipe[]];
}>();
const { $vuetify } = useNuxtApp();
const display = useDisplay();
const preferences = useUserSortPreferences();
const EVENTS = {
@@ -215,7 +215,7 @@ const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => {
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
return display.smAndDown.value || preferences.value.useMobileCards;
});
const displayTitleIcon = computed(() => {

View File

@@ -0,0 +1,143 @@
<template>
<div class="text-center">
<v-menu
offset-y
start
:eager="isMenuContentLoaded"
:bottom="!menuTop"
:nudge-bottom="!menuTop ? '5' : '0'"
:top="menuTop"
:nudge-top="menuTop ? '5' : '0'"
allow-overflow
close-delay="125"
:open-on-hover="$vuetify.display.mdAndUp"
content-class="d-print-none"
@update:model-value="onMenuToggle"
>
<template #activator="{ props: activatorProps }">
<v-btn
icon
:variant="fab ? 'flat' : undefined"
:rounded="fab ? 'circle' : undefined"
:size="fab ? 'small' : undefined"
:color="fab ? 'info' : 'secondary'"
:fab="fab"
v-bind="activatorProps"
@click.prevent
@mouseenter="onHover"
>
<v-icon
:size="!fab ? undefined : 'x-large'"
:color="fab ? 'white' : 'secondary'"
>
{{ icon }}
</v-icon>
</v-btn>
</template>
<RecipeContextMenuContent
v-if="isMenuContentLoaded"
v-bind="contentProps"
@deleted="$emit('deleted', $event)"
/>
</v-menu>
</div>
</template>
<script setup lang="ts">
import type { Recipe } from "~/lib/api/types/recipe";
interface ContextMenuIncludes {
delete?: boolean;
edit?: boolean;
download?: boolean;
duplicate?: boolean;
mealplanner?: boolean;
shoppingList?: boolean;
print?: boolean;
printPreferences?: boolean;
share?: boolean;
recipeActions?: boolean;
}
interface ContextMenuItem {
title: string;
icon: string;
color?: string;
event: string;
isPublic: boolean;
}
interface Props {
useItems?: ContextMenuIncludes;
appendItems?: ContextMenuItem[];
leadingItems?: ContextMenuItem[];
menuTop?: boolean;
fab?: boolean;
color?: string;
slug: string;
menuIcon?: string | null;
name: string;
recipe?: Recipe;
recipeId: string;
recipeScale?: number;
}
const props = withDefaults(defineProps<Props>(), {
useItems: () => ({
delete: true,
edit: true,
download: true,
duplicate: false,
mealplanner: true,
shoppingList: true,
print: true,
printPreferences: true,
share: true,
recipeActions: true,
}),
appendItems: () => [],
leadingItems: () => [],
menuTop: true,
fab: false,
color: "primary",
menuIcon: null,
recipe: undefined,
recipeScale: 1,
});
defineEmits<{
[key: string]: any;
deleted: [slug: string];
}>();
const { $globals } = useNuxtApp();
const isMenuContentLoaded = ref(false);
const icon = computed(() => {
return props.menuIcon || $globals.icons.dotsVertical;
});
// Props to pass to the content component (excluding internal wrapper props)
const contentProps = computed(() => {
const { ...rest } = props;
return rest;
});
function onHover() {
if (!isMenuContentLoaded.value) {
isMenuContentLoaded.value = true;
}
}
function onMenuToggle(isOpen: boolean) {
if (isOpen && !isMenuContentLoaded.value) {
isMenuContentLoaded.value = true;
}
}
const RecipeContextMenuContent = defineAsyncComponent(
() => import("./RecipeContextMenuContent.vue"),
);
</script>

View File

@@ -1,159 +1,125 @@
<template>
<div class="text-center">
<!-- Recipe Share Dialog -->
<RecipeDialogShare v-model="shareDialog" :recipe-id="recipeId" :name="name" />
<RecipeDialogPrintPreferences v-model="printPreferencesDialog" :recipe="recipeRef" />
<BaseDialog
v-model="recipeDeleteDialog"
:title="$t('recipe.delete-recipe')"
color="error"
:icon="$globals.icons.alertCircle"
can-confirm
@confirm="deleteRecipe()"
>
<v-card-text>
<template v-if="isAdminAndNotOwner">
{{ $t("recipe.admin-delete-confirmation") }}
</template>
<template v-else>
{{ $t("recipe.delete-confirmation") }}
</template>
</v-card-text>
</BaseDialog>
<BaseDialog
v-model="recipeDuplicateDialog"
:title="$t('recipe.duplicate')"
color="primary"
:icon="$globals.icons.duplicate"
can-confirm
@confirm="duplicateRecipe()"
>
<v-card-text>
<v-text-field
v-model="recipeName"
density="compact"
:label="$t('recipe.recipe-name')"
autofocus
@keyup.enter="duplicateRecipe()"
/>
</v-card-text>
</BaseDialog>
<BaseDialog
v-model="mealplannerDialog"
:title="$t('recipe.add-recipe-to-mealplan')"
color="primary"
:icon="$globals.icons.calendar"
can-confirm
@confirm="addRecipeToPlan()"
>
<v-card-text>
<v-menu
v-model="pickerMenu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="auto"
>
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="newMealdateString"
:label="$t('general.date')"
:prepend-icon="$globals.icons.calendar"
v-bind="activatorProps"
readonly
/>
</template>
<v-date-picker
v-model="newMealdate"
hide-header
:first-day-of-week="firstDayOfWeek"
:local="$i18n.locale"
@update:model-value="pickerMenu = false"
/>
</v-menu>
<v-select
v-model="newMealType"
:return-object="false"
:items="planTypeOptions"
:label="$t('recipe.entry-type')"
item-title="text"
item-value="value"
/>
</v-card-text>
</BaseDialog>
<RecipeDialogAddToShoppingList
v-if="shoppingLists && recipeRefWithScale"
v-model="shoppingListDialog"
:recipes="[recipeRefWithScale]"
:shopping-lists="shoppingLists"
/>
<v-menu
offset-y
start
:bottom="!menuTop"
:nudge-bottom="!menuTop ? '5' : '0'"
:top="menuTop"
:nudge-top="menuTop ? '5' : '0'"
allow-overflow
close-delay="125"
:open-on-hover="$vuetify.display.mdAndUp"
content-class="d-print-none"
>
<template #activator="{ props: activatorProps }">
<v-btn
icon
:variant="fab ? 'flat' : undefined"
:rounded="fab ? 'circle' : undefined"
:size="fab ? 'small' : undefined"
:color="fab ? 'info' : 'secondary'"
:fab="fab"
v-bind="activatorProps"
@click.prevent
>
<v-icon
:size="!fab ? undefined : 'x-large'"
:color="fab ? 'white' : 'secondary'"
>
{{ icon }}
</v-icon>
</v-btn>
<RecipeDialogShare v-model="shareDialog" :recipe-id="recipeId" :name="name" />
<RecipeDialogPrintPreferences v-model="printPreferencesDialog" :recipe="recipeRef" />
<BaseDialog
v-model="recipeDeleteDialog"
:title="$t('recipe.delete-recipe')"
color="error"
:icon="$globals.icons.alertCircle"
can-confirm
@confirm="deleteRecipe()"
>
<v-card-text>
<template v-if="isAdminAndNotOwner">
{{ $t("recipe.admin-delete-confirmation") }}
</template>
<v-list density="compact">
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
<template #prepend>
<v-icon :color="item.color">
{{ item.icon }}
</v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
<v-divider />
<v-list-item
v-for="(action, index) in recipeActions"
:key="index"
@click="executeRecipeAction(action)"
>
<template #prepend>
<v-icon color="undefined">
{{ $globals.icons.linkVariantPlus }}
</v-icon>
</template>
<v-list-item-title>
{{ action.title }}
</v-list-item-title>
</v-list-item>
</div>
</v-list>
</v-menu>
</div>
<template v-else>
{{ $t("recipe.delete-confirmation") }}
</template>
</v-card-text>
</BaseDialog>
<BaseDialog
v-model="recipeDuplicateDialog"
:title="$t('recipe.duplicate')"
color="primary"
:icon="$globals.icons.duplicate"
can-confirm
@confirm="duplicateRecipe()"
>
<v-card-text>
<v-text-field
v-model="recipeName"
density="compact"
:label="$t('recipe.recipe-name')"
autofocus
@keyup.enter="duplicateRecipe()"
/>
</v-card-text>
</BaseDialog>
<BaseDialog
v-model="mealplannerDialog"
:title="$t('recipe.add-recipe-to-mealplan')"
color="primary"
:icon="$globals.icons.calendar"
can-confirm
@confirm="addRecipeToPlan()"
>
<v-card-text>
<v-menu
v-model="pickerMenu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="auto"
>
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="newMealdateString"
:label="$t('general.date')"
:prepend-icon="$globals.icons.calendar"
v-bind="activatorProps"
readonly
/>
</template>
<v-date-picker
v-model="newMealdate"
hide-header
:first-day-of-week="firstDayOfWeek"
:local="$i18n.locale"
@update:model-value="pickerMenu = false"
/>
</v-menu>
<v-select
v-model="newMealType"
:return-object="false"
:items="planTypeOptions"
:label="$t('recipe.entry-type')"
item-title="text"
item-value="value"
/>
</v-card-text>
</BaseDialog>
<RecipeDialogAddToShoppingList
v-if="shoppingLists && recipeRefWithScale"
v-model="shoppingListDialog"
:recipes="[recipeRefWithScale]"
:shopping-lists="shoppingLists"
/>
<v-list density="compact">
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
<template #prepend>
<v-icon :color="item.color">
{{ item.icon }}
</v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
<v-divider />
<v-list-item
v-for="(action, index) in recipeActions"
:key="index"
@click="executeRecipeAction(action)"
>
<template #prepend>
<v-icon color="undefined">
{{ $globals.icons.linkVariantPlus }}
</v-icon>
</template>
<v-list-item-title>
{{ action.title }}
</v-list-item-title>
</v-list-item>
</div>
</v-list>
</template>
<script setup lang="ts">
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
import RecipeDialogShare from "./RecipeDialogShare.vue";
import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
import RecipeDialogPrintPreferences from "~/components/Domain/Recipe/RecipeDialogPrintPreferences.vue";
import RecipeDialogShare from "~/components/Domain/Recipe/RecipeDialogShare.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useUserApi } from "~/composables/api";
import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
@@ -225,7 +191,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
[key: string]: any;
delete: [slug: string];
deleted: [slug: string];
}>();
const api = useUserApi();
@@ -336,8 +302,6 @@ const defaultItems: { [key: string]: ContextMenuItem } = {
// Add leading and Appending Items
menuItems.value = [...menuItems.value, ...props.leadingItems, ...props.appendItems];
const icon = props.menuIcon || $globals.icons.dotsVertical;
// ===========================================================================
// Context Menu Event Handler
@@ -407,7 +371,7 @@ async function deleteRecipe() {
if (data?.slug) {
router.push(`/g/${groupSlug.value}`);
}
emit("delete", props.slug);
emit("deleted", props.slug);
}
const download = useDownloader();

View File

@@ -1,715 +0,0 @@
<template>
<v-container
fluid
class="px-0"
>
<div class="search-container pb-8">
<form
class="search-box pa-2"
@submit.prevent="search"
>
<div class="d-flex justify-center mb-2">
<v-text-field
ref="input"
v-model="state.search"
variant="outlined"
hide-details
clearable
color="primary"
:placeholder="$t('search.search-placeholder')"
:prepend-inner-icon="$globals.icons.search"
@keyup.enter="hideKeyboard"
/>
</div>
<div class="search-row">
<!-- Category Filter -->
<SearchFilter
v-if="categories"
v-model="selectedCategories"
v-model:require-all="state.requireAllCategories"
:items="categories"
>
<v-icon start>
{{ $globals.icons.categories }}
</v-icon>
{{ $t("category.categories") }}
</SearchFilter>
<!-- Tag Filter -->
<SearchFilter
v-if="tags"
v-model="selectedTags"
v-model:require-all="state.requireAllTags"
:items="tags"
>
<v-icon start>
{{ $globals.icons.tags }}
</v-icon>
{{ $t("tag.tags") }}
</SearchFilter>
<!-- Tool Filter -->
<SearchFilter
v-if="tools"
v-model="selectedTools"
v-model:require-all="state.requireAllTools"
:items="tools"
>
<v-icon start>
{{ $globals.icons.potSteam }}
</v-icon>
{{ $t("tool.tools") }}
</SearchFilter>
<!-- Food Filter -->
<SearchFilter
v-if="foods"
v-model="selectedFoods"
v-model:require-all="state.requireAllFoods"
:items="foods"
>
<v-icon start>
{{ $globals.icons.foods }}
</v-icon>
{{ $t("general.foods") }}
</SearchFilter>
<!-- Household Filter -->
<SearchFilter
v-if="households.length > 1"
v-model="selectedHouseholds"
:items="households"
radio
>
<v-icon start>
{{ $globals.icons.household }}
</v-icon>
{{ $t("household.households") }}
</SearchFilter>
<!-- Sort Options -->
<v-menu
offset-y
nudge-bottom="3"
>
<template #activator="{ props }">
<v-btn
class="ml-auto"
size="small"
color="accent"
v-bind="props"
>
<v-icon :start="!$vuetify.display.xs">
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
</v-icon>
{{ $vuetify.display.xs ? null : sortText }}
</v-btn>
</template>
<v-card>
<v-list>
<v-list-item
slim
density="comfortable"
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
@click="toggleOrderDirection()"
/>
<v-divider />
<v-list-item
v-for="v in sortable"
:key="v.name"
:active="state.orderBy === v.value"
slim
density="comfortable"
:prepend-icon="v.icon"
:title="v.name"
@click="setOrderBy(v.value)"
/>
</v-list>
</v-card>
</v-menu>
<!-- Settings -->
<v-menu
offset-y
bottom
start
nudge-bottom="3"
:close-on-content-click="false"
>
<template #activator="{ props }">
<v-btn
size="small"
color="accent"
dark
v-bind="props"
>
<v-icon size="small">
{{ $globals.icons.cog }}
</v-icon>
</v-btn>
</template>
<v-card>
<v-card-text>
<v-switch
v-model="state.auto"
:label="$t('search.auto-search')"
single-line
/>
<v-btn
block
color="primary"
@click="reset"
>
{{ $t("general.reset") }}
</v-btn>
</v-card-text>
</v-card>
</v-menu>
</div>
<div
v-if="!state.auto"
class="search-button-container"
>
<v-btn
size="x-large"
color="primary"
type="submit"
block
>
<v-icon start>
{{ $globals.icons.search }}
</v-icon>
{{ $t("search.search") }}
</v-btn>
</div>
</form>
</div>
<v-divider />
<v-container class="mt-6 px-md-6">
<RecipeCardSection
v-if="state.ready"
class="mt-n5"
:icon="$globals.icons.silverwareForkKnife"
:title="$t('general.recipes')"
:recipes="recipes"
:query="passedQueryWithSeed"
disable-sort
@item-selected="filterItems"
@replace-recipes="replaceRecipes"
@append-recipes="appendRecipes"
/>
</v-container>
</v-container>
</template>
<script lang="ts">
import { watchDebounced } from "@vueuse/shared";
import SearchFilter from "~/components/Domain/SearchFilter.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import {
useCategoryStore,
usePublicCategoryStore,
useFoodStore,
usePublicFoodStore,
useHouseholdStore,
usePublicHouseholdStore,
useTagStore,
usePublicTagStore,
useToolStore,
usePublicToolStore,
} from "~/composables/store";
import { useUserSearchQuerySession, useUserSortPreferences } from "~/composables/use-users/preferences";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useLazyRecipes } from "~/composables/recipes";
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
import type { HouseholdSummary } from "~/lib/api/types/household";
export default defineNuxtComponent({
components: { SearchFilter, RecipeCardSection },
setup() {
const router = useRouter();
const i18n = useI18n();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState();
const state = ref({
auto: true,
ready: false,
search: "",
orderBy: "created_at",
orderDirection: "desc" as "asc" | "desc",
// and/or
requireAllCategories: false,
requireAllTags: false,
requireAllTools: false,
requireAllFoods: false,
});
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const searchQuerySession = useUserSearchQuerySession();
const sortPreferences = useUserSortPreferences();
watch(() => state.value.orderBy, (newValue) => {
sortPreferences.value.orderBy = newValue;
});
watch(() => state.value.orderDirection, (newValue) => {
sortPreferences.value.orderDirection = newValue;
});
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const categories = isOwnGroup.value ? useCategoryStore() : usePublicCategoryStore(groupSlug.value);
const selectedCategories = ref<NoUndefinedField<RecipeCategory>[]>([]);
const foods = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
const selectedFoods = ref<IngredientFood[]>([]);
const households = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
const selectedHouseholds = ref([] as NoUndefinedField<HouseholdSummary>[]);
const tags = isOwnGroup.value ? useTagStore() : usePublicTagStore(groupSlug.value);
const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]);
const tools = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
const selectedTools = ref<NoUndefinedField<RecipeTool>[]>([]);
function calcPassedQuery(): RecipeSearchQuery {
return {
// the search clear button sets search to null, which still renders the query param for a moment,
// whereas an empty string is not rendered
search: state.value.search ? state.value.search : "",
categories: toIDArray(selectedCategories.value),
foods: toIDArray(selectedFoods.value),
households: toIDArray(selectedHouseholds.value),
tags: toIDArray(selectedTags.value),
tools: toIDArray(selectedTools.value),
requireAllCategories: state.value.requireAllCategories,
requireAllTags: state.value.requireAllTags,
requireAllTools: state.value.requireAllTools,
requireAllFoods: state.value.requireAllFoods,
orderBy: state.value.orderBy,
orderDirection: state.value.orderDirection,
};
}
const passedQuery = ref<RecipeSearchQuery>(calcPassedQuery());
// we calculate this separately because otherwise we can't check for query changes
const passedQueryWithSeed = computed(() => {
return {
...passedQuery.value,
_searchSeed: Date.now().toString(),
};
});
const queryDefaults = {
search: "",
orderBy: "created_at",
orderDirection: "desc" as "asc" | "desc",
requireAllCategories: false,
requireAllTags: false,
requireAllTools: false,
requireAllFoods: false,
};
function reset() {
state.value.search = queryDefaults.search;
state.value.orderBy = queryDefaults.orderBy;
state.value.orderDirection = queryDefaults.orderDirection;
sortPreferences.value.orderBy = queryDefaults.orderBy;
sortPreferences.value.orderDirection = queryDefaults.orderDirection;
state.value.requireAllCategories = queryDefaults.requireAllCategories;
state.value.requireAllTags = queryDefaults.requireAllTags;
state.value.requireAllTools = queryDefaults.requireAllTools;
state.value.requireAllFoods = queryDefaults.requireAllFoods;
selectedCategories.value = [];
selectedFoods.value = [];
selectedHouseholds.value = [];
selectedTags.value = [];
selectedTools.value = [];
}
function toggleOrderDirection() {
state.value.orderDirection = state.value.orderDirection === "asc" ? "desc" : "asc";
sortPreferences.value.orderDirection = state.value.orderDirection;
}
function setOrderBy(value: string) {
state.value.orderBy = value;
sortPreferences.value.orderBy = value;
}
function toIDArray(array: { id: string }[]) {
// we sort the array to make sure the query is always the same
return array.map(item => item.id).sort();
}
function hideKeyboard() {
input.value.blur();
}
const input: Ref<any> = ref(null);
async function search() {
const oldQueryValueString = JSON.stringify(passedQuery.value);
const newQueryValue = calcPassedQuery();
const newQueryValueString = JSON.stringify(newQueryValue);
if (oldQueryValueString === newQueryValueString) {
return;
}
passedQuery.value = newQueryValue;
const query = {
categories: passedQuery.value.categories,
foods: passedQuery.value.foods,
tags: passedQuery.value.tags,
tools: passedQuery.value.tools,
// Only add the query param if it's not the default value
...{
auto: state.value.auto ? undefined : "false",
search: passedQuery.value.search === queryDefaults.search ? undefined : passedQuery.value.search,
households: !passedQuery.value.households?.length || passedQuery.value.households?.length === households.store.value.length ? undefined : passedQuery.value.households,
requireAllCategories: passedQuery.value.requireAllCategories ? "true" : undefined,
requireAllTags: passedQuery.value.requireAllTags ? "true" : undefined,
requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined,
},
};
await router.push({ query });
searchQuerySession.value.recipe = JSON.stringify(query);
}
function waitUntilAndExecute(
condition: () => boolean,
callback: () => void,
opts = { timeout: 2000, interval: 500 },
): Promise<void> {
return new Promise((resolve, reject) => {
const state = {
timeout: undefined as number | undefined,
interval: undefined as number | undefined,
};
const check = () => {
if (condition()) {
clearInterval(state.interval);
clearTimeout(state.timeout);
callback();
resolve();
}
};
// For some reason these were returning NodeJS.Timeout
state.interval = setInterval(check, opts.interval) as unknown as number;
state.timeout = setTimeout(() => {
clearInterval(state.interval);
reject(new Error("Timeout"));
}, opts.timeout) as unknown as number;
});
}
const sortText = computed(() => {
const sort = sortable.find(s => s.value === state.value.orderBy);
if (!sort) return "";
return `${sort.name}`;
});
const sortable = [
{
icon: $globals.icons.orderAlphabeticalAscending,
name: i18n.t("general.sort-alphabetically"),
value: "name",
},
{
icon: $globals.icons.newBox,
name: i18n.t("general.created"),
value: "created_at",
},
{
icon: $globals.icons.chefHat,
name: i18n.t("general.last-made"),
value: "last_made",
},
{
icon: $globals.icons.star,
name: i18n.t("general.rating"),
value: "rating",
},
{
icon: $globals.icons.update,
name: i18n.t("general.updated"),
value: "updated_at",
},
{
icon: $globals.icons.diceMultiple,
name: i18n.t("general.random"),
value: "random",
},
];
watch(
() => route.query,
() => {
if (!Object.keys(route.query).length) {
reset();
}
},
);
function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) {
if (urlPrefix === "categories") {
const result = categories.store.value.filter(category => (category.id as string).includes(item.id as string));
selectedCategories.value = result as NoUndefinedField<RecipeTag>[];
}
else if (urlPrefix === "tags") {
const result = tags.store.value.filter(tag => (tag.id as string).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
}
else if (urlPrefix === "tools") {
const result = tools.store.value.filter(tool => (tool.id).includes(item.id || ""));
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
}
}
async function hydrateSearch() {
const query = router.currentRoute.value.query;
if (query.auto?.length) {
state.value.auto = query.auto === "true";
}
if (query.search?.length) {
state.value.search = query.search as string;
}
else {
state.value.search = queryDefaults.search;
}
state.value.orderBy = sortPreferences.value.orderBy;
state.value.orderDirection = sortPreferences.value.orderDirection as "asc" | "desc";
if (query.requireAllCategories?.length) {
state.value.requireAllCategories = query.requireAllCategories === "true";
}
else {
state.value.requireAllCategories = queryDefaults.requireAllCategories;
}
if (query.requireAllTags?.length) {
state.value.requireAllTags = query.requireAllTags === "true";
}
else {
state.value.requireAllTags = queryDefaults.requireAllTags;
}
if (query.requireAllTools?.length) {
state.value.requireAllTools = query.requireAllTools === "true";
}
else {
state.value.requireAllTools = queryDefaults.requireAllTools;
}
if (query.requireAllFoods?.length) {
state.value.requireAllFoods = query.requireAllFoods === "true";
}
else {
state.value.requireAllFoods = queryDefaults.requireAllFoods;
}
const promises: Promise<void>[] = [];
if (query.categories?.length) {
promises.push(
waitUntilAndExecute(
() => categories.store.value.length > 0,
() => {
const result = categories.store.value.filter(item =>
(query.categories as string[]).includes(item.id as string),
);
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
},
),
);
}
else {
selectedCategories.value = [];
}
if (query.tags?.length) {
promises.push(
waitUntilAndExecute(
() => tags.store.value.length > 0,
() => {
const result = tags.store.value.filter(item => (query.tags as string[]).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
},
),
);
}
else {
selectedTags.value = [];
}
if (query.tools?.length) {
promises.push(
waitUntilAndExecute(
() => tools.store.value.length > 0,
() => {
const result = tools.store.value.filter(item => (query.tools as string[]).includes(item.id));
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
},
),
);
}
else {
selectedTools.value = [];
}
if (query.foods?.length) {
promises.push(
waitUntilAndExecute(
() => {
if (foods.store.value) {
return foods.store.value.length > 0;
}
return false;
},
() => {
const result = foods.store.value?.filter(item => (query.foods as string[]).includes(item.id));
selectedFoods.value = result ?? [];
},
),
);
}
else {
selectedFoods.value = [];
}
if (query.households?.length) {
promises.push(
waitUntilAndExecute(
() => {
if (households.store.value) {
return households.store.value.length > 0;
}
return false;
},
() => {
const result = households.store.value?.filter(item => (query.households as string[]).includes(item.id));
selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? [];
},
),
);
}
else {
selectedHouseholds.value = [];
}
await Promise.allSettled(promises);
};
onMounted(async () => {
// restore the user's last search query
if (searchQuerySession.value.recipe && !(Object.keys(route.query).length > 0)) {
try {
const query = JSON.parse(searchQuerySession.value.recipe);
await router.replace({ query });
}
catch {
searchQuerySession.value.recipe = "";
router.replace({ query: {} });
}
}
await hydrateSearch();
await search();
state.value.ready = true;
});
watchDebounced(
[
() => state.value.search,
() => state.value.requireAllCategories,
() => state.value.requireAllTags,
() => state.value.requireAllTools,
() => state.value.requireAllFoods,
() => state.value.orderBy,
() => state.value.orderDirection,
selectedCategories,
selectedFoods,
selectedHouseholds,
selectedTags,
selectedTools,
],
async () => {
if (state.value.ready && state.value.auto) {
await search();
}
},
{
debounce: 500,
},
);
return {
sortText,
search,
reset,
state,
categories: categories.store as unknown as NoUndefinedField<RecipeCategory>[],
tags: tags.store as unknown as NoUndefinedField<RecipeTag>[],
foods: foods.store,
tools: tools.store as unknown as NoUndefinedField<RecipeTool>[],
households: households.store as unknown as NoUndefinedField<HouseholdSummary>[],
sortable,
toggleOrderDirection,
setOrderBy,
hideKeyboard,
input,
selectedCategories,
selectedFoods,
selectedHouseholds,
selectedTags,
selectedTools,
appendRecipes,
assignSorted,
recipes,
removeRecipe,
replaceRecipes,
passedQueryWithSeed,
filterItems,
};
},
});
</script>
<style lang="css">
.search-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 1rem;
}
.search-container {
display: flex;
justify-content: center;
}
.search-box {
width: 950px;
}
.search-button-container {
margin: 3rem auto 0 auto;
max-width: 500px;
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<v-container
fluid
class="px-0"
>
<RecipeExplorerPageSearch
ref="searchComponent"
@ready="onSearchReady"
/>
<v-divider />
<v-container class="mt-6 px-md-6">
<RecipeCardSection
v-if="ready"
class="mt-n5"
:icon="$globals.icons.silverwareForkKnife"
:title="$t('general.recipes')"
:recipes="recipes"
:query="searchQuery"
disable-sort
@item-selected="onItemSelected"
@replace-recipes="replaceRecipes"
@append-recipes="appendRecipes"
/>
</v-container>
</v-container>
</template>
<script lang="ts">
import RecipeExplorerPageSearch from "./RecipeExplorerPageParts/RecipeExplorerPageSearch.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useLazyRecipes } from "~/composables/recipes";
export default defineNuxtComponent({
components: { RecipeCardSection, RecipeExplorerPageSearch },
setup() {
const $auth = useMealieAuth();
const route = useRoute();
const { isOwnGroup } = useLoggedInState();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { recipes, appendRecipes, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const ready = ref(false);
const searchComponent = ref<InstanceType<typeof RecipeExplorerPageSearch>>();
const searchQuery = computed(() => {
return searchComponent.value?.passedQueryWithSeed || {};
});
function onSearchReady() {
ready.value = true;
}
function onItemSelected(item: any, urlPrefix: string) {
searchComponent.value?.filterItems(item, urlPrefix);
}
return {
ready,
searchComponent,
searchQuery,
recipes,
appendRecipes,
replaceRecipes,
onSearchReady,
onItemSelected,
};
},
});
</script>

View File

@@ -0,0 +1,231 @@
<template>
<div class="search-container pb-8">
<form
class="search-box pa-2"
@submit.prevent="search"
>
<div class="d-flex justify-center mb-2">
<v-text-field
ref="input"
v-model="state.search"
variant="outlined"
hide-details
clearable
color="primary"
:placeholder="$t('search.search-placeholder')"
:prepend-inner-icon="$globals.icons.search"
@keyup.enter="hideKeyboard"
/>
</div>
<div class="search-row">
<RecipeExplorerPageSearchFilters />
<!-- Sort Options -->
<v-menu
offset-y
nudge-bottom="3"
>
<template #activator="{ props }">
<v-btn
class="ml-auto"
size="small"
color="accent"
v-bind="props"
>
<v-icon :start="!$vuetify.display.xs">
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
</v-icon>
{{ $vuetify.display.xs ? null : sortText }}
</v-btn>
</template>
<v-card>
<v-list>
<v-list-item
slim
density="comfortable"
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
@click="toggleOrderDirection"
/>
<v-divider />
<v-list-item
v-for="v in sortable"
:key="v.name"
:active="state.orderBy === v.value"
slim
density="comfortable"
:prepend-icon="v.icon"
:title="v.name"
@click="setOrderBy(v.value)"
/>
</v-list>
</v-card>
</v-menu>
<!-- Settings -->
<v-menu
offset-y
bottom
start
nudge-bottom="3"
:close-on-content-click="false"
>
<template #activator="{ props }">
<v-btn
size="small"
color="accent"
dark
v-bind="props"
>
<v-icon size="small">
{{ $globals.icons.cog }}
</v-icon>
</v-btn>
</template>
<v-card>
<v-card-text>
<v-switch
v-model="state.auto"
:label="$t('search.auto-search')"
single-line
/>
<v-btn
block
color="primary"
@click="reset"
>
{{ $t("general.reset") }}
</v-btn>
</v-card-text>
</v-card>
</v-menu>
</div>
<div
v-if="!state.auto"
class="search-button-container"
>
<v-btn
size="x-large"
color="primary"
type="submit"
block
>
<v-icon start>
{{ $globals.icons.search }}
</v-icon>
{{ $t("search.search") }}
</v-btn>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import RecipeExplorerPageSearchFilters from "./RecipeExplorerPageSearchFilters.vue";
import { useRecipeExplorerSearch, clearRecipeExplorerSearchState } from "~/composables/use-recipe-explorer-search";
const emit = defineEmits<{
ready: [];
}>();
const $auth = useMealieAuth();
const route = useRoute();
const { $globals } = useNuxtApp();
const i18n = useI18n();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const {
state,
passedQueryWithSeed,
search,
reset,
toggleOrderDirection,
setOrderBy,
filterItems,
initialize,
} = useRecipeExplorerSearch(groupSlug);
defineExpose({
passedQueryWithSeed,
filterItems,
});
onMounted(async () => {
await initialize();
emit("ready");
});
onUnmounted(() => {
// Clear the cache when component unmounts to ensure fresh state on remount
clearRecipeExplorerSearchState(groupSlug.value);
});
const sortText = computed(() => {
const sort = sortable.value.find(s => s.value === state.value.orderBy);
if (!sort) return "";
return `${sort.name}`;
});
const sortable = computed(() => [
{
icon: $globals.icons.orderAlphabeticalAscending,
name: i18n.t("general.sort-alphabetically"),
value: "name",
},
{
icon: $globals.icons.newBox,
name: i18n.t("general.created"),
value: "created_at",
},
{
icon: $globals.icons.chefHat,
name: i18n.t("general.last-made"),
value: "last_made",
},
{
icon: $globals.icons.star,
name: i18n.t("general.rating"),
value: "rating",
},
{
icon: $globals.icons.update,
name: i18n.t("general.updated"),
value: "updated_at",
},
{
icon: $globals.icons.diceMultiple,
name: i18n.t("general.random"),
value: "random",
},
]);
// Methods
const input: Ref<any> = ref(null);
function hideKeyboard() {
input.value?.blur();
}
</script>
<style scoped>
.search-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 1rem;
}
.search-container {
display: flex;
justify-content: center;
}
.search-box {
width: 950px;
}
.search-button-container {
margin: 3rem auto 0 auto;
max-width: 500px;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<!-- Category Filter -->
<SearchFilter
v-if="categories"
v-model="selectedCategories"
v-model:require-all="state.requireAllCategories"
:items="categories"
>
<v-icon start>
{{ $globals.icons.categories }}
</v-icon>
{{ $t("category.categories") }}
</SearchFilter>
<!-- Tag Filter -->
<SearchFilter
v-if="tags"
v-model="selectedTags"
v-model:require-all="state.requireAllTags"
:items="tags"
>
<v-icon start>
{{ $globals.icons.tags }}
</v-icon>
{{ $t("tag.tags") }}
</SearchFilter>
<!-- Tool Filter -->
<SearchFilter
v-if="tools"
v-model="selectedTools"
v-model:require-all="state.requireAllTools"
:items="tools"
>
<v-icon start>
{{ $globals.icons.potSteam }}
</v-icon>
{{ $t("tool.tools") }}
</SearchFilter>
<!-- Food Filter -->
<SearchFilter
v-if="foods"
v-model="selectedFoods"
v-model:require-all="state.requireAllFoods"
:items="foods"
>
<v-icon start>
{{ $globals.icons.foods }}
</v-icon>
{{ $t("general.foods") }}
</SearchFilter>
<!-- Household Filter -->
<SearchFilter
v-if="households.length > 1"
v-model="selectedHouseholds"
:items="households"
radio
>
<v-icon start>
{{ $globals.icons.household }}
</v-icon>
{{ $t("household.households") }}
</SearchFilter>
</template>
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useRecipeExplorerSearch } from "~/composables/use-recipe-explorer-search";
import {
useCategoryStore,
usePublicCategoryStore,
useFoodStore,
usePublicFoodStore,
useHouseholdStore,
usePublicHouseholdStore,
useTagStore,
usePublicTagStore,
useToolStore,
usePublicToolStore,
} from "~/composables/store";
const $auth = useMealieAuth();
const route = useRoute();
const { isOwnGroup } = useLoggedInState();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const {
state,
selectedCategories,
selectedFoods,
selectedHouseholds,
selectedTags,
selectedTools,
} = useRecipeExplorerSearch(groupSlug);
const { store: categories } = isOwnGroup.value ? useCategoryStore() : usePublicCategoryStore(groupSlug.value);
const { store: tags } = isOwnGroup.value ? useTagStore() : usePublicTagStore(groupSlug.value);
const { store: tools } = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
const { store: foods } = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
</script>

View File

@@ -43,8 +43,6 @@ const props = withDefaults(defineProps<Props>(), {
buttonStyle: false,
});
const api = useUserApi();
const $auth = useMealieAuth();
const { userRatings, refreshUserRatings } = useUserSelfRatings();
const isFavorite = computed(() => {
@@ -53,6 +51,9 @@ const isFavorite = computed(() => {
});
async function toggleFavorite() {
const api = useUserApi();
const $auth = useMealieAuth();
if (!$auth.user.value) return;
if (!isFavorite.value) {
await api.users.addFavorite($auth.user.value?.id, props.recipeId);

View File

@@ -119,6 +119,7 @@
<script setup lang="ts">
import { whenever } from "@vueuse/core";
import { formatISO } from "date-fns";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { useHouseholdSelf } from "~/composables/use-households";
@@ -148,7 +149,7 @@ const newTimelineEventImageName = ref<string>("");
const newTimelineEventImagePreviewUrl = ref<string>();
const newTimelineEventTimestamp = ref<Date>(new Date());
const newTimelineEventTimestampString = computed(() => {
return newTimelineEventTimestamp.value.toISOString().substring(0, 10);
return formatISO(newTimelineEventTimestamp.value, { representation: "date" });
});
const lastMade = ref(props.recipe.lastMade);
@@ -169,7 +170,7 @@ whenever(
() => madeThisDialog.value,
() => {
// Set timestamp to now
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
newTimelineEventTimestamp.value = new Date();
},
);

View File

@@ -1,7 +1,7 @@
<template>
<div>
<v-container v-show="!isCookMode" key="recipe-page" class="px-0" :class="{ 'pa-0': $vuetify.display.smAndDown.value }">
<v-card :flat="$vuetify.display.smAndDown.value" class="d-print-none">
<v-container v-show="!isCookMode" key="recipe-page" class="px-0" :class="{ 'pa-0': $vuetify.display.smAndDown }">
<v-card :flat="$vuetify.display.smAndDown" class="d-print-none">
<RecipePageHeader
:recipe="recipe"
:recipe-scale="scale"
@@ -107,7 +107,7 @@
<v-divider />
</v-col>
<v-col class="overflow-y-auto"
:class="$vuetify.display.smAndDown.value ? 'py-2': 'py-6'"
:class="$vuetify.display.smAndDown ? 'py-2': 'py-6'"
style="height: 100%" cols="12" sm="7">
<h2 class="text-h5 px-4 font-weight-medium opacity-80">
{{ $t('recipe.instructions') }}
@@ -188,7 +188,7 @@ import { useNavigationWarning } from "~/composables/use-navigation-warning";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const { $vuetify } = useNuxtApp();
const display = useDisplay();
const i18n = useI18n();
const $auth = useMealieAuth();
const route = useRoute();
@@ -278,7 +278,7 @@ async function deleteRecipe() {
*/
const landscape = computed(() => {
const preferLandscape = recipe.value.settings?.landscapeView;
const smallScreen = !$vuetify.display.smAndUp.value;
const smallScreen = !display.smAndUp.value;
if (preferLandscape) {
return true;

View File

@@ -27,7 +27,7 @@ const props = withDefaults(defineProps<Props>(), {
maxWidth: undefined,
});
const { $vuetify } = useNuxtApp();
const display = useDisplay();
const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { user } = usePageUser();
@@ -42,7 +42,7 @@ if (user) {
const hideImage = ref(false);
const imageHeight = computed(() => {
return $vuetify.display.xs.value ? "200" : "400";
return display.xs.value ? "200" : "400";
});
const recipeImageUrl = computed(() => {

View File

@@ -29,32 +29,49 @@
{{ activeText }}
</p>
<v-divider class="mb-4" />
<v-checkbox-btn
v-for="ing in unusedIngredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
>
<template #label>
<RecipeIngredientHtml :markup="parseIngredientText(ing)" />
</template>
</v-checkbox-btn>
<template v-if="usedIngredients.length > 0">
<template v-if="Object.keys(groupedUnusedIngredients).length > 0">
<h4 class="py-3 ml-1">
{{ $t("recipe.linked-to-other-step") }}
{{ $t("recipe.unlinked") }}
</h4>
<template v-for="(ingredients, title) in groupedUnusedIngredients" :key="title">
<h4 v-if="title" class="py-3 ml-1 pl-4">
{{ title }}
</h4>
<v-checkbox-btn
v-for="ing in usedIngredients"
v-for="ing in ingredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
class="ml-4"
>
<template #label>
<RecipeIngredientHtml :markup="parseIngredientText(ing)" />
</template>
</v-checkbox-btn>
</template>
</template>
<template v-if="Object.keys(groupedUsedIngredients).length > 0">
<h4 class="py-3 ml-1">
{{ $t("recipe.linked-to-other-step") }}
</h4>
<template v-for="(ingredients, title) in groupedUsedIngredients" :key="title">
<h4 v-if="title" class="py-3 ml-1 pl-4">
{{ title }}
</h4>
<v-checkbox-btn
v-for="ing in ingredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
class="ml-4"
>
<template #label>
<RecipeIngredientHtml :markup="parseIngredientText(ing)" />
</template>
</v-checkbox-btn>
</template>
</template>
</v-card-text>
<v-divider />
@@ -563,6 +580,71 @@ const ingredientLookup = computed(() => {
}, results);
});
// Map each ingredient's referenceId to its section title
const ingredientSectionTitles = computed(() => {
const titleMap: { [key: string]: string } = {};
let currentTitle = "";
// Go through all ingredients in order
props.recipe.recipeIngredient.forEach((ingredient) => {
if (ingredient.referenceId === undefined) {
return;
}
// If this ingredient has a title, update the current title
if (ingredient.title) {
currentTitle = ingredient.title;
}
// Assign the current title to this ingredient
titleMap[ingredient.referenceId] = currentTitle;
});
return titleMap;
});
const groupedUnusedIngredients = computed(() => {
const groups: { [key: string]: RecipeIngredient[] } = {};
// Group ingredients by section title
unusedIngredients.value.forEach((ingredient) => {
if (ingredient.referenceId === undefined) {
return;
}
// Use the section title from the mapping, or fallback to the ingredient's own title
const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || "";
if (!groups[title]) {
groups[title] = [];
}
groups[title].push(ingredient);
});
return groups;
});
const groupedUsedIngredients = computed(() => {
const groups: { [key: string]: RecipeIngredient[] } = {};
// Group ingredients by section title
usedIngredients.value.forEach((ingredient) => {
if (ingredient.referenceId === undefined) {
return;
}
// Use the section title from the mapping, or fallback to the ingredient's own title
const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || "";
if (!groups[title]) {
groups[title] = [];
}
groups[title].push(ingredient);
});
return groups;
});
function getIngredientByRefId(refId: string | undefined) {
if (refId === undefined) {
return "";

View File

@@ -80,7 +80,7 @@
:recipe="recipes.get(event.recipeId)"
:show-recipe-cards="showRecipeCards"
:width="$vuetify.display.smAndDown ? '100%' : undefined"
@update="updateTimelineEvent(index)"
@update="updateTimelineEvent(index, $event)"
@delete="deleteTimelineEvent(index)"
/>
</v-timeline>
@@ -186,20 +186,17 @@ function toggleEventTypeOption(option: TimelineEventType) {
}
// Timeline Actions
async function updateTimelineEvent(index: number) {
const event = timelineEvents.value[index];
const payload: RecipeTimelineEventUpdate = {
subject: event.subject,
eventMessage: event.eventMessage,
image: event.image,
};
const { response } = await api.recipes.updateTimelineEvent(event.id, payload);
async function updateTimelineEvent(index: number, event: RecipeTimelineEventUpdate) {
const eventId = timelineEvents.value[index].id;
const { response } = await api.recipes.updateTimelineEvent(eventId, event);
if (response?.status !== 200) {
alert.error(i18n.t("events.something-went-wrong") as string);
return;
}
// Update the local event data to reflect the changes in the UI
timelineEvents.value[index] = response.data;
alert.success(i18n.t("events.event-updated") as string);
}

View File

@@ -43,7 +43,7 @@
edit: true,
delete: true,
}"
@update="$emit('update')"
@update="$emit('update', $event)"
@delete="$emit('delete')"
/>
</v-col>
@@ -96,7 +96,7 @@ import RecipeCardMobile from "./RecipeCardMobile.vue";
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
import { useStaticRoutes } from "~/composables/api";
import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events";
import type { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe";
import type { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate } from "~/lib/api/types/recipe";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
@@ -113,11 +113,12 @@ const props = withDefaults(defineProps<Props>(), {
defineEmits<{
selected: [];
update: [];
update: [event: RecipeTimelineEventUpdate];
delete: [];
}>();
const { $vuetify, $globals } = useNuxtApp();
const { $globals } = useNuxtApp();
const display = useDisplay();
const { recipeTimelineEventImage } = useStaticRoutes();
const { eventTypeOptions } = useTimelineEventTypes();
@@ -127,7 +128,7 @@ const route = useRoute();
const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
const useMobileFormat = computed(() => {
return $vuetify.display.smAndDown.value;
return display.smAndDown.value;
});
const attrs = computed(() => {

View File

@@ -9,10 +9,11 @@
>
<template #activator="{ props }">
<v-badge
:model-value="selected.length > 0"
v-memo="[selectedCount]"
:model-value="selectedCount > 0"
size="small"
color="primary"
:content="selected.length"
:content="selectedCount"
>
<v-btn
size="small"
@@ -28,6 +29,7 @@
<v-card-text>
<v-text-field
v-model="state.search"
v-memo="[state.search]"
class="mb-2"
hide-details
density="comfortable"
@@ -43,7 +45,7 @@
hide-details
class="my-auto"
color="primary"
:label="`${requireAll ? $t('search.has-all') : $t('search.has-any')}`"
:label="requireAllValue ? $t('search.has-all') : $t('search.has-any')"
/>
<v-spacer />
<v-btn
@@ -73,7 +75,8 @@
>
<template #default="{ item }">
<v-list-item
:key="item.id"
:key="`radio-${item.id}`"
v-memo="[item.id, item.name, selectedRadio?.id]"
:value="item"
:title="item.name"
>
@@ -101,7 +104,8 @@
>
<template #default="{ item }">
<v-list-item
:key="item.id"
:key="`checkbox-${item.id}`"
v-memo="[item.id, item.name, selectedIds.has(item.id)]"
:value="item"
:title="item.name"
>
@@ -134,6 +138,8 @@
</template>
<script lang="ts">
import { watchDebounced } from "@vueuse/core";
export interface SelectableItem {
id: string;
name: string;
@@ -165,6 +171,9 @@ export default defineNuxtComponent({
menu: false,
});
// Use shallowRef for better performance with arrays
const debouncedSearch = shallowRef("");
const requireAllValue = computed({
get: () => props.requireAll,
set: (value) => {
@@ -172,6 +181,7 @@ export default defineNuxtComponent({
},
});
// Use shallowRef to prevent deep reactivity on large arrays
const selected = computed({
get: () => props.modelValue as SelectableItem[],
set: (value) => {
@@ -186,21 +196,40 @@ export default defineNuxtComponent({
},
});
watchDebounced(
() => state.search,
(newSearch) => {
debouncedSearch.value = newSearch;
},
{ debounce: 500, maxWait: 1500, immediate: false }, // Increased debounce time
);
const filtered = computed(() => {
if (!state.search) {
return props.items;
const items = props.items;
const search = debouncedSearch.value;
if (!search || search.length < 2) { // Only filter after 2 characters
return items;
}
return props.items.filter(item => item.name.toLowerCase().includes(state.search.toLowerCase()));
const searchLower = search.toLowerCase();
return items.filter(item => item.name.toLowerCase().includes(searchLower));
});
const selectedCount = computed(() => selected.value.length);
const selectedIds = computed(() => {
return new Set(selected.value.map(item => item.id));
});
const handleCheckboxClick = (item: SelectableItem) => {
console.log(selected.value, item);
if (selected.value.includes(item)) {
selected.value = selected.value.filter(i => i !== item);
const currentSelection = selected.value;
const isSelected = selectedIds.value.has(item.id);
if (isSelected) {
selected.value = currentSelection.filter(i => i.id !== item.id);
}
else {
selected.value.push(item);
selected.value = [...currentSelection, item];
}
};
@@ -221,6 +250,8 @@ export default defineNuxtComponent({
state,
selected,
selectedRadio,
selectedCount,
selectedIds,
filtered,
handleCheckboxClick,
handleRadioClick,

View File

@@ -69,7 +69,7 @@
</div>
<BaseButton
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
size="small"
small
color="info"
:icon="$globals.icons.tagArrowRight"
:text="$t('shopping-list.save-label')"

View File

@@ -1,5 +1,5 @@
<template>
<div class="d-flex justify-center pb-6 mt-n1">
<div class="d-flex pb-6 mt-n1 ml-10">
<div style="flex-basis: 500px">
<strong> {{ $t("user.password-strength", { strength: pwStrength.strength.value }) }}</strong>
<v-progress-linear

View File

@@ -1,6 +1,6 @@
<template>
<div>
<v-card-title>
<v-card-title class="pt-0">
<v-icon
size="large"
class="mr-3"
@@ -10,7 +10,7 @@
<span class="headline"> {{ $t("user-registration.account-details") }}</span>
</v-card-title>
<v-divider />
<v-card-text>
<v-card-text class="mt-2">
<v-form
ref="domAccountForm"
@submit.prevent

View File

@@ -1,6 +1,5 @@
<template>
<v-app dark>
<NuxtPwaManifest />
<TheSnackbar />
<AppHeader>
@@ -17,7 +16,6 @@
absolute
:top-link="topLinks"
:secondary-links="cookbookLinks || []"
:bottom-links="bottomLinks"
>
<v-menu
offset-y
@@ -85,25 +83,6 @@
</template>
</v-list>
</v-menu>
<template #bottom>
<v-list-item @click.stop="languageDialog = true">
<template #prepend>
<v-icon>{{ $globals.icons.translate }}</v-icon>
</template>
<v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
<LanguageDialog v-model="languageDialog" />
</v-list-item>
<v-list-item @click="toggleDark">
<template #prepend>
<v-icon>
{{ $vuetify.theme.current.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
</v-icon>
</template>
<v-list-item-title>
{{ $vuetify.theme.current.dark ? $t("settings.theme.light-mode") : $t("settings.theme.dark-mode") }}
</v-list-item-title>
</v-list-item>
</template>
</AppSidebar>
<v-main class="pt-12">
<v-scroll-x-transition>
@@ -122,18 +101,17 @@ import { useAppInfo } from "~/composables/api";
import { useCookbookPreferences } from "~/composables/use-users/preferences";
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
import { useHouseholdStore, usePublicHouseholdStore } from "~/composables/store/use-household-store";
import { useToggleDarkMode } from "~/composables/use-utils";
import type { ReadCookBook } from "~/lib/api/types/cookbook";
import type { HouseholdSummary } from "~/lib/api/types/household";
export default defineNuxtComponent({
setup() {
const i18n = useI18n();
const { $globals, $vuetify } = useNuxtApp();
const { $globals } = useNuxtApp();
const display = useDisplay();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const isAdmin = computed(() => $auth.user.value?.admin);
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
@@ -191,13 +169,11 @@ export default defineNuxtComponent({
const appInfo = useAppInfo();
const showImageImport = computed(() => appInfo.value?.enableOpenaiImageServices);
const toggleDark = useToggleDarkMode();
const languageDialog = ref<boolean>(false);
const sidebar = ref<boolean>(false);
onMounted(() => {
sidebar.value = $vuetify.display.mdAndUp.value;
sidebar.value = display.lgAndUp.value;
});
function cookbookAsLink(cookbook: ReadCookBook): SideBarLink {
@@ -286,19 +262,6 @@ export default defineNuxtComponent({
},
]);
const bottomLinks = computed<SideBarLink[]>(() =>
isAdmin.value
? [
{
icon: $globals.icons.cog,
title: i18n.t("general.settings"),
to: "/admin/site-settings",
restricted: true,
},
]
: [],
);
const topLinks = computed<SideBarLink[]>(() => [
{
icon: $globals.icons.silverwareForkKnife,
@@ -367,11 +330,9 @@ export default defineNuxtComponent({
groupSlug,
cookbookLinks,
createLinks,
bottomLinks,
topLinks,
isOwnGroup,
languageDialog,
toggleDark,
sidebar,
};
},

View File

@@ -1,5 +1,6 @@
<template>
<v-navigation-drawer v-model="showDrawer" class="d-flex flex-column d-print-none position-fixed">
<v-navigation-drawer v-model="showDrawer" class="d-flex flex-column d-print-none position-fixed" touchless>
<LanguageDialog v-model="languageDialog" />
<!-- User Profile -->
<template v-if="loggedIn">
<v-list-item lines="two" :to="userProfileLink" exact>
@@ -82,30 +83,32 @@
</template>
<!-- Bottom Navigation Links -->
<template v-if="bottomLinks" #append>
<v-list v-model:selected="bottomSelected" nav density="compact">
<template v-for="nav in bottomLinks">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
<v-list-item :key="nav.key || nav.title" exact link :to="nav.to" :href="nav.href"
:target="nav.href ? '_blank' : null">
<template #prepend>
<v-icon>{{ nav.icon }}</v-icon>
</template>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</div>
</template>
<slot name="bottom" />
<template #append>
<v-list v-model:selected="bottomSelected" nav density="comfortable">
<v-menu location="end bottom" :offset="15">
<template #activator="{ props }">
<v-list-item v-bind="props" :prepend-icon="$globals.icons.cog" :title="$t('general.settings')" />
</template>
<v-list density="comfortable" color="primary">
<v-list-item :prepend-icon="$globals.icons.translate" :title="$t('sidebar.language')" @click="languageDialog=true" />
<v-list-item :prepend-icon="$vuetify.theme.current.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight" :title="$vuetify.theme.current.dark ? $t('settings.theme.light-mode') : $t('settings.theme.dark-mode')" @click="toggleDark" />
<v-divider v-if="loggedIn" class="my-2" />
<v-list-item v-if="loggedIn" :prepend-icon="$globals.icons.cog" :title="$t('profile.user-settings')" to="/user/profile" />
<v-list-item v-if="canManage" :prepend-icon="$globals.icons.manageData" :title="$t('data-pages.data-management')" to="/group/data" />
<v-divider v-if="isAdmin" class="my-2" />
<v-list-item v-if="isAdmin" :prepend-icon="$globals.icons.wrench" :title="$t('settings.admin-settings')" to="/admin/site-settings" />
</v-list>
</v-menu>
</v-list>
</template>
</v-navigation-drawer>
</template>
<script lang="ts">
import { useWindowSize } from "@vueuse/core";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { SidebarLinks } from "~/types/application-types";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import { useToggleDarkMode } from "~/composables/use-utils";
export default defineNuxtComponent({
components: {
@@ -130,48 +133,34 @@ export default defineNuxtComponent({
required: false,
default: null,
},
bottomLinks: {
type: Array as () => SidebarLinks,
required: false,
default: () => ([]),
},
},
emits: ["update:modelValue"],
setup(props, context) {
const $auth = useMealieAuth();
const { loggedIn, isOwnGroup } = useLoggedInState();
const isAdmin = computed(() => $auth.user.value?.admin);
const canManage = computed(() => $auth.user.value?.canManage);
const userFavoritesLink = computed(() => $auth.user.value ? `/user/${$auth.user.value.id}/favorites` : undefined);
const userProfileLink = computed(() => $auth.user.value ? "/user/profile" : undefined);
const toggleDark = useToggleDarkMode();
const state = reactive({
dropDowns: {} as Record<string, boolean>,
topSelected: null as string[] | null,
secondarySelected: null as string[] | null,
bottomSelected: null as string[] | null,
hasOpenedBefore: false as boolean,
languageDialog: false as boolean,
});
// model to control the drawer
const showDrawer = computed({
get: () => props.modelValue,
set: value => context.emit("update:modelValue", value),
});
watch(showDrawer, () => {
if (window.innerWidth < 760 && state.hasOpenedBefore === false) {
state.hasOpenedBefore = true;
}
});
const { width: wWidth } = useWindowSize();
watch(wWidth, (w) => {
if (w > 760) {
showDrawer.value = true;
}
else {
showDrawer.value = false;
}
});
const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || []), ...(props.bottomLinks || [])]);
const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || [])]);
function initDropdowns() {
allLinks.value.forEach((link) => {
state.dropDowns[link.title] = link.childrenStartExpanded || false;
@@ -193,8 +182,11 @@ export default defineNuxtComponent({
userProfileLink,
showDrawer,
loggedIn,
isAdmin,
canManage,
isOwnGroup,
sessionUser: $auth.user,
toggleDark,
};
},
});

View File

@@ -0,0 +1,48 @@
<template>
<div class="icon-container">
<v-divider class="icon-divider" />
<v-avatar
:class="['pa-2', 'icon-avatar']"
color="primary"
:size="size"
>
<slot>
<svg
class="icon-white"
viewBox="0 0 24 24"
:style="{ width: size + 'px', height: size + 'px' }"
>
<path
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
/>
</svg>
</slot>
</v-avatar>
</div>
</template>
<script setup lang="ts">
const { size } = withDefaults(defineProps<{ size?: number }>(), { size: 75 });
</script>
<style scoped>
.icon-white {
fill: white;
}
.icon-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
position: relative;
margin-top: 2.5rem;
}
.icon-divider {
width: 100%;
margin-bottom: -2.5rem;
}
.icon-avatar {
border-color: rgba(0, 0, 0, 0.12);
border: 2px;
}
</style>

View File

@@ -33,9 +33,10 @@
<!-- Check Box -->
<v-checkbox
v-if="inputField.type === fieldTypes.BOOLEAN"
v-model="modelValue[inputField.varName]"
v-model="model[inputField.varName]"
:name="inputField.varName"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
:hint="inputField.hint"
:hide-details="!inputField.hint"
:persistent-hint="!!inputField.hint"
@@ -51,9 +52,9 @@
<!-- Text Field -->
<v-text-field
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
v-model="modelValue[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
variant="solo-filled"
flat
@@ -62,7 +63,7 @@
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules), ...defaultRules] : []"
:rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules as any), ...defaultRules] : []"
lazy-validation
@blur="emitBlur"
/>
@@ -70,9 +71,9 @@
<!-- Text Area -->
<v-textarea
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
v-model="modelValue[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled"
flat
rows="3"
@@ -81,7 +82,7 @@
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:rules="[...rulesByKey(inputField.rules), ...defaultRules]"
:rules="[...rulesByKey(inputField.rules as any), ...defaultRules]"
lazy-validation
@blur="emitBlur"
/>
@@ -89,12 +90,11 @@
<!-- Option Select -->
<v-select
v-else-if="inputField.type === fieldTypes.SELECT"
v-model="modelValue[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled"
flat
:prepend-icon="inputField.icons ? modelValue[inputField.varName] : null"
:label="inputField.label"
:name="inputField.varName"
:items="inputField.options"
@@ -106,15 +106,7 @@
persistent-hint
lazy-validation
@blur="emitBlur"
>
<template #item="{ item }">
<v-list-item
v-bind="props"
:title="item.raw.text"
:subtitle="item.raw.description"
/>
</template>
</v-select>
/>
<!-- Color Picker -->
<div
@@ -127,7 +119,7 @@
<v-btn
class="my-2 ml-auto"
style="min-width: 200px"
:color="modelValue[inputField.varName]"
:color="model[inputField.varName]"
dark
v-bind="templateProps"
>
@@ -135,7 +127,7 @@
</v-btn>
</template>
<v-color-picker
v-model="modelValue[inputField.varName]"
v-model="model[inputField.varName]"
value="#7417BE"
hide-canvas
hide-inputs
@@ -146,11 +138,12 @@
</v-menu>
</div>
<!-- Object Type -->
<div v-else-if="inputField.type === fieldTypes.OBJECT">
<auto-form
v-model="modelValue[inputField.varName]"
v-model="model[inputField.varName]"
:color="color"
:items="inputField.items"
:items="(inputField as any).items"
@blur="emitBlur"
/>
</div>
@@ -158,7 +151,7 @@
<!-- List Type -->
<div v-else-if="inputField.type === fieldTypes.LIST">
<div
v-for="(item, idx) in modelValue[inputField.varName]"
v-for="(item, idx) in model[inputField.varName]"
:key="idx"
>
<p>
@@ -168,15 +161,15 @@
class="ml-5"
x-small
delete
@click="removeByIndex(modelValue[inputField.varName], idx)"
@click="removeByIndex(model[inputField.varName], idx)"
/>
</span>
</p>
<v-divider class="mb-5 mx-2" />
<auto-form
v-model="modelValue[inputField.varName][idx]"
v-model="model[inputField.varName][idx]"
:color="color"
:items="inputField.items"
:items="(inputField as any).items"
@blur="emitBlur"
/>
</div>
@@ -184,7 +177,7 @@
<v-spacer />
<BaseButton
small
@click="modelValue[inputField.varName].push(getTemplate(inputField.items))"
@click="model[inputField.varName].push(getTemplate((inputField as any).items))"
>
{{ $t("general.new") }}
</BaseButton>
@@ -205,7 +198,13 @@ const BLUR_EVENT = "blur";
type ValidatorKey = keyof typeof validators;
// Use defineModel for v-model
const modelValue = defineModel<[object, Array<any>]>();
const modelValue = defineModel<Record<string, any> | any[]>({
type: [Object, Array],
required: true,
});
// alias to avoid template TS complaining about possible undefined
const model = modelValue as any;
const props = defineProps({
updateMode: {
@@ -246,26 +245,39 @@ const emit = defineEmits(["blur", "update:modelValue"]);
function rulesByKey(keys?: ValidatorKey[] | null) {
if (keys === undefined || keys === null) {
return [];
return [] as any[];
}
const list = [] as ((v: string) => boolean | string)[];
const list: any[] = [];
keys.forEach((key) => {
const split = key.split(":");
const validatorKey = split[0] as ValidatorKey;
if (validatorKey in validators) {
if (split.length === 1) {
list.push(validators[validatorKey]);
list.push((validators as any)[validatorKey]);
}
else {
list.push(validators[validatorKey](split[1]));
list.push((validators as any)[validatorKey](split[1] as any));
}
}
});
return list;
}
const defaultRules = computed(() => rulesByKey(props.globalRules as ValidatorKey[]));
const defaultRules = computed<any[]>(() => rulesByKey(props.globalRules as any));
// Combined state map for readonly and disabled fields
const fieldState = computed<Record<string, { readonly: boolean; disabled: boolean }>>(() => {
const map: Record<string, { readonly: boolean; disabled: boolean }> = {};
(props.items || []).forEach((field: any) => {
const base = (field.disableUpdate && props.updateMode) || (!props.updateMode && field.disableCreate);
map[field.varName] = {
readonly: base || !!props.readonlyFields?.includes(field.varName),
disabled: base || !!props.disabledFields?.includes(field.varName),
};
});
return map;
});
function removeByIndex(list: never[], index: number) {
// Removes the item at the index

View File

@@ -90,13 +90,13 @@ export default defineNuxtComponent({
},
},
setup() {
const { $vuetify } = useNuxtApp();
const display = useDisplay();
const hasHeading = computed(() => false);
const hasAltHeading = computed(() => false);
const classes = computed(() => {
return {
"v-card--material--has-heading": hasHeading,
"mt-3": $vuetify.display.name.value === "xs" || $vuetify.display.name.value === "sm",
"mt-3": display.name.value === "xs" || display.name.value === "sm",
};
});

View File

@@ -1,293 +0,0 @@
<template>
<div :style="`width: ${width}; height: 100%;`">
<LanguageDialog v-model="langDialog" />
<v-card>
<div>
<v-toolbar
width="100%"
color="primary"
class="d-flex justify-center"
style="margin-bottom: 4rem"
dark
>
<v-toolbar-title class="headline text-h4 text-center mx-0">
Mealie
</v-toolbar-title>
</v-toolbar>
<div class="icon-container">
<v-divider class="icon-divider" />
<v-avatar
class="pa-2 icon-avatar"
color="primary"
size="75"
>
<svg
class="icon-white"
style="width: 75"
viewBox="0 0 24 24"
>
<path
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
/>
</svg>
</v-avatar>
</div>
</div>
<div class="d-flex justify-center grow items-center my-4">
<slot :width="pageWidth" />
</div>
<div class="mx-2 my-4">
<v-progress-linear
v-if="wizardPage > 0"
:value="Math.ceil((wizardPage / maxPageNumber) * 100)"
striped
height="10"
/>
</div>
<v-divider class="ma-2" />
<v-card-actions width="100%">
<v-btn
v-if="prevButtonShow"
:disabled="!prevButtonEnable"
:color="prevButtonColor"
@click="decrementPage"
>
<v-icon v-if="prevButtonIconRef">
{{ prevButtonIconRef }}
</v-icon>
{{ prevButtonTextRef }}
</v-btn>
<v-spacer />
<v-btn
v-if="nextButtonShow"
variant="elevated"
:disabled="!nextButtonEnable"
:color="nextButtonColorRef"
@click="incrementPage"
>
<div v-if="isSubmitting">
<v-progress-circular
indeterminate
color="white"
size="24"
/>
</div>
<div v-else>
<v-icon v-if="nextButtonIconRef && !nextButtonIconAfter">
{{ nextButtonIconRef }}
</v-icon>
{{ nextButtonTextRef }}
<v-icon v-if="nextButtonIconRef && nextButtonIconAfter">
{{ nextButtonIconRef }}
</v-icon>
</div>
</v-btn>
</v-card-actions>
<v-card-actions class="justify-center flex-column py-8">
<BaseButton
large
color="primary"
@click="langDialog = true"
>
<template #icon>
{{ $globals.icons.translate }}
</template>
{{ $t("language-dialog.choose-language") }}
</BaseButton>
</v-card-actions>
</v-card>
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
modelValue: {
type: Number,
required: true,
},
minPageNumber: {
type: Number,
default: 0,
},
maxPageNumber: {
type: Number,
required: true,
},
width: {
type: [String, Number],
default: "1200px",
},
pageWidth: {
type: [String, Number],
default: "600px",
},
prevButtonText: {
type: String,
default: undefined,
},
prevButtonIcon: {
type: String,
default: null,
},
prevButtonColor: {
type: String,
default: "grey-darken-3",
},
prevButtonShow: {
type: Boolean,
default: true,
},
prevButtonEnable: {
type: Boolean,
default: true,
},
nextButtonText: {
type: String,
default: undefined,
},
nextButtonIcon: {
type: String,
default: null,
},
nextButtonIconAfter: {
type: Boolean,
default: true,
},
nextButtonColor: {
type: String,
default: undefined,
},
nextButtonShow: {
type: Boolean,
default: true,
},
nextButtonEnable: {
type: Boolean,
default: true,
},
nextButtonIsSubmit: {
type: Boolean,
default: false,
},
title: {
type: String,
required: true,
},
icon: {
type: String,
default: null,
},
isSubmitting: {
type: Boolean,
default: false,
},
},
emits: ["update:modelValue", "submit"],
setup(props, context) {
const i18n = useI18n();
const { $globals } = useNuxtApp();
const ready = ref(false);
const langDialog = ref(false);
const wizardPage = computed({
get: () => props.modelValue,
set: value => context.emit("update:modelValue", value),
});
const prevButtonTextRef = computed(() => props.prevButtonText || i18n.t("general.back"));
const prevButtonIconRef = computed(() => props.prevButtonIcon || $globals.icons.back);
const nextButtonTextRef = computed(
() => props.nextButtonText || (
props.nextButtonIsSubmit ? i18n.t("general.submit") : i18n.t("general.next")
),
);
const nextButtonIconRef = computed(
() => props.nextButtonIcon || (
props.nextButtonIsSubmit ? $globals.icons.createAlt : $globals.icons.forward
),
);
const nextButtonColorRef = computed(
() => props.nextButtonColor || (props.nextButtonIsSubmit ? "success" : "info"),
);
function goToPage(page: number) {
if (page < props.minPageNumber) {
goToPage(props.minPageNumber);
return;
}
else if (page > props.maxPageNumber) {
goToPage(props.maxPageNumber);
return;
}
wizardPage.value = page;
}
function decrementPage() {
goToPage(wizardPage.value - 1);
}
function incrementPage() {
if (props.nextButtonIsSubmit) {
context.emit("submit", wizardPage.value);
}
else {
goToPage(wizardPage.value + 1);
}
}
ready.value = true;
return {
wizardPage,
ready,
langDialog,
prevButtonTextRef,
prevButtonIconRef,
nextButtonTextRef,
nextButtonIconRef,
nextButtonColorRef,
decrementPage,
incrementPage,
};
},
});
</script>
<style lang="css" scoped>
.icon-primary {
fill: var(--v-primary-base);
}
.icon-white {
fill: white;
}
.icon-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
position: relative;
margin-top: 2.5rem;
}
.icon-divider {
width: 100%;
margin-bottom: -2.5rem;
}
.icon-avatar {
border-color: rgba(0, 0, 0, 0.12);
border: 2px;
}
.bg-off-white {
background: #f5f8fa;
}
.preferred-width {
width: 840px;
}
</style>

View File

@@ -10,8 +10,9 @@
:min="min"
:max="max"
type="number"
size="small"
variant="plain"
density="compact"
style="width: 60px;"
/>
</div>
</template>

View File

@@ -39,7 +39,7 @@ export default defineNuxtComponent({
}
const value = computed(() => {
const rawHtml = marked.parse(props.source || "", { async: false });
const rawHtml = marked.parse(props.source || "", { async: false, breaks: true });
return sanitizeMarkdown(rawHtml);
});

View File

@@ -3,6 +3,7 @@ import type { Composer } from "vue-i18n";
import type { ApiRequestInstance, RequestResponse } from "~/lib/api/types/non-generated";
import { AdminAPI, PublicApi, UserApi } from "~/lib/api";
import { PublicExploreApi } from "~/lib/api/client-public";
import { useGlobalI18n } from "~/composables/use-global-i18n";
const request = {
async safe<T, U>(
@@ -56,8 +57,7 @@ function getRequests(axiosInstance: AxiosInstance): ApiRequestInstance {
export const useRequests = function (i18n?: Composer): ApiRequestInstance {
const { $axios } = useNuxtApp();
if (!i18n) {
// Only works in a setup block
i18n = useI18n();
i18n = useGlobalI18n();
}
$axios.defaults.headers.common["Accept-Language"] = i18n.locale.value;

View File

@@ -37,7 +37,7 @@ function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, us
}
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true) {
const { quantity, food, unit, note } = ingredient;
const { quantity, food, unit, note, title } = ingredient;
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
const usePluralFood = (!quantity) || quantity * scale > 1;
@@ -66,6 +66,7 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1,
const foodName = useFoodName(food || undefined, usePluralFood);
return {
title: title ? sanitizeIngredientHTML(title) : undefined,
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
name: foodName ? sanitizeIngredientHTML(foodName) : undefined,

View File

@@ -150,7 +150,10 @@ export function useShoppingListCrud(
loadingCounter.value += 1;
// make sure it's inserted into the end of the list, which may have been updated
// ensure list id is set to the current list, which may not have loaded yet in the factory
createListItemData.value.shoppingListId = shoppingList.value.id;
// ensure item is inserted into the end of the list, which may have been updated
createListItemData.value.position = shoppingList.value?.listItems?.length
? (shoppingList.value.listItems.reduce((a, b) => (a.position || 0) > (b.position || 0) ? a : b).position || 0) + 1
: 0;

View File

@@ -1,18 +1,29 @@
import type { Composer } from "vue-i18n";
import { useReadOnlyStore, useStore } from "../partials/use-store-factory";
import type { ReadCookBook } from "~/lib/api/types/cookbook";
import type { ReadCookBook, UpdateCookBook } from "~/lib/api/types/cookbook";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
const store: Ref<ReadCookBook[]> = ref([]);
const cookbooks: Ref<ReadCookBook[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export const useCookbookStore = function (i18n?: Composer) {
const api = useUserApi(i18n);
return useStore<ReadCookBook>(store, loading, api.cookbooks);
const store = useStore<ReadCookBook>(cookbooks, loading, api.cookbooks);
const updateAll = async function (updateData: UpdateCookBook[]) {
loading.value = true;
updateData.forEach((cookbook, index) => {
cookbook.position = index;
});
const { data } = await api.cookbooks.updateAll(updateData);
loading.value = false;
return data;
};
return { ...store, updateAll };
};
export const usePublicCookbookStore = function (groupSlug: string, i18n?: Composer) {
const api = usePublicExploreApi(groupSlug, i18n).explore;
return useReadOnlyStore<ReadCookBook>(store, publicLoading, api.cookbooks);
return useReadOnlyStore<ReadCookBook>(cookbooks, publicLoading, api.cookbooks);
};

View File

@@ -0,0 +1,10 @@
import type { Composer } from "vue-i18n";
let i18n: Composer | null = null;
export function useGlobalI18n() {
if (!i18n) {
i18n = useI18n();
}
return i18n;
}

View File

@@ -9,7 +9,7 @@ export const LOCALES = [
{
name: "简体中文 (Chinese simplified)",
value: "zh-CN",
progress: 35,
progress: 38,
dir: "ltr",
},
{
@@ -51,7 +51,7 @@ export const LOCALES = [
{
name: "Slovenčina (Slovak)",
value: "sk-SK",
progress: 37,
progress: 46,
dir: "ltr",
},
{
@@ -81,7 +81,7 @@ export const LOCALES = [
{
name: "Polski (Polish)",
value: "pl-PL",
progress: 40,
progress: 42,
dir: "ltr",
},
{
@@ -93,7 +93,7 @@ export const LOCALES = [
{
name: "Nederlands (Dutch)",
value: "nl-NL",
progress: 45,
progress: 49,
dir: "ltr",
},
{
@@ -123,25 +123,25 @@ export const LOCALES = [
{
name: "Italiano (Italian)",
value: "it-IT",
progress: 39,
progress: 40,
dir: "ltr",
},
{
name: "Íslenska (Icelandic)",
value: "is-IS",
progress: 2,
progress: 3,
dir: "ltr",
},
{
name: "Magyar (Hungarian)",
value: "hu-HU",
progress: 40,
progress: 44,
dir: "ltr",
},
{
name: "Hrvatski (Croatian)",
value: "hr-HR",
progress: 27,
progress: 28,
dir: "ltr",
},
{
@@ -159,7 +159,7 @@ export const LOCALES = [
{
name: "Français (French)",
value: "fr-FR",
progress: 52,
progress: 64,
dir: "ltr",
},
{
@@ -171,7 +171,7 @@ export const LOCALES = [
{
name: "Belge (Belgian)",
value: "fr-BE",
progress: 36,
progress: 41,
dir: "ltr",
},
{
@@ -189,7 +189,7 @@ export const LOCALES = [
{
name: "Español (Spanish)",
value: "es-ES",
progress: 41,
progress: 42,
dir: "ltr",
},
{
@@ -201,19 +201,19 @@ export const LOCALES = [
{
name: "British English",
value: "en-GB",
progress: 23,
progress: 43,
dir: "ltr",
},
{
name: "Ελληνικά (Greek)",
value: "el-GR",
progress: 39,
progress: 40,
dir: "ltr",
},
{
name: "Deutsch (German)",
value: "de-DE",
progress: 66,
progress: 72,
dir: "ltr",
},
{
@@ -225,13 +225,13 @@ export const LOCALES = [
{
name: "Čeština (Czech)",
value: "cs-CZ",
progress: 39,
progress: 42,
dir: "ltr",
},
{
name: "Català (Catalan)",
value: "ca-ES",
progress: 37,
progress: 38,
dir: "ltr",
},
{

View File

@@ -0,0 +1,465 @@
import { watchDebounced } from "@vueuse/shared";
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { HouseholdSummary } from "~/lib/api/types/household";
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
import {
useCategoryStore,
usePublicCategoryStore,
useFoodStore,
usePublicFoodStore,
useHouseholdStore,
usePublicHouseholdStore,
useTagStore,
usePublicTagStore,
useToolStore,
usePublicToolStore,
} from "~/composables/store";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useUserSearchQuerySession, useUserSortPreferences } from "~/composables/use-users/preferences";
// Type for the composable return value
interface RecipeExplorerSearchState {
state: Ref<{
auto: boolean;
ready: boolean;
search: string;
orderBy: string;
orderDirection: "asc" | "desc";
requireAllCategories: boolean;
requireAllTags: boolean;
requireAllTools: boolean;
requireAllFoods: boolean;
}>;
selectedCategories: Ref<NoUndefinedField<RecipeCategory>[]>;
selectedFoods: Ref<IngredientFood[]>;
selectedHouseholds: Ref<NoUndefinedField<HouseholdSummary>[]>;
selectedTags: Ref<NoUndefinedField<RecipeTag>[]>;
selectedTools: Ref<NoUndefinedField<RecipeTool>[]>;
passedQueryWithSeed: ComputedRef<RecipeSearchQuery & { _searchSeed: string }>;
search: () => Promise<void>;
reset: () => void;
toggleOrderDirection: () => void;
setOrderBy: (value: string) => void;
filterItems: (item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) => void;
initialize: () => Promise<void>;
}
// Memo storage for singleton instances
const memo: Record<string, RecipeExplorerSearchState> = {};
function createRecipeExplorerSearchState(groupSlug: ComputedRef<string>): RecipeExplorerSearchState {
const router = useRouter();
const route = useRoute();
const { isOwnGroup } = useLoggedInState();
const searchQuerySession = useUserSearchQuerySession();
const sortPreferences = useUserSortPreferences();
// State management
const state = ref({
auto: true,
ready: false,
search: "",
orderBy: "created_at",
orderDirection: "desc" as "asc" | "desc",
requireAllCategories: false,
requireAllTags: false,
requireAllTools: false,
requireAllFoods: false,
});
// Store references
const categories = isOwnGroup ? useCategoryStore() : usePublicCategoryStore(groupSlug.value);
const foods = isOwnGroup ? useFoodStore() : usePublicFoodStore(groupSlug.value);
const households = isOwnGroup ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
const tags = isOwnGroup ? useTagStore() : usePublicTagStore(groupSlug.value);
const tools = isOwnGroup ? useToolStore() : usePublicToolStore(groupSlug.value);
// Selected items
const selectedCategories = ref<NoUndefinedField<RecipeCategory>[]>([]);
const selectedFoods = ref<IngredientFood[]>([]);
const selectedHouseholds = ref<NoUndefinedField<HouseholdSummary>[]>([]);
const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]);
const selectedTools = ref<NoUndefinedField<RecipeTool>[]>([]);
// Query defaults
const queryDefaults = {
search: "",
orderBy: "created_at",
orderDirection: "desc" as "asc" | "desc",
requireAllCategories: false,
requireAllTags: false,
requireAllTools: false,
requireAllFoods: false,
};
// Sync sort preferences
watch(() => state.value.orderBy, (newValue) => {
sortPreferences.value.orderBy = newValue;
});
watch(() => state.value.orderDirection, (newValue) => {
sortPreferences.value.orderDirection = newValue;
});
// Utility functions
function toIDArray(array: { id: string }[]) {
return array.map(item => item.id).sort();
}
function calcPassedQuery(): RecipeSearchQuery {
return {
search: state.value.search ? state.value.search : "",
categories: toIDArray(selectedCategories.value),
foods: toIDArray(selectedFoods.value),
households: toIDArray(selectedHouseholds.value),
tags: toIDArray(selectedTags.value),
tools: toIDArray(selectedTools.value),
requireAllCategories: state.value.requireAllCategories,
requireAllTags: state.value.requireAllTags,
requireAllTools: state.value.requireAllTools,
requireAllFoods: state.value.requireAllFoods,
orderBy: state.value.orderBy,
orderDirection: state.value.orderDirection,
};
}
const passedQuery = ref<RecipeSearchQuery>(calcPassedQuery());
const passedQueryWithSeed = computed(() => {
return {
...passedQuery.value,
_searchSeed: Date.now().toString(),
};
});
// Wait utility for async hydration
function waitUntilAndExecute(
condition: () => boolean,
callback: () => void,
opts = { timeout: 2000, interval: 500 },
): Promise<void> {
return new Promise((resolve, reject) => {
const state = {
timeout: undefined as number | undefined,
interval: undefined as number | undefined,
};
const check = () => {
if (condition()) {
clearInterval(state.interval);
clearTimeout(state.timeout);
callback();
resolve();
}
};
state.interval = setInterval(check, opts.interval) as unknown as number;
state.timeout = setTimeout(() => {
clearInterval(state.interval);
reject(new Error("Timeout"));
}, opts.timeout) as unknown as number;
});
}
// Main functions
function reset() {
state.value.search = queryDefaults.search;
state.value.orderBy = queryDefaults.orderBy;
state.value.orderDirection = queryDefaults.orderDirection;
sortPreferences.value.orderBy = queryDefaults.orderBy;
sortPreferences.value.orderDirection = queryDefaults.orderDirection;
state.value.requireAllCategories = queryDefaults.requireAllCategories;
state.value.requireAllTags = queryDefaults.requireAllTags;
state.value.requireAllTools = queryDefaults.requireAllTools;
state.value.requireAllFoods = queryDefaults.requireAllFoods;
selectedCategories.value = [];
selectedFoods.value = [];
selectedHouseholds.value = [];
selectedTags.value = [];
selectedTools.value = [];
}
function toggleOrderDirection() {
state.value.orderDirection = state.value.orderDirection === "asc" ? "desc" : "asc";
sortPreferences.value.orderDirection = state.value.orderDirection;
}
function setOrderBy(value: string) {
state.value.orderBy = value;
sortPreferences.value.orderBy = value;
}
async function search() {
const oldQueryValueString = JSON.stringify(passedQuery.value);
const newQueryValue = calcPassedQuery();
const newQueryValueString = JSON.stringify(newQueryValue);
if (oldQueryValueString === newQueryValueString) {
return;
}
passedQuery.value = newQueryValue;
const query = {
categories: passedQuery.value.categories,
foods: passedQuery.value.foods,
tags: passedQuery.value.tags,
tools: passedQuery.value.tools,
// Only add the query param if it's not the default value
...{
auto: state.value.auto ? undefined : "false",
search: passedQuery.value.search === queryDefaults.search ? undefined : passedQuery.value.search,
households: !passedQuery.value.households?.length || passedQuery.value.households?.length === households.store.value.length ? undefined : passedQuery.value.households,
requireAllCategories: passedQuery.value.requireAllCategories ? "true" : undefined,
requireAllTags: passedQuery.value.requireAllTags ? "true" : undefined,
requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined,
},
};
await router.push({ query });
searchQuerySession.value.recipe = JSON.stringify(query);
}
function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) {
if (urlPrefix === "categories") {
const result = categories.store.value.filter(category => (category.id as string).includes(item.id as string));
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
}
else if (urlPrefix === "tags") {
const result = tags.store.value.filter(tag => (tag.id as string).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
}
else if (urlPrefix === "tools") {
const result = tools.store.value.filter(tool => (tool.id).includes(item.id || ""));
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
}
}
async function hydrateSearch() {
const query = router.currentRoute.value.query;
if (query.auto?.length) {
state.value.auto = query.auto === "true";
}
if (query.search?.length) {
state.value.search = query.search as string;
}
else {
state.value.search = queryDefaults.search;
}
state.value.orderBy = sortPreferences.value.orderBy;
state.value.orderDirection = sortPreferences.value.orderDirection as "asc" | "desc";
if (query.requireAllCategories?.length) {
state.value.requireAllCategories = query.requireAllCategories === "true";
}
else {
state.value.requireAllCategories = queryDefaults.requireAllCategories;
}
if (query.requireAllTags?.length) {
state.value.requireAllTags = query.requireAllTags === "true";
}
else {
state.value.requireAllTags = queryDefaults.requireAllTags;
}
if (query.requireAllTools?.length) {
state.value.requireAllTools = query.requireAllTools === "true";
}
else {
state.value.requireAllTools = queryDefaults.requireAllTools;
}
if (query.requireAllFoods?.length) {
state.value.requireAllFoods = query.requireAllFoods === "true";
}
else {
state.value.requireAllFoods = queryDefaults.requireAllFoods;
}
const promises: Promise<void>[] = [];
if (query.categories?.length) {
promises.push(
waitUntilAndExecute(
() => categories.store.value.length > 0,
() => {
const result = categories.store.value.filter(item =>
(query.categories as string[]).includes(item.id as string),
);
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
},
),
);
}
else {
selectedCategories.value = [];
}
if (query.tags?.length) {
promises.push(
waitUntilAndExecute(
() => tags.store.value.length > 0,
() => {
const result = tags.store.value.filter(item => (query.tags as string[]).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
},
),
);
}
else {
selectedTags.value = [];
}
if (query.tools?.length) {
promises.push(
waitUntilAndExecute(
() => tools.store.value.length > 0,
() => {
const result = tools.store.value.filter(item => (query.tools as string[]).includes(item.id));
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
},
),
);
}
else {
selectedTools.value = [];
}
if (query.foods?.length) {
promises.push(
waitUntilAndExecute(
() => {
if (foods.store.value) {
return foods.store.value.length > 0;
}
return false;
},
() => {
const result = foods.store.value?.filter(item => (query.foods as string[]).includes(item.id));
selectedFoods.value = result ?? [];
},
),
);
}
else {
selectedFoods.value = [];
}
if (query.households?.length) {
promises.push(
waitUntilAndExecute(
() => {
if (households.store.value) {
return households.store.value.length > 0;
}
return false;
},
() => {
const result = households.store.value?.filter(item => (query.households as string[]).includes(item.id));
selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? [];
},
),
);
}
else {
selectedHouseholds.value = [];
}
await Promise.allSettled(promises);
}
async function initialize() {
// Restore the user's last search query
if (searchQuerySession.value.recipe && !(Object.keys(route.query).length > 0)) {
try {
const query = JSON.parse(searchQuerySession.value.recipe);
await router.replace({ query });
}
catch {
searchQuerySession.value.recipe = "";
router.replace({ query: {} });
}
}
await hydrateSearch();
await search();
state.value.ready = true;
}
// Watch for route query changes
watch(
() => route.query,
() => {
if (!Object.keys(route.query).length) {
reset();
}
},
);
// Auto-search when parameters change
watchDebounced(
[
() => state.value.search,
() => state.value.requireAllCategories,
() => state.value.requireAllTags,
() => state.value.requireAllTools,
() => state.value.requireAllFoods,
() => state.value.orderBy,
() => state.value.orderDirection,
selectedCategories,
selectedFoods,
selectedHouseholds,
selectedTags,
selectedTools,
],
async () => {
if (state.value.ready && state.value.auto) {
await search();
}
},
{
debounce: 500,
},
);
const composableInstance: RecipeExplorerSearchState = {
// State
state,
selectedCategories,
selectedFoods,
selectedHouseholds,
selectedTags,
selectedTools,
// Computed
passedQueryWithSeed,
// Methods
search,
reset,
toggleOrderDirection,
setOrderBy,
filterItems,
initialize,
};
return composableInstance;
}
export function useRecipeExplorerSearch(groupSlug: ComputedRef<string>): RecipeExplorerSearchState {
const key = groupSlug.value;
if (!memo[key]) {
memo[key] = createRecipeExplorerSearchState(groupSlug);
}
return memo[key];
}
export function clearRecipeExplorerSearchState(groupSlug: string) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete memo[groupSlug];
}

View File

@@ -5,26 +5,31 @@ const userRatings = ref<UserRatingSummary[]>([]);
const loading = ref(false);
const ready = ref(false);
export const useUserSelfRatings = function () {
const $auth = useMealieAuth();
const api = useUserApi();
const $auth = useMealieAuth();
export const useUserSelfRatings = function () {
async function refreshUserRatings() {
if (!$auth.user.value || loading.value) {
return;
}
loading.value = true;
const api = useUserApi();
const { data } = await api.users.getSelfRatings();
userRatings.value = data?.ratings || [];
loading.value = false;
ready.value = true;
}
async function setRating(slug: string, rating: number | null, isFavorite: boolean | null) {
loading.value = true;
const api = useUserApi();
const userId = $auth.user.value?.id || "";
await api.users.setRating(userId, slug, rating, isFavorite);
loading.value = false;
await refreshUserRatings();
}

View File

@@ -1,57 +1,99 @@
/* eslint-disable @typescript-eslint/no-require-imports */
// CODE_GEN_ID: DATE_LOCALES
import * as afZA from "./lang/dateTimeFormats/af-ZA.json";
import * as arSA from "./lang/dateTimeFormats/ar-SA.json";
import * as bgBG from "./lang/dateTimeFormats/bg-BG.json";
import * as caES from "./lang/dateTimeFormats/ca-ES.json";
import * as csCZ from "./lang/dateTimeFormats/cs-CZ.json";
import * as daDK from "./lang/dateTimeFormats/da-DK.json";
import * as deDE from "./lang/dateTimeFormats/de-DE.json";
import * as elGR from "./lang/dateTimeFormats/el-GR.json";
import * as enGB from "./lang/dateTimeFormats/en-GB.json";
import * as enUS from "./lang/dateTimeFormats/en-US.json";
import * as esES from "./lang/dateTimeFormats/es-ES.json";
import * as etEE from "./lang/dateTimeFormats/et-EE.json";
import * as fiFI from "./lang/dateTimeFormats/fi-FI.json";
import * as frBE from "./lang/dateTimeFormats/fr-BE.json";
import * as frCA from "./lang/dateTimeFormats/fr-CA.json";
import * as frFR from "./lang/dateTimeFormats/fr-FR.json";
import * as glES from "./lang/dateTimeFormats/gl-ES.json";
import * as heIL from "./lang/dateTimeFormats/he-IL.json";
import * as hrHR from "./lang/dateTimeFormats/hr-HR.json";
import * as huHU from "./lang/dateTimeFormats/hu-HU.json";
import * as isIS from "./lang/dateTimeFormats/is-IS.json";
import * as itIT from "./lang/dateTimeFormats/it-IT.json";
import * as jaJP from "./lang/dateTimeFormats/ja-JP.json";
import * as koKR from "./lang/dateTimeFormats/ko-KR.json";
import * as ltLT from "./lang/dateTimeFormats/lt-LT.json";
import * as lvLV from "./lang/dateTimeFormats/lv-LV.json";
import * as nlNL from "./lang/dateTimeFormats/nl-NL.json";
import * as noNO from "./lang/dateTimeFormats/no-NO.json";
import * as plPL from "./lang/dateTimeFormats/pl-PL.json";
import * as ptBR from "./lang/dateTimeFormats/pt-BR.json";
import * as ptPT from "./lang/dateTimeFormats/pt-PT.json";
import * as roRO from "./lang/dateTimeFormats/ro-RO.json";
import * as ruRU from "./lang/dateTimeFormats/ru-RU.json";
import * as skSK from "./lang/dateTimeFormats/sk-SK.json";
import * as slSI from "./lang/dateTimeFormats/sl-SI.json";
import * as srSP from "./lang/dateTimeFormats/sr-SP.json";
import * as svSE from "./lang/dateTimeFormats/sv-SE.json";
import * as trTR from "./lang/dateTimeFormats/tr-TR.json";
import * as ukUA from "./lang/dateTimeFormats/uk-UA.json";
import * as viVN from "./lang/dateTimeFormats/vi-VN.json";
import * as zhCN from "./lang/dateTimeFormats/zh-CN.json";
import * as zhTW from "./lang/dateTimeFormats/zh-TW.json";
const datetimeFormats = {
// CODE_GEN_ID: DATE_LOCALES
"af-ZA": require("./lang/dateTimeFormats/af-ZA.json"),
"ar-SA": require("./lang/dateTimeFormats/ar-SA.json"),
"bg-BG": require("./lang/dateTimeFormats/bg-BG.json"),
"ca-ES": require("./lang/dateTimeFormats/ca-ES.json"),
"cs-CZ": require("./lang/dateTimeFormats/cs-CZ.json"),
"da-DK": require("./lang/dateTimeFormats/da-DK.json"),
"de-DE": require("./lang/dateTimeFormats/de-DE.json"),
"el-GR": require("./lang/dateTimeFormats/el-GR.json"),
"en-GB": require("./lang/dateTimeFormats/en-GB.json"),
"en-US": require("./lang/dateTimeFormats/en-US.json"),
"es-ES": require("./lang/dateTimeFormats/es-ES.json"),
"et-EE": require("./lang/dateTimeFormats/et-EE.json"),
"fi-FI": require("./lang/dateTimeFormats/fi-FI.json"),
"fr-BE": require("./lang/dateTimeFormats/fr-BE.json"),
"fr-CA": require("./lang/dateTimeFormats/fr-CA.json"),
"fr-FR": require("./lang/dateTimeFormats/fr-FR.json"),
"gl-ES": require("./lang/dateTimeFormats/gl-ES.json"),
"he-IL": require("./lang/dateTimeFormats/he-IL.json"),
"hr-HR": require("./lang/dateTimeFormats/hr-HR.json"),
"hu-HU": require("./lang/dateTimeFormats/hu-HU.json"),
"is-IS": require("./lang/dateTimeFormats/is-IS.json"),
"it-IT": require("./lang/dateTimeFormats/it-IT.json"),
"ja-JP": require("./lang/dateTimeFormats/ja-JP.json"),
"ko-KR": require("./lang/dateTimeFormats/ko-KR.json"),
"lt-LT": require("./lang/dateTimeFormats/lt-LT.json"),
"lv-LV": require("./lang/dateTimeFormats/lv-LV.json"),
"nl-NL": require("./lang/dateTimeFormats/nl-NL.json"),
"no-NO": require("./lang/dateTimeFormats/no-NO.json"),
"pl-PL": require("./lang/dateTimeFormats/pl-PL.json"),
"pt-BR": require("./lang/dateTimeFormats/pt-BR.json"),
"pt-PT": require("./lang/dateTimeFormats/pt-PT.json"),
"ro-RO": require("./lang/dateTimeFormats/ro-RO.json"),
"ru-RU": require("./lang/dateTimeFormats/ru-RU.json"),
"sk-SK": require("./lang/dateTimeFormats/sk-SK.json"),
"sl-SI": require("./lang/dateTimeFormats/sl-SI.json"),
"sr-SP": require("./lang/dateTimeFormats/sr-SP.json"),
"sv-SE": require("./lang/dateTimeFormats/sv-SE.json"),
"tr-TR": require("./lang/dateTimeFormats/tr-TR.json"),
"uk-UA": require("./lang/dateTimeFormats/uk-UA.json"),
"vi-VN": require("./lang/dateTimeFormats/vi-VN.json"),
"zh-CN": require("./lang/dateTimeFormats/zh-CN.json"),
"zh-TW": require("./lang/dateTimeFormats/zh-TW.json"),
// END: DATE_LOCALES
"af-ZA": afZA,
"ar-SA": arSA,
"bg-BG": bgBG,
"ca-ES": caES,
"cs-CZ": csCZ,
"da-DK": daDK,
"de-DE": deDE,
"el-GR": elGR,
"en-GB": enGB,
"en-US": enUS,
"es-ES": esES,
"et-EE": etEE,
"fi-FI": fiFI,
"fr-BE": frBE,
"fr-CA": frCA,
"fr-FR": frFR,
"gl-ES": glES,
"he-IL": heIL,
"hr-HR": hrHR,
"hu-HU": huHU,
"is-IS": isIS,
"it-IT": itIT,
"ja-JP": jaJP,
"ko-KR": koKR,
"lt-LT": ltLT,
"lv-LV": lvLV,
"nl-NL": nlNL,
"no-NO": noNO,
"pl-PL": plPL,
"pt-BR": ptBR,
"pt-PT": ptPT,
"ro-RO": roRO,
"ru-RU": ruRU,
"sk-SK": skSK,
"sl-SI": slSI,
"sr-SP": srSP,
"sv-SE": svSE,
"tr-TR": trTR,
"uk-UA": ukUA,
"vi-VN": viVN,
"zh-CN": zhCN,
"zh-TW": zhTW,
};
// END: DATE_LOCALES
export default defineI18nConfig(() => {
return {
legacy: false,
locale: "en-US",
availableLocales: Object.keys(datetimeFormats),
datetimeFormats,
datetimeFormats: datetimeFormats as any,
fallbackLocale: "en-US",
fallbackWarn: true,
};

View File

@@ -561,6 +561,7 @@
"see-original-text": "Sien oorspronklike teks",
"original-text-with-value": "Oorspronklike teks: {originalText}",
"ingredient-linker": "Bestanddele koppelaar",
"unlinked": "Not linked yet",
"linked-to-other-step": "Gekoppel aan 'n ander stap",
"auto": "Outomaties",
"cook-mode": "Kook modus",

View File

@@ -561,6 +561,7 @@
"see-original-text": "عرض النص الأصلي",
"original-text-with-value": "النص الأصلي: {originalText}",
"ingredient-linker": "رابط المكون",
"unlinked": "Not linked yet",
"linked-to-other-step": "مرتبط بخطوة أخرى",
"auto": "تلقائي",
"cook-mode": "وضع الطبخ",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Виж оригиналния текст",
"original-text-with-value": "Оригинален текст: {originalText}",
"ingredient-linker": "Инструмент за свързване на съставки",
"unlinked": "Not linked yet",
"linked-to-other-step": "Свързано към друга стъпка",
"auto": "Автоматично",
"cook-mode": "Режим на готвене",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Mostra el text original",
"original-text-with-value": "Text original: {originalText}",
"ingredient-linker": "Enllaça ingredients",
"unlinked": "No enllaçada",
"linked-to-other-step": "Enllaça a un altre pas",
"auto": "Automàtic",
"cook-mode": "Mode \"cuinant\"",

View File

@@ -1,12 +1,12 @@
{
"about": {
"about": "O aplikaci",
"about-mealie": "O Mealie",
"about-mealie": "O aplikaci Mealie",
"api-docs": "Dokumentace API",
"api-port": "API port",
"api-port": "Port API",
"application-mode": "Režim aplikace",
"database-type": "Database Type",
"database-url": "URL databáze",
"database-url": "Adresa URL databáze",
"default-group": "Výchozí skupina",
"default-household": "Výchozí domácnost",
"demo": "Demo",
@@ -14,7 +14,7 @@
"development": "Vývoj",
"docs": "Dokumentace",
"download-log": "Stáhnout log",
"download-recipe-json": "Poslední scrapovaný JSON",
"download-recipe-json": "Poslední získaný JSON",
"github": "GitHub",
"log-lines": "Řádky logů",
"not-demo": "Není demo",
@@ -22,7 +22,7 @@
"production": "Produkce",
"support": "Podpora",
"version": "Verze",
"unknown-version": "neznámá",
"unknown-version": "neznámé",
"sponsor": "Sponzor"
},
"asset": {
@@ -30,7 +30,7 @@
"code": "Kód",
"file": "Soubor",
"image": "Obrázek",
"new-asset": "Nový zdroj",
"new-asset": "Nový prostředek",
"pdf": "PDF",
"recipe": "Recept",
"show-assets": "Zobrazit zdroje",
@@ -69,7 +69,7 @@
"new-notification": "Nové oznámení",
"event-notifiers": "Notifikace událostí",
"apprise-url-skipped-if-blank": "Apprise URL (přeskočeno pokud je prázdné)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Jelikož adresa URL obvykle obsahuje citlivé informace, zůstává toto pole při úpravách záměrně prázdné. Zadejte novou adresu URL, chcete-li ji změnit. V opačném případě ponechejte pole prázdné.",
"enable-notifier": "Povolit notifikaci",
"what-events": "K jakým událostem by se měl tento oznamovatel přihlásit?",
"user-events": "Uživatelské události",
@@ -81,7 +81,7 @@
"category-events": "Události kategorie",
"when-a-new-user-joins-your-group": "Když se nový uživatel připojí do vaší skupiny",
"recipe-events": "Události receptu",
"label-events": "Label Events"
"label-events": "Události štítků"
},
"general": {
"add": "Přidat",
@@ -474,7 +474,7 @@
"comment": "Komentář",
"comments": "Komentáře",
"delete-confirmation": "Opravdu chcete smazat tento recept?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"admin-delete-confirmation": "Chystáte se odstranit recept, který nepoužívá oprávnění správce. Opravdu chcete pokračovat?",
"delete-recipe": "Smazat recept",
"description": "Popis",
"disable-amount": "Nezobrazovat množství ingrediencí",
@@ -549,9 +549,9 @@
"failed-to-add-recipes-to-list": "Přidání receptu do seznamu se nezdařilo",
"failed-to-add-recipe-to-mealplan": "Přidání receptu do jídelníčku selhalo",
"failed-to-add-to-list": "Přidání do seznamu se nezdařilo",
"yield": "Úroda",
"yield": "Výnos",
"yields-amount-with-text": "Pro {amount} {text}",
"yield-text": "Yield Text",
"yield-text": "Text porcí",
"quantity": "Množství",
"choose-unit": "Vybrat jednotku",
"press-enter-to-create": "Stiskněte enter pro vytvoření",
@@ -561,6 +561,7 @@
"see-original-text": "Zobrazit původní text",
"original-text-with-value": "Původní text: {originalText}",
"ingredient-linker": "Propojení ingrediencí",
"unlinked": "Zatím nepropojeno",
"linked-to-other-step": "Propojeno s jiným krokem receptu",
"auto": "Automaticky",
"cook-mode": "Režim vaření",
@@ -582,14 +583,14 @@
"made-this": "Toto jsem uvařil",
"how-did-it-turn-out": "Jak to dopadlo?",
"user-made-this": "{user} udělal toto",
"added-to-timeline": "Added to timeline",
"failed-to-add-to-timeline": "Failed to add to timeline",
"failed-to-update-recipe": "Failed to update recipe",
"added-to-timeline-but-failed-to-add-image": "Added to timeline, but failed to add image",
"added-to-timeline": "Přidáno na časovou osu",
"failed-to-add-to-timeline": "Přidání na časovou osu selhalo",
"failed-to-update-recipe": "Aktualizace receptu selhala",
"added-to-timeline-but-failed-to-add-image": "Přidáno na časovou osu, ale přidání obrázku selhalo",
"api-extras-description": "Recepty jsou klíčovým rysem rozhraní pro API Mealie. Umožňují vytvářet vlastní klíče/hodnoty JSON v rámci receptu pro odkazy na aplikace třetích stran. Tyto klíče můžete použít pro poskytnutí informací, například pro aktivaci automatizace nebo vlastních zpráv pro přenos do požadovaného zařízení.",
"message-key": "Klíč zprávy",
"parse": "Analyzovat",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"ingredients-not-parsed-description": "Zdá se, že vaše ingredience doposud nebyly analyzovány. Kliknutím na tlačítko „{parse}“ níže, abyste rozložili ingredience do strukturovaných jídel.",
"attach-images-hint": "Přiložit obrázky přetažením jich do editoru",
"drop-image": "Vložit obrázek",
"enable-ingredient-amounts-to-use-this-feature": "Chcete-li tuto funkci používat, povolte množství ingrediencí",
@@ -607,10 +608,10 @@
"create-recipe-from-an-image": "Vytvořit recept z obrázku",
"create-recipe-from-an-image-description": "Vytvořte recept nahráním obrázku. Mealie se pokusí z obrázku extrahovat text pomocí AI a vytvořit z něj recept.",
"crop-and-rotate-the-image": "Oříznout a otočit obrázek tak, aby byl viditelný pouze text a aby byl ve správné orientaci.",
"create-from-images": "Create from Images",
"create-from-images": "Vytvořit z obrázků",
"should-translate-description": "Přeložit recept do mého jazyka",
"please-wait-image-procesing": "Počkejte prosím, obrázek se zpracovává. Může to chvíli trvat.",
"please-wait-images-processing": "Please wait, the images are processing. This may take some time.",
"please-wait-images-processing": "Počkejte prosím, probíhá zpracování obrázků. Může to chvíli trvat.",
"bulk-url-import": "Hromadný import adres URL",
"debug-scraper": "Ladící Scraper",
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Vytvořte recept zadáním názvu. Všechny recepty musí mít jedinečná jména.",
@@ -646,8 +647,8 @@
"debug": "Ladit",
"tree-view": "Stromové zobrazení",
"recipe-servings": "Počet porcí",
"recipe-yield": "Recipe Yield",
"recipe-yield-text": "Recipe Yield Text",
"recipe-yield": "Počet porcí receptu",
"recipe-yield-text": "Text porcí receptu",
"unit": "Jednotka",
"upload-image": "Nahrát obrázek",
"screen-awake": "Udržovat obrazovku vzhůru",
@@ -659,15 +660,15 @@
"explanation": "Chcete-li použít analyzátor ingrediencí, klikněte na tlačítko \"Analyzovat vše\" pro zahájení procesu. Jakmile budou zpracované suroviny k dispozici, můžete zkontrolovat položky a ověřit, že byly správně analyzovány. Skóre důvěry modelu se zobrazuje vpravo od názvu položky. Toto skóre je průměrem všech jednotlivých skóre a nemusí být vždy zcela přesné.",
"alerts-explainer": "Upozornění se zobrazí v případě, že je nalezena odpovídající potravina nebo jednotka, ale v databázi neexistuje.",
"select-parser": "Vyberte analyzátor",
"natural-language-processor": "Natural Language Processor",
"brute-parser": "Brute Parser",
"natural-language-processor": "Procesor přirozeného jazyka",
"brute-parser": "Analýza hrubou silou",
"openai-parser": "Analyzátor OpenAI",
"parse-all": "Parsovat vše",
"no-unit": "Žádná jednotka",
"missing-unit": "Vytvořit chybějící jednotku: {unit}",
"missing-food": "Vytvořit chybějící jídlo: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"this-unit-could-not-be-parsed-automatically": "Tuto jednotku nelze analyzovat automaticky",
"this-food-could-not-be-parsed-automatically": "Toto jídlo nelze analyzovat automaticky",
"no-food": "Žádné jídlo"
},
"reset-servings-count": "Resetovat počet porcí",
@@ -675,8 +676,8 @@
"upload-another-image": "Nahrát další obrázek",
"upload-images": "Nahrát obrázky",
"upload-more-images": "Nahrát více obrázků",
"set-as-cover-image": "Set as recipe cover image",
"cover-image": "Cover image"
"set-as-cover-image": "Nastavit recept jako úvodní obrázek",
"cover-image": "Úvodní obrázek"
},
"recipe-finder": {
"recipe-finder": "Vyhledávač receptů",
@@ -1137,7 +1138,7 @@
"create-alias": "Vytvořit alias",
"manage-aliases": "Spravovat aliasy",
"seed-data": "Seed Data",
"seed": "Seed",
"seed": "Zdroj",
"data-management": "Správa dat",
"data-management-description": "Vyberte datovou sadu, ve které chcete provést změny.",
"select-data": "Vybrat data",
@@ -1169,7 +1170,7 @@
"group-details": "Podrobnosti o skupině",
"group-details-description": "Než vytvoříte svůj účet, musíte vytvořit skupinu. Vaše skupina bude obsahovat pouze vás, ale později budete moct přizvat jiné uživatele. Členové vaší skupiny mohou sdílet jídelníčky, nákupní seznamy, recepty a další!",
"use-seed-data": "Použít Seed Data",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes. These are translated into the language you currently have selected. You can always add to or modify this data later.",
"use-seed-data-description": "Mealie obdržíte se sbírkou potravin, jednotek a štítků. Můžete je uložit ve své skupině, abyste mohli lépe organizovat svoje recepty. Ty jsou přeloženy do jazyka, který jste právě vybrali. Tato data můžete kdykoli doplnit nebo změnit.",
"account-details": "Podrobnosti účtu"
},
"validation": {

View File

@@ -14,9 +14,9 @@
"development": "Udvikling",
"docs": "Dokumenter",
"download-log": "Download log",
"download-recipe-json": "Sidst skrabet JSON",
"download-recipe-json": "Senest hentede JSON",
"github": "GitHub",
"log-lines": "Log linjer",
"log-lines": "Log-linjer",
"not-demo": "Ikke demo",
"portfolio": "Portefølje",
"production": "Produktion",
@@ -39,13 +39,13 @@
"category": {
"categories": "Kategorier",
"category-created": "Kategori oprettet",
"category-creation-failed": "Oprettelse af kategorien fejlede",
"category-creation-failed": "Oprettelse af kategorien mislykkedes",
"category-deleted": "Kategori slettet",
"category-deletion-failed": "Sletning af kategori fejlede",
"category-deletion-failed": "Sletning af kategori mislykkedes",
"category-filter": "Kategorifilter",
"category-update-failed": "Kategoriopdatering fejlede",
"category-update-failed": "Opdatering af kategori mislykkedes",
"category-updated": "Kategori opdateret",
"uncategorized-count": "Ukategoriseret {count}",
"uncategorized-count": "Ikke kategoriseret {count}",
"create-a-category": "Opret en kategori",
"category-name": "Kategorinavn",
"category": "Kategori"
@@ -53,8 +53,8 @@
"events": {
"apprise-url": "Apprise URL",
"database": "Database",
"delete-event": "Slet event",
"event-delete-confirmation": "Er du sikker på, at du vil slette denne hændelse?",
"delete-event": "Slet hændelse",
"event-delete-confirmation": "Er du sikker på, at du vil slette denne begivenhed?",
"event-deleted": "Hændelse slettet",
"event-updated": "Hændelse opdateret",
"new-notification-form-description": "Mealie bruger Apprise-biblioteket for at generere notifikationer. De giver mange muligheder for notifikationer til tjenester. Kig i deres wiki for en gennemgående guide til, hvordan en URL oprettes i din situation. Hvis muligt, kan valget af din type af notifikation omfatte flere ekstrafunktioner.",
@@ -69,7 +69,7 @@
"new-notification": "Ny notifikation",
"event-notifiers": "Notifikation om begivenheder",
"apprise-url-skipped-if-blank": "Informations link (sprunget over hvis ladet være tomt)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Eftersom Apprise URL'er typisk indeholder følsomme oplysninger, er feltet bevidst tom imens du skriver. Hvis du ønsker at opdatere URL'en, kan du skrive en ny, ellers efterlad feltet tomt for at bevare den nuværende URL.",
"enable-notifier": "Aktiver Notifikationer",
"what-events": "Hvilke begivenheder skal denne anmelder abonnere på?",
"user-events": "Brugerhændelser",
@@ -561,6 +561,7 @@
"see-original-text": "Vis den oprindelige tekst",
"original-text-with-value": "Oprindelig tekst: {originalText}",
"ingredient-linker": "Ingrediens-linker",
"unlinked": "Ikke forbundet endnu",
"linked-to-other-step": "Linket til andet trin",
"auto": "Automatisk",
"cook-mode": "Tilberedningsvisning",
@@ -589,7 +590,7 @@
"api-extras-description": "Opskrifter ekstra er en central feature i Mealie API. De giver dig mulighed for at oprette brugerdefinerede JSON nøgle / værdi par inden for en opskrift, at henvise til fra 3. parts applikationer. Du kan bruge disse nøgler til at give oplysninger, for eksempel til at udløse automatiseringer eller brugerdefinerede beskeder til at videresende til din ønskede enhed.",
"message-key": "Beskednøgle",
"parse": "Behandl data",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"ingredients-not-parsed-description": "Det ser ud til at dine ingredienser ikke er klassificeret endnu. Klik på knappen \"{parse}\" nedenfor for at analysere ingredienserne til strukturerede fødevarer.",
"attach-images-hint": "Vedhæft billeder ved at trække dem ind i redigeringsværktøjet",
"drop-image": "Slet billede",
"enable-ingredient-amounts-to-use-this-feature": "Aktiver mængde af ingredienser for at bruge denne funktion",
@@ -675,8 +676,8 @@
"upload-another-image": "Upload et andet billede",
"upload-images": "Upload billeder",
"upload-more-images": "Upload flere billeder",
"set-as-cover-image": "Set as recipe cover image",
"cover-image": "Cover image"
"set-as-cover-image": "Angiv som opskriftens coverbillede",
"cover-image": "Coverbillede"
},
"recipe-finder": {
"recipe-finder": "Opskriftssøger",
@@ -1169,7 +1170,7 @@
"group-details": "Gruppeoplysninger",
"group-details-description": "Før du opretter en konto, skal du oprette en gruppe. Din gruppe vil kun indeholde dig, men du vil kunne invitere andre senere. Medlemmer i din gruppe kan dele madplaner, indkøbslister, opskrifter og meget mere!",
"use-seed-data": "Anved standard data",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes. These are translated into the language you currently have selected. You can always add to or modify this data later.",
"use-seed-data-description": "Mealie kommer med en samling af Fødevarer, Enheder, og Etiketter som kan blive brugt til at udfylde din gruppe med nyttig data til at organisere dine opskrifter. De er oversat til det sprog, du i øjeblikket har valgt. Du kan altid tilføje og ændre disse data senere.",
"account-details": "Kontodetaljer"
},
"validation": {

View File

@@ -69,7 +69,7 @@
"new-notification": "Neue Benachrichtigung",
"event-notifiers": "Ereignis-Benachrichtigungen",
"apprise-url-skipped-if-blank": "Apprise-URL (wird übersprungen, wenn leer)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Da Apprise-URLs normalerweise sensible Informationen enthalten, wird dieses Feld während der Bearbeitung absichtlich leer gelassen. Wenn Sie die URL aktualisieren möchten, geben Sie hier die neue ein. Andernfalls lassen Sie diese leer, um die aktuelle URL zu behalten.",
"enable-notifier": "Benachrichtigen aktivieren",
"what-events": "Welche Ereignisse soll diese Benachrichtigung abonnieren?",
"user-events": "Benutzer-Ereignisse",
@@ -561,6 +561,7 @@
"see-original-text": "Originaltext anzeigen",
"original-text-with-value": "Originaltext: {originalText}",
"ingredient-linker": "Zutaten-Verlinkung",
"unlinked": "Nicht verbunden",
"linked-to-other-step": "In anderem Schritt verlinkt",
"auto": "Automatisch",
"cook-mode": "Koch-Modus",
@@ -1169,7 +1170,7 @@
"group-details": "Gruppendetails",
"group-details-description": "Bevor du ein Konto erstellst, musst du eine Gruppe erstellen. Deine Gruppe wird nur dich enthalten, aber du kannst andere später einladen. Mitglieder in deiner Gruppe können Essenspläne, Einkaufslisten, Rezepte und vieles mehr teilen!",
"use-seed-data": "Musterdaten",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes. These are translated into the language you currently have selected. You can always add to or modify this data later.",
"use-seed-data-description": "Mealie enthält eine Sammlung von Lebensmitteln, Einheiten und Labels, die verwendet werden können, um deine Gruppe mit hilfreichen Daten für die Organisation deiner Rezepte zu füllen. Diese werden in die Sprache übersetzt, die Sie gerade ausgewählt haben. Sie können diese Daten jederzeit später hinzufügen oder ändern.",
"account-details": "Kontoinformationen"
},
"validation": {
@@ -1359,7 +1360,7 @@
},
"cookbook": {
"cookbooks": "Kochbücher",
"description": "Kochbücher sind ein weiterer Weg, Rezepte zu organisieren, indem man verschiedene Filter erstellt. Das Erstellen eines Kochbuchs fügt einen Eintrag zur Seitenleiste hinzu und alle Rezepte, die den gewählten Filtern zustimmen, werden in dem Kochbuch angezeigt.",
"description": "Kochbücher sind ein weiterer Weg, Rezepte zu organisieren, indem man verschiedene Filter erstellt. Das Erstellen eines Kochbuchs fügt einen Eintrag zur Seitenleiste hinzu und alle Rezepte, die mit den gewählten Filtern übereinstimmen, werden in dem Kochbuch angezeigt.",
"hide-cookbooks-from-other-households": "Kochbücher von anderen Haushalten ausblenden",
"hide-cookbooks-from-other-households-description": "Wenn aktiviert, werden nur Kochbücher deines Haushalts in der Seitenleiste angezeigt",
"public-cookbook": "Öffentliches Kochbuch",

View File

@@ -557,10 +557,11 @@
"press-enter-to-create": "Πατήστε Enter για δημιουργία",
"choose-food": "Επιλέξτε τρόφιμο",
"notes": "Σημειώσεις",
"toggle-section": "Εναλλαγή τμημάτων",
"toggle-section": "Ενεργοποίηση/απενεργοποίηση τμήματος",
"see-original-text": "Προβολή Αρχικού Κειμένου",
"original-text-with-value": "Αρχικό Κείμενο: {originalText}",
"ingredient-linker": "Συνδυασμός συστατικών",
"unlinked": "Δεν έχει συνδεθεί ακόμα",
"linked-to-other-step": "Συνδεδεμένο με άλλο βήμα",
"auto": "Αυτόματο",
"cook-mode": "Λειτουργία Μαγειρέματος",
@@ -581,7 +582,7 @@
"open-timeline": "Ανοιγμα χρονολόγιου",
"made-this": "Το έφτιαξα",
"how-did-it-turn-out": "Ποιό ήταν το αποτέλεσμα;",
"user-made-this": "Ο/η {user} το έφτιαξε αυτό",
"user-made-this": "Ο/η {user} έφτιαξε αυτό",
"added-to-timeline": "Προστέθηκε στο χρονολόγιο",
"failed-to-add-to-timeline": "Αποτυχία προσθήκης στο χρονολόγιο",
"failed-to-update-recipe": "Αποτυχία ενημέρωσης συνταγής",
@@ -702,8 +703,8 @@
"include": "Συμπερίληψη",
"max-results": "Μέγιστα Αποτελέσματα",
"or": "Ή",
"has-any": "Περιέχει",
"has-all": "Περιέχει τα πάντα",
"has-any": "Περιέχει τουλάχιστον",
"has-all": "Περιέχει όλα τα παρακάτω",
"clear-selection": "Απαλοιφή επιλογής",
"results": "Αποτελέσματα",
"search": "Αναζήτηση",
@@ -885,7 +886,7 @@
"copy-as-text": "Αντιγραφή ως κείμενο",
"copy-as-markdown": "Αντιγραφή ως Markdown",
"delete-checked": "Διαγραφή επιλεγμένων",
"toggle-label-sort": "Εναλλαγή ταξινόμησης ετικετών",
"toggle-label-sort": "Ενεργοποίηση/απενεργοποίηση ταξινόμησης ετικετών",
"reorder-labels": "Αναδιάταξη ετικετών",
"uncheck-all-items": "Αποεπιλογή όλων των αντικειμένων",
"check-all-items": "Επιλογή όλων των αντικειμένων",

View File

@@ -561,6 +561,7 @@
"see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}",
"ingredient-linker": "Ingredient Linker",
"unlinked": "Not linked yet",
"linked-to-other-step": "Linked to other step",
"auto": "Auto",
"cook-mode": "Cook Mode",
@@ -1120,10 +1121,10 @@
"data-exports-description": "This section provides links to available exports that are ready to download. These exports do expire, so be sure to grab them while they're still available.",
"data-exports": "Data Exports",
"tag": "Tag",
"categorize": "Categorize",
"categorize": "Categorise",
"update-settings": "Update Settings",
"tag-recipes": "Tag Recipes",
"categorize-recipes": "Categorize Recipes",
"categorize-recipes": "Categorise Recipes",
"export-recipes": "Export Recipes",
"delete-recipes": "Delete Recipes",
"source-unit-will-be-deleted": "Source Unit will be deleted"
@@ -1169,7 +1170,7 @@
"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. These are translated into the language you currently have selected. You can always add to or modify this data later.",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organising your recipes. These are translated into the language you currently have selected. You can always add to or modify this data later.",
"account-details": "Account Details"
},
"validation": {
@@ -1359,13 +1360,13 @@
},
"cookbook": {
"cookbooks": "Cookbooks",
"description": "Cookbooks are another way to organize recipes by creating cross sections of recipes, organizers, and other filters. Creating a cookbook will add an entry to the side-bar and all the recipes with the filters chosen will be displayed in the cookbook.",
"description": "Cookbooks are another way to organise recipes by creating cross-sections of recipes, organizers, and other filters. Creating a cookbook will add an entry to the sidebar and all the recipes with the filters chosen will be displayed in the cookbook.",
"hide-cookbooks-from-other-households": "Hide Cookbooks from Other Households",
"hide-cookbooks-from-other-households-description": "When enabled, only cookbooks from your household will appear on the sidebar",
"public-cookbook": "Public Cookbook",
"public-cookbook-description": "Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page.",
"filter-options": "Filter Options",
"filter-options-description": "When require all is selected the cookbook will only include recipes that have all of the items selected. This applies to each subset of selectors and not a cross section of the selected items.",
"filter-options-description": "When require all is selected the cookbook will only include recipes that have all of the items selected. This applies to each subset of selectors and not a cross-section of the selected items.",
"require-all-categories": "Require All Categories",
"require-all-tags": "Require All Tags",
"require-all-tools": "Require All Tools",

View File

@@ -561,6 +561,7 @@
"see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}",
"ingredient-linker": "Ingredient Linker",
"unlinked": "Not linked yet",
"linked-to-other-step": "Linked to other step",
"auto": "Auto",
"cook-mode": "Cook Mode",

View File

@@ -69,7 +69,7 @@
"new-notification": "Nueva notificación",
"event-notifiers": "Notificaciones de eventos",
"apprise-url-skipped-if-blank": "URL de Apprise (omitida si está en blanco)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Dado que las URL de Apprise suelen contener información confidencial, este campo se deja en blanco intencionalmente durante la edición. Si desea actualizar la URL introdúzcala aquí, de lo contrario, déjelo en blanco para conservar la URL actual.\n",
"enable-notifier": "Habilitar notificador",
"what-events": "¿A qué eventos debe suscribirse este notificador?",
"user-events": "Eventos de los usuarios",
@@ -561,6 +561,7 @@
"see-original-text": "Mostrar Texto Original",
"original-text-with-value": "Texto original: {originalText}",
"ingredient-linker": "Vincular ingredientes",
"unlinked": "Not linked yet",
"linked-to-other-step": "Enlazado a otro paso",
"auto": "Auto",
"cook-mode": "Modo Cocinar",
@@ -1169,7 +1170,7 @@
"group-details": "Detalles del grupo",
"group-details-description": "Antes de crear una cuenta, debe crear un grupo. En el grupo sólo estará usted, pero puede invitar a otros más tarde. Los miembros de un grupo pueden compartir menús, listas de la compra, recetas y más...",
"use-seed-data": "Utilizar datos de ejemplo",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes. These are translated into the language you currently have selected. You can always add to or modify this data later.",
"use-seed-data-description": "Mealie incluye una colección de alimentos, unidades y etiquetas que puedes usar para completar tu grupo con datos útiles para organizar tus recetas. Estos datos están traducidos al idioma que hayas seleccionado. Siempre puedes añadir o modificar estos datos más adelante.\n",
"account-details": "Información de la cuenta"
},
"validation": {

View File

@@ -561,6 +561,7 @@
"see-original-text": "Vaata originaalteksti",
"original-text-with-value": "Originaaltekst: {originalText}",
"ingredient-linker": "Koostisosa linkija",
"unlinked": "Not linked yet",
"linked-to-other-step": "Lingitud järgmise sammuga",
"auto": "Automaatne",
"cook-mode": "Küpsetusviis",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Katso Alkuperäinen Teksti",
"original-text-with-value": "Alkuperäinen Teksti: {originalText}",
"ingredient-linker": "Ainesosan linkittäjä",
"unlinked": "Not linked yet",
"linked-to-other-step": "Linkitetty toiseen vaiheeseen",
"auto": "Automaattinen",
"cook-mode": "Kokkitila",

View File

@@ -45,7 +45,7 @@
"category-filter": "Filtre par catégories",
"category-update-failed": "La mise à jour de la catégorie a échoué",
"category-updated": "Catégorie mise à jour",
"uncategorized-count": "{count} non catégorisée|{count} non catégorisées",
"uncategorized-count": "{count} non catégorisées",
"create-a-category": "Créer une catégorie",
"category-name": "Nom de la catégorie",
"category": "Catégorie"
@@ -69,7 +69,7 @@
"new-notification": "Nouvelle notification",
"event-notifiers": "Notifications d'événements",
"apprise-url-skipped-if-blank": "URL Apprise (ignoré si vide)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Comme les URL Apprise contiennent généralement des informations sensibles, ce champ est laissé intentionnellement vide lors de l'édition. Si vous souhaitez mettre à jour l'URL, veuillez entrer la nouvelle URL ici, sinon laisser vide pour conserver l'URL courante.",
"enable-notifier": "Activer la notification",
"what-events": "À quels événements cette notification doit-elle s'abonner ?",
"user-events": "Evénements utilisateur",
@@ -81,7 +81,7 @@
"category-events": "Événements de catégories",
"when-a-new-user-joins-your-group": "Lorsqu'un nouvel utilisateur rejoint votre groupe",
"recipe-events": "Événements de recette",
"label-events": "Label Events"
"label-events": "Étiquette des événements"
},
"general": {
"add": "Ajouter",
@@ -341,7 +341,7 @@
"meal-type": "Type de repas",
"breakfast": "Petit-déjeuner",
"lunch": "Déjeuner",
"dinner": "Dîner",
"dinner": "Souper",
"type-any": "Tous",
"day-any": "Tous",
"editor": "Éditeur",
@@ -350,7 +350,7 @@
"meal-note": "Note du repas",
"note-only": "Note uniquement",
"random-meal": "Repas aléatoire",
"random-dinner": "Dîner aléatoire",
"random-dinner": "Souper aléatoire",
"random-side": "Accompagnement aléatoire",
"this-rule-will-apply": "Cette règle s'appliquera {dayCriteria} {mealTypeCriteria}.",
"to-all-days": "à tous les jours",
@@ -381,7 +381,7 @@
"nextcloud": {
"description": "Importer des recettes depuis Nextcloud Cookbook",
"description-long": "Les recettes Nextcloud peuvent être importées depuis un fichier zip qui contient les données stockées dans Nextcloud. Consultez la structure de dossiers d'exemple ci-dessous pour vous assurer que vos recettes peuvent être importées.",
"title": "Nextcloud Cookbook"
"title": "Cookbook Nextcloud"
},
"copymethat": {
"description-long": "Mealie peut importer des recettes à partir de Copy Me That. Exportez vos recettes au format HTML, puis téléchargez le .zip ci-dessous.",
@@ -561,6 +561,7 @@
"see-original-text": "Afficher le texte original",
"original-text-with-value": "Texte original: {originalText}",
"ingredient-linker": "Liaison dingrédients",
"unlinked": "Not linked yet",
"linked-to-other-step": "Déjà associé à une autre étape",
"auto": "Auto",
"cook-mode": "Mode Cuisine",
@@ -582,10 +583,10 @@
"made-this": "Je lai cuisiné",
"how-did-it-turn-out": "Cétait bon?",
"user-made-this": "{user} la cuisiné",
"added-to-timeline": "Ajouté à lhistorique",
"failed-to-add-to-timeline": "Ajout dans lhistorique en échec",
"failed-to-update-recipe": "Impossible de mettre à jour la recette",
"added-to-timeline-but-failed-to-add-image": "Ajouté à lhistorique, mais impossible dajouter limage",
"added-to-timeline": "Ajouté à la ligne du temps",
"failed-to-add-to-timeline": "Impossible d'ajouter à la ligne du temps",
"failed-to-update-recipe": "Impossible de modifier la recette",
"added-to-timeline-but-failed-to-add-image": "Ajouté à la ligne du temps, mais impossible d'ajouter l'image",
"api-extras-description": "Les suppléments des recettes sont une fonctionnalité clé de lAPI Mealie. Ils permettent de créer des paires JSON clé/valeur personnalisées dans une recette, qui peuvent être référencées depuis des applications tierces. Ces clés peuvent être utilisées par exemple pour déclencher des tâches automatisées ou des messages personnalisés à transmettre à lappareil souhaité.",
"message-key": "Clé de message",
"parse": "Analyser",
@@ -607,10 +608,10 @@
"create-recipe-from-an-image": "Créer une recette à partir dune image",
"create-recipe-from-an-image-description": "Créez une recette en téléchargeant une image de celle-ci. Mealie utilisera lIA pour tenter dextraire le texte et de créer une recette.",
"crop-and-rotate-the-image": "Rogner et pivoter limage pour que seul le texte soit visible, et quil soit dans la bonne orientation.",
"create-from-images": "Créer à partir dimages",
"create-from-images": "Créer à partir dune image",
"should-translate-description": "Traduire la recette dans ma langue",
"please-wait-image-procesing": "Veuillez patienter, limage est en cours de traitement. Cela peut prendre du temps.",
"please-wait-images-processing": "Veuillez patienter, les images sont en cours de traitement. Cela peut prendre un certain temps.",
"please-wait-images-processing": "Un peu de patience, les images sont en cours de traitement. Cela peut prendre un certain temps.",
"bulk-url-import": "Importation en masse d'URL",
"debug-scraper": "Déboguer le récupérateur",
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Créer une recette en fournissant le nom. Toutes les recettes doivent avoir des noms uniques.",
@@ -675,8 +676,8 @@
"upload-another-image": "Télécharger une autre image",
"upload-images": "Télécharger des images",
"upload-more-images": "Télécharger d'autres images",
"set-as-cover-image": "Set as recipe cover image",
"cover-image": "Cover image"
"set-as-cover-image": "Définir comme image de couverture de recette",
"cover-image": "Image de couverture"
},
"recipe-finder": {
"recipe-finder": "Recherche de recette",
@@ -1169,7 +1170,7 @@
"group-details": "Détails du groupe",
"group-details-description": "Avant de créer un compte, vous devrez créer un groupe. Votre groupe ne contiendra que vous, mais vous pourrez inviter dautres personnes plus tard. Les membres de votre groupe peuvent partager leur menu de la semaine, leurs listes dachat, leurs recettes et plus encore!",
"use-seed-data": "Utiliser l'initialisation de données",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes. These are translated into the language you currently have selected. You can always add to or modify this data later.",
"use-seed-data-description": "Mealie propose une collection d'aliments, d'unités et d'étiquettes qui peuvent être utilisés pour remplir votre groupe avec des données utiles pour organiser vos recettes. Celles-ci sont traduites dans la langue que vous avez sélectionnée. Vous pouvez toujours ajouter ou modifier ces données plus tard.",
"account-details": "Détails du compte"
},
"validation": {

View File

@@ -69,7 +69,7 @@
"new-notification": "Nouvelle notification",
"event-notifiers": "Notifications d'événements",
"apprise-url-skipped-if-blank": "URL Apprise (ignoré si vide)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Comme les URL Apprise contiennent généralement des informations sensibles, ce champ est laissé intentionnellement vide lors de l'édition. Si vous souhaitez mettre à jour l'URL, veuillez entrer la nouvelle URL ici, sinon laisser vide pour conserver l'URL courante.",
"enable-notifier": "Activer la notification",
"what-events": "À quels événements cette notification doit-elle s'abonner ?",
"user-events": "Événements de l'utilisateur",
@@ -81,7 +81,7 @@
"category-events": "Événements de catégories",
"when-a-new-user-joins-your-group": "Lorsqu'un nouvel utilisateur rejoint votre groupe",
"recipe-events": "Événements de recette",
"label-events": "Label Events"
"label-events": "Étiquette des événements"
},
"general": {
"add": "Ajouter",
@@ -561,6 +561,7 @@
"see-original-text": "Afficher le texte original",
"original-text-with-value": "Texte original: {originalText}",
"ingredient-linker": "Association dingrédients",
"unlinked": "Not linked yet",
"linked-to-other-step": "Lié à une autre étape",
"auto": "Auto",
"cook-mode": "Mode Cuisine",
@@ -675,8 +676,8 @@
"upload-another-image": "Télécharger une autre image",
"upload-images": "Télécharger des images",
"upload-more-images": "Télécharger d'autres images",
"set-as-cover-image": "Set as recipe cover image",
"cover-image": "Cover image"
"set-as-cover-image": "Définir comme image de couverture de la recette",
"cover-image": "Image de couverture"
},
"recipe-finder": {
"recipe-finder": "Recherche de recette",
@@ -1169,7 +1170,7 @@
"group-details": "Détails du groupe",
"group-details-description": "Avant de créer un compte, vous devrez créer un groupe. Votre groupe ne contiendra que vous, mais vous pourrez inviter dautres personnes plus tard. Les membres de votre groupe peuvent partager leur menu de la semaine, leurs listes dachat, leurs recettes et plus encore!",
"use-seed-data": "Utiliser l'initialisation de données",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes. These are translated into the language you currently have selected. You can always add to or modify this data later.",
"use-seed-data-description": "Mealie est livrée avec une collection d'aliments, d'unités et d'étiquettes qui peuvent être utilisés pour remplir votre groupe avec des données utiles pour organiser vos recettes. Ceux-ci sont traduits dans la langue que vous avez sélectionnée. Vous pouvez toujours ajouter ou modifier ces données plus tard.",
"account-details": "Détails du compte"
},
"validation": {

View File

@@ -69,7 +69,7 @@
"new-notification": "Nouvelle notification",
"event-notifiers": "Notifications d'événements",
"apprise-url-skipped-if-blank": "URL Apprise (ignoré si vide)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Comme les URL Apprise contiennent généralement des informations sensibles, ce champ est laissé intentionnellement vide lors de l'édition. Si vous souhaitez mettre à jour l'URL, veuillez entrer la nouvelle URL ici, sinon laisser vide pour conserver l'URL courante.",
"enable-notifier": "Activer la notification",
"what-events": "À quels événements cette notification doit-elle s'abonner ?",
"user-events": "Événements utilisateur",
@@ -81,7 +81,7 @@
"category-events": "Événements de catégories",
"when-a-new-user-joins-your-group": "Lorsqu'un nouvel utilisateur rejoint votre groupe",
"recipe-events": "Événements de recette",
"label-events": "Label Events"
"label-events": "Étiquette des événements"
},
"general": {
"add": "Ajouter",
@@ -561,6 +561,7 @@
"see-original-text": "Afficher le texte original",
"original-text-with-value": "Texte original: {originalText}",
"ingredient-linker": "Liaison dingrédients",
"unlinked": "Pas encore associée",
"linked-to-other-step": "Déjà associé à une autre étape",
"auto": "Auto",
"cook-mode": "Mode Cuisine",
@@ -675,8 +676,8 @@
"upload-another-image": "Télécharger une autre image",
"upload-images": "Télécharger des images",
"upload-more-images": "Télécharger d'autres images",
"set-as-cover-image": "Set as recipe cover image",
"cover-image": "Cover image"
"set-as-cover-image": "Définir comme image de couverture de la recette",
"cover-image": "Image de couverture"
},
"recipe-finder": {
"recipe-finder": "Recherche de recette",
@@ -1169,7 +1170,7 @@
"group-details": "Détails du groupe",
"group-details-description": "Avant de créer un compte, vous devrez créer un groupe. Votre groupe ne contiendra que vous, mais vous pourrez inviter dautres personnes plus tard. Les membres de votre groupe peuvent partager leur menu de la semaine, leurs listes dachat, leurs recettes et plus encore!",
"use-seed-data": "Utiliser l'initialisation de données",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes. These are translated into the language you currently have selected. You can always add to or modify this data later.",
"use-seed-data-description": "Mealie est livrée avec une collection d'aliments, d'unités et d'étiquettes qui peuvent être utilisés pour remplir votre groupe avec des données utiles pour organiser vos recettes. Ceux-ci sont traduits dans la langue que vous avez sélectionnée. Vous pouvez toujours ajouter ou modifier ces données plus tard.",
"account-details": "Détails du compte"
},
"validation": {

View File

@@ -561,6 +561,7 @@
"see-original-text": "Mostrar Texto Orixinal",
"original-text-with-value": "Texto Orixinal: {originalText}",
"ingredient-linker": "Conector de ingredientes",
"unlinked": "Not linked yet",
"linked-to-other-step": "Ligado a outro paso",
"auto": "Auto",
"cook-mode": "Modo Cociñeiro",

View File

@@ -561,6 +561,7 @@
"see-original-text": "הטקסט המקורי",
"original-text-with-value": "הטקסט המקורי: {originalText}",
"ingredient-linker": "קישוריות רכיבים",
"unlinked": "Not linked yet",
"linked-to-other-step": "קשור לצעד אחד",
"auto": "אוטומטי",
"cook-mode": "מצב בישול",

View File

@@ -69,7 +69,7 @@
"new-notification": "Nova Obavijest",
"event-notifiers": "Obavještavatelji Događaja",
"apprise-url-skipped-if-blank": "Apprise URL (preskočeno ako je prazno)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Ovo polje je namjerno ostavljeno prazno prilikom uređivanja jer Apprise poveznice tipično sadrže osjetljive informacije. Ako želite promijeniti poveznicu, molimo unesite novu ovdje, inače ostavite prazno da zadržite trenutnu poveznicu.",
"enable-notifier": "Omogući obavještavanje",
"what-events": "Na koje događaje bi ovaj obavještavatelj trebao biti pretplaćen?",
"user-events": "Događaji Korisnika",
@@ -300,12 +300,12 @@
"household-recipe-preferences": "Postavke recepata u domaćinstvu",
"default-recipe-preferences-description": "Ovo su zadane postavke, kada se u tvojem domaćinstvu izradi novi recept. Ove postavke se mogu promijeniti za pojedinačne recepte u izborniku postavki recepata.",
"allow-users-outside-of-your-household-to-see-your-recipes": "Dopustite korisnicima izvan vašega kućanstva da vide vaše recepte",
"allow-users-outside-of-your-household-to-see-your-recipes-description": "When enabled you can use a public share link to share specific recipes without authorizing the user. When disabled, you can only share recipes with users who are in your household or with a pre-generated private link",
"household-preferences": "Household Preferences"
"allow-users-outside-of-your-household-to-see-your-recipes-description": "Kada je omogućeno, možete koristiti javnu povezncu dijeljene veze za dijeljenje određenih recepata bez autorizacije korisnika. Kada je onemogućeno, recepte možete dijeliti samo s korisnicima koji su u vašoj grupi ili s prethodno generiranom privatnom vezom",
"household-preferences": "Postavke recepata u domaćinstvu"
},
"meal-plan": {
"create-a-new-meal-plan": "Kreirajte Novi Plan Obroka",
"update-this-meal-plan": "Update this Meal Plan",
"update-this-meal-plan": "Izmijenite ovaj Plan Obroka",
"dinner-this-week": "Večera Ove Sedmice",
"dinner-today": "Večera Danas",
"dinner-tonight": "VEČERA NOĆAS",
@@ -323,13 +323,13 @@
"mealplan-settings": "Postavke Plana obroka",
"mealplan-update-failed": "Ažuriranje Plana obroka nije uspjelo",
"mealplan-updated": "Plan obroka je Ažuriran",
"mealplan-households-description": "If no household is selected, recipes can be added from any household",
"any-category": "Any Category",
"any-tag": "Any Tag",
"any-household": "Any Household",
"mealplan-households-description": "Ako nijedno kućanstvo nije odabrano, recepti mogu biti dodani iz bilo kojeg kućanstva",
"any-category": "Bilo koja Kategorija",
"any-tag": "Bilo koja Oznaka",
"any-household": "Bilo koje Kućanstvo",
"no-meal-plan-defined-yet": "Plan obroka još nije definiran",
"no-meal-planned-for-today": "Nema Plan obroka za današnji dan",
"numberOfDays-hint": "Number of days on page load",
"numberOfDays-hint": "Broj dana na očitavanju stranice",
"numberOfDays-label": "Default Days",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Samo recepti s ovim kategorijama bit će korišteni u planovima obroka",
"planner": "Planer",
@@ -561,6 +561,7 @@
"see-original-text": "Prikaži Izvorni Tekst",
"original-text-with-value": "Izvorni Tekst: {originalText}",
"ingredient-linker": "Poveznik Sastojaka",
"unlinked": "Not linked yet",
"linked-to-other-step": "Povezano s drugim korakom",
"auto": "Auto",
"cook-mode": "Način Kuhanja",
@@ -602,7 +603,7 @@
"import-with-url": "Učitaj preko URL-a",
"create-recipe": "Kreiraj recept",
"create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes",
"create-recipes": "Kreiraj recept",
"import-with-zip": "Učitaj pomoću .zip-a",
"create-recipe-from-an-image": "Create Recipe from an Image",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
@@ -628,7 +629,7 @@
"import-from-html-or-json": "Import from HTML or JSON",
"import-from-html-or-json-description": "Import a single recipe from raw HTML or JSON. This is useful if you have a recipe from a site that Mealie can't scrape normally, or from some other external source.",
"json-import-format-description-colon": "To import via JSON, it must be in valid format:",
"json-editor": "JSON Editor",
"json-editor": "JSON uređivač",
"zip-files-must-have-been-exported-from-mealie": ".zip datoteke moraju biti izvezeni iz Mealie-a",
"create-a-recipe-by-uploading-a-scan": "Izradite recept tako što ćete učitati skeniranu kopiju.",
"upload-a-png-image-from-a-recipe-book": "Učitajte png sliku iz kuharice",
@@ -641,19 +642,19 @@
"report-deletion-failed": "Brisanje nije uspjelo",
"recipe-debugger": "Ispravljač Pogrešaka Recepta",
"recipe-debugger-description": "Preuzmite URL recepta koji želite ispraviti i zalijepite ga ovdje. URL će biti obrađen od strane scraper-a za recepte i rezultati će biti prikazani. Ako ne vidite nikakve povratne podatke, to znači da web stranica koju pokušavate obraditi nije podržana od strane Mealie-a ili njegove biblioteke za scraper-e.",
"use-openai": "Use OpenAI",
"use-openai": "Koristi OpenAI",
"recipe-debugger-use-openai-description": "Use OpenAI to parse the results instead of relying on the scraper library. When creating a recipe via URL, this is done automatically if the scraper library fails, but you may test it manually here.",
"debug": "Ispravljanje grešaka",
"tree-view": "Prikaz Stabla",
"recipe-servings": "Recipe Servings",
"recipe-servings": "Serviranja recepta",
"recipe-yield": "Konačna Količina Recepta",
"recipe-yield-text": "Recipe Yield Text",
"unit": "Jedinica",
"upload-image": "Učitavanje Slike",
"screen-awake": "Keep Screen Awake",
"remove-image": "Remove image",
"nextStep": "Next step",
"recipe-actions": "Recipe Actions",
"screen-awake": "Zadrži ekran uključenim",
"remove-image": "Ukloni sliku",
"nextStep": "Sljedeći korak",
"recipe-actions": "Akcije recepta",
"parser": {
"ingredient-parser": "Ingredient Parser",
"explanation": "To use the ingredient parser, click the 'Parse All' button to start the process. Once the processed ingredients are available, you can review the items and verify that they were parsed correctly. The model's confidence score is displayed on the right of the item title. This score is an average of all the individual scores and may not always be completely accurate.",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Eredeti szöveg megjelenítése",
"original-text-with-value": "Eredeti szöveg: {originalText}",
"ingredient-linker": "Hozzávaló összekötő",
"unlinked": "Még nincs csatolva",
"linked-to-other-step": "Egy másik lépéssel összekapcsolva",
"auto": "Automatikus",
"cook-mode": "Főzési mód",

View File

@@ -69,7 +69,7 @@
"new-notification": "Ný tilkynning",
"event-notifiers": "Viðburðar tilkynningar",
"apprise-url-skipped-if-blank": "Apprise URL (sleppt ef tómt)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Þar sem \"Apprise\" slóðir innihalda yfirleitt viðkvæmar upplýsingar, er þessum reit viljandi skilið eftir auðum við breytingar. Ef þú vilt uppfæra slóðina skaltu slá inn þá nýju hér, annars skaltu skilja reitinn eftir auðan til að halda núverandi slóð.",
"enable-notifier": "Virkja tilkynningar",
"what-events": "Hvaða viðburði ætti þessi tilkynnir að vera áskrifandi að?",
"user-events": "Notenda viðburðir",
@@ -81,7 +81,7 @@
"category-events": "Flokka viðburðir",
"when-a-new-user-joins-your-group": "Þegar nýr notandi bætist við í þinn hóp",
"recipe-events": "Uppskriftar viðburðir",
"label-events": "Label Events"
"label-events": "Merkja viðburð"
},
"general": {
"add": "Bæta við",
@@ -118,12 +118,12 @@
"image-upload-failed": "Upphal myndar mistókst",
"import": "Hlaða inn",
"json": "JSON",
"keyword": "Keyword",
"keyword": "Stikkorð",
"link-copied": "Hlekkur afritaður",
"loading": "Loading",
"loading": "Hleður",
"loading-events": "Hleð atburðum",
"loading-recipe": "Hleð uppskrift",
"loading-ocr-data": "Loading OCR data...",
"loading-ocr-data": "Hleður OCR gögnum...",
"loading-recipes": "Hleð uppskriftum",
"message": "Skilaboð",
"monday": "Mánudagur",
@@ -134,7 +134,7 @@
"no-recipe-found": "Engin uppskrift finnst",
"ok": "Allt í lagi",
"options": "Valmöguleikar:",
"plural-name": "Plural Name",
"plural-name": "Nafn í fleirtölu",
"print": "Prenta",
"print-preferences": "Prent valmöguleikar",
"random": "Handahófskennt",
@@ -142,127 +142,127 @@
"recent": "Nýlegt",
"recipe": "Uppskrift",
"recipes": "Uppskriftir",
"rename-object": "Rename {0}",
"rename-object": "Endurnefna {0}",
"reset": "Endurstilla",
"saturday": "Laugardagur",
"save": "Vista",
"settings": "Stillingar",
"share": "Deila",
"show-all": "Sýna allt",
"shuffle": "Shuffle",
"sort": "Sort",
"sort-ascending": "Sort Ascending",
"sort-descending": "Sort Descending",
"sort-alphabetically": "Alphabetical",
"status": "Status",
"subject": "Subject",
"submit": "Submit",
"success-count": "Success: {count}",
"sunday": "Sunday",
"system": "System",
"templates": "Templates:",
"test": "Test",
"themes": "Themes",
"thursday": "Thursday",
"title": "Title",
"token": "Token",
"tuesday": "Tuesday",
"type": "Type",
"update": "Update",
"updated": "Updated",
"upload": "Upload",
"shuffle": "Blanda",
"sort": "Raða",
"sort-ascending": "Raða í réttri röð",
"sort-descending": "Raða í öfugri röð",
"sort-alphabetically": "Stafrófsröð",
"status": "Staða",
"subject": "Efni",
"submit": "Staðfesta",
"success-count": "Tókst: {count}",
"sunday": "Sunnudagur",
"system": "Kerfi",
"templates": "Sniðmót:",
"test": "Próf",
"themes": "Þema",
"thursday": "Fimmtudagur",
"title": "Titill",
"token": "Tóki",
"tuesday": "Þriðjudagur",
"type": "Tegund",
"update": "Uppfærsla",
"updated": "Uppfært",
"upload": "Hlaða upp",
"url": "URL",
"view": "View",
"wednesday": "Wednesday",
"yes": "Yes",
"foods": "Foods",
"units": "Units",
"back": "Back",
"next": "Next",
"start": "Start",
"toggle-view": "Toggle View",
"date": "Date",
"id": "Id",
"owner": "Owner",
"change-owner": "Change Owner",
"date-added": "Date Added",
"none": "None",
"run": "Run",
"menu": "Menu",
"a-name-is-required": "A Name is Required",
"delete-with-name": "Delete {name}",
"confirm-delete-generic-with-name": "Are you sure you want to delete this {name}?",
"view": "Skoða",
"wednesday": "Miðvikudagur",
"yes": "",
"foods": "Matur",
"units": "Einingar",
"back": "Til baka",
"next": "Næst",
"start": "Byrja",
"toggle-view": "Skipta um sýn",
"date": "Dagsetning",
"id": "Eingildi",
"owner": "Eigandi",
"change-owner": "Breyta um eiganda",
"date-added": "Dagsetningu bætt við",
"none": "Ekkert",
"run": "Keyra",
"menu": "Matseðill",
"a-name-is-required": "Nafn er krafist",
"delete-with-name": "Eyða út {name}",
"confirm-delete-generic-with-name": "Ertu viss um að þú viljir eyða út {name}?",
"confirm-delete-own-admin-account": "Please note that you are trying to delete your own admin account! This action cannot be undone and will permanently delete your account?",
"organizer": "Organizer",
"transfer": "Transfer",
"copy": "Copy",
"color": "Color",
"timestamp": "Timestamp",
"last-made": "Last Made",
"learn-more": "Learn More",
"organizer": "Skipuleggjari",
"transfer": "Færa",
"copy": "Afrita",
"color": "Litur",
"timestamp": "Tímastimpill",
"last-made": "Síðast gert",
"learn-more": "Læra meira",
"this-feature-is-currently-inactive": "This feature is currently inactive",
"clipboard-not-supported": "Clipboard not supported",
"copied-to-clipboard": "Copied to clipboard",
"your-browser-does-not-support-clipboard": "Your browser does not support clipboard",
"copied-items-to-clipboard": "No item copied to clipboard|One item copied to clipboard|Copied {count} items to clipboard",
"actions": "Actions",
"selected-count": "Selected: {count}",
"actions": "Aðgerðir",
"selected-count": "Valið: {count}",
"export-all": "Export All",
"refresh": "Refresh",
"upload-file": "Upload File",
"created-on-date": "Created on: {0}",
"refresh": "Endurhlaða",
"upload-file": "Hlaða upp skrá",
"created-on-date": "Búið til: {0}",
"unsaved-changes": "You have unsaved changes. Do you want to save before leaving? Okay to save, Cancel to discard changes.",
"clipboard-copy-failure": "Failed to copy to the clipboard.",
"confirm-delete-generic-items": "Are you sure you want to delete the following items?",
"organizers": "Organizers",
"caution": "Caution",
"organizers": "Skipuleggjarar",
"caution": "Varúð",
"show-advanced": "Show Advanced",
"add-field": "Add Field",
"add-field": "Bæta við dálk",
"date-created": "Date Created",
"date-updated": "Date Updated"
"date-updated": "Dagsetning uppfærð"
},
"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",
"are-you-sure-you-want-to-delete-the-group": "Ertu viss um að þú viljir eyða <b>{groupName}<b/>?",
"cannot-delete-default-group": "Ekki hægt að eyða sjálfvöldum hóp",
"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",
"create-group": "Búa til hóp",
"error-updating-group": "Villa í að uppfæra hóp",
"group": "Hópur",
"group-deleted": "Hóp eytt",
"group-deletion-failed": "Villa í að eyða hóp",
"group-id-with-value": "Group ID: {groupID}",
"group-name": "Group Name",
"group-not-found": "Group not found",
"group-name": "Nafn hóps",
"group-not-found": "Fann ekki hóp",
"group-token": "Group Token",
"group-with-value": "Group: {groupID}",
"groups": "Groups",
"manage-groups": "Manage Groups",
"user-group": "User Group",
"user-group-created": "User Group Created",
"groups": "Hópar",
"manage-groups": "Umsjá hópa",
"user-group": "Notendahópur",
"user-group-created": "Notendahópur búinn til",
"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."
},
"manage-members": "Manage Members",
"manage-members": "Umsjá meðlima",
"manage-members-description": "Manage the permissions of the members in your household. {manage} allows the user to access the data-management page, and {invite} allows the user to generate invitation links for other users. Group owners cannot change their own permissions.",
"manage": "Manage",
"manage-household": "Manage Household",
"invite": "Invite",
"looking-to-update-your-profile": "Looking to Update Your Profile?",
"default-recipe-preferences-description": "These are the default settings when a new recipe is created in your group. These can be changed for individual recipes in the recipe settings menu.",
"default-recipe-preferences": "Default Recipe Preferences",
"group-preferences": "Group Preferences",
"private-group": "Private Group",
"private-group-description": "Setting your group to private will disable all public view options. This overrides any individual public view settings",
"enable-public-access": "Enable Public Access",
"manage": "Umsjá",
"manage-household": "Umsjá heimilis",
"invite": "Bjóða",
"looking-to-update-your-profile": "Viltu uppfæra prófílinn þinn?",
"default-recipe-preferences-description": "Þetta eru sjálfgefnar stillingar þegar ný uppskrift er búin til í hópnum þínum. Hægt er að breyta þeim fyrir einstakar uppskriftir í stillingavalmynd uppskrifta.",
"default-recipe-preferences": "Sjálfgefnar stillingar uppskrifta",
"group-preferences": "Stillingar hóps",
"private-group": "Lokaður hópur",
"private-group-description": "Ef þú stillir hópinn þinn sem lokaðan hóp lokast á alla almennan aðgang. Þessi stilling hefur forgang fram yfir einstakar stillingar fyrir almenna sýn",
"enable-public-access": "Virkja almennan aðgang",
"enable-public-access-description": "Make group recipes public by default, and allow visitors to view recipes without logging-in",
"allow-users-outside-of-your-group-to-see-your-recipes": "Allow users outside of your group to see your recipes",
"allow-users-outside-of-your-group-to-see-your-recipes-description": "When enabled you can use a public share link to share specific recipes without authorizing the user. When disabled, you can only share recipes with users who are in your group or with a pre-generated private link",
"show-nutrition-information": "Show nutrition information",
"show-nutrition-information": "Sýna næringargildi innihalds",
"show-nutrition-information-description": "When enabled the nutrition information will be shown on the recipe if available. If there is no nutrition information available, the nutrition information will not be shown",
"show-recipe-assets": "Show recipe assets",
"show-recipe-assets": "Sýna skrár og efni uppskriftar",
"show-recipe-assets-description": "When enabled the recipe assets will be shown on the recipe if available",
"default-to-landscape-view": "Default to landscape view",
"default-to-landscape-view-description": "When enabled the recipe header section will be shown in landscape view",
@@ -270,9 +270,9 @@
"disable-users-from-commenting-on-recipes-description": "Hides the comment section on the recipe page and disables commenting",
"disable-organizing-recipe-ingredients-by-units-and-food": "Disable organizing recipe ingredients by units and food",
"disable-organizing-recipe-ingredients-by-units-and-food-description": "Hides the Food, Unit, and Amount fields for ingredients and treats ingredients as plain text fields",
"general-preferences": "General Preferences",
"general-preferences": "Almenni valmöguleikar",
"group-recipe-preferences": "Group Recipe Preferences",
"report": "Report",
"report": "Skýrsla",
"report-with-id": "Report ID: {id}",
"group-management": "Group Management",
"admin-group-management": "Admin Group Management",
@@ -561,6 +561,7 @@
"see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}",
"ingredient-linker": "Ingredient Linker",
"unlinked": "Not linked yet",
"linked-to-other-step": "Linked to other step",
"auto": "Auto",
"cook-mode": "Cook Mode",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Vedi Testo Originale",
"original-text-with-value": "Testo originale: {originalText}",
"ingredient-linker": "Linker degli Ingredienti",
"unlinked": "Not linked yet",
"linked-to-other-step": "Collegato ad un altro passaggio",
"auto": "Automatico",
"cook-mode": "Modalità di Cottura",

View File

@@ -561,6 +561,7 @@
"see-original-text": "元のテキストを見る",
"original-text-with-value": "原文: {originalText}",
"ingredient-linker": "材料リンク",
"unlinked": "Not linked yet",
"linked-to-other-step": "他のステップにリンクしています",
"auto": "自動",
"cook-mode": "調理モード",

View File

@@ -561,6 +561,7 @@
"see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}",
"ingredient-linker": "Ingredient Linker",
"unlinked": "Not linked yet",
"linked-to-other-step": "Linked to other step",
"auto": "자동",
"cook-mode": "Cook Mode",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Rodyti originalų tekstą",
"original-text-with-value": "Originalus tekstas: {originalText}",
"ingredient-linker": "Ingredientų siejimas",
"unlinked": "Not linked yet",
"linked-to-other-step": "Susietas su kitu žingsniu",
"auto": "Automatiškai",
"cook-mode": "Gaminimo režimas",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Skatīt oriģinālo tekstu",
"original-text-with-value": "Oriģinālais teksts: {originalText}",
"ingredient-linker": "Sastāvdaļu Linker",
"unlinked": "Not linked yet",
"linked-to-other-step": "Saistīts ar citu soli",
"auto": "Automātiski",
"cook-mode": "Gatavošanas režīms",

View File

@@ -69,7 +69,7 @@
"new-notification": "Nieuwe melding",
"event-notifiers": "Meldingen van gebeurtenissen",
"apprise-url-skipped-if-blank": "URL van Apprise (overgeslagen als veld leeg is)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Aangezien Apprise URL's doorgaans gevoelige informatie bevatten, wordt dit veld opzettelijk leeg gelaten tijdens het bewerken. Als je de URL wilt bijwerken, vul dan de nieuwe hier in, anders laat het leeg om de huidige URL te behouden.",
"enable-notifier": "Activeer melding",
"what-events": "Op welke gebeurtenissen moet deze melding zich abonneren?",
"user-events": "Gebeurtenissen van gebruiker",
@@ -561,6 +561,7 @@
"see-original-text": "Zie oorspronkelijke tekst",
"original-text-with-value": "Oorspronkelijke tekst: {originalText}",
"ingredient-linker": "Ingrediëntenkoppelaar",
"unlinked": "Nog niet gelinkt",
"linked-to-other-step": "Gekoppeld aan andere stap",
"auto": "Automatisch",
"cook-mode": "Kookmodus",
@@ -1021,7 +1022,7 @@
"enable-advanced-content-description": "Schakelt geavanceerde functies, zoals recepten opschalen, API-sleutels, webhooks en gegevensbeheer in. Geen zorgen, je kan dit later altijd aanpassen",
"favorite-recipes": "Favoriete recepten",
"email-or-username": "E-mailadres of gebruikersnaam",
"remember-me": "Herinner mij",
"remember-me": "Blijf ingelogd",
"please-enter-your-email-and-password": "Voer je e-mailadres en wachtwoord in",
"invalid-credentials": "Ongeldige inloggegevens",
"account-locked-please-try-again-later": "Account geblokkeerd. Probeer het later opnieuw",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Se opprinnelig tekst",
"original-text-with-value": "Opprinnelig tekst: {originalText}",
"ingredient-linker": "Tilknytt ingredienser",
"unlinked": "Not linked yet",
"linked-to-other-step": "Tilknyttet et annet steg",
"auto": "Automatisk",
"cook-mode": "Tilberedelsesmodus",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Zobacz oryginalny tekst",
"original-text-with-value": "Oryginalny tekst: {originalText}",
"ingredient-linker": "Linkier do składników",
"unlinked": "Jeszcze nie połączony",
"linked-to-other-step": "Powiązane z innym krokiem",
"auto": "Automatycznie",
"cook-mode": "Tryb Gotowania",

View File

@@ -69,7 +69,7 @@
"new-notification": "Nova Notificação",
"event-notifiers": "Notificações de Eventos",
"apprise-url-skipped-if-blank": "URL Apprise (ignorado se estiver em branco)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Como URLs de notificação normalmente contém informações confidenciais, este campo foi deixando intencionalmente em branco quando editado. Se você deseja atualizar o URL, por favor insira o novo localizador aqui. Caso contrário, deixe em branco para manter o URL atual.",
"enable-notifier": "Habilitar Notificador",
"what-events": "A quais eventos este notificador deve subscrever?",
"user-events": "Eventos do usuário",
@@ -561,6 +561,7 @@
"see-original-text": "Exibir texto original",
"original-text-with-value": "Texto Original: {originalText}",
"ingredient-linker": "Ingrediente do Linker",
"unlinked": "Ainda não vinculado",
"linked-to-other-step": "Ligado a outro passo",
"auto": "Automático",
"cook-mode": "Modo Cozinheiro",
@@ -675,8 +676,8 @@
"upload-another-image": "Carregar outra imagem",
"upload-images": "Carregar imagens",
"upload-more-images": "Carregar mais imagens",
"set-as-cover-image": "Set as recipe cover image",
"cover-image": "Cover image"
"set-as-cover-image": "Definir como imagem de capa da receita",
"cover-image": "Imagem de capa"
},
"recipe-finder": {
"recipe-finder": "Localizador de Receitas",
@@ -1169,7 +1170,7 @@
"group-details": "Detalhes do Grupo",
"group-details-description": "Antes de criar uma conta é necessário criar um grupo. O seu grupo só conterá você, mas você poderá convidar os outros mais tarde. Os membros do seu grupo podem compartilhar planos de refeição, listas de compras, receitas e muito mais!",
"use-seed-data": "Usar dados semeados",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes. These are translated into the language you currently have selected. You can always add to or modify this data later.",
"use-seed-data-description": "O Mealie vem com uma coleção de Alimentos, Unidades e Rótulos que podem ser usados para preencher seu grupo com dados úteis ou para organizar suas receitas. Eles são traduzidos para o idioma selecionado. Você sempre pode adicionar ou modificar esses dados posteriormente.",
"account-details": "Detalhes da Conta"
},
"validation": {

View File

@@ -561,6 +561,7 @@
"see-original-text": "Mostrar texto original",
"original-text-with-value": "Texto Original: {originalText}",
"ingredient-linker": "Conector de ingredientes",
"unlinked": "Not linked yet",
"linked-to-other-step": "Ligado a outro passo",
"auto": "Auto",
"cook-mode": "Modo Cozinheiro",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Vezi Textul Original",
"original-text-with-value": "Text original: {originalText}",
"ingredient-linker": "Legarea cu ingrediente",
"unlinked": "Not linked yet",
"linked-to-other-step": "Conectat la alt pas",
"auto": "Auto",
"cook-mode": "Modul de gătire",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Показать исходный текст",
"original-text-with-value": "Исходный текст: {originalText}",
"ingredient-linker": "Связка ингредиентов",
"unlinked": "Not linked yet",
"linked-to-other-step": "Связан с другим шагом",
"auto": "Авто",
"cook-mode": "Режим готовки",

View File

@@ -69,7 +69,7 @@
"new-notification": "Nové upozornenie",
"event-notifiers": "Upozornenia udalostí",
"apprise-url-skipped-if-blank": "Informačná URL (preskočená, ak je prázdna)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Keďže Apprise URL typicky obsahujú citlivé informácie, toto pole je ponechané zámerne prázdne počas úprav. Ak si prajete aktualizovať URL, prosím zadajte novú sem, inak ho nechajte prázdne pre zachovanie aktuálnej URL.",
"enable-notifier": "Zapnúť notifikátor",
"what-events": "Pre ktoré udalosti si želáte zapnúť notifikátor?",
"user-events": "Udalosti používateľa",
@@ -81,7 +81,7 @@
"category-events": "Udalosti kategórií",
"when-a-new-user-joins-your-group": "Keď sa k vašej skupine pripojí nový používateľ",
"recipe-events": "Udalosti receptov",
"label-events": "Label Events"
"label-events": "Udalosti označení"
},
"general": {
"add": "Pridať",
@@ -474,7 +474,7 @@
"comment": "Komentár",
"comments": "Komentáre",
"delete-confirmation": "Naozaj chcete odstrániť zvolený recept?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"admin-delete-confirmation": "Budete mazať recept, ktorý nie je váš s použitím administrátorských oprávnení. Ste si istý?",
"delete-recipe": "Odstrániť recept",
"description": "Popis",
"disable-amount": "Vypnúť množstvá surovín",
@@ -561,6 +561,7 @@
"see-original-text": "Pozrieť pôvodný text",
"original-text-with-value": "Pôvodný text: {originalText}",
"ingredient-linker": "Prepojenie surovín",
"unlinked": "Zatiaľ neprepojené",
"linked-to-other-step": "Prepojené s iným krokom",
"auto": "Automaticky",
"cook-mode": "Režim varenia",
@@ -585,11 +586,11 @@
"added-to-timeline": "Pridané na časovú os",
"failed-to-add-to-timeline": "Pridanie na časovú os skončilo chybou",
"failed-to-update-recipe": "Recept sa nepodarilo aktualizovať",
"added-to-timeline-but-failed-to-add-image": "Added to timeline, but failed to add image",
"added-to-timeline-but-failed-to-add-image": "Pridané na časovú os, ale zlyhalo pridanie obrázku",
"api-extras-description": "API dolnky receptov sú kľúčovou funkcionalitou Mealie API. Umožňujú používateľom vytvárať vlastné JSON páry kľúč/hodnota v rámci receptu, a využiť v aplikáciách tretích strán. Údaje uložené pod jednotlivými kľúčmi je možné využiť napríklad ako spúšťač automatizovaných procesov, či pri zasielaní vlastných správ do vami zvolených zariadení.",
"message-key": "Kľúč správy",
"parse": "Analyzovať",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"ingredients-not-parsed-description": "",
"attach-images-hint": "Pridaj obrázky ich potiahnutím a pustením na editor",
"drop-image": "Odstrániť obrázok",
"enable-ingredient-amounts-to-use-this-feature": "Povoľ množstvám prísad využívať túto vlastnosť",
@@ -610,7 +611,7 @@
"create-from-images": "Vytvoriť z obrázka",
"should-translate-description": "Preložiť recept do môjho jazyka",
"please-wait-image-procesing": "Čakajte, prosím. Obrázok sa spracováva. Môže to chvíľku trvať.",
"please-wait-images-processing": "Please wait, the images are processing. This may take some time.",
"please-wait-images-processing": "Prosím počkajte, obrázky sa spracúvajú. Toto môže chvíľu trvať.",
"bulk-url-import": "Hromadný URL import",
"debug-scraper": "Ladiť scraper",
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Vytvoriť recept zadaním názvu. Všetky recepty musia mať jedinečné názvy.",
@@ -666,17 +667,17 @@
"no-unit": "Bez jednotky",
"missing-unit": "Vytvoriť chýbajúcu jednotku: {unit}",
"missing-food": "Vytvoriť chýbajúcu surovinu: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"this-unit-could-not-be-parsed-automatically": "Túto jednotku nebolo možné parsovať automaticky",
"this-food-could-not-be-parsed-automatically": "Toto jedlo nebolo možné parsovať automaticky",
"no-food": "Žiadne suroviny"
},
"reset-servings-count": "Resetovať počet porcií",
"not-linked-ingredients": "Ďalšie suroviny",
"upload-another-image": "Upload another image",
"upload-another-image": "Nahrať iný obrázok",
"upload-images": "Nahrať obrázky",
"upload-more-images": "Nahrať ďalšie obrázky",
"set-as-cover-image": "Set as recipe cover image",
"cover-image": "Cover image"
"set-as-cover-image": "Nastaviť ako titulný obrázok receptu",
"cover-image": "Titulný obrázok"
},
"recipe-finder": {
"recipe-finder": "Hľadač receptov",
@@ -1169,7 +1170,7 @@
"group-details": "Podrobnosti o skupine",
"group-details-description": "Pred vytvorením účtu musíte vytvoriť skupinu. Vaša skupina bude obsahovať iba vás, ale neskôr budete môcť pozvať ostatných. Členovia vašej skupiny môžu zdieľať stravovacie plány, nákupné zoznamy, recepty a ďalšie!",
"use-seed-data": "Použiť predvolené dáta",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes. These are translated into the language you currently have selected. You can always add to or modify this data later.",
"use-seed-data-description": "Mealie sa dodáva so zbierkou ingrediencií, jednotiek a označení. Môžete ich použiť vo vašej skupine pre lepšiu organizáciu vašich receptov. Tieto sú preložené do jazyka, ktorý ste si práve zvolili. Tieto dáta môžete kedykoľvek doplniť alebo zmeniť.",
"account-details": "Detaily účtu"
},
"validation": {
@@ -1311,7 +1312,7 @@
"welcome-user": "👋 Vitajte, {0}!",
"description": "Spravujte svoj profil, recepty a nastavenia skupín.",
"invite-link": "Odkaz s pozvánkou",
"get-invite-link": "Odkaz s pozvánkou",
"get-invite-link": "Vytvoriť odkaz s pozvánkou",
"get-public-link": "Vytvoriť verejný odkaz",
"account-summary": "Zhrnutie účtu",
"account-summary-description": "Tu je súhrn informácií o vašej skupine.",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Prikaži izvirno besedilo",
"original-text-with-value": "Originalno besedilo: {originalText}",
"ingredient-linker": "Povezovanje sestavin",
"unlinked": "Not linked yet",
"linked-to-other-step": "Povezano s naslednjim korakom",
"auto": "Samodejno",
"cook-mode": "Način kuhanja",

View File

@@ -561,6 +561,7 @@
"see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}",
"ingredient-linker": "Повезивач састојака",
"unlinked": "Not linked yet",
"linked-to-other-step": "Повезан са другим кораком",
"auto": "Auto",
"cook-mode": "Cook Mode",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Visa originaltext",
"original-text-with-value": "Originaltext: {originalText}",
"ingredient-linker": "Länka ingredienser",
"unlinked": "Not linked yet",
"linked-to-other-step": "Kopplat till annat steg",
"auto": "Auto",
"cook-mode": "Matlagningsläge",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Orijinal Metni Göster",
"original-text-with-value": "Orijinal Metin: {originalText}",
"ingredient-linker": "Malzeme Bağlayıcı",
"unlinked": "Not linked yet",
"linked-to-other-step": "Başka bir adıma bağlı",
"auto": "Otomatik",
"cook-mode": "Pişirme Modu",

View File

@@ -561,6 +561,7 @@
"see-original-text": "Переглянути оригінальний текст",
"original-text-with-value": "Оригінальний текст: {originalText}",
"ingredient-linker": "Зв'язування інгредієнтів",
"unlinked": "Not linked yet",
"linked-to-other-step": "Зв'язано з іншим кроком",
"auto": "Авто",
"cook-mode": "Режим кухаря",

View File

@@ -561,6 +561,7 @@
"see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}",
"ingredient-linker": "Ingredient Linker",
"unlinked": "Not linked yet",
"linked-to-other-step": "Linked to other step",
"auto": "Auto",
"cook-mode": "Cook Mode",

View File

@@ -561,6 +561,7 @@
"see-original-text": "查看原文",
"original-text-with-value": "原文: {originalText}",
"ingredient-linker": "食材关联器",
"unlinked": "Not linked yet",
"linked-to-other-step": "已关联到其他步骤",
"auto": "自动",
"cook-mode": "烹饪模式",
@@ -1204,7 +1205,7 @@
},
"demo": {
"info_message_with_version": "这是{version} 版本的演示模式",
"demo_username": "{username}\n用户名:{username}",
"demo_username": "用户名:{username}",
"demo_password": "密码:{password}"
},
"ocr-editor": {

View File

@@ -561,6 +561,7 @@
"see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}",
"ingredient-linker": "Ingredient Linker",
"unlinked": "Not linked yet",
"linked-to-other-step": "Linked to other step",
"auto": "Auto",
"cook-mode": "Cook Mode",

View File

@@ -15,7 +15,6 @@
v-model="sidebar"
absolute
:top-link="topLinks"
:bottom-links="bottomLinks"
:user="{ data: true }"
:secondary-header="$t('sidebar.developer')"
:secondary-links="developerLinks"
@@ -36,13 +35,15 @@ import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
import type { SidebarLinks } from "~/types/application-types";
import { useGlobalI18n } from "~/composables/use-global-i18n";
const i18n = useI18n();
const { $globals, $vuetify } = useNuxtApp();
const i18n = useGlobalI18n();
const display = useDisplay();
const { $globals } = useNuxtApp();
const sidebar = ref<boolean>(false);
onMounted(() => {
sidebar.value = !$vuetify.display.md.value;
sidebar.value = display.lgAndUp.value;
});
const topLinks: SidebarLinks = [
@@ -112,13 +113,4 @@ const developerLinks: SidebarLinks = [
],
},
];
const bottomLinks: SidebarLinks = [
{
icon: $globals.icons.heart,
title: i18n.t("about.support"),
href: "https://github.com/sponsors/hay-kot",
restricted: true,
},
];
</script>

View File

@@ -1,6 +1,5 @@
<template>
<v-app dark>
<NuxtPwaManifest />
<TheSnackbar />
<AppHeader :menu="false" />
@@ -14,11 +13,10 @@
</v-app>
</template>
<script lang="ts">
<script setup lang="ts">
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
import { useGlobalI18n } from "~/composables/use-global-i18n";
export default defineNuxtComponent({
components: { TheSnackbar, AppHeader },
});
useGlobalI18n(); // ensure i18n is initialized
</script>

View File

@@ -1,6 +1,5 @@
<template>
<v-app dark>
<NuxtPwaManifest />
<TheSnackbar />
<v-banner
@@ -25,6 +24,7 @@
<script lang="ts">
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
import { useAppInfo } from "~/composables/api";
import { useGlobalI18n } from "~/composables/use-global-i18n";
export default defineNuxtComponent({
components: { TheSnackbar },
@@ -33,7 +33,7 @@ export default defineNuxtComponent({
const isDemo = computed(() => appInfo?.value?.demoStatus || false);
const i18n = useI18n();
const i18n = useGlobalI18n();
const version = computed(() => appInfo?.value?.version || i18n.t("about.unknown-version"));
return {

View File

@@ -2,10 +2,9 @@
<DefaultLayout />
</template>
<script lang="ts">
<script setup lang="ts">
import DefaultLayout from "@/components/Layout/DefaultLayout.vue";
import { useGlobalI18n } from "~/composables/use-global-i18n";
export default defineNuxtComponent({
components: { DefaultLayout },
});
useGlobalI18n(); // ensure i18n is initialized
</script>

View File

@@ -46,6 +46,8 @@
</template>
<script lang="ts">
import { useGlobalI18n } from "~/composables/use-global-i18n";
export default defineNuxtComponent({
props: {
error: {
@@ -58,7 +60,7 @@ export default defineNuxtComponent({
layout: "basic",
});
const i18n = useI18n();
const i18n = useGlobalI18n();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const ready = ref(false);

View File

@@ -152,6 +152,7 @@ import {
mdiCookie,
mdiBellPlus,
mdiLinkVariantPlus,
mdiTableEdit,
} from "@mdi/js";
export const icons = {
@@ -240,6 +241,7 @@ export const icons = {
linkVariantPlus: mdiLinkVariantPlus,
lock: mdiLock,
logout: mdiLogout,
manageData: mdiTableEdit,
menu: mdiMenu,
messageText: mdiMessageText,
newBox: mdiNewBox,
@@ -324,5 +326,4 @@ export const icons = {
preserveLines: mdiText,
preserveBlocks: mdiTextBoxOutline,
flatten: mdiMinus,
};

Some files were not shown because too many files have changed in this diff Show More