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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,15 +32,16 @@
### Database
| Variables | Default | Description |
| ------------------------------------------------------- | :------: | ----------------------------------------------------------------------- |
| DB_ENGINE | sqlite | Optional: 'sqlite', 'postgres' |
| POSTGRES_USER<super>[&dagger;][secrets]</super> | mealie | Postgres database user |
| POSTGRES_PASSWORD<super>[&dagger;][secrets]</super> | mealie | Postgres database password |
| POSTGRES_SERVER<super>[&dagger;][secrets]</super> | postgres | Postgres database server address |
| POSTGRES_PORT<super>[&dagger;][secrets]</super> | 5432 | Postgres database port |
| POSTGRES_DB<super>[&dagger;][secrets]</super> | mealie | Postgres database name |
| POSTGRES_URL_OVERRIDE<super>[&dagger;][secrets]</super> | None | Optional Postgres URL override to use instead of POSTGRES\_\* variables |
| Variables | Default | Description |
|---------------------------------------------------------|:--------:|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DB_ENGINE | sqlite | Optional: 'sqlite', 'postgres' |
| SQLITE_MIGRATE_JOURNAL_WAL | False | If set to true, switches SQLite's journal mode to WAL, which allows for multiple concurrent accesses. This can be useful when you have a decent amount of concurrency or when using certain remote storage systems such as Ceph. |
| POSTGRES_USER<super>[&dagger;][secrets]</super> | mealie | Postgres database user |
| POSTGRES_PASSWORD<super>[&dagger;][secrets]</super> | mealie | Postgres database password |
| POSTGRES_SERVER<super>[&dagger;][secrets]</super> | postgres | Postgres database server address |
| POSTGRES_PORT<super>[&dagger;][secrets]</super> | 5432 | Postgres database port |
| POSTGRES_DB<super>[&dagger;][secrets]</super> | mealie | Postgres database name |
| POSTGRES_URL_OVERRIDE<super>[&dagger;][secrets]</super> | None | Optional Postgres URL override to use instead of POSTGRES\_\* variables |
### Email

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,9 +14,9 @@
"development": "Udvikling",
"docs": "Dokumenter",
"download-log": "Download log",
"download-recipe-json": "Sidst skrabet JSON",
"download-recipe-json": "Senest hentede JSON",
"github": "GitHub",
"log-lines": "Log linjer",
"log-lines": "Log-linjer",
"not-demo": "Ikke demo",
"portfolio": "Portefølje",
"production": "Produktion",
@@ -39,13 +39,13 @@
"category": {
"categories": "Kategorier",
"category-created": "Kategori oprettet",
"category-creation-failed": "Oprettelse af kategorien fejlede",
"category-creation-failed": "Oprettelse af kategorien mislykkedes",
"category-deleted": "Kategori slettet",
"category-deletion-failed": "Sletning af kategori fejlede",
"category-deletion-failed": "Sletning af kategori mislykkedes",
"category-filter": "Kategorifilter",
"category-update-failed": "Kategoriopdatering fejlede",
"category-update-failed": "Opdatering af kategori mislykkedes",
"category-updated": "Kategori opdateret",
"uncategorized-count": "Ukategoriseret {count}",
"uncategorized-count": "Ikke kategoriseret {count}",
"create-a-category": "Opret en kategori",
"category-name": "Kategorinavn",
"category": "Kategori"
@@ -53,8 +53,8 @@
"events": {
"apprise-url": "Apprise URL",
"database": "Database",
"delete-event": "Slet event",
"event-delete-confirmation": "Er du sikker på, at du vil slette denne hændelse?",
"delete-event": "Slet hændelse",
"event-delete-confirmation": "Er du sikker på, at du vil slette denne begivenhed?",
"event-deleted": "Hændelse slettet",
"event-updated": "Hændelse opdateret",
"new-notification-form-description": "Mealie bruger Apprise-biblioteket for at generere notifikationer. De giver mange muligheder for notifikationer til tjenester. Kig i deres wiki for en gennemgående guide til, hvordan en URL oprettes i din situation. Hvis muligt, kan valget af din type af notifikation omfatte flere ekstrafunktioner.",
@@ -561,6 +561,7 @@
"see-original-text": "Vis den oprindelige tekst",
"original-text-with-value": "Oprindelig tekst: {originalText}",
"ingredient-linker": "Ingrediens-linker",
"unlinked": "Ikke forbundet endnu",
"linked-to-other-step": "Linket til andet trin",
"auto": "Automatisk",
"cook-mode": "Tilberedningsvisning",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,7 +69,7 @@
"new-notification": "Nouvelle notification",
"event-notifiers": "Notifications d'événements",
"apprise-url-skipped-if-blank": "URL Apprise (ignoré si vide)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Comme les URL Apprise contiennent généralement des informations sensibles, ce champ est laissé intentionnellement vide lors de l'édition. Si vous souhaitez mettre à jour l'URL, veuillez entrer la nouvelle URL ici, sinon laisser vide pour conserver l'URL courante.",
"enable-notifier": "Activer la notification",
"what-events": "À quels événements cette notification doit-elle s'abonner ?",
"user-events": "Evénements utilisateur",
@@ -81,7 +81,7 @@
"category-events": "Événements de catégories",
"when-a-new-user-joins-your-group": "Lorsqu'un nouvel utilisateur rejoint votre groupe",
"recipe-events": "Événements de recette",
"label-events": "Label Events"
"label-events": "Étiquette des événements"
},
"general": {
"add": "Ajouter",
@@ -561,6 +561,7 @@
"see-original-text": "Afficher le texte original",
"original-text-with-value": "Texte original: {originalText}",
"ingredient-linker": "Liaison dingrédients",
"unlinked": "Not linked yet",
"linked-to-other-step": "Déjà associé à une autre étape",
"auto": "Auto",
"cook-mode": "Mode Cuisine",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -193,12 +193,12 @@
"delete-with-name": "Eyða út {name}",
"confirm-delete-generic-with-name": "Ertu viss um að þú viljir eyða út {name}?",
"confirm-delete-own-admin-account": "Please note that you are trying to delete your own admin account! This action cannot be undone and will permanently delete your account?",
"organizer": "Organizer",
"organizer": "Skipuleggjari",
"transfer": "Færa",
"copy": "Afrita",
"color": "Litur",
"timestamp": "Tímastimpill",
"last-made": "Last Made",
"last-made": "Síðast gert",
"learn-more": "Læra meira",
"this-feature-is-currently-inactive": "This feature is currently inactive",
"clipboard-not-supported": "Clipboard not supported",
@@ -214,16 +214,16 @@
"unsaved-changes": "You have unsaved changes. Do you want to save before leaving? Okay to save, Cancel to discard changes.",
"clipboard-copy-failure": "Failed to copy to the clipboard.",
"confirm-delete-generic-items": "Are you sure you want to delete the following items?",
"organizers": "Organizers",
"organizers": "Skipuleggjarar",
"caution": "Varúð",
"show-advanced": "Show Advanced",
"add-field": "Add Field",
"add-field": "Bæta við dálk",
"date-created": "Date Created",
"date-updated": "Date Updated"
"date-updated": "Dagsetning uppfærð"
},
"group": {
"are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete <b>{groupName}<b/>?",
"cannot-delete-default-group": "Cannot delete default group",
"are-you-sure-you-want-to-delete-the-group": "Ertu viss um að þú viljir eyða <b>{groupName}<b/>?",
"cannot-delete-default-group": "Ekki hægt að eyða sjálfvöldum hóp",
"cannot-delete-group-with-users": "Cannot delete group with users",
"confirm-group-deletion": "Confirm Group Deletion",
"create-group": "Búa til hóp",
@@ -237,18 +237,18 @@
"group-token": "Group Token",
"group-with-value": "Group: {groupID}",
"groups": "Hópar",
"manage-groups": "Manage Groups",
"manage-groups": "Umsjá hópa",
"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",
"settings": {
"keep-my-recipes-private": "Keep My Recipes Private",
"keep-my-recipes-private-description": "Sets your group and all recipes defaults to private. You can always change this later."
},
"manage-members": "Manage Members",
"manage-members": "Umsjá meðlima",
"manage-members-description": "Manage the permissions of the members in your household. {manage} allows the user to access the data-management page, and {invite} allows the user to generate invitation links for other users. Group owners cannot change their own permissions.",
"manage": "Manage",
"manage-household": "Manage Household",
"manage": "Umsjá",
"manage-household": "Umsjá heimilis",
"invite": "Bjóða",
"looking-to-update-your-profile": "Viltu uppfæra prófílinn þinn?",
"default-recipe-preferences-description": "Þetta eru sjálfgefnar stillingar þegar ný uppskrift er búin til í hópnum þínum. Hægt er að breyta þeim fyrir einstakar uppskriftir í stillingavalmynd uppskrifta.",
@@ -270,7 +270,7 @@
"disable-users-from-commenting-on-recipes-description": "Hides the comment section on the recipe page and disables commenting",
"disable-organizing-recipe-ingredients-by-units-and-food": "Disable organizing recipe ingredients by units and food",
"disable-organizing-recipe-ingredients-by-units-and-food-description": "Hides the Food, Unit, and Amount fields for ingredients and treats ingredients as plain text fields",
"general-preferences": "General Preferences",
"general-preferences": "Almenni valmöguleikar",
"group-recipe-preferences": "Group Recipe Preferences",
"report": "Skýrsla",
"report-with-id": "Report ID: {id}",
@@ -561,6 +561,7 @@
"see-original-text": "See Original Text",
"original-text-with-value": "Original Text: {originalText}",
"ingredient-linker": "Ingredient Linker",
"unlinked": "Not linked yet",
"linked-to-other-step": "Linked to other step",
"auto": "Auto",
"cook-mode": "Cook Mode",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -561,6 +561,7 @@
"see-original-text": "查看原文",
"original-text-with-value": "原文: {originalText}",
"ingredient-linker": "食材关联器",
"unlinked": "Not linked yet",
"linked-to-other-step": "已关联到其他步骤",
"auto": "自动",
"cook-mode": "烹饪模式",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { defineNuxtConfig } from "nuxt/config";
import commonjs from "vite-plugin-commonjs";
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": "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": "manifest", "href": "/manifest.webmanifest", "data-n-head": "ssr" },
],
},
@@ -126,12 +126,6 @@ export default defineNuxtConfig({
baseURL: process.env.SUB_PATH || "",
},
vite: {
plugins: [
commonjs(),
],
},
auth: {
isEnabled: true,
// disableServerSideAuth: true,
@@ -148,7 +142,7 @@ export default defineNuxtConfig({
signInResponseTokenPointer: "/access_token",
type: "Bearer",
cookieName: AUTH_TOKEN,
maxAgeInSeconds: 604800, // 7 days
maxAgeInSeconds: parseInt(process.env.TOKEN_TIME || "48") * 3600, // TOKEN_TIME is in hours
},
pages: {
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
},
// PWA module configuration: https://go.nuxtjs.dev/pwa
// PWA module configuration: https://vite-pwa-org.netlify.app/frameworks/nuxt.html
pwa: {
mode: process.env.NODE_ENV === "production" ? "production" : "development",
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: {
start_url: "/",
scope: "/",
lang: "en",
name: "Mealie",
short_name: "Mealie",
id: "mealie",
description: "Mealie is a recipe management and meal planning app",
theme_color: process.env.THEME_LIGHT_PRIMARY || "#E58325",
background_color: "#FFFFFF",
id: "/",
start_url: "/",
scope: "/",
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: [
"standalone",
"minimal-ui",
"browser",
"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: {
action: "/r/create/url",
method: "GET",
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",
// "url": "url",
},
},
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",
"version": "3.1.1",
"version": "3.2.0",
"private": true,
"scripts": {
"dev": "nuxt dev",
@@ -19,10 +19,8 @@
},
"dependencies": {
"@mdi/js": "^7.4.47",
"@nuxt/eslint": "1.2.0",
"@nuxt/fonts": "^0.11.4",
"@nuxtjs/i18n": "^9.2.1",
"@nuxtjs/proxy": "^2.1.0",
"@sidebase/nuxt-auth": "0.10.0",
"@vite-pwa/nuxt": "0.10.6",
"@vueuse/core": "^12.7.0",
@@ -32,16 +30,16 @@
"isomorphic-dompurify": "^2.22.0",
"json-editor-vue": "^0.18.1",
"marked": "^15.0.12",
"next-auth": "~4.21.1",
"next-auth": "~4.24.0",
"nuxt": "^3.15.4",
"typescript": "5.3",
"vite": "^6.2.0",
"vite-plugin-commonjs": "^0.10.4",
"vue-advanced-cropper": "^2.8.9",
"vue-draggable-plus": "^0.6.0",
"vuetify-nuxt-module": "0.18.3"
"vuetify": "^3.9.7",
"vuetify-nuxt-module": "^0.18.3"
},
"devDependencies": {
"@nuxt/eslint": "1.2.0",
"@nuxt/types": "^2.18.1",
"@nuxtjs/eslint-config-typescript": "^12.1.0",
"@nuxtjs/eslint-module": "^4.1.0",
@@ -56,6 +54,8 @@
"lint-staged": "^15.4.3",
"prettier": "^3.5.2",
"sass-embedded": "^1.85.1",
"typescript": "5.3",
"vite-plugin-commonjs": "^0.10.4",
"vitest": "^3.0.7"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"

View File

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

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