Compare commits

..

83 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
166 changed files with 6847 additions and 6012 deletions

View File

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

View File

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

View File

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

View File

@@ -88,6 +88,8 @@ tasks:
- rm -r ./dev/data/recipes/ - rm -r ./dev/data/recipes/
- rm -r ./dev/data/users/ - rm -r ./dev/data/users/
- rm -f ./dev/data/mealie*.db - 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/mealie.log
- rm -f ./dev/data/.secret - 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(): def inject_nuxt_values():
all_date_locales = [ datetime_files = list(datetime_dir.glob("*.json"))
f'"{match.stem}": require("./lang/dateTimeFormats/{match.name}"),' for match in 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 = [] all_langs = []
for match in locales_dir.glob("*.json"): for match in locales_dir.glob("*.json"):
@@ -186,7 +202,6 @@ def inject_nuxt_values():
all_langs.append(lang_string) all_langs.append(lang_string)
all_langs.sort() all_langs.sort()
all_date_locales.sort()
log.debug(f"injecting locales into nuxt config -> {nuxt_config}") log.debug(f"injecting locales into nuxt config -> {nuxt_config}")
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs) inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)

View File

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

View File

@@ -45,7 +45,7 @@ Once the prerequisites are installed you can cd into the project base directory
=== "Linux / macOS" === "Linux / macOS"
```bash ```bash
# Naviate To The Root Directory # Navigate To The Root Directory
cd /path/to/project cd /path/to/project
# Utilize the Taskfile to Install Dependencies # 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. Managing shopping lists can be done from the Sidebar > Shopping Lists.
Here you will be able to: Here you will be able to:
- See items already on the Shopping List - See items already on the Shopping List
- See linked recipes with ingredients - See linked recipes with ingredients
- Toggling via the 'Pot' icon will show you the linked recipe, allowing you to click to access it. - 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
Notifiers are event-driven notifications sent when specific actions are performed within Mealie. Some actions include: Notifiers are event-driven notifications sent when specific actions are performed within Mealie. Some actions include:
- Creating / Updating a recipe - Creating / Updating a recipe
- Adding items to a shopping list - Adding items to a shopping list
- Creating a new mealplan - 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. 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: Common use cases for groups include:
- Hosting multiple instances of Mealie for others who want to keep their data private and secure - Hosting multiple instances of Mealie for others who want to keep their data private and secure
- Creating completely isolated recipe pools - 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. 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: Common use cases for households include:
- Sharing a common recipe pool amongst families - Sharing a common recipe pool amongst families
- Maintaining separate meal plans and shopping lists from other households - Maintaining separate meal plans and shopping lists from other households
- Maintaining separate integrations and customizations from other households - Maintaining separate integrations and customizations from other households

View File

@@ -32,15 +32,16 @@
### Database ### Database
| Variables | Default | Description | | Variables | Default | Description |
| ------------------------------------------------------- | :------: | ----------------------------------------------------------------------- | |---------------------------------------------------------|:--------:|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DB_ENGINE | sqlite | Optional: 'sqlite', 'postgres' | | DB_ENGINE | sqlite | Optional: 'sqlite', 'postgres' |
| POSTGRES_USER<super>[&dagger;][secrets]</super> | mealie | Postgres database user | | 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_PASSWORD<super>[&dagger;][secrets]</super> | mealie | Postgres database password | | POSTGRES_USER<super>[&dagger;][secrets]</super> | mealie | Postgres database user |
| POSTGRES_SERVER<super>[&dagger;][secrets]</super> | postgres | Postgres database server address | | POSTGRES_PASSWORD<super>[&dagger;][secrets]</super> | mealie | Postgres database password |
| POSTGRES_PORT<super>[&dagger;][secrets]</super> | 5432 | Postgres database port | | POSTGRES_SERVER<super>[&dagger;][secrets]</super> | postgres | Postgres database server address |
| POSTGRES_DB<super>[&dagger;][secrets]</super> | mealie | Postgres database name | | POSTGRES_PORT<super>[&dagger;][secrets]</super> | 5432 | Postgres database port |
| POSTGRES_URL_OVERRIDE<super>[&dagger;][secrets]</super> | None | Optional Postgres URL override to use instead of POSTGRES\_\* variables | | 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 ### Email

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: 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! 1. Take a backup just in case!
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.1.1` 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. 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 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. ## 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 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. - [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` `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 ```yaml
services: services:
mealie: mealie:
image: ghcr.io/mealie-recipes/mealie:v3.1.1 # (3) image: ghcr.io/mealie-recipes/mealie:v3.2.0 # (3)
container_name: mealie container_name: mealie
restart: always restart: always
ports: ports:

View File

@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
```yaml ```yaml
services: services:
mealie: mealie:
image: ghcr.io/mealie-recipes/mealie:v3.1.1 # (3) image: ghcr.io/mealie-recipes/mealie:v3.2.0 # (3)
container_name: mealie container_name: mealie
restart: always restart: always
ports: 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. 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 ### Before Upgrading
- Read The Release Notes - [Read The Release Notes](https://github.com/mealie-recipes/mealie/releases)
- Identify Breaking Changes - Identify Breaking Changes
- Create a Backup and Download from the UI - Create a Backup and Download from the UI
- Upgrade - Upgrade

View File

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

View File

@@ -87,13 +87,11 @@
class="ma-0 pa-0" class="ma-0 pa-0"
/> />
<div v-else class="my-0 px-1 py-0" /> <!-- Empty div to keep the layout consistent --> <div v-else class="my-0 px-1 py-0" /> <!-- Empty div to keep the layout consistent -->
<RecipeRating <RecipeCardRating
v-if="showRecipeContent" v-if="showRecipeContent"
:class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']" :class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']"
:value="rating" :model-value="rating"
:recipe-id="recipeId" :recipe-id="recipeId"
:slug="slug"
small
/> />
<!-- If we're not logged-in, no items display, so we hide this menu --> <!-- If we're not logged-in, no items display, so we hide this menu -->
@@ -128,9 +126,9 @@
<script setup lang="ts"> <script setup lang="ts">
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue"; import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue"; import RecipeContextMenu from "./RecipeContextMenu/RecipeContextMenu.vue";
import RecipeCardImage from "./RecipeCardImage.vue"; import RecipeCardImage from "./RecipeCardImage.vue";
import RecipeRating from "./RecipeRating.vue"; import RecipeCardRating from "./RecipeCardRating.vue";
import RecipeChips from "./RecipeChips.vue"; import RecipeChips from "./RecipeChips.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; 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[]]; appendRecipes: [recipes: Recipe[]];
}>(); }>();
const { $vuetify } = useNuxtApp(); const display = useDisplay();
const preferences = useUserSortPreferences(); const preferences = useUserSortPreferences();
const EVENTS = { const EVENTS = {
@@ -215,7 +215,7 @@ const $auth = useMealieAuth();
const { $globals } = useNuxtApp(); const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => { const useMobileCards = computed(() => {
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards; return display.smAndDown.value || preferences.value.useMobileCards;
}); });
const displayTitleIcon = computed(() => { 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> <template>
<div class="text-center"> <RecipeDialogShare v-model="shareDialog" :recipe-id="recipeId" :name="name" />
<!-- Recipe Share Dialog --> <RecipeDialogPrintPreferences v-model="printPreferencesDialog" :recipe="recipeRef" />
<RecipeDialogShare v-model="shareDialog" :recipe-id="recipeId" :name="name" /> <BaseDialog
<RecipeDialogPrintPreferences v-model="printPreferencesDialog" :recipe="recipeRef" /> v-model="recipeDeleteDialog"
<BaseDialog :title="$t('recipe.delete-recipe')"
v-model="recipeDeleteDialog" color="error"
:title="$t('recipe.delete-recipe')" :icon="$globals.icons.alertCircle"
color="error" can-confirm
:icon="$globals.icons.alertCircle" @confirm="deleteRecipe()"
can-confirm >
@confirm="deleteRecipe()" <v-card-text>
> <template v-if="isAdminAndNotOwner">
<v-card-text> {{ $t("recipe.admin-delete-confirmation") }}
<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>
</template> </template>
<v-list density="compact"> <template v-else>
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)"> {{ $t("recipe.delete-confirmation") }}
<template #prepend> </template>
<v-icon :color="item.color"> </v-card-text>
{{ item.icon }} </BaseDialog>
</v-icon> <BaseDialog
</template> v-model="recipeDuplicateDialog"
<v-list-item-title>{{ item.title }}</v-list-item-title> :title="$t('recipe.duplicate')"
</v-list-item> color="primary"
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length"> :icon="$globals.icons.duplicate"
<v-divider /> can-confirm
<v-list-item @confirm="duplicateRecipe()"
v-for="(action, index) in recipeActions" >
:key="index" <v-card-text>
@click="executeRecipeAction(action)" <v-text-field
> v-model="recipeName"
<template #prepend> density="compact"
<v-icon color="undefined"> :label="$t('recipe.recipe-name')"
{{ $globals.icons.linkVariantPlus }} autofocus
</v-icon> @keyup.enter="duplicateRecipe()"
</template> />
<v-list-item-title> </v-card-text>
{{ action.title }} </BaseDialog>
</v-list-item-title> <BaseDialog
</v-list-item> v-model="mealplannerDialog"
</div> :title="$t('recipe.add-recipe-to-mealplan')"
</v-list> color="primary"
</v-menu> :icon="$globals.icons.calendar"
</div> 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> </template>
<script setup lang="ts"> <script setup lang="ts">
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue"; import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue"; import RecipeDialogPrintPreferences from "~/components/Domain/Recipe/RecipeDialogPrintPreferences.vue";
import RecipeDialogShare from "./RecipeDialogShare.vue"; import RecipeDialogShare from "~/components/Domain/Recipe/RecipeDialogShare.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions"; import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
@@ -225,7 +191,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{ const emit = defineEmits<{
[key: string]: any; [key: string]: any;
delete: [slug: string]; deleted: [slug: string];
}>(); }>();
const api = useUserApi(); const api = useUserApi();
@@ -336,8 +302,6 @@ const defaultItems: { [key: string]: ContextMenuItem } = {
// Add leading and Appending Items // Add leading and Appending Items
menuItems.value = [...menuItems.value, ...props.leadingItems, ...props.appendItems]; menuItems.value = [...menuItems.value, ...props.leadingItems, ...props.appendItems];
const icon = props.menuIcon || $globals.icons.dotsVertical;
// =========================================================================== // ===========================================================================
// Context Menu Event Handler // Context Menu Event Handler
@@ -407,7 +371,7 @@ async function deleteRecipe() {
if (data?.slug) { if (data?.slug) {
router.push(`/g/${groupSlug.value}`); router.push(`/g/${groupSlug.value}`);
} }
emit("delete", props.slug); emit("deleted", props.slug);
} }
const download = useDownloader(); 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, buttonStyle: false,
}); });
const api = useUserApi();
const $auth = useMealieAuth();
const { userRatings, refreshUserRatings } = useUserSelfRatings(); const { userRatings, refreshUserRatings } = useUserSelfRatings();
const isFavorite = computed(() => { const isFavorite = computed(() => {
@@ -53,6 +51,9 @@ const isFavorite = computed(() => {
}); });
async function toggleFavorite() { async function toggleFavorite() {
const api = useUserApi();
const $auth = useMealieAuth();
if (!$auth.user.value) return; if (!$auth.user.value) return;
if (!isFavorite.value) { if (!isFavorite.value) {
await api.users.addFavorite($auth.user.value?.id, props.recipeId); await api.users.addFavorite($auth.user.value?.id, props.recipeId);

View File

@@ -119,6 +119,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { whenever } from "@vueuse/core"; import { whenever } from "@vueuse/core";
import { formatISO } from "date-fns";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
@@ -148,7 +149,7 @@ const newTimelineEventImageName = ref<string>("");
const newTimelineEventImagePreviewUrl = ref<string>(); const newTimelineEventImagePreviewUrl = ref<string>();
const newTimelineEventTimestamp = ref<Date>(new Date()); const newTimelineEventTimestamp = ref<Date>(new Date());
const newTimelineEventTimestampString = computed(() => { const newTimelineEventTimestampString = computed(() => {
return newTimelineEventTimestamp.value.toISOString().substring(0, 10); return formatISO(newTimelineEventTimestamp.value, { representation: "date" });
}); });
const lastMade = ref(props.recipe.lastMade); const lastMade = ref(props.recipe.lastMade);
@@ -169,7 +170,7 @@ whenever(
() => madeThisDialog.value, () => madeThisDialog.value,
() => { () => {
// Set timestamp to now // 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> <template>
<div> <div>
<v-container v-show="!isCookMode" key="recipe-page" class="px-0" :class="{ 'pa-0': $vuetify.display.smAndDown.value }"> <v-container v-show="!isCookMode" key="recipe-page" class="px-0" :class="{ 'pa-0': $vuetify.display.smAndDown }">
<v-card :flat="$vuetify.display.smAndDown.value" class="d-print-none"> <v-card :flat="$vuetify.display.smAndDown" class="d-print-none">
<RecipePageHeader <RecipePageHeader
:recipe="recipe" :recipe="recipe"
:recipe-scale="scale" :recipe-scale="scale"
@@ -107,7 +107,7 @@
<v-divider /> <v-divider />
</v-col> </v-col>
<v-col class="overflow-y-auto" <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"> style="height: 100%" cols="12" sm="7">
<h2 class="text-h5 px-4 font-weight-medium opacity-80"> <h2 class="text-h5 px-4 font-weight-medium opacity-80">
{{ $t('recipe.instructions') }} {{ $t('recipe.instructions') }}
@@ -188,7 +188,7 @@ import { useNavigationWarning } from "~/composables/use-navigation-warning";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true }); const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const { $vuetify } = useNuxtApp(); const display = useDisplay();
const i18n = useI18n(); const i18n = useI18n();
const $auth = useMealieAuth(); const $auth = useMealieAuth();
const route = useRoute(); const route = useRoute();
@@ -278,7 +278,7 @@ async function deleteRecipe() {
*/ */
const landscape = computed(() => { const landscape = computed(() => {
const preferLandscape = recipe.value.settings?.landscapeView; const preferLandscape = recipe.value.settings?.landscapeView;
const smallScreen = !$vuetify.display.smAndUp.value; const smallScreen = !display.smAndUp.value;
if (preferLandscape) { if (preferLandscape) {
return true; return true;

View File

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

View File

@@ -29,32 +29,49 @@
{{ activeText }} {{ activeText }}
</p> </p>
<v-divider class="mb-4" /> <v-divider class="mb-4" />
<v-checkbox-btn <template v-if="Object.keys(groupedUnusedIngredients).length > 0">
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">
<h4 class="py-3 ml-1"> <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> </h4>
<v-checkbox-btn <v-checkbox-btn
v-for="ing in usedIngredients" v-for="ing in ingredients"
:key="ing.referenceId" :key="ing.referenceId"
v-model="activeRefs" v-model="activeRefs"
:value="ing.referenceId" :value="ing.referenceId"
class="ml-4"
> >
<template #label> <template #label>
<RecipeIngredientHtml :markup="parseIngredientText(ing)" /> <RecipeIngredientHtml :markup="parseIngredientText(ing)" />
</template> </template>
</v-checkbox-btn> </v-checkbox-btn>
</template> </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-card-text>
<v-divider /> <v-divider />
@@ -563,6 +580,71 @@ const ingredientLookup = computed(() => {
}, results); }, 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) { function getIngredientByRefId(refId: string | undefined) {
if (refId === undefined) { if (refId === undefined) {
return ""; return "";

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<template> <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"> <div style="flex-basis: 500px">
<strong> {{ $t("user.password-strength", { strength: pwStrength.strength.value }) }}</strong> <strong> {{ $t("user.password-strength", { strength: pwStrength.strength.value }) }}</strong>
<v-progress-linear <v-progress-linear

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<template> <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 --> <!-- User Profile -->
<template v-if="loggedIn"> <template v-if="loggedIn">
<v-list-item lines="two" :to="userProfileLink" exact> <v-list-item lines="two" :to="userProfileLink" exact>
@@ -82,30 +83,32 @@
</template> </template>
<!-- Bottom Navigation Links --> <!-- Bottom Navigation Links -->
<template v-if="bottomLinks" #append> <template #append>
<v-list v-model:selected="bottomSelected" nav density="compact"> <v-list v-model:selected="bottomSelected" nav density="comfortable">
<template v-for="nav in bottomLinks"> <v-menu location="end bottom" :offset="15">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title"> <template #activator="{ props }">
<v-list-item :key="nav.key || nav.title" exact link :to="nav.to" :href="nav.href" <v-list-item v-bind="props" :prepend-icon="$globals.icons.cog" :title="$t('general.settings')" />
:target="nav.href ? '_blank' : null"> </template>
<template #prepend> <v-list density="comfortable" color="primary">
<v-icon>{{ nav.icon }}</v-icon> <v-list-item :prepend-icon="$globals.icons.translate" :title="$t('sidebar.language')" @click="languageDialog=true" />
</template> <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-list-item-title>{{ nav.title }}</v-list-item-title> <v-divider v-if="loggedIn" class="my-2" />
</v-list-item> <v-list-item v-if="loggedIn" :prepend-icon="$globals.icons.cog" :title="$t('profile.user-settings')" to="/user/profile" />
</div> <v-list-item v-if="canManage" :prepend-icon="$globals.icons.manageData" :title="$t('data-pages.data-management')" to="/group/data" />
</template> <v-divider v-if="isAdmin" class="my-2" />
<slot name="bottom" /> <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> </v-list>
</template> </template>
</v-navigation-drawer> </v-navigation-drawer>
</template> </template>
<script lang="ts"> <script lang="ts">
import { useWindowSize } from "@vueuse/core";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { SidebarLinks } from "~/types/application-types"; import type { SidebarLinks } from "~/types/application-types";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue"; import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import { useToggleDarkMode } from "~/composables/use-utils";
export default defineNuxtComponent({ export default defineNuxtComponent({
components: { components: {
@@ -130,48 +133,34 @@ export default defineNuxtComponent({
required: false, required: false,
default: null, default: null,
}, },
bottomLinks: {
type: Array as () => SidebarLinks,
required: false,
default: () => ([]),
},
}, },
emits: ["update:modelValue"], emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const $auth = useMealieAuth(); const $auth = useMealieAuth();
const { loggedIn, isOwnGroup } = useLoggedInState(); 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 userFavoritesLink = computed(() => $auth.user.value ? `/user/${$auth.user.value.id}/favorites` : undefined);
const userProfileLink = computed(() => $auth.user.value ? "/user/profile" : undefined); const userProfileLink = computed(() => $auth.user.value ? "/user/profile" : undefined);
const toggleDark = useToggleDarkMode();
const state = reactive({ const state = reactive({
dropDowns: {} as Record<string, boolean>, dropDowns: {} as Record<string, boolean>,
topSelected: null as string[] | null, topSelected: null as string[] | null,
secondarySelected: null as string[] | null, secondarySelected: null as string[] | null,
bottomSelected: null as string[] | null, bottomSelected: null as string[] | null,
hasOpenedBefore: false as boolean, hasOpenedBefore: false as boolean,
languageDialog: false as boolean,
}); });
// model to control the drawer // model to control the drawer
const showDrawer = computed({ const showDrawer = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: value => context.emit("update:modelValue", value), 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() { function initDropdowns() {
allLinks.value.forEach((link) => { allLinks.value.forEach((link) => {
state.dropDowns[link.title] = link.childrenStartExpanded || false; state.dropDowns[link.title] = link.childrenStartExpanded || false;
@@ -193,8 +182,11 @@ export default defineNuxtComponent({
userProfileLink, userProfileLink,
showDrawer, showDrawer,
loggedIn, loggedIn,
isAdmin,
canManage,
isOwnGroup, isOwnGroup,
sessionUser: $auth.user, 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 --> <!-- Check Box -->
<v-checkbox <v-checkbox
v-if="inputField.type === fieldTypes.BOOLEAN" v-if="inputField.type === fieldTypes.BOOLEAN"
v-model="modelValue[inputField.varName]" v-model="model[inputField.varName]"
:name="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" :hint="inputField.hint"
:hide-details="!inputField.hint" :hide-details="!inputField.hint"
:persistent-hint="!!inputField.hint" :persistent-hint="!!inputField.hint"
@@ -51,9 +52,9 @@
<!-- Text Field --> <!-- Text Field -->
<v-text-field <v-text-field
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD" v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
v-model="modelValue[inputField.varName]" v-model="model[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))" :readonly="fieldState[inputField.varName]?.readonly"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))" :disabled="fieldState[inputField.varName]?.disabled"
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'" :type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
variant="solo-filled" variant="solo-filled"
flat flat
@@ -62,7 +63,7 @@
:label="inputField.label" :label="inputField.label"
:name="inputField.varName" :name="inputField.varName"
:hint="inputField.hint || ''" :hint="inputField.hint || ''"
:rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules), ...defaultRules] : []" :rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules as any), ...defaultRules] : []"
lazy-validation lazy-validation
@blur="emitBlur" @blur="emitBlur"
/> />
@@ -70,9 +71,9 @@
<!-- Text Area --> <!-- Text Area -->
<v-textarea <v-textarea
v-else-if="inputField.type === fieldTypes.TEXT_AREA" v-else-if="inputField.type === fieldTypes.TEXT_AREA"
v-model="modelValue[inputField.varName]" v-model="model[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))" :readonly="fieldState[inputField.varName]?.readonly"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))" :disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled" variant="solo-filled"
flat flat
rows="3" rows="3"
@@ -81,7 +82,7 @@
:label="inputField.label" :label="inputField.label"
:name="inputField.varName" :name="inputField.varName"
:hint="inputField.hint || ''" :hint="inputField.hint || ''"
:rules="[...rulesByKey(inputField.rules), ...defaultRules]" :rules="[...rulesByKey(inputField.rules as any), ...defaultRules]"
lazy-validation lazy-validation
@blur="emitBlur" @blur="emitBlur"
/> />
@@ -89,12 +90,11 @@
<!-- Option Select --> <!-- Option Select -->
<v-select <v-select
v-else-if="inputField.type === fieldTypes.SELECT" v-else-if="inputField.type === fieldTypes.SELECT"
v-model="modelValue[inputField.varName]" v-model="model[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))" :readonly="fieldState[inputField.varName]?.readonly"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))" :disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled" variant="solo-filled"
flat flat
:prepend-icon="inputField.icons ? modelValue[inputField.varName] : null"
:label="inputField.label" :label="inputField.label"
:name="inputField.varName" :name="inputField.varName"
:items="inputField.options" :items="inputField.options"
@@ -119,7 +119,7 @@
<v-btn <v-btn
class="my-2 ml-auto" class="my-2 ml-auto"
style="min-width: 200px" style="min-width: 200px"
:color="modelValue[inputField.varName]" :color="model[inputField.varName]"
dark dark
v-bind="templateProps" v-bind="templateProps"
> >
@@ -127,7 +127,7 @@
</v-btn> </v-btn>
</template> </template>
<v-color-picker <v-color-picker
v-model="modelValue[inputField.varName]" v-model="model[inputField.varName]"
value="#7417BE" value="#7417BE"
hide-canvas hide-canvas
hide-inputs hide-inputs
@@ -138,11 +138,12 @@
</v-menu> </v-menu>
</div> </div>
<!-- Object Type -->
<div v-else-if="inputField.type === fieldTypes.OBJECT"> <div v-else-if="inputField.type === fieldTypes.OBJECT">
<auto-form <auto-form
v-model="modelValue[inputField.varName]" v-model="model[inputField.varName]"
:color="color" :color="color"
:items="inputField.items" :items="(inputField as any).items"
@blur="emitBlur" @blur="emitBlur"
/> />
</div> </div>
@@ -150,7 +151,7 @@
<!-- List Type --> <!-- List Type -->
<div v-else-if="inputField.type === fieldTypes.LIST"> <div v-else-if="inputField.type === fieldTypes.LIST">
<div <div
v-for="(item, idx) in modelValue[inputField.varName]" v-for="(item, idx) in model[inputField.varName]"
:key="idx" :key="idx"
> >
<p> <p>
@@ -160,15 +161,15 @@
class="ml-5" class="ml-5"
x-small x-small
delete delete
@click="removeByIndex(modelValue[inputField.varName], idx)" @click="removeByIndex(model[inputField.varName], idx)"
/> />
</span> </span>
</p> </p>
<v-divider class="mb-5 mx-2" /> <v-divider class="mb-5 mx-2" />
<auto-form <auto-form
v-model="modelValue[inputField.varName][idx]" v-model="model[inputField.varName][idx]"
:color="color" :color="color"
:items="inputField.items" :items="(inputField as any).items"
@blur="emitBlur" @blur="emitBlur"
/> />
</div> </div>
@@ -176,7 +177,7 @@
<v-spacer /> <v-spacer />
<BaseButton <BaseButton
small small
@click="modelValue[inputField.varName].push(getTemplate(inputField.items))" @click="model[inputField.varName].push(getTemplate((inputField as any).items))"
> >
{{ $t("general.new") }} {{ $t("general.new") }}
</BaseButton> </BaseButton>
@@ -197,7 +198,13 @@ const BLUR_EVENT = "blur";
type ValidatorKey = keyof typeof validators; type ValidatorKey = keyof typeof validators;
// Use defineModel for v-model // 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({ const props = defineProps({
updateMode: { updateMode: {
@@ -238,26 +245,39 @@ const emit = defineEmits(["blur", "update:modelValue"]);
function rulesByKey(keys?: ValidatorKey[] | null) { function rulesByKey(keys?: ValidatorKey[] | null) {
if (keys === undefined || keys === null) { if (keys === undefined || keys === null) {
return []; return [] as any[];
} }
const list = [] as ((v: string) => boolean | string)[]; const list: any[] = [];
keys.forEach((key) => { keys.forEach((key) => {
const split = key.split(":"); const split = key.split(":");
const validatorKey = split[0] as ValidatorKey; const validatorKey = split[0] as ValidatorKey;
if (validatorKey in validators) { if (validatorKey in validators) {
if (split.length === 1) { if (split.length === 1) {
list.push(validators[validatorKey]); list.push((validators as any)[validatorKey]);
} }
else { else {
list.push(validators[validatorKey](split[1])); list.push((validators as any)[validatorKey](split[1] as any));
} }
} }
}); });
return list; 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) { function removeByIndex(list: never[], index: number) {
// Removes the item at the index // Removes the item at the index

View File

@@ -90,13 +90,13 @@ export default defineNuxtComponent({
}, },
}, },
setup() { setup() {
const { $vuetify } = useNuxtApp(); const display = useDisplay();
const hasHeading = computed(() => false); const hasHeading = computed(() => false);
const hasAltHeading = computed(() => false); const hasAltHeading = computed(() => false);
const classes = computed(() => { const classes = computed(() => {
return { return {
"v-card--material--has-heading": hasHeading, "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

@@ -39,7 +39,7 @@ export default defineNuxtComponent({
} }
const value = computed(() => { const value = computed(() => {
const rawHtml = marked.parse(props.source || "", { async: false }); const rawHtml = marked.parse(props.source || "", { async: false, breaks: true });
return sanitizeMarkdown(rawHtml); 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 type { ApiRequestInstance, RequestResponse } from "~/lib/api/types/non-generated";
import { AdminAPI, PublicApi, UserApi } from "~/lib/api"; import { AdminAPI, PublicApi, UserApi } from "~/lib/api";
import { PublicExploreApi } from "~/lib/api/client-public"; import { PublicExploreApi } from "~/lib/api/client-public";
import { useGlobalI18n } from "~/composables/use-global-i18n";
const request = { const request = {
async safe<T, U>( async safe<T, U>(
@@ -56,8 +57,7 @@ function getRequests(axiosInstance: AxiosInstance): ApiRequestInstance {
export const useRequests = function (i18n?: Composer): ApiRequestInstance { export const useRequests = function (i18n?: Composer): ApiRequestInstance {
const { $axios } = useNuxtApp(); const { $axios } = useNuxtApp();
if (!i18n) { if (!i18n) {
// Only works in a setup block i18n = useGlobalI18n();
i18n = useI18n();
} }
$axios.defaults.headers.common["Accept-Language"] = i18n.locale.value; $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) { 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 usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
const usePluralFood = (!quantity) || quantity * scale > 1; const usePluralFood = (!quantity) || quantity * scale > 1;
@@ -66,6 +66,7 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1,
const foodName = useFoodName(food || undefined, usePluralFood); const foodName = useFoodName(food || undefined, usePluralFood);
return { return {
title: title ? sanitizeIngredientHTML(title) : undefined,
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined, quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined, unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
name: foodName ? sanitizeIngredientHTML(foodName) : undefined, name: foodName ? sanitizeIngredientHTML(foodName) : undefined,

View File

@@ -1,18 +1,29 @@
import type { Composer } from "vue-i18n"; import type { Composer } from "vue-i18n";
import { useReadOnlyStore, useStore } from "../partials/use-store-factory"; 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"; import { usePublicExploreApi, useUserApi } from "~/composables/api";
const store: Ref<ReadCookBook[]> = ref([]); const cookbooks: Ref<ReadCookBook[]> = ref([]);
const loading = ref(false); const loading = ref(false);
const publicLoading = ref(false); const publicLoading = ref(false);
export const useCookbookStore = function (i18n?: Composer) { export const useCookbookStore = function (i18n?: Composer) {
const api = useUserApi(i18n); 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) { export const usePublicCookbookStore = function (groupSlug: string, i18n?: Composer) {
const api = usePublicExploreApi(groupSlug, i18n).explore; 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

@@ -51,7 +51,7 @@ export const LOCALES = [
{ {
name: "Slovenčina (Slovak)", name: "Slovenčina (Slovak)",
value: "sk-SK", value: "sk-SK",
progress: 37, progress: 46,
dir: "ltr", dir: "ltr",
}, },
{ {
@@ -81,7 +81,7 @@ export const LOCALES = [
{ {
name: "Polski (Polish)", name: "Polski (Polish)",
value: "pl-PL", value: "pl-PL",
progress: 40, progress: 42,
dir: "ltr", dir: "ltr",
}, },
{ {
@@ -93,7 +93,7 @@ export const LOCALES = [
{ {
name: "Nederlands (Dutch)", name: "Nederlands (Dutch)",
value: "nl-NL", value: "nl-NL",
progress: 45, progress: 49,
dir: "ltr", dir: "ltr",
}, },
{ {
@@ -123,7 +123,7 @@ export const LOCALES = [
{ {
name: "Italiano (Italian)", name: "Italiano (Italian)",
value: "it-IT", value: "it-IT",
progress: 39, progress: 40,
dir: "ltr", dir: "ltr",
}, },
{ {
@@ -135,13 +135,13 @@ export const LOCALES = [
{ {
name: "Magyar (Hungarian)", name: "Magyar (Hungarian)",
value: "hu-HU", value: "hu-HU",
progress: 41, progress: 44,
dir: "ltr", dir: "ltr",
}, },
{ {
name: "Hrvatski (Croatian)", name: "Hrvatski (Croatian)",
value: "hr-HR", value: "hr-HR",
progress: 27, progress: 28,
dir: "ltr", dir: "ltr",
}, },
{ {
@@ -159,7 +159,7 @@ export const LOCALES = [
{ {
name: "Français (French)", name: "Français (French)",
value: "fr-FR", value: "fr-FR",
progress: 55, progress: 64,
dir: "ltr", dir: "ltr",
}, },
{ {
@@ -189,7 +189,7 @@ export const LOCALES = [
{ {
name: "Español (Spanish)", name: "Español (Spanish)",
value: "es-ES", value: "es-ES",
progress: 41, progress: 42,
dir: "ltr", dir: "ltr",
}, },
{ {
@@ -201,19 +201,19 @@ export const LOCALES = [
{ {
name: "British English", name: "British English",
value: "en-GB", value: "en-GB",
progress: 23, progress: 43,
dir: "ltr", dir: "ltr",
}, },
{ {
name: "Ελληνικά (Greek)", name: "Ελληνικά (Greek)",
value: "el-GR", value: "el-GR",
progress: 39, progress: 40,
dir: "ltr", dir: "ltr",
}, },
{ {
name: "Deutsch (German)", name: "Deutsch (German)",
value: "de-DE", value: "de-DE",
progress: 66, progress: 72,
dir: "ltr", dir: "ltr",
}, },
{ {
@@ -225,13 +225,13 @@ export const LOCALES = [
{ {
name: "Čeština (Czech)", name: "Čeština (Czech)",
value: "cs-CZ", value: "cs-CZ",
progress: 41, progress: 42,
dir: "ltr", dir: "ltr",
}, },
{ {
name: "Català (Catalan)", name: "Català (Catalan)",
value: "ca-ES", value: "ca-ES",
progress: 37, progress: 38,
dir: "ltr", 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 loading = ref(false);
const ready = ref(false); const ready = ref(false);
export const useUserSelfRatings = function () { const $auth = useMealieAuth();
const $auth = useMealieAuth();
const api = useUserApi();
export const useUserSelfRatings = function () {
async function refreshUserRatings() { async function refreshUserRatings() {
if (!$auth.user.value || loading.value) { if (!$auth.user.value || loading.value) {
return; return;
} }
loading.value = true; loading.value = true;
const api = useUserApi();
const { data } = await api.users.getSelfRatings(); const { data } = await api.users.getSelfRatings();
userRatings.value = data?.ratings || []; userRatings.value = data?.ratings || [];
loading.value = false; loading.value = false;
ready.value = true; ready.value = true;
} }
async function setRating(slug: string, rating: number | null, isFavorite: boolean | null) { async function setRating(slug: string, rating: number | null, isFavorite: boolean | null) {
loading.value = true; loading.value = true;
const api = useUserApi();
const userId = $auth.user.value?.id || ""; const userId = $auth.user.value?.id || "";
await api.users.setRating(userId, slug, rating, isFavorite); await api.users.setRating(userId, slug, rating, isFavorite);
loading.value = false; loading.value = false;
await refreshUserRatings(); 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 = { const datetimeFormats = {
// CODE_GEN_ID: DATE_LOCALES "af-ZA": afZA,
"af-ZA": require("./lang/dateTimeFormats/af-ZA.json"), "ar-SA": arSA,
"ar-SA": require("./lang/dateTimeFormats/ar-SA.json"), "bg-BG": bgBG,
"bg-BG": require("./lang/dateTimeFormats/bg-BG.json"), "ca-ES": caES,
"ca-ES": require("./lang/dateTimeFormats/ca-ES.json"), "cs-CZ": csCZ,
"cs-CZ": require("./lang/dateTimeFormats/cs-CZ.json"), "da-DK": daDK,
"da-DK": require("./lang/dateTimeFormats/da-DK.json"), "de-DE": deDE,
"de-DE": require("./lang/dateTimeFormats/de-DE.json"), "el-GR": elGR,
"el-GR": require("./lang/dateTimeFormats/el-GR.json"), "en-GB": enGB,
"en-GB": require("./lang/dateTimeFormats/en-GB.json"), "en-US": enUS,
"en-US": require("./lang/dateTimeFormats/en-US.json"), "es-ES": esES,
"es-ES": require("./lang/dateTimeFormats/es-ES.json"), "et-EE": etEE,
"et-EE": require("./lang/dateTimeFormats/et-EE.json"), "fi-FI": fiFI,
"fi-FI": require("./lang/dateTimeFormats/fi-FI.json"), "fr-BE": frBE,
"fr-BE": require("./lang/dateTimeFormats/fr-BE.json"), "fr-CA": frCA,
"fr-CA": require("./lang/dateTimeFormats/fr-CA.json"), "fr-FR": frFR,
"fr-FR": require("./lang/dateTimeFormats/fr-FR.json"), "gl-ES": glES,
"gl-ES": require("./lang/dateTimeFormats/gl-ES.json"), "he-IL": heIL,
"he-IL": require("./lang/dateTimeFormats/he-IL.json"), "hr-HR": hrHR,
"hr-HR": require("./lang/dateTimeFormats/hr-HR.json"), "hu-HU": huHU,
"hu-HU": require("./lang/dateTimeFormats/hu-HU.json"), "is-IS": isIS,
"is-IS": require("./lang/dateTimeFormats/is-IS.json"), "it-IT": itIT,
"it-IT": require("./lang/dateTimeFormats/it-IT.json"), "ja-JP": jaJP,
"ja-JP": require("./lang/dateTimeFormats/ja-JP.json"), "ko-KR": koKR,
"ko-KR": require("./lang/dateTimeFormats/ko-KR.json"), "lt-LT": ltLT,
"lt-LT": require("./lang/dateTimeFormats/lt-LT.json"), "lv-LV": lvLV,
"lv-LV": require("./lang/dateTimeFormats/lv-LV.json"), "nl-NL": nlNL,
"nl-NL": require("./lang/dateTimeFormats/nl-NL.json"), "no-NO": noNO,
"no-NO": require("./lang/dateTimeFormats/no-NO.json"), "pl-PL": plPL,
"pl-PL": require("./lang/dateTimeFormats/pl-PL.json"), "pt-BR": ptBR,
"pt-BR": require("./lang/dateTimeFormats/pt-BR.json"), "pt-PT": ptPT,
"pt-PT": require("./lang/dateTimeFormats/pt-PT.json"), "ro-RO": roRO,
"ro-RO": require("./lang/dateTimeFormats/ro-RO.json"), "ru-RU": ruRU,
"ru-RU": require("./lang/dateTimeFormats/ru-RU.json"), "sk-SK": skSK,
"sk-SK": require("./lang/dateTimeFormats/sk-SK.json"), "sl-SI": slSI,
"sl-SI": require("./lang/dateTimeFormats/sl-SI.json"), "sr-SP": srSP,
"sr-SP": require("./lang/dateTimeFormats/sr-SP.json"), "sv-SE": svSE,
"sv-SE": require("./lang/dateTimeFormats/sv-SE.json"), "tr-TR": trTR,
"tr-TR": require("./lang/dateTimeFormats/tr-TR.json"), "uk-UA": ukUA,
"uk-UA": require("./lang/dateTimeFormats/uk-UA.json"), "vi-VN": viVN,
"vi-VN": require("./lang/dateTimeFormats/vi-VN.json"), "zh-CN": zhCN,
"zh-CN": require("./lang/dateTimeFormats/zh-CN.json"), "zh-TW": zhTW,
"zh-TW": require("./lang/dateTimeFormats/zh-TW.json"),
// END: DATE_LOCALES
}; };
// END: DATE_LOCALES
export default defineI18nConfig(() => { export default defineI18nConfig(() => {
return { return {
legacy: false, legacy: false,
locale: "en-US", locale: "en-US",
availableLocales: Object.keys(datetimeFormats), availableLocales: Object.keys(datetimeFormats),
datetimeFormats, datetimeFormats: datetimeFormats as any,
fallbackLocale: "en-US", fallbackLocale: "en-US",
fallbackWarn: true, fallbackWarn: true,
}; };

View File

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

View File

@@ -561,6 +561,7 @@
"see-original-text": "عرض النص الأصلي", "see-original-text": "عرض النص الأصلي",
"original-text-with-value": "النص الأصلي: {originalText}", "original-text-with-value": "النص الأصلي: {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": "Виж оригиналния текст", "see-original-text": "Виж оригиналния текст",
"original-text-with-value": "Оригинален текст: {originalText}", "original-text-with-value": "Оригинален текст: {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": "Mostra el text original", "see-original-text": "Mostra el text original",
"original-text-with-value": "Text original: {originalText}", "original-text-with-value": "Text original: {originalText}",
"ingredient-linker": "Enllaça ingredients", "ingredient-linker": "Enllaça ingredients",
"unlinked": "No enllaçada",
"linked-to-other-step": "Enllaça a un altre pas", "linked-to-other-step": "Enllaça a un altre pas",
"auto": "Automàtic", "auto": "Automàtic",
"cook-mode": "Mode \"cuinant\"", "cook-mode": "Mode \"cuinant\"",

View File

@@ -549,7 +549,7 @@
"failed-to-add-recipes-to-list": "Přidání receptu do seznamu se nezdařilo", "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-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", "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}", "yields-amount-with-text": "Pro {amount} {text}",
"yield-text": "Text porcí", "yield-text": "Text porcí",
"quantity": "Množství", "quantity": "Množství",
@@ -561,6 +561,7 @@
"see-original-text": "Zobrazit původní text", "see-original-text": "Zobrazit původní text",
"original-text-with-value": "Původní text: {originalText}", "original-text-with-value": "Původní text: {originalText}",
"ingredient-linker": "Propojení ingrediencí", "ingredient-linker": "Propojení ingrediencí",
"unlinked": "Zatím nepropojeno",
"linked-to-other-step": "Propojeno s jiným krokem receptu", "linked-to-other-step": "Propojeno s jiným krokem receptu",
"auto": "Automaticky", "auto": "Automaticky",
"cook-mode": "Režim vaření", "cook-mode": "Režim vaření",

View File

@@ -14,9 +14,9 @@
"development": "Udvikling", "development": "Udvikling",
"docs": "Dokumenter", "docs": "Dokumenter",
"download-log": "Download log", "download-log": "Download log",
"download-recipe-json": "Sidst skrabet JSON", "download-recipe-json": "Senest hentede JSON",
"github": "GitHub", "github": "GitHub",
"log-lines": "Log linjer", "log-lines": "Log-linjer",
"not-demo": "Ikke demo", "not-demo": "Ikke demo",
"portfolio": "Portefølje", "portfolio": "Portefølje",
"production": "Produktion", "production": "Produktion",
@@ -39,13 +39,13 @@
"category": { "category": {
"categories": "Kategorier", "categories": "Kategorier",
"category-created": "Kategori oprettet", "category-created": "Kategori oprettet",
"category-creation-failed": "Oprettelse af kategorien fejlede", "category-creation-failed": "Oprettelse af kategorien mislykkedes",
"category-deleted": "Kategori slettet", "category-deleted": "Kategori slettet",
"category-deletion-failed": "Sletning af kategori fejlede", "category-deletion-failed": "Sletning af kategori mislykkedes",
"category-filter": "Kategorifilter", "category-filter": "Kategorifilter",
"category-update-failed": "Kategoriopdatering fejlede", "category-update-failed": "Opdatering af kategori mislykkedes",
"category-updated": "Kategori opdateret", "category-updated": "Kategori opdateret",
"uncategorized-count": "Ukategoriseret {count}", "uncategorized-count": "Ikke kategoriseret {count}",
"create-a-category": "Opret en kategori", "create-a-category": "Opret en kategori",
"category-name": "Kategorinavn", "category-name": "Kategorinavn",
"category": "Kategori" "category": "Kategori"
@@ -53,8 +53,8 @@
"events": { "events": {
"apprise-url": "Apprise URL", "apprise-url": "Apprise URL",
"database": "Database", "database": "Database",
"delete-event": "Slet event", "delete-event": "Slet hændelse",
"event-delete-confirmation": "Er du sikker på, at du vil slette denne hændelse?", "event-delete-confirmation": "Er du sikker på, at du vil slette denne begivenhed?",
"event-deleted": "Hændelse slettet", "event-deleted": "Hændelse slettet",
"event-updated": "Hændelse opdateret", "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.", "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.",
@@ -561,6 +561,7 @@
"see-original-text": "Vis den oprindelige tekst", "see-original-text": "Vis den oprindelige tekst",
"original-text-with-value": "Oprindelig tekst: {originalText}", "original-text-with-value": "Oprindelig tekst: {originalText}",
"ingredient-linker": "Ingrediens-linker", "ingredient-linker": "Ingrediens-linker",
"unlinked": "Ikke forbundet endnu",
"linked-to-other-step": "Linket til andet trin", "linked-to-other-step": "Linket til andet trin",
"auto": "Automatisk", "auto": "Automatisk",
"cook-mode": "Tilberedningsvisning", "cook-mode": "Tilberedningsvisning",

View File

@@ -69,7 +69,7 @@
"new-notification": "Neue Benachrichtigung", "new-notification": "Neue Benachrichtigung",
"event-notifiers": "Ereignis-Benachrichtigungen", "event-notifiers": "Ereignis-Benachrichtigungen",
"apprise-url-skipped-if-blank": "Apprise-URL (wird übersprungen, wenn leer)", "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", "enable-notifier": "Benachrichtigen aktivieren",
"what-events": "Welche Ereignisse soll diese Benachrichtigung abonnieren?", "what-events": "Welche Ereignisse soll diese Benachrichtigung abonnieren?",
"user-events": "Benutzer-Ereignisse", "user-events": "Benutzer-Ereignisse",
@@ -561,6 +561,7 @@
"see-original-text": "Originaltext anzeigen", "see-original-text": "Originaltext anzeigen",
"original-text-with-value": "Originaltext: {originalText}", "original-text-with-value": "Originaltext: {originalText}",
"ingredient-linker": "Zutaten-Verlinkung", "ingredient-linker": "Zutaten-Verlinkung",
"unlinked": "Nicht verbunden",
"linked-to-other-step": "In anderem Schritt verlinkt", "linked-to-other-step": "In anderem Schritt verlinkt",
"auto": "Automatisch", "auto": "Automatisch",
"cook-mode": "Koch-Modus", "cook-mode": "Koch-Modus",
@@ -1169,7 +1170,7 @@
"group-details": "Gruppendetails", "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!", "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": "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" "account-details": "Kontoinformationen"
}, },
"validation": { "validation": {

View File

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

View File

@@ -561,6 +561,7 @@
"see-original-text": "See Original Text", "see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}", "original-text-with-value": "Original Text: {originalText}",
"ingredient-linker": "Ingredient Linker", "ingredient-linker": "Ingredient Linker",
"unlinked": "Not linked yet",
"linked-to-other-step": "Linked to other step", "linked-to-other-step": "Linked to other step",
"auto": "Auto", "auto": "Auto",
"cook-mode": "Cook Mode", "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-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", "data-exports": "Data Exports",
"tag": "Tag", "tag": "Tag",
"categorize": "Categorize", "categorize": "Categorise",
"update-settings": "Update Settings", "update-settings": "Update Settings",
"tag-recipes": "Tag Recipes", "tag-recipes": "Tag Recipes",
"categorize-recipes": "Categorize Recipes", "categorize-recipes": "Categorise Recipes",
"export-recipes": "Export Recipes", "export-recipes": "Export Recipes",
"delete-recipes": "Delete Recipes", "delete-recipes": "Delete Recipes",
"source-unit-will-be-deleted": "Source Unit will be deleted" "source-unit-will-be-deleted": "Source Unit will be deleted"
@@ -1169,7 +1170,7 @@
"group-details": "Group Details", "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!", "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": "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" "account-details": "Account Details"
}, },
"validation": { "validation": {
@@ -1359,13 +1360,13 @@
}, },
"cookbook": { "cookbook": {
"cookbooks": "Cookbooks", "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": "Hide Cookbooks from Other Households",
"hide-cookbooks-from-other-households-description": "When enabled, only cookbooks from your household will appear on the sidebar", "hide-cookbooks-from-other-households-description": "When enabled, only cookbooks from your household will appear on the sidebar",
"public-cookbook": "Public Cookbook", "public-cookbook": "Public Cookbook",
"public-cookbook-description": "Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page.", "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": "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-categories": "Require All Categories",
"require-all-tags": "Require All Tags", "require-all-tags": "Require All Tags",
"require-all-tools": "Require All Tools", "require-all-tools": "Require All Tools",

View File

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

View File

@@ -69,7 +69,7 @@
"new-notification": "Nueva notificación", "new-notification": "Nueva notificación",
"event-notifiers": "Notificaciones de eventos", "event-notifiers": "Notificaciones de eventos",
"apprise-url-skipped-if-blank": "URL de Apprise (omitida si está en blanco)", "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", "enable-notifier": "Habilitar notificador",
"what-events": "¿A qué eventos debe suscribirse este notificador?", "what-events": "¿A qué eventos debe suscribirse este notificador?",
"user-events": "Eventos de los usuarios", "user-events": "Eventos de los usuarios",
@@ -561,6 +561,7 @@
"see-original-text": "Mostrar Texto Original", "see-original-text": "Mostrar Texto Original",
"original-text-with-value": "Texto original: {originalText}", "original-text-with-value": "Texto original: {originalText}",
"ingredient-linker": "Vincular ingredientes", "ingredient-linker": "Vincular ingredientes",
"unlinked": "Not linked yet",
"linked-to-other-step": "Enlazado a otro paso", "linked-to-other-step": "Enlazado a otro paso",
"auto": "Auto", "auto": "Auto",
"cook-mode": "Modo Cocinar", "cook-mode": "Modo Cocinar",
@@ -1169,7 +1170,7 @@
"group-details": "Detalles del grupo", "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...", "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": "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" "account-details": "Información de la cuenta"
}, },
"validation": { "validation": {

View File

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

View File

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

View File

@@ -69,7 +69,7 @@
"new-notification": "Nouvelle notification", "new-notification": "Nouvelle notification",
"event-notifiers": "Notifications d'événements", "event-notifiers": "Notifications d'événements",
"apprise-url-skipped-if-blank": "URL Apprise (ignoré si vide)", "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", "enable-notifier": "Activer la notification",
"what-events": "À quels événements cette notification doit-elle s'abonner ?", "what-events": "À quels événements cette notification doit-elle s'abonner ?",
"user-events": "Evénements utilisateur", "user-events": "Evénements utilisateur",
@@ -81,7 +81,7 @@
"category-events": "Événements de catégories", "category-events": "Événements de catégories",
"when-a-new-user-joins-your-group": "Lorsqu'un nouvel utilisateur rejoint votre groupe", "when-a-new-user-joins-your-group": "Lorsqu'un nouvel utilisateur rejoint votre groupe",
"recipe-events": "Événements de recette", "recipe-events": "Événements de recette",
"label-events": "Label Events" "label-events": "Étiquette des événements"
}, },
"general": { "general": {
"add": "Ajouter", "add": "Ajouter",
@@ -561,6 +561,7 @@
"see-original-text": "Afficher le texte original", "see-original-text": "Afficher le texte original",
"original-text-with-value": "Texte original: {originalText}", "original-text-with-value": "Texte original: {originalText}",
"ingredient-linker": "Liaison dingrédients", "ingredient-linker": "Liaison dingrédients",
"unlinked": "Not linked yet",
"linked-to-other-step": "Déjà associé à une autre étape", "linked-to-other-step": "Déjà associé à une autre étape",
"auto": "Auto", "auto": "Auto",
"cook-mode": "Mode Cuisine", "cook-mode": "Mode Cuisine",

View File

@@ -69,7 +69,7 @@
"new-notification": "Nouvelle notification", "new-notification": "Nouvelle notification",
"event-notifiers": "Notifications d'événements", "event-notifiers": "Notifications d'événements",
"apprise-url-skipped-if-blank": "URL Apprise (ignoré si vide)", "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", "enable-notifier": "Activer la notification",
"what-events": "À quels événements cette notification doit-elle s'abonner ?", "what-events": "À quels événements cette notification doit-elle s'abonner ?",
"user-events": "Événements de l'utilisateur", "user-events": "Événements de l'utilisateur",
@@ -81,7 +81,7 @@
"category-events": "Événements de catégories", "category-events": "Événements de catégories",
"when-a-new-user-joins-your-group": "Lorsqu'un nouvel utilisateur rejoint votre groupe", "when-a-new-user-joins-your-group": "Lorsqu'un nouvel utilisateur rejoint votre groupe",
"recipe-events": "Événements de recette", "recipe-events": "Événements de recette",
"label-events": "Label Events" "label-events": "Étiquette des événements"
}, },
"general": { "general": {
"add": "Ajouter", "add": "Ajouter",
@@ -561,6 +561,7 @@
"see-original-text": "Afficher le texte original", "see-original-text": "Afficher le texte original",
"original-text-with-value": "Texte original: {originalText}", "original-text-with-value": "Texte original: {originalText}",
"ingredient-linker": "Association dingrédients", "ingredient-linker": "Association dingrédients",
"unlinked": "Not linked yet",
"linked-to-other-step": "Lié à une autre étape", "linked-to-other-step": "Lié à une autre étape",
"auto": "Auto", "auto": "Auto",
"cook-mode": "Mode Cuisine", "cook-mode": "Mode Cuisine",
@@ -675,8 +676,8 @@
"upload-another-image": "Télécharger une autre image", "upload-another-image": "Télécharger une autre image",
"upload-images": "Télécharger des images", "upload-images": "Télécharger des images",
"upload-more-images": "Télécharger d'autres images", "upload-more-images": "Télécharger d'autres images",
"set-as-cover-image": "Set as recipe cover image", "set-as-cover-image": "Définir comme image de couverture de la recette",
"cover-image": "Cover image" "cover-image": "Image de couverture"
}, },
"recipe-finder": { "recipe-finder": {
"recipe-finder": "Recherche de recette", "recipe-finder": "Recherche de recette",
@@ -1169,7 +1170,7 @@
"group-details": "Détails du groupe", "group-details": "Détails du groupe",
"group-details-description": "Avant de créer un compte, vous devrez créer un groupe. Votre groupe ne contiendra que vous, mais vous pourrez inviter dautres personnes plus tard. Les membres de votre groupe peuvent partager leur menu de la semaine, leurs listes dachat, leurs recettes et plus encore!", "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": "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" "account-details": "Détails du compte"
}, },
"validation": { "validation": {

View File

@@ -561,6 +561,7 @@
"see-original-text": "Afficher le texte original", "see-original-text": "Afficher le texte original",
"original-text-with-value": "Texte original: {originalText}", "original-text-with-value": "Texte original: {originalText}",
"ingredient-linker": "Liaison dingrédients", "ingredient-linker": "Liaison dingrédients",
"unlinked": "Pas encore associée",
"linked-to-other-step": "Déjà associé à une autre étape", "linked-to-other-step": "Déjà associé à une autre étape",
"auto": "Auto", "auto": "Auto",
"cook-mode": "Mode Cuisine", "cook-mode": "Mode Cuisine",

View File

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

View File

@@ -561,6 +561,7 @@
"see-original-text": "הטקסט המקורי", "see-original-text": "הטקסט המקורי",
"original-text-with-value": "הטקסט המקורי: {originalText}", "original-text-with-value": "הטקסט המקורי: {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": "Nova Obavijest", "new-notification": "Nova Obavijest",
"event-notifiers": "Obavještavatelji Događaja", "event-notifiers": "Obavještavatelji Događaja",
"apprise-url-skipped-if-blank": "Apprise URL (preskočeno ako je prazno)", "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", "enable-notifier": "Omogući obavještavanje",
"what-events": "Na koje događaje bi ovaj obavještavatelj trebao biti pretplaćen?", "what-events": "Na koje događaje bi ovaj obavještavatelj trebao biti pretplaćen?",
"user-events": "Događaji Korisnika", "user-events": "Događaji Korisnika",
@@ -300,12 +300,12 @@
"household-recipe-preferences": "Postavke recepata u domaćinstvu", "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.", "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": "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", "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": "Household Preferences" "household-preferences": "Postavke recepata u domaćinstvu"
}, },
"meal-plan": { "meal-plan": {
"create-a-new-meal-plan": "Kreirajte Novi Plan Obroka", "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-this-week": "Večera Ove Sedmice",
"dinner-today": "Večera Danas", "dinner-today": "Večera Danas",
"dinner-tonight": "VEČERA NOĆAS", "dinner-tonight": "VEČERA NOĆAS",
@@ -323,13 +323,13 @@
"mealplan-settings": "Postavke Plana obroka", "mealplan-settings": "Postavke Plana obroka",
"mealplan-update-failed": "Ažuriranje Plana obroka nije uspjelo", "mealplan-update-failed": "Ažuriranje Plana obroka nije uspjelo",
"mealplan-updated": "Plan obroka je Ažuriran", "mealplan-updated": "Plan obroka je Ažuriran",
"mealplan-households-description": "If no household is selected, recipes can be added from any household", "mealplan-households-description": "Ako nijedno kućanstvo nije odabrano, recepti mogu biti dodani iz bilo kojeg kućanstva",
"any-category": "Any Category", "any-category": "Bilo koja Kategorija",
"any-tag": "Any Tag", "any-tag": "Bilo koja Oznaka",
"any-household": "Any Household", "any-household": "Bilo koje Kućanstvo",
"no-meal-plan-defined-yet": "Plan obroka još nije definiran", "no-meal-plan-defined-yet": "Plan obroka još nije definiran",
"no-meal-planned-for-today": "Nema Plan obroka za današnji dan", "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", "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", "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", "planner": "Planer",
@@ -561,6 +561,7 @@
"see-original-text": "Prikaži Izvorni Tekst", "see-original-text": "Prikaži Izvorni Tekst",
"original-text-with-value": "Izvorni Tekst: {originalText}", "original-text-with-value": "Izvorni Tekst: {originalText}",
"ingredient-linker": "Poveznik Sastojaka", "ingredient-linker": "Poveznik Sastojaka",
"unlinked": "Not linked yet",
"linked-to-other-step": "Povezano s drugim korakom", "linked-to-other-step": "Povezano s drugim korakom",
"auto": "Auto", "auto": "Auto",
"cook-mode": "Način Kuhanja", "cook-mode": "Način Kuhanja",
@@ -602,7 +603,7 @@
"import-with-url": "Učitaj preko URL-a", "import-with-url": "Učitaj preko URL-a",
"create-recipe": "Kreiraj recept", "create-recipe": "Kreiraj recept",
"create-recipe-description": "Create a new recipe from scratch.", "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", "import-with-zip": "Učitaj pomoću .zip-a",
"create-recipe-from-an-image": "Create Recipe from an Image", "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.", "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": "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.", "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-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", "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.", "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", "upload-a-png-image-from-a-recipe-book": "Učitajte png sliku iz kuharice",
@@ -641,19 +642,19 @@
"report-deletion-failed": "Brisanje nije uspjelo", "report-deletion-failed": "Brisanje nije uspjelo",
"recipe-debugger": "Ispravljač Pogrešaka Recepta", "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.", "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.", "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", "debug": "Ispravljanje grešaka",
"tree-view": "Prikaz Stabla", "tree-view": "Prikaz Stabla",
"recipe-servings": "Recipe Servings", "recipe-servings": "Serviranja recepta",
"recipe-yield": "Konačna Količina Recepta", "recipe-yield": "Konačna Količina Recepta",
"recipe-yield-text": "Recipe Yield Text", "recipe-yield-text": "Recipe Yield Text",
"unit": "Jedinica", "unit": "Jedinica",
"upload-image": "Učitavanje Slike", "upload-image": "Učitavanje Slike",
"screen-awake": "Keep Screen Awake", "screen-awake": "Zadrži ekran uključenim",
"remove-image": "Remove image", "remove-image": "Ukloni sliku",
"nextStep": "Next step", "nextStep": "Sljedeći korak",
"recipe-actions": "Recipe Actions", "recipe-actions": "Akcije recepta",
"parser": { "parser": {
"ingredient-parser": "Ingredient 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.", "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", "see-original-text": "Eredeti szöveg megjelenítése",
"original-text-with-value": "Eredeti szöveg: {originalText}", "original-text-with-value": "Eredeti szöveg: {originalText}",
"ingredient-linker": "Hozzávaló összekötő", "ingredient-linker": "Hozzávaló összekötő",
"unlinked": "Még nincs csatolva",
"linked-to-other-step": "Egy másik lépéssel összekapcsolva", "linked-to-other-step": "Egy másik lépéssel összekapcsolva",
"auto": "Automatikus", "auto": "Automatikus",
"cook-mode": "Főzési mód", "cook-mode": "Főzési mód",

View File

@@ -193,12 +193,12 @@
"delete-with-name": "Eyða út {name}", "delete-with-name": "Eyða út {name}",
"confirm-delete-generic-with-name": "Ertu viss um að þú viljir 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?", "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", "organizer": "Skipuleggjari",
"transfer": "Færa", "transfer": "Færa",
"copy": "Afrita", "copy": "Afrita",
"color": "Litur", "color": "Litur",
"timestamp": "Tímastimpill", "timestamp": "Tímastimpill",
"last-made": "Last Made", "last-made": "Síðast gert",
"learn-more": "Læra meira", "learn-more": "Læra meira",
"this-feature-is-currently-inactive": "This feature is currently inactive", "this-feature-is-currently-inactive": "This feature is currently inactive",
"clipboard-not-supported": "Clipboard not supported", "clipboard-not-supported": "Clipboard not supported",
@@ -214,16 +214,16 @@
"unsaved-changes": "You have unsaved changes. Do you want to save before leaving? Okay to save, Cancel to discard changes.", "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.", "clipboard-copy-failure": "Failed to copy to the clipboard.",
"confirm-delete-generic-items": "Are you sure you want to delete the following items?", "confirm-delete-generic-items": "Are you sure you want to delete the following items?",
"organizers": "Organizers", "organizers": "Skipuleggjarar",
"caution": "Varúð", "caution": "Varúð",
"show-advanced": "Show Advanced", "show-advanced": "Show Advanced",
"add-field": "Add Field", "add-field": "Bæta við dálk",
"date-created": "Date Created", "date-created": "Date Created",
"date-updated": "Date Updated" "date-updated": "Dagsetning uppfærð"
}, },
"group": { "group": {
"are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete <b>{groupName}<b/>?", "are-you-sure-you-want-to-delete-the-group": "Ertu viss um að þú viljir eyða <b>{groupName}<b/>?",
"cannot-delete-default-group": "Cannot delete default group", "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", "cannot-delete-group-with-users": "Cannot delete group with users",
"confirm-group-deletion": "Confirm Group Deletion", "confirm-group-deletion": "Confirm Group Deletion",
"create-group": "Búa til hóp", "create-group": "Búa til hóp",
@@ -237,18 +237,18 @@
"group-token": "Group Token", "group-token": "Group Token",
"group-with-value": "Group: {groupID}", "group-with-value": "Group: {groupID}",
"groups": "Hópar", "groups": "Hópar",
"manage-groups": "Manage Groups", "manage-groups": "Umsjá hópa",
"user-group": "Notendahópur", "user-group": "Notendahópur",
"user-group-created": "User Group Created", "user-group-created": "Notendahópur búinn til",
"user-group-creation-failed": "User Group Creation Failed", "user-group-creation-failed": "User Group Creation Failed",
"settings": { "settings": {
"keep-my-recipes-private": "Keep My Recipes Private", "keep-my-recipes-private": "Keep My Recipes Private",
"keep-my-recipes-private-description": "Sets your group and all recipes defaults to private. You can always change this later." "keep-my-recipes-private-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-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": "Umsjá",
"manage-household": "Manage Household", "manage-household": "Umsjá heimilis",
"invite": "Bjóða", "invite": "Bjóða",
"looking-to-update-your-profile": "Viltu uppfæra prófílinn þinn?", "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-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.",
@@ -270,7 +270,7 @@
"disable-users-from-commenting-on-recipes-description": "Hides the comment section on the recipe page and disables commenting", "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": "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", "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", "group-recipe-preferences": "Group Recipe Preferences",
"report": "Skýrsla", "report": "Skýrsla",
"report-with-id": "Report ID: {id}", "report-with-id": "Report ID: {id}",
@@ -561,6 +561,7 @@
"see-original-text": "See Original Text", "see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}", "original-text-with-value": "Original Text: {originalText}",
"ingredient-linker": "Ingredient Linker", "ingredient-linker": "Ingredient Linker",
"unlinked": "Not linked yet",
"linked-to-other-step": "Linked to other step", "linked-to-other-step": "Linked to other step",
"auto": "Auto", "auto": "Auto",
"cook-mode": "Cook Mode", "cook-mode": "Cook Mode",

View File

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

View File

@@ -561,6 +561,7 @@
"see-original-text": "元のテキストを見る", "see-original-text": "元のテキストを見る",
"original-text-with-value": "原文: {originalText}", "original-text-with-value": "原文: {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": "See Original Text", "see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}", "original-text-with-value": "Original Text: {originalText}",
"ingredient-linker": "Ingredient Linker", "ingredient-linker": "Ingredient Linker",
"unlinked": "Not linked yet",
"linked-to-other-step": "Linked to other step", "linked-to-other-step": "Linked to other step",
"auto": "자동", "auto": "자동",
"cook-mode": "Cook Mode", "cook-mode": "Cook Mode",

View File

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

View File

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

View File

@@ -69,7 +69,7 @@
"new-notification": "Nieuwe melding", "new-notification": "Nieuwe melding",
"event-notifiers": "Meldingen van gebeurtenissen", "event-notifiers": "Meldingen van gebeurtenissen",
"apprise-url-skipped-if-blank": "URL van Apprise (overgeslagen als veld leeg is)", "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", "enable-notifier": "Activeer melding",
"what-events": "Op welke gebeurtenissen moet deze melding zich abonneren?", "what-events": "Op welke gebeurtenissen moet deze melding zich abonneren?",
"user-events": "Gebeurtenissen van gebruiker", "user-events": "Gebeurtenissen van gebruiker",
@@ -561,6 +561,7 @@
"see-original-text": "Zie oorspronkelijke tekst", "see-original-text": "Zie oorspronkelijke tekst",
"original-text-with-value": "Oorspronkelijke tekst: {originalText}", "original-text-with-value": "Oorspronkelijke tekst: {originalText}",
"ingredient-linker": "Ingrediëntenkoppelaar", "ingredient-linker": "Ingrediëntenkoppelaar",
"unlinked": "Nog niet gelinkt",
"linked-to-other-step": "Gekoppeld aan andere stap", "linked-to-other-step": "Gekoppeld aan andere stap",
"auto": "Automatisch", "auto": "Automatisch",
"cook-mode": "Kookmodus", "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", "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", "favorite-recipes": "Favoriete recepten",
"email-or-username": "E-mailadres of gebruikersnaam", "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", "please-enter-your-email-and-password": "Voer je e-mailadres en wachtwoord in",
"invalid-credentials": "Ongeldige inloggegevens", "invalid-credentials": "Ongeldige inloggegevens",
"account-locked-please-try-again-later": "Account geblokkeerd. Probeer het later opnieuw", "account-locked-please-try-again-later": "Account geblokkeerd. Probeer het later opnieuw",

View File

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

View File

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

View File

@@ -69,7 +69,7 @@
"new-notification": "Nova Notificação", "new-notification": "Nova Notificação",
"event-notifiers": "Notificações de Eventos", "event-notifiers": "Notificações de Eventos",
"apprise-url-skipped-if-blank": "URL Apprise (ignorado se estiver em branco)", "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", "enable-notifier": "Habilitar Notificador",
"what-events": "A quais eventos este notificador deve subscrever?", "what-events": "A quais eventos este notificador deve subscrever?",
"user-events": "Eventos do usuário", "user-events": "Eventos do usuário",
@@ -561,6 +561,7 @@
"see-original-text": "Exibir texto original", "see-original-text": "Exibir texto original",
"original-text-with-value": "Texto Original: {originalText}", "original-text-with-value": "Texto Original: {originalText}",
"ingredient-linker": "Ingrediente do Linker", "ingredient-linker": "Ingrediente do Linker",
"unlinked": "Ainda não vinculado",
"linked-to-other-step": "Ligado a outro passo", "linked-to-other-step": "Ligado a outro passo",
"auto": "Automático", "auto": "Automático",
"cook-mode": "Modo Cozinheiro", "cook-mode": "Modo Cozinheiro",
@@ -675,8 +676,8 @@
"upload-another-image": "Carregar outra imagem", "upload-another-image": "Carregar outra imagem",
"upload-images": "Carregar imagens", "upload-images": "Carregar imagens",
"upload-more-images": "Carregar mais imagens", "upload-more-images": "Carregar mais imagens",
"set-as-cover-image": "Set as recipe cover image", "set-as-cover-image": "Definir como imagem de capa da receita",
"cover-image": "Cover image" "cover-image": "Imagem de capa"
}, },
"recipe-finder": { "recipe-finder": {
"recipe-finder": "Localizador de Receitas", "recipe-finder": "Localizador de Receitas",
@@ -1169,7 +1170,7 @@
"group-details": "Detalhes do Grupo", "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!", "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": "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" "account-details": "Detalhes da Conta"
}, },
"validation": { "validation": {

View File

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

View File

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

View File

@@ -561,6 +561,7 @@
"see-original-text": "Показать исходный текст", "see-original-text": "Показать исходный текст",
"original-text-with-value": "Исходный текст: {originalText}", "original-text-with-value": "Исходный текст: {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": "Nové upozornenie", "new-notification": "Nové upozornenie",
"event-notifiers": "Upozornenia udalostí", "event-notifiers": "Upozornenia udalostí",
"apprise-url-skipped-if-blank": "Informačná URL (preskočená, ak je prázdna)", "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", "enable-notifier": "Zapnúť notifikátor",
"what-events": "Pre ktoré udalosti si želáte zapnúť notifikátor?", "what-events": "Pre ktoré udalosti si želáte zapnúť notifikátor?",
"user-events": "Udalosti používateľa", "user-events": "Udalosti používateľa",
@@ -81,7 +81,7 @@
"category-events": "Udalosti kategórií", "category-events": "Udalosti kategórií",
"when-a-new-user-joins-your-group": "Keď sa k vašej skupine pripojí nový používateľ", "when-a-new-user-joins-your-group": "Keď sa k vašej skupine pripojí nový používateľ",
"recipe-events": "Udalosti receptov", "recipe-events": "Udalosti receptov",
"label-events": "Label Events" "label-events": "Udalosti označení"
}, },
"general": { "general": {
"add": "Pridať", "add": "Pridať",
@@ -474,7 +474,7 @@
"comment": "Komentár", "comment": "Komentár",
"comments": "Komentáre", "comments": "Komentáre",
"delete-confirmation": "Naozaj chcete odstrániť zvolený recept?", "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", "delete-recipe": "Odstrániť recept",
"description": "Popis", "description": "Popis",
"disable-amount": "Vypnúť množstvá surovín", "disable-amount": "Vypnúť množstvá surovín",
@@ -561,6 +561,7 @@
"see-original-text": "Pozrieť pôvodný text", "see-original-text": "Pozrieť pôvodný text",
"original-text-with-value": "Pôvodný text: {originalText}", "original-text-with-value": "Pôvodný text: {originalText}",
"ingredient-linker": "Prepojenie surovín", "ingredient-linker": "Prepojenie surovín",
"unlinked": "Zatiaľ neprepojené",
"linked-to-other-step": "Prepojené s iným krokom", "linked-to-other-step": "Prepojené s iným krokom",
"auto": "Automaticky", "auto": "Automaticky",
"cook-mode": "Režim varenia", "cook-mode": "Režim varenia",
@@ -585,11 +586,11 @@
"added-to-timeline": "Pridané na časovú os", "added-to-timeline": "Pridané na časovú os",
"failed-to-add-to-timeline": "Pridanie na časovú os skončilo chybou", "failed-to-add-to-timeline": "Pridanie na časovú os skončilo chybou",
"failed-to-update-recipe": "Recept sa nepodarilo aktualizovať", "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í.", "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", "message-key": "Kľúč správy",
"parse": "Analyzovať", "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", "attach-images-hint": "Pridaj obrázky ich potiahnutím a pustením na editor",
"drop-image": "Odstrániť obrázok", "drop-image": "Odstrániť obrázok",
"enable-ingredient-amounts-to-use-this-feature": "Povoľ množstvám prísad využívať túto vlastnosť", "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", "create-from-images": "Vytvoriť z obrázka",
"should-translate-description": "Preložiť recept do môjho jazyka", "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-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", "bulk-url-import": "Hromadný URL import",
"debug-scraper": "Ladiť scraper", "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.", "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", "no-unit": "Bez jednotky",
"missing-unit": "Vytvoriť chýbajúcu jednotku: {unit}", "missing-unit": "Vytvoriť chýbajúcu jednotku: {unit}",
"missing-food": "Vytvoriť chýbajúcu surovinu: {food}", "missing-food": "Vytvoriť chýbajúcu surovinu: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit 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": "This food could not be parsed automatically", "this-food-could-not-be-parsed-automatically": "Toto jedlo nebolo možné parsovať automaticky",
"no-food": "Žiadne suroviny" "no-food": "Žiadne suroviny"
}, },
"reset-servings-count": "Resetovať počet porcií", "reset-servings-count": "Resetovať počet porcií",
"not-linked-ingredients": "Ďalšie suroviny", "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-images": "Nahrať obrázky",
"upload-more-images": "Nahrať ďalšie obrázky", "upload-more-images": "Nahrať ďalšie obrázky",
"set-as-cover-image": "Set as recipe cover image", "set-as-cover-image": "Nastaviť ako titulný obrázok receptu",
"cover-image": "Cover image" "cover-image": "Titulný obrázok"
}, },
"recipe-finder": { "recipe-finder": {
"recipe-finder": "Hľadač receptov", "recipe-finder": "Hľadač receptov",
@@ -1169,7 +1170,7 @@
"group-details": "Podrobnosti o skupine", "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!", "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": "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" "account-details": "Detaily účtu"
}, },
"validation": { "validation": {
@@ -1311,7 +1312,7 @@
"welcome-user": "👋 Vitajte, {0}!", "welcome-user": "👋 Vitajte, {0}!",
"description": "Spravujte svoj profil, recepty a nastavenia skupín.", "description": "Spravujte svoj profil, recepty a nastavenia skupín.",
"invite-link": "Odkaz s pozvánkou", "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", "get-public-link": "Vytvoriť verejný odkaz",
"account-summary": "Zhrnutie účtu", "account-summary": "Zhrnutie účtu",
"account-summary-description": "Tu je súhrn informácií o vašej skupine.", "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", "see-original-text": "Prikaži izvirno besedilo",
"original-text-with-value": "Originalno besedilo: {originalText}", "original-text-with-value": "Originalno besedilo: {originalText}",
"ingredient-linker": "Povezovanje sestavin", "ingredient-linker": "Povezovanje sestavin",
"unlinked": "Not linked yet",
"linked-to-other-step": "Povezano s naslednjim korakom", "linked-to-other-step": "Povezano s naslednjim korakom",
"auto": "Samodejno", "auto": "Samodejno",
"cook-mode": "Način kuhanja", "cook-mode": "Način kuhanja",

View File

@@ -561,6 +561,7 @@
"see-original-text": "See Original Text", "see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}", "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", "auto": "Auto",
"cook-mode": "Cook Mode", "cook-mode": "Cook Mode",

View File

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

View File

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

View File

@@ -561,6 +561,7 @@
"see-original-text": "Переглянути оригінальний текст", "see-original-text": "Переглянути оригінальний текст",
"original-text-with-value": "Оригінальний текст: {originalText}", "original-text-with-value": "Оригінальний текст: {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": "See Original Text", "see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}", "original-text-with-value": "Original Text: {originalText}",
"ingredient-linker": "Ingredient Linker", "ingredient-linker": "Ingredient Linker",
"unlinked": "Not linked yet",
"linked-to-other-step": "Linked to other step", "linked-to-other-step": "Linked to other step",
"auto": "Auto", "auto": "Auto",
"cook-mode": "Cook Mode", "cook-mode": "Cook Mode",

View File

@@ -561,6 +561,7 @@
"see-original-text": "查看原文", "see-original-text": "查看原文",
"original-text-with-value": "原文: {originalText}", "original-text-with-value": "原文: {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": "See Original Text", "see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}", "original-text-with-value": "Original Text: {originalText}",
"ingredient-linker": "Ingredient Linker", "ingredient-linker": "Ingredient Linker",
"unlinked": "Not linked yet",
"linked-to-other-step": "Linked to other step", "linked-to-other-step": "Linked to other step",
"auto": "Auto", "auto": "Auto",
"cook-mode": "Cook Mode", "cook-mode": "Cook Mode",

View File

@@ -15,7 +15,6 @@
v-model="sidebar" v-model="sidebar"
absolute absolute
:top-link="topLinks" :top-link="topLinks"
:bottom-links="bottomLinks"
:user="{ data: true }" :user="{ data: true }"
:secondary-header="$t('sidebar.developer')" :secondary-header="$t('sidebar.developer')"
:secondary-links="developerLinks" :secondary-links="developerLinks"
@@ -36,13 +35,15 @@ import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue"; import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue"; import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
import type { SidebarLinks } from "~/types/application-types"; import type { SidebarLinks } from "~/types/application-types";
import { useGlobalI18n } from "~/composables/use-global-i18n";
const i18n = useI18n(); const i18n = useGlobalI18n();
const { $globals, $vuetify } = useNuxtApp(); const display = useDisplay();
const { $globals } = useNuxtApp();
const sidebar = ref<boolean>(false); const sidebar = ref<boolean>(false);
onMounted(() => { onMounted(() => {
sidebar.value = !$vuetify.display.md.value; sidebar.value = display.lgAndUp.value;
}); });
const topLinks: SidebarLinks = [ 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> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { defineNuxtConfig } from "nuxt/config"; import { defineNuxtConfig } from "nuxt/config";
import commonjs from "vite-plugin-commonjs";
const AUTH_TOKEN = "mealie.auth.token"; const AUTH_TOKEN = "mealie.auth.token";
@@ -56,6 +55,7 @@ export default defineNuxtConfig({
{ "rel": "shortcut icon", "type": "image/png", "href": "/icons/icon-x64.png", "data-n-head": "ssr" }, { "rel": "shortcut icon", "type": "image/png", "href": "/icons/icon-x64.png", "data-n-head": "ssr" },
{ "rel": "apple-touch-icon", "type": "image/png", "href": "/icons/apple-touch-icon.png", "data-n-head": "ssr" }, { "rel": "apple-touch-icon", "type": "image/png", "href": "/icons/apple-touch-icon.png", "data-n-head": "ssr" },
{ "rel": "mask-icon", "href": "/icons/safari-pinned-tab.svg", "data-n-head": "ssr" }, { "rel": "mask-icon", "href": "/icons/safari-pinned-tab.svg", "data-n-head": "ssr" },
{ "rel": "manifest", "href": "/manifest.webmanifest", "data-n-head": "ssr" },
], ],
}, },
@@ -126,12 +126,6 @@ export default defineNuxtConfig({
baseURL: process.env.SUB_PATH || "", baseURL: process.env.SUB_PATH || "",
}, },
vite: {
plugins: [
commonjs(),
],
},
auth: { auth: {
isEnabled: true, isEnabled: true,
// disableServerSideAuth: true, // disableServerSideAuth: true,
@@ -148,7 +142,7 @@ export default defineNuxtConfig({
signInResponseTokenPointer: "/access_token", signInResponseTokenPointer: "/access_token",
type: "Bearer", type: "Bearer",
cookieName: AUTH_TOKEN, cookieName: AUTH_TOKEN,
maxAgeInSeconds: 604800, // 7 days maxAgeInSeconds: parseInt(process.env.TOKEN_TIME || "48") * 3600, // TOKEN_TIME is in hours
}, },
pages: { pages: {
login: "/login", login: "/login",
@@ -240,37 +234,57 @@ export default defineNuxtConfig({
vueI18n: "./../i18n.config.ts", // note: we need to up one ../ because the default root of lang dir is the /frontend/i18n, which can not be configured vueI18n: "./../i18n.config.ts", // note: we need to up one ../ because the default root of lang dir is the /frontend/i18n, which can not be configured
}, },
// PWA module configuration: https://go.nuxtjs.dev/pwa // PWA module configuration: https://vite-pwa-org.netlify.app/frameworks/nuxt.html
pwa: { pwa: {
mode: process.env.NODE_ENV === "production" ? "production" : "development",
registerType: "autoUpdate", registerType: "autoUpdate",
useCredentials: true, devOptions: {
enabled: false,
suppressWarnings: true,
},
workbox: {
navigateFallback: "/",
globPatterns: ["**/*.{js,css,html,png,svg,ico}"],
cleanupOutdatedCaches: true,
skipWaiting: true,
clientsClaim: true,
},
client: {
installPrompt: true,
periodicSyncForUpdates: 120,
},
includeAssets: ["favicon.ico", "apple-touch-icon.png", "safari-pinned-tab.svg"],
manifest: { manifest: {
start_url: "/",
scope: "/",
lang: "en",
name: "Mealie", name: "Mealie",
short_name: "Mealie", short_name: "Mealie",
id: "mealie", id: "/",
description: "Mealie is a recipe management and meal planning app", start_url: "/",
theme_color: process.env.THEME_LIGHT_PRIMARY || "#E58325", scope: "/",
background_color: "#FFFFFF",
display: "standalone", display: "standalone",
background_color: "#FFFFFF",
theme_color: process.env.THEME_LIGHT_PRIMARY || "#E58325",
description: "Mealie is a recipe management and meal planning app",
lang: "en",
display_override: [ display_override: [
"standalone", "standalone",
"minimal-ui", "minimal-ui",
"browser", "browser",
"window-controls-overlay", "window-controls-overlay",
], ],
orientation: "portrait-primary",
categories: ["food", "lifestyle"],
prefer_related_applications: false,
handle_links: "preferred",
launch_handler: {
client_mode: ["focus-existing", "auto"],
},
edge_side_panel: {
preferred_width: 400,
},
share_target: { share_target: {
action: "/r/create/url", action: "/r/create/url",
method: "GET", method: "GET",
params: { params: {
/* title and url are not currently used in Mealie. If there are issues
with sharing, uncommenting those lines might help solve the puzzle. */
// "title": "title",
text: "recipe_import_url", text: "recipe_import_url",
// "url": "url",
}, },
}, },
icons: [ icons: [
@@ -383,17 +397,6 @@ export default defineNuxtConfig({
], ],
}, },
], ],
prefer_related_applications: false,
handle_links: "preferred",
categories: [
"food",
],
launch_handler: {
client_mode: ["focus-existing", "auto"],
},
edge_side_panel: {
preferred_width: 400,
},
}, },
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "mealie", "name": "mealie",
"version": "3.1.1", "version": "3.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "nuxt dev", "dev": "nuxt dev",
@@ -19,10 +19,8 @@
}, },
"dependencies": { "dependencies": {
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@nuxt/eslint": "1.2.0",
"@nuxt/fonts": "^0.11.4", "@nuxt/fonts": "^0.11.4",
"@nuxtjs/i18n": "^9.2.1", "@nuxtjs/i18n": "^9.2.1",
"@nuxtjs/proxy": "^2.1.0",
"@sidebase/nuxt-auth": "0.10.0", "@sidebase/nuxt-auth": "0.10.0",
"@vite-pwa/nuxt": "0.10.6", "@vite-pwa/nuxt": "0.10.6",
"@vueuse/core": "^12.7.0", "@vueuse/core": "^12.7.0",
@@ -32,16 +30,16 @@
"isomorphic-dompurify": "^2.22.0", "isomorphic-dompurify": "^2.22.0",
"json-editor-vue": "^0.18.1", "json-editor-vue": "^0.18.1",
"marked": "^15.0.12", "marked": "^15.0.12",
"next-auth": "~4.21.1", "next-auth": "~4.24.0",
"nuxt": "^3.15.4", "nuxt": "^3.15.4",
"typescript": "5.3",
"vite": "^6.2.0", "vite": "^6.2.0",
"vite-plugin-commonjs": "^0.10.4",
"vue-advanced-cropper": "^2.8.9", "vue-advanced-cropper": "^2.8.9",
"vue-draggable-plus": "^0.6.0", "vue-draggable-plus": "^0.6.0",
"vuetify-nuxt-module": "0.18.3" "vuetify": "^3.9.7",
"vuetify-nuxt-module": "^0.18.3"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/eslint": "1.2.0",
"@nuxt/types": "^2.18.1", "@nuxt/types": "^2.18.1",
"@nuxtjs/eslint-config-typescript": "^12.1.0", "@nuxtjs/eslint-config-typescript": "^12.1.0",
"@nuxtjs/eslint-module": "^4.1.0", "@nuxtjs/eslint-module": "^4.1.0",
@@ -56,6 +54,8 @@
"lint-staged": "^15.4.3", "lint-staged": "^15.4.3",
"prettier": "^3.5.2", "prettier": "^3.5.2",
"sass-embedded": "^1.85.1", "sass-embedded": "^1.85.1",
"typescript": "5.3",
"vite-plugin-commonjs": "^0.10.4",
"vitest": "^3.0.7" "vitest": "^3.0.7"
}, },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"

View File

@@ -9,7 +9,7 @@
width="100%" width="100%"
max-height="125" max-height="125"
max-width="125" max-width="125"
:src="require('~/static/svgs/manage-group-settings.svg')" src="/svgs/manage-group-settings.svg"
/> />
</template> </template>
<template #title> <template #title>

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