Compare commits

...

276 Commits

Author SHA1 Message Date
Michael Genson
db2c14093d fix: Explorer Page State Not Working On Hitting Back (#6171) 2025-09-14 22:28:17 -05:00
github-actions[bot]
9a0525c3a0 docs(auto): Update image tag, for release v3.2.0 (#6164)
Co-authored-by: michael-genson <71845777+michael-genson@users.noreply.github.com>
2025-09-13 22:05:25 +00:00
renovate[bot]
a2e5826da0 fix(deps): update dependency ingredient-parser-nlp to v2.3.0 (#6163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-13 16:54:11 -05:00
Michael Genson
d4f4ba0c8d fix: Ingredient Parser Drops Units Sometimes (#6150) 2025-09-13 15:49:08 -05:00
Michael Genson
8cd5835dd8 fix: Can't Edit Timeline Events (#6160) 2025-09-13 15:36:18 -05:00
renovate[bot]
7aa131b326 fix(deps): update dependency axios to v1.12.0 [security] (#6158)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-13 15:02:46 -05:00
Sören
af264bd288 fix: add breaks option to markdown rendering, to get old linebreak behaviour (#6156) 2025-09-13 17:29:23 +00:00
Hayden
72388e8bcf chore(l10n): New Crowdin updates (#6143) 2025-09-10 10:28:17 +02:00
Helge
c0afef46d6 docs: fix typo starting-dev-server.md (#6142) 2025-09-09 18:43:48 +00:00
Arsène Reymond
f90665cce9 feat: Improve first time setup ux (#6106) 2025-09-09 12:21:58 -05:00
renovate[bot]
942ac741cd fix(deps): update dependency next-auth to ~4.24.0 [security] (#6133)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 14:43:48 +00:00
Hayden
1d3a7e8d62 chore(l10n): New Crowdin updates (#6139) 2025-09-09 12:43:16 +00:00
renovate[bot]
5e85fc409e fix(deps): update dependency openai to v1.107.0 (#6129)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 08:35:08 +00:00
Michael Genson
2c20e96ede fix: Refactor and Optimize Explore Page Search (#6070)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-09-09 08:16:37 +00:00
renovate[bot]
608fc39747 chore(deps): update node.js to f3e50c7 (#6136)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 07:51:17 +00:00
renovate[bot]
ed2f40cd6a fix(deps): update dependency vite to v6.2.7 [security] (#6132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-09-09 07:37:46 +00:00
Michael Genson
a080cdb432 chore: Update GitHub Configs (#6135) 2025-09-09 07:21:06 +00:00
renovate[bot]
83101e3ed5 fix(deps): update dependency rapidfuzz to v3.14.1 (#6137)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 03:31:57 +00:00
renovate[bot]
5d90997ace chore(config): migrate renovate config (#6134)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 20:56:09 -05:00
Kuchenpirat
c78c6cf926 dev: list availlable frontend updates on renovate dependency dashboard (#6130) 2025-09-08 21:19:24 +00:00
Michael Genson
e26191d116 fix: Upgrade Vuetify, fix Dev Dependencies, and fix Migration Tree View (#6127) 2025-09-08 22:49:28 +02:00
Xavier L.
3774f68393 feat: Add option to switch sqlite to WAL (#6050)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-09-08 11:23:37 -05:00
Nico Hirsch
c46c412bf5 fix: Don't open the sidebar drawer by default on medium screens (#6107) 2025-09-08 14:58:39 +00:00
github-actions[bot]
aa9e61a16f chore(auto): Update pre-commit hooks (#6125)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-09-08 10:24:15 +00:00
Michael Genson
b2f8d63f33 fix: Missing Locale Dates (#6116) 2025-09-08 09:47:37 +00:00
Hayden
72b47a1103 chore(l10n): New Crowdin updates (#6123) 2025-09-08 02:50:03 +00:00
renovate[bot]
29e150d547 chore(deps): update dependency mkdocs-material to v9.6.19 (#6121)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-07 21:39:06 -05:00
Zach Wolf
e9ae6d86a4 docs: link to GitHub Release Notes (#6122)
Co-authored-by: TheMerinoWolf <zwolf@zwolf-mbp-16-m4.localdomain>
2025-09-08 02:08:43 +00:00
Hayden
f799938373 chore(l10n): New Crowdin updates (#6113) 2025-09-07 19:02:20 +00:00
github-actions[bot]
e5fff4ec5c chore: automatic locale sync (#6117)
Co-authored-by: GitHub Action <action@github.com>
2025-09-07 18:51:21 +00:00
Carl
192e531c1f Docs: Fix install grammar (#6118)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-09-07 18:31:32 +00:00
Michael Genson
45e710ee72 fix: Context Menu Dialogs Not Working (#6108) 2025-09-05 17:41:43 +02:00
Hayden
be579ed664 chore(l10n): New Crowdin updates (#6105) 2025-09-04 22:37:57 -05:00
renovate[bot]
fe953896f8 fix(deps): update dependency openai to v1.106.1 (#6103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 22:27:09 +00:00
renovate[bot]
decf7cb307 chore(deps): update dependency ruff to v0.12.12 (#6102)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 17:15:17 -05:00
Arsène Reymond
d396a8fdc2 fix: Cookboks page padding (#6097) 2025-09-04 19:59:54 +00:00
renovate[bot]
a3ef49f559 chore(deps): update dependency pytest to v8.4.2 (#6101)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 21:48:31 +02:00
Michael Genson
41e8458389 fix: Optimize Recipe Context Menu (#6071) 2025-09-04 16:19:47 +00:00
Hayden
18dc2fc6a8 chore(l10n): New Crowdin updates (#6100) 2025-09-04 18:08:58 +02:00
renovate[bot]
6355b3c8db fix(deps): update dependency openai to v1.106.0 (#6099)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 17:17:40 +02:00
renovate[bot]
3ac8af138f fix(deps): update dependency openai to v1.105.0 (#6094)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 13:35:58 +02:00
renovate[bot]
2b3803fb2e chore(deps): update node.js to d22c0ce (#6096)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 08:17:06 +02:00
renovate[bot]
6a80e70486 chore(deps): update node.js to bfee10f (#6095)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-03 22:09:22 +00:00
Hayden
f1dc854770 chore(l10n): New Crowdin updates (#6093) 2025-09-03 15:18:24 +00:00
Kuchenpirat
581aa929bd feat: consolidate settings gui (#6043)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-09-03 15:07:06 +00:00
Michael Genson
461e51bd22 fix: Optimize Recipe Favorites/Ratings (#6075) 2025-09-03 16:56:38 +02:00
Patrick Lehner (he/him)
1cdf43c599 fix: Shopping list top buttons layout (margin and row wrapping) (#6091) 2025-09-03 09:26:25 +00:00
Arsène Reymond
6bfbc7ca0a fix: set touchless on AppSidebar (#6092) 2025-09-03 09:11:36 +00:00
Michael Genson
608dbaa4c1 fix: Incorrect Usage of $vuetify.display (#6066) 2025-09-03 08:36:42 +00:00
renovate[bot]
89c1e007cb fix(deps): update dependency openai to v1.104.2 (#6086)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-03 08:27:44 +02:00
Hayden
fb5db583d2 chore(l10n): New Crowdin updates (#6088) 2025-09-03 06:09:31 +00:00
Michael Genson
bef3045e65 fix: Make Frontend Respect TOKEN_TIME (#6089) 2025-09-03 05:56:54 +00:00
Michael Genson
ff958a5015 fix: Fix PWA (#6090) 2025-09-03 07:44:52 +02:00
Hayden
37789c342e chore(l10n): New Crowdin updates (#6080)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-09-02 16:46:31 +00:00
renovate[bot]
b6b8bea925 fix(deps): update dependency openai to v1.103.0 (#6083)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 18:33:01 +02:00
Patrick Lehner (he/him)
60834178ba docs: Fix list formatting on 'Features' docs page (#6082) 2025-09-02 10:16:36 -05:00
github-actions[bot]
0375a0bd5a chore(auto): Update pre-commit hooks (#6077)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-09-01 15:54:52 +00:00
Patrick Lehner (he/him)
3361f9a7c3 fix: Fix RecipeLastMade dialog date picker being off by a day (#6079) 2025-09-01 10:44:30 -05:00
Hayden
0883ef05ab chore(l10n): New Crowdin updates (#6076) 2025-08-31 22:13:27 -05:00
Hayden
c4eb020a66 chore(l10n): New Crowdin updates (#6073) 2025-08-31 11:25:57 -05:00
github-actions[bot]
600f407b4f chore: automatic locale sync (#6069)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-31 02:51:20 +00:00
Hayden
6f92a829d6 chore(l10n): New Crowdin updates (#6067) 2025-08-30 21:41:32 -05:00
Hayden
6b11ff5128 chore(l10n): New Crowdin updates (#6063) 2025-08-30 15:48:37 +00:00
renovate[bot]
29fdad1574 chore(deps): update dependency coverage to v7.10.6 (#6062)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-29 23:52:17 -05:00
Hayden
54b3df105c chore(l10n): New Crowdin updates (#6058) 2025-08-29 22:00:47 +00:00
Richard vL
9a3303b06c fix: re-ordering of cookbooks (#5975)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-29 21:50:09 +00:00
Andrew Brock
c17accd82b fix: import from Paprika not importing some images (#5911)
Co-authored-by: brokeh <git@brocky.net>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-29 21:39:37 +00:00
Felix Schneider
18f7e8d935 feat: group recipe ingredients by section titles (#5864)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-29 21:25:25 +00:00
Xavier L.
6d2936cab6 fix: Handle missing OIDC groups claim (#6054) 2025-08-29 21:07:00 +00:00
renovate[bot]
cc2e33a254 chore(deps): update dependency ruff to v0.12.11 (#6056)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-28 17:03:16 +00:00
renovate[bot]
eee6f8113c fix(deps): update dependency alembic to v1.16.5 (#6048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-28 09:42:40 +02:00
Hayden
bd10cb8cd8 chore(l10n): New Crowdin updates (#6049) 2025-08-28 07:24:34 +02:00
renovate[bot]
d03081c4e6 fix(deps): update dependency authlib to v1.6.3 (#6018)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 17:56:03 +00:00
renovate[bot]
64d865bf7e chore(deps): update dependency coverage to v7.10.5 (#6021)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 17:44:26 +00:00
renovate[bot]
27efda2772 fix(deps): update dependency rapidfuzz to v3.14.0 (#6044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 19:33:05 +02:00
renovate[bot]
81986e63b8 fix(deps): update dependency beautifulsoup4 to v4.13.5 (#6026)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 17:46:07 +02:00
Michael Genson
42eef17cfb fix: Make String Cleaner More Robust (#6032) 2025-08-27 14:19:43 +00:00
renovate[bot]
1f724856b1 fix(deps): update dependency typing-extensions to v4.15.0 (#6035)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 16:06:53 +02:00
renovate[bot]
618ea06b7a fix(deps): update dependency orjson to v3.11.3 (#6041)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 14:52:29 +02:00
Hayden
ca2039ae35 chore(l10n): New Crowdin updates (#6034)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-27 10:47:56 +00:00
renovate[bot]
15ecab86d1 fix(deps): update dependency openai to v1.102.0 (#6042)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 12:36:20 +02:00
github-actions[bot]
aa164424d3 docs(auto): Update image tag, for release v3.1.2 (#6037)
Co-authored-by: michael-genson <71845777+michael-genson@users.noreply.github.com>
2025-08-25 18:25:01 +00:00
renovate[bot]
99acb349bd fix(deps): update dependency lxml to v6.0.1 (#6011)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 13:14:05 -05:00
Michael Genson
894162a669 fix: Remove Frontend Timeout (#6033) 2025-08-25 12:28:43 -05:00
Michael Genson
347af7d417 fix: Can't add first shopping list item to shopping list (#6013) 2025-08-25 11:53:36 -05:00
Michael Genson
cac1699aeb fix: Light Mode Using Dark Mode Background Color (#6014) 2025-08-25 13:34:00 +00:00
Hayden
d577966bfb chore(l10n): New Crowdin updates (#6017)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-25 11:21:11 +00:00
github-actions[bot]
c663efde09 chore(auto): Update pre-commit hooks (#6029)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-25 07:31:11 +00:00
Michael Genson
9e568a1182 fix: Simplify AutoForm and fix select (#6022)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-25 07:19:46 +00:00
github-actions[bot]
fc38ef2ba9 chore: automatic locale sync (#6024) 2025-08-25 06:46:24 +00:00
Michael Genson
323a8100db fix: Remove Temperature from OpenAI Integration (#6023) 2025-08-25 08:36:15 +02:00
DrDonoso
01d3d5d325 fix: theme dark/light are swapped (#6001)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-22 18:27:57 +00:00
renovate[bot]
3f52c66f02 chore(deps): update dependency mkdocs-material to v9.6.18 (#6008)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 19:20:09 +02:00
Hayden
566f744220 chore(l10n): New Crowdin updates (#6009) 2025-08-22 17:33:12 +02:00
renovate[bot]
561b50ba45 chore(deps): update dependency ruff to v0.12.10 (#6004)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 11:21:46 +02:00
renovate[bot]
4228c9e753 fix(deps): update dependency openai to v1.101.0 (#6005)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 08:59:19 +02:00
Hayden
2a5c3f6457 chore(l10n): New Crowdin updates (#6006) 2025-08-22 08:30:41 +02:00
Hayden
389f8b4279 chore(l10n): New Crowdin updates (#5999) 2025-08-21 17:42:31 +02:00
Hayden
f2b71e981e chore(l10n): New Crowdin updates (#5995) 2025-08-20 08:05:17 +00:00
github-actions[bot]
ec7e3a5103 docs(auto): Update image tag, for release v3.1.1 (#5994)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-20 05:57:49 +00:00
eMerzh
6f0183cc4b feat: Allow env_nested config with __ (#5616) 2025-08-19 21:00:53 +00:00
renovate[bot]
12d38c89ea fix(deps): update dependency requests to v2.32.5 (#5987)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-19 12:38:30 -05:00
renovate[bot]
492c9a948d fix(deps): update dependency openai to v1.100.2 (#5993)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 19:23:24 +02:00
github-actions[bot]
a808c8a18b docs(auto): Update image tag, for release 3.1.0 (#5992)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-19 18:49:54 +02:00
Hayden
0c6483aefa chore(l10n): New Crowdin updates (#5991) 2025-08-19 18:21:45 +02:00
renovate[bot]
1568c33c37 fix(deps): update dependency openai to v1.100.1 (#5986)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 14:07:56 +02:00
Hayden
5d0cf8422b chore(l10n): New Crowdin updates (#5989) 2025-08-19 13:27:24 +02:00
tauhammerhead
d322abc1b4 fix: Update variable name in RecipeCard.vue to enable household ratings to appear on recipes (#5985)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-18 19:55:48 +02:00
Michael Genson
c34180632b fix: Missing Items On Admin Pages (#5984) 2025-08-18 19:21:29 +02:00
Hayden
c74f016f66 chore(l10n): New Crowdin updates (#5982) 2025-08-18 13:07:38 +00:00
Felix Schneider
e11ee47109 feat: save default recipe ordering in local storage (#5826)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-18 12:35:12 +00:00
github-actions[bot]
a044ca2779 chore(auto): Update pre-commit hooks (#5981)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-18 11:35:41 +00:00
Hayden
f584fa33f4 chore(l10n): New Crowdin updates (#5979) 2025-08-18 13:25:23 +02:00
Hayden
4f33c3a44f chore(l10n): New Crowdin updates (#5965) 2025-08-17 07:52:13 +00:00
github-actions[bot]
394c271294 chore: automatic locale sync (#5969)
Co-authored-by: GitHub Action <action@github.com>
2025-08-17 07:13:48 +00:00
renovate[bot]
cb3eb59501 chore(deps): update dependency coverage to v7.10.4 (#5967)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-17 09:02:55 +02:00
Mario Džoić
c41a4a52ed fix: error when trying to change recipe image (#5771)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-16 08:41:46 +00:00
Michael Genson
6cbc308d83 fix: Add Recipe From Another Household To Shopping List (#5892) 2025-08-16 08:05:50 +00:00
Hayden
db765b0693 chore(l10n): New Crowdin updates (#5964) 2025-08-16 09:55:13 +02:00
renovate[bot]
184262df17 chore(deps): update dependency mkdocs-material to v9.6.17 (#5962)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-15 19:10:34 +00:00
renovate[bot]
df10cf8211 fix(deps): update dependency ingredient-parser-nlp to v2.2.0 (#5963)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-15 18:58:47 +00:00
Hristo Kapanakov
c91d216fe9 feat: Allow using OIDC auth cache instead of session (#5746)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-15 09:43:29 +00:00
Michael Genson
1c23d855ae fix: Remove Recipes From Cookbook API (#5899) 2025-08-15 08:44:45 +00:00
Hayden
fd966e5843 chore(l10n): New Crowdin updates (#5958) 2025-08-15 00:01:31 +00:00
renovate[bot]
f9f1285cb3 chore(deps): update dependency ruff to v0.12.9 (#5956)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-14 12:39:52 -05:00
renovate[bot]
dbf1202f69 chore(deps): update node.js to 572a90d (#5955)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-14 12:21:14 -05:00
renovate[bot]
b3f12ee7ac fix(deps): update dependency orjson to v3.11.2 (#5941)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-14 15:39:50 +00:00
renovate[bot]
556dc699c7 chore(deps): update node.js to 08535d6 (#5952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-14 10:27:47 -05:00
Michael Genson
481ce92d84 fix: CONTAINS ALL doesn't contain all (#5900) 2025-08-14 12:21:40 +00:00
Michael Genson
1df26aeb99 fix: Add Hint Text To Apprise URL (#5895) 2025-08-14 12:10:44 +00:00
Hayden
5c4694c3d8 chore(l10n): New Crowdin updates (#5953) 2025-08-14 11:50:10 +00:00
Michael Genson
9b2b8091ac fix: Auto Form Select (#5919)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-14 11:24:21 +00:00
Michael Genson
ad60b4445b fix: User Registration Form Validation and Other Setup Wizard Things (#5920) 2025-08-14 07:26:24 +00:00
Hayden
040fb48807 chore(l10n): New Crowdin updates (#5943)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-14 05:44:38 +00:00
Michael Genson
f8ce5b3afb feat: Pin Docker Digest And Add to Renovate (#5949) 2025-08-14 05:33:46 +00:00
Michael Genson
31530a68e1 feat: Remove Not-Sort-By-Label and Refactor Shopping List Page (#5866)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-14 07:23:11 +02:00
Denis Danilov
51ca65e3c3 fix: change libldap-2.5 to libldap2 in docker (#5946) 2025-08-13 21:57:28 +00:00
renovate[bot]
19eae21b00 fix(deps): update dependency openai to v1.99.9 (#5939)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 16:20:42 +02:00
renovate[bot]
34e4480f08 fix(deps): update dependency tzdata to v2025 (#5942)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-12 18:11:02 +00:00
Hayden
ad875c8fd5 chore(l10n): New Crowdin updates (#5938) 2025-08-12 11:44:55 +02:00
renovate[bot]
6ec7baa2f1 fix(deps): update dependency openai to v1.99.8 (#5935)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 19:24:08 -05:00
Hayden
f39929a905 chore(l10n): New Crowdin updates (#5936) 2025-08-11 21:39:08 +00:00
Ross
4b69e5b33a feat: Button to select recipe cover image when creating recipe from multiple images (#5647)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
Co-authored-by: Kuchenpirat <jojow@gmx.net>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-11 21:17:28 +00:00
renovate[bot]
4c2b559e73 chore(deps): update dependency coverage to v7.10.3 (#5932)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 19:19:24 +00:00
renovate[bot]
178e038c79 fix(deps): update dependency sqlalchemy to v2.0.43 (#5934)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 19:07:20 +00:00
Skye Samuels
0b3fe2c8da fix: add confidence calculation for BruteForceParser (#5903)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-11 18:56:35 +00:00
renovate[bot]
d4e62c5ab6 fix(deps): update dependency openai to v1.99.7 (#5924)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 18:40:48 +00:00
Hayden
0f591fd273 chore(l10n): New Crowdin updates (#5926) 2025-08-11 18:30:08 +00:00
renovate[bot]
d271252ecc fix(deps): update dependency recipe-scrapers to v15.9.0 (#5925) 2025-08-11 13:18:48 -05:00
github-actions[bot]
4d211e236d chore: automatic locale sync (#5929) 2025-08-11 17:36:43 +00:00
github-actions[bot]
7926812136 chore(auto): Update pre-commit hooks (#5933)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-08-11 16:39:36 +00:00
Richard vL
f37d8e488f fix: Added copy icons to first-login message (#5716)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-11 16:27:10 +00:00
renovate[bot]
56df696546 chore(deps): update dependency pre-commit to v4.3.0 (#5928) 2025-08-10 01:54:37 +00:00
Craig Matear
d3436a5ca8 feat: Add label notifier (#5879)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-10 01:43:23 +00:00
renovate[bot]
9e46c57e78 chore(deps): update dependency pylint to v3.3.8 (#5923)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-09 16:17:43 +00:00
renovate[bot]
79b6be8550 chore(deps): update dependency freezegun to v1.5.5 (#5922)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-09 11:01:23 -05:00
Hayden
bd9e654de7 chore(l10n): New Crowdin updates (#5918)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-08 17:50:48 +00:00
renovate[bot]
f149bcc98f fix(deps): update dependency openai to v1.99.5 (#5917) 2025-08-08 17:15:06 +00:00
Hayden
0095cc7153 chore(l10n): New Crowdin updates (#5915) 2025-08-08 08:28:00 +02:00
renovate[bot]
f276ecd96e fix(deps): update dependency openai to v1.99.3 (#5914)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-07 21:58:57 +00:00
renovate[bot]
87bfdb633b chore(deps): update dependency ruff to v0.12.8 (#5913)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-07 16:47:05 -05:00
Hayden
e47f386643 chore(l10n): New Crowdin updates (#5912) 2025-08-07 21:30:31 +00:00
renovate[bot]
5c9fd22f11 chore(deps): update dependency coverage to v7.10.2 (#5887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-07 17:18:44 +00:00
renovate[bot]
30ee276e52 fix(deps): update dependency pillow-heif to v1.1.0 (#5870)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-07 12:07:04 -05:00
Patrick Lehner (he/him)
50c8e9be79 feat: Move create-item button in shopping list to the top (#5687)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-08-07 15:47:21 +00:00
Hayden
c4fdcec85f chore(l10n): New Crowdin updates (#5908) 2025-08-07 08:49:42 +02:00
renovate[bot]
f7b32debbb fix(deps): update dependency openai to v1.99.1 (#5901)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 15:02:11 +00:00
Hayden
1f1cc10f21 chore(l10n): New Crowdin updates (#5904) 2025-08-06 16:50:43 +02:00
Will Ratner
2ae3427a59 fix: correct JPEG media type in get_image_url to prevent API errors (#5897)
Co-authored-by: Will Ratner <will@ratner.tech>
2025-08-05 16:12:42 +00:00
Hayden
357f843df5 chore(l10n): New Crowdin updates (#5896) 2025-08-05 09:04:54 +02:00
lucasfijen
3973fe99a1 docs: Fix broken postgres docker-compose example (#5894) 2025-08-04 14:21:42 -05:00
github-actions[bot]
8d51c14d24 chore(auto): Update pre-commit hooks (#5889)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-08-04 11:09:10 +00:00
Hayden
f2af8a0bc1 chore(l10n): New Crowdin updates (#5890) 2025-08-04 12:58:44 +02:00
Hayden
c130e8e92d chore(l10n): New Crowdin updates (#5884) 2025-08-03 22:50:17 +02:00
github-actions[bot]
ea099a743b chore: automatic locale sync (#5881)
Co-authored-by: GitHub Action <action@github.com>
2025-08-03 11:02:19 +00:00
Hayden
d21e685219 chore(l10n): New Crowdin updates (#5882) 2025-08-03 12:27:59 +02:00
renovate[bot]
04d0144369 fix(deps): update dependency apprise to v1.9.4 (#5878)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-02 20:02:48 +00:00
Hayden
8a8580e83c chore(l10n): New Crowdin updates (#5877) 2025-08-02 19:51:01 +00:00
Hayden
6b6c167153 chore(l10n): New Crowdin updates (#5869) 2025-08-02 07:37:58 +00:00
Hayden
e1c4a703a2 chore(l10n): New Crowdin updates (#5867) 2025-08-01 19:35:27 +00:00
Felix Schneider
591c96e52b chore: update references to GitHub repository (#5861) 2025-08-01 08:43:57 +00:00
Hayden
b157c7034f chore(l10n): New Crowdin updates (#5862) 2025-08-01 10:32:26 +02:00
Michael Genson
245ca5fe3b feat: Remove "Is Food" and "Disable Amounts" Flags (#5684)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-31 17:36:24 +02:00
Kuchenpirat
efc0d31724 fix: tags & tools edit confirm (#5860)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-07-31 15:06:59 +00:00
renovate[bot]
4b7f7b4b8a chore(deps): update dependency freezegun to v1.5.4 (#5853)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-31 16:07:14 +02:00
renovate[bot]
7aee575352 chore(deps): update dependency mypy to v1.17.1 (#5856)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-31 15:00:16 +02:00
Hayden
2ef5b0d389 chore(l10n): New Crowdin updates (#5855) 2025-07-31 08:55:26 +02:00
Hayden
387a12cf1a chore(l10n): New Crowdin updates (#5854) 2025-07-30 14:11:38 -05:00
Kuchenpirat
f26e74f0f2 chore: script setup #3 - recipe components (#5849) 2025-07-30 18:37:02 +00:00
renovate[bot]
f2b6512eb1 fix(deps): update dependency openai to v1.98.0 (#5852)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-30 19:29:44 +02:00
Hayden
93f3762016 chore(l10n): New Crowdin updates (#5847) 2025-07-30 09:41:00 +02:00
Kuchenpirat
620465f14c fix: script setup #2 and some fixes (#5845) 2025-07-30 00:05:26 +00:00
renovate[bot]
a132b83f1b chore(deps): update dependency ruff to v0.12.7 (#5843)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-29 22:58:02 +00:00
Mario Džoić
5f522b5324 fix: allow admin users to delete other household recipes (#5767)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-07-30 00:46:23 +02:00
renovate[bot]
bd0aed06ce chore(deps): update dependency ruff to v0.12.6 (#5840)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-30 00:12:47 +02:00
Michael Genson
f9f88fb8a4 fix: Nuxt 3 Ingredient Parsing Issues and Tooltip Positions (#5829)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-29 21:53:33 +00:00
Kuchenpirat
eefe613aaf fix: QueryFilter Hydration & script setup (#5839) 2025-07-29 21:43:13 +00:00
Michael Genson
d6d247f1f8 fix: Missing Yield Text (#5827) 2025-07-29 20:33:44 +00:00
Michael Genson
3b74ddd9ad fix: Delete Group From Admin Page (#5837) 2025-07-29 18:53:52 +00:00
Hayden
eec4eeb76a chore(l10n): New Crowdin updates (#5838) 2025-07-29 20:42:51 +02:00
renovate[bot]
73a9e470c3 fix(deps): update dependency sqlalchemy to v2.0.42 (#5836)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-29 12:53:53 -05:00
Sebastian Frysztak
b578d8d4f7 fix: remove v-lazy from RecipeCard (#5835) 2025-07-29 11:28:03 -05:00
Michael Genson
7a43546eeb fix: Data Management Headers (#5830) 2025-07-29 09:05:46 +00:00
Hayden
e7c310934d chore(l10n): New Crowdin updates (#5831) 2025-07-29 10:55:03 +02:00
Hayden
07d5928f18 chore(l10n): New Crowdin updates (#5828) 2025-07-28 20:17:45 +02:00
Michael Genson
fa5bc17ed8 fix: Manual Serving Edits (#5813) 2025-07-28 17:54:59 +02:00
Mario Džoić
f8cb80ed7f fix: make only checkbox reactive (#5739)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-28 13:09:00 +00:00
Felix Schneider
066cd13e13 fix: Reduce margin in RecipePageInstructions (#5783)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-28 12:44:09 +00:00
Michael Genson
a087760d53 fix: Optimize Recipe Timeline Requests (#5811) 2025-07-28 11:25:49 +00:00
Michael Genson
675ac9c32b fix: Make Sure Test Webhook Always Fires (#5816)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-28 08:12:30 +00:00
Michael Genson
d7191983bd fix: JSON Editor Breaks On Invalid JSON (#5814)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-28 07:50:50 +00:00
renovate[bot]
14b3fd524f chore(deps): update dependency coverage to v7.10.1 (#5821)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-28 09:39:32 +02:00
github-actions[bot]
38ed0d0532 chore: automatic locale sync (#5815)
Co-authored-by: GitHub Action <action@github.com>
2025-07-28 07:28:24 +00:00
github-actions[bot]
3b48f73b91 chore(auto): Update pre-commit hooks (#5825)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-28 07:17:03 +00:00
Hayden
ae7e7942e3 chore(l10n): New Crowdin updates (#5817) 2025-07-28 09:07:03 +02:00
Felix Schneider
ec1eddc06d fix: add confirm button to bulk delete of tags (#5785)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-27 03:04:41 +02:00
Hayden
2781771f6b chore(l10n): New Crowdin updates (#5809) 2025-07-26 17:22:36 +00:00
renovate[bot]
9b35a2f904 chore(deps): update dependency mkdocs-material to v9.6.16 (#5808)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-26 17:11:02 +00:00
renovate[bot]
a323350915 fix(deps): update dependency orjson to v3.11.1 (#5802)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-26 16:59:04 +00:00
renovate[bot]
9b94cfdd24 chore(deps): update dependency rich to v14.1.0 (#5800)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-26 11:46:15 -05:00
Hayden
2818ff56ee chore(l10n): New Crowdin updates (#5805) 2025-07-26 16:31:54 +00:00
renovate[bot]
d728df7d40 chore(deps): update dependency coverage to v7.10.0 (#5796)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-26 11:21:06 -05:00
Hayden
a531eb649e chore(l10n): New Crowdin updates (#5804) 2025-07-25 18:46:56 +02:00
Michael Genson
fb4aa2b713 fix: Better UX and Error Handling For Adding Timeline Events (#5798) 2025-07-25 12:18:10 +00:00
renovate[bot]
0df9d4b958 chore(deps): update dependency ruff to v0.12.5 (#5795)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-25 14:03:10 +02:00
Felix Schneider
99523c70ed fix: send the correct value for recipe scale and ensure the body is sent correctly (#5737) 2025-07-25 11:42:17 +00:00
Hayden
5c7a4fb861 chore(l10n): New Crowdin updates (#5794) 2025-07-25 12:22:29 +02:00
Hayden
436a24f8b2 chore(l10n): New Crowdin updates (#5792) 2025-07-24 09:34:34 +02:00
Hayden
93534de638 chore(l10n): New Crowdin updates (#5787) 2025-07-22 22:52:21 -05:00
renovate[bot]
f29a11d20e fix(deps): update dependency openai to v1.97.1 (#5781)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 14:47:52 +00:00
Hayden
880bd91f4a chore(l10n): New Crowdin updates (#5780) 2025-07-22 16:36:05 +02:00
Hayden
8e68782ff6 chore(l10n): New Crowdin updates (#5775) 2025-07-21 22:46:59 -05:00
Mario Džoić
c1e5937ff3 fix: cookbook random recipe selector (#5768) 2025-07-21 15:02:36 +00:00
Hayden
6d1e39f871 chore(l10n): New Crowdin updates (#5770) 2025-07-21 14:12:38 +00:00
github-actions[bot]
5b92e969dc docs(auto): Update image tag, for release v3.0.2 (#5769)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-21 14:02:45 +00:00
github-actions[bot]
992a74499d chore(auto): Update pre-commit hooks (#5765)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-21 15:52:31 +02:00
github-actions[bot]
4744e6371a chore: automatic locale sync (#5766)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-21 08:44:33 +00:00
Michael Genson
00a03559ff fix: RTL Settings Ignored (#5762) 2025-07-21 07:51:28 +00:00
Hayden
a1a45078fb chore(l10n): New Crowdin updates (#5764) 2025-07-21 09:39:25 +02:00
Felix Schneider
88d0b466ce fix: style of recipe actions to be compliant with design schema (#5736) 2025-07-20 18:05:56 +02:00
Kuchenpirat
e638df37d1 fix: multiple regressions on data management page (#5758) 2025-07-20 15:06:39 +00:00
renovate[bot]
44b180f5c0 fix(deps): update dependency authlib to v1.6.1 (#5757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-20 09:49:20 -05:00
Hayden
3feb11d138 chore(l10n): New Crowdin updates (#5759) 2025-07-20 14:03:15 +00:00
github-actions[bot]
41633be864 chore: automatic locale sync (#5756)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-20 09:45:39 +00:00
Hayden
ce8fa2194b chore(l10n): New Crowdin updates (#5755) 2025-07-20 09:35:33 +00:00
Michael Genson
7d1c5ad14b fix: Patch XSS Vulnerability (#5754) 2025-07-19 20:45:33 -05:00
renovate[bot]
6274a3dd39 fix(deps): update dependency orjson to v3.11.0 (#5727)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 15:04:20 -05:00
renovate[bot]
a1e8e1aa20 chore(deps): update dependency pytest-asyncio to v1.1.0 (#5730)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 12:34:22 -05:00
renovate[bot]
b4aff0d8e9 fix(deps): update dependency openai to v1.97.0 (#5726)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 17:17:48 +00:00
renovate[bot]
e07df8fc43 chore(deps): update dependency mypy to v1.17.0 (#5718)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 17:05:44 +00:00
renovate[bot]
9413e403b4 fix(deps): update dependency alembic to v1.16.4 (#5661)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 16:54:04 +00:00
renovate[bot]
6eab88d44c fix(deps): update dependency fastapi to ^0.116.0 (#5654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 16:42:18 +00:00
renovate[bot]
754878eb63 chore(deps): update dependency coverage to v7.9.2 (#5622)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 11:26:37 -05:00
Hayden
c65456c646 chore(l10n): New Crowdin updates (#5750) 2025-07-19 15:58:43 +02:00
Hayden
dd55d23a37 chore(l10n): New Crowdin updates (#5749) 2025-07-19 09:04:57 +02:00
Michael Genson
aafed68964 fix: Mealplan Regressions (#5748) 2025-07-18 22:24:59 +00:00
Jason Frank
108ac40b22 fix: Update admin_backups.py to handle API backup file uploads correctly. (#5715)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-07-18 22:14:33 +00:00
renovate[bot]
d2e9c04af1 chore(deps): update dependency ruff to v0.12.4 (#5743)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-18 16:58:10 -05:00
Mario Džoić
3a1f58037d fix: meal planner date range is correctly set (#5725)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-07-18 14:40:35 -05:00
Hayden
2731fb4a01 chore(l10n): New Crowdin updates (#5744) 2025-07-18 13:21:23 +00:00
Hayden
769db7202d chore(l10n): New Crowdin updates (#5728) 2025-07-18 11:06:11 +02:00
Hayden
644e871ec1 chore(l10n): New Crowdin updates (#5722) 2025-07-15 12:43:15 +02:00
Hayden
8987faa4f6 chore(l10n): New Crowdin updates (#5717) 2025-07-15 06:21:46 +02:00
Hayden
c237b33126 chore(l10n): New Crowdin updates (#5714) 2025-07-14 14:34:56 +02:00
github-actions[bot]
7098b67784 chore(auto): Update pre-commit hooks (#5713)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-07-14 09:10:51 +00:00
renovate[bot]
2d21d00651 fix(deps): update dependency openai to v1.95.1 (#5683)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 11:00:06 +02:00
Hayden
198d5e4e06 chore(l10n): New Crowdin updates (#5710) 2025-07-13 21:39:56 +00:00
github-actions[bot]
98fc333df3 docs(auto): Update image tag, for release v3.0.1 (#5708)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-13 15:04:56 +00:00
renovate[bot]
c0fb27f979 chore(deps): update dependency freezegun to v1.5.3 (#5702)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 16:54:11 +02:00
github-actions[bot]
9ec1599427 chore: automatic locale sync (#5705)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-13 14:36:47 +00:00
Kuchenpirat
9cfc54b1f5 fix: user & household creation (#5699) 2025-07-13 13:57:28 +00:00
Hayden
40d2ac9a6b chore(l10n): New Crowdin updates (#5706) 2025-07-13 12:05:51 +02:00
Hayden
44db525049 chore(l10n): New Crowdin updates (#5701) 2025-07-12 21:41:46 +00:00
Kuchenpirat
d737cb3e14 fix: set correct github tag in init py (#5693) 2025-07-12 08:54:14 -05:00
Hayden
1034d87a99 chore(l10n): New Crowdin updates (#5691) 2025-07-12 12:30:57 +02:00
Kuchenpirat
1243e6804c fix: crud table bulk actions (#5686) 2025-07-12 00:47:54 +00:00
369 changed files with 28762 additions and 27650 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

@@ -17,7 +17,7 @@ jobs:
name: Build Package
uses: ./.github/workflows/build-package.yml
with:
tag: release
tag: ${{ github.event.release.tag_name }}
publish:
permissions:

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: check-yaml
exclude: "mkdocs.yml"
@@ -12,7 +12,7 @@ repos:
exclude: ^tests/data/
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.2
rev: v0.12.12
hooks:
- id: ruff
- id: ruff-format

View File

@@ -71,6 +71,7 @@ tasks:
desc: run code generators
cmds:
- poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
- task: docs:gen
- task: py:format
dev:services:
@@ -87,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

@@ -35,7 +35,7 @@ conventional_commits = true
filter_unconventional = true
# regex for preprocessing the commit messages
commit_preprocessors = [
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/hay-kot/mealie/issues/${2}))"},
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/mealie-recipes/mealie/issues/${2}))"},
]
# regex for parsing and grouping commits
commit_parsers = [

View File

@@ -173,15 +173,36 @@ 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"):
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}" }},'
match_data = LOCALE_DATA.get(match.stem)
match_dir = match_data.dir if match_data else "ltr"
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},'
all_langs.append(lang_string)
all_langs.sort()
log.debug(f"injecting locales into nuxt config -> {nuxt_config}")
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)
inject_inline(i18n_config, CodeKeys.nuxt_local_dates, all_date_locales)

View File

@@ -8,8 +8,8 @@ from utils import log
# ============================================================
template = """// This Code is auto generated by gen_ts_types.py
{% for name in global %}import {{ name }} from "@/components/global/{{ name }}.vue";
{% endfor %}{% for name in layout %}import {{ name }} from "@/components/layout/{{ name }}.vue";
{% for name in global %}import type {{ name }} from "@/components/global/{{ name }}.vue";
{% endfor %}{% for name in layout %}import type {{ name }} from "@/components/layout/{{ name }}.vue";
{% endfor %}
declare module "vue" {
export interface GlobalComponents {

View File

@@ -44,7 +44,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 cup unsalted butter, cut into cubes",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "ea3b6702-9532-4fbc-a40b-f99917831c26",
@@ -54,7 +53,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 cup light brown sugar",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "c5bbfefb-1e23-4ffd-af88-c0363a0fae82",
@@ -64,7 +62,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1/2 cup granulated white sugar",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "034f481b-c426-4a17-b983-5aea9be4974b",
@@ -74,7 +71,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "2 large eggs",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "37c1f796-3bdb-4856-859f-dbec90bc27e4",
@@ -84,7 +80,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "2 tsp vanilla extract",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "85561ace-f249-401d-834c-e600a2f6280e",
@@ -94,7 +89,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1/2 cup creamy peanut butter",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "ac91bda0-e8a8-491a-976a-ae4e72418cfd",
@@ -104,7 +98,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 tsp cornstarch",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "4d1256b3-115e-4475-83cd-464fbc304cb0",
@@ -114,7 +107,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 tsp baking soda",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "64627441-39f9-4ee3-8494-bafe36451d12",
@@ -124,7 +116,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1/2 tsp salt",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "7ae212d0-3cd1-44b0-899e-ec5bd91fd384",
@@ -134,7 +125,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 cup cake flour",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "06967994-8548-4952-a8cc-16e8db228ebd",
@@ -144,7 +134,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "2 cups all-purpose flour",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "bdb33b23-c767-4465-acf8-3b8e79eb5691",
@@ -154,7 +143,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "2 cups peanut butter chips",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "12ba0af8-affd-4fb2-9cca-6f1b3e8d3aef",
@@ -164,7 +152,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1½ cups Reese's Pieces candies",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "4bdc0598-a3eb-41ee-8af0-4da9348fbfe2",
@@ -221,7 +208,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"showAssets": False,
"landscapeView": False,
"disableComments": False,
"disableAmount": True,
"locked": False,
},
"assets": [],

View File

@@ -1,7 +1,8 @@
###############################################
# Frontend Build
###############################################
FROM node:20 AS frontend-builder
FROM node:20@sha256:f3e50c7689a1b6982fab45b1b23ba5adf1fd725e233dc640918fb59f7a57b174 \
AS frontend-builder
WORKDIR /frontend
@@ -20,7 +21,8 @@ RUN yarn generate
###############################################
# Base Image - Python
###############################################
FROM python:3.12-slim AS python-base
FROM python:3.12-slim@sha256:2267adc248a477c1f1a852a07a5a224d42abe54c28aafa572efa157dfb001bba \
AS python-base
ENV MEALIE_HOME="/app"
@@ -132,7 +134,7 @@ RUN apt-get update \
gosu \
iproute2 \
libldap-common \
libldap-2.5 \
libldap2 \
&& rm -rf /var/lib/apt/lists/*
# create directory used for Docker Secrets

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

@@ -13,14 +13,14 @@ Steps:
#### 1. Get your API Token
Create an API token from Mealie's User Settings page (https://hay-kot.github.io/mealie/documentation/users-groups/user-settings/#api-key-generation)
Create an API token from Mealie's User Settings page (https://docs.mealie.io/documentation/getting-started/api-usage/#getting-a-token)
#### 2. Create Home Assistant Sensors
Create REST sensors in home assistant to get the details of today's meal.
We will create sensors to get the name and ID of the first meal in today's meal plan (note that this may not be what is wanted if there is more than one meal planned for the day). We need the ID as well as the name to be able to retrieve the image for the meal.
Make sure the url and port (`http://mealie:9000` ) matches your installation's address and _API_ port.
Make sure the url and port (`http://mealie:9000`) matches your installation's address and _API_ port.
```yaml
rest:

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
1. Take a backup just in case!
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.0.0`
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

@@ -1,5 +1,8 @@
# Installing with PostgreSQL
!!! Warning
When upgrading postgresql major versions, manual steps are required [Postgres#37](https://github.com/docker-library/postgres/issues/37).
PostgreSQL might be considered if you need to support many concurrent users. In addition, some features are only enabled on PostgreSQL, such as fuzzy search.
**For Environment Variable Configuration, see** [Backend Configuration](./backend-config.md)
@@ -7,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
```yaml
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.0.0 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.2.0 # (3)
container_name: mealie
restart: always
ports:
@@ -38,7 +41,7 @@ services:
postgres:
container_name: postgres
image: postgres:15
image: postgres:17
restart: always
volumes:
- mealie-pgdata:/var/lib/postgresql/data
@@ -46,6 +49,7 @@ services:
POSTGRES_PASSWORD: mealie
POSTGRES_USER: mealie
PGUSER: mealie
POSTGRES_DB: mealie
healthcheck:
test: ["CMD", "pg_isready"]
interval: 30s

View File

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

View File

@@ -2,6 +2,3 @@
## Feature Requests
[Please request new features on Github](https://github.com/mealie-recipes/mealie/discussions/new?category=feature-request)
## Progress
See the [Github Projects page](https://github.com/users/hay-kot/projects/2) to see what is currently being worked on

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

File diff suppressed because one or more lines are too long

View File

@@ -351,7 +351,7 @@
<!-- Custom narrow footer -->
<div class="md-footer-meta__inner md-grid">
<div class="md-footer-social">
<a class="md-footer-social__link" href="https://github.com/hay-kot/mealie" rel="noopener" target="_blank"
<a class="md-footer-social__link" href="https://github.com/mealie-recipes/mealie" rel="noopener" target="_blank"
title="github.com">
<svg style="width: 32px; height: 32px" viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
<path

View File

@@ -44,78 +44,54 @@
</div>
</template>
<script lang="ts">
import type { ReadCookBook } from "~/lib/api/types/cookbook";
<script setup lang="ts">
import { Organizer } from "~/lib/api/types/non-generated";
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
import type { ReadCookBook } from "~/lib/api/types/cookbook";
export default defineNuxtComponent({
components: { QueryFilterBuilder },
props: {
modelValue: {
type: Object as () => ReadCookBook,
required: true,
},
actions: {
type: Object as () => any,
required: true,
},
const modelValue = defineModel<ReadCookBook>({ required: true });
const i18n = useI18n();
const cookbook = toRef(modelValue);
function handleInput(value: string | undefined) {
cookbook.value.queryFilterString = value || "";
}
const fieldDefs: FieldDefinition[] = [
{
name: "recipe_category.id",
label: i18n.t("category.categories"),
type: Organizer.Category,
},
emits: ["update:modelValue"],
setup(props, { emit }) {
const i18n = useI18n();
const cookbook = toRef(() => props.modelValue);
function handleInput(value: string | undefined) {
cookbook.value.queryFilterString = value || "";
emit("update:modelValue", cookbook.value);
}
const fieldDefs: FieldDefinition[] = [
{
name: "recipe_category.id",
label: i18n.t("category.categories"),
type: Organizer.Category,
},
{
name: "tags.id",
label: i18n.t("tag.tags"),
type: Organizer.Tag,
},
{
name: "recipe_ingredient.food.id",
label: i18n.t("recipe.ingredients"),
type: Organizer.Food,
},
{
name: "tools.id",
label: i18n.t("tool.tools"),
type: Organizer.Tool,
},
{
name: "household_id",
label: i18n.t("household.households"),
type: Organizer.Household,
},
{
name: "created_at",
label: i18n.t("general.date-created"),
type: "date",
},
{
name: "updated_at",
label: i18n.t("general.date-updated"),
type: "date",
},
];
return {
cookbook,
handleInput,
fieldDefs,
};
{
name: "tags.id",
label: i18n.t("tag.tags"),
type: Organizer.Tag,
},
});
{
name: "recipe_ingredient.food.id",
label: i18n.t("recipe.ingredients"),
type: Organizer.Food,
},
{
name: "tools.id",
label: i18n.t("tool.tools"),
type: Organizer.Tool,
},
{
name: "household_id",
label: i18n.t("household.households"),
type: Organizer.Household,
},
{
name: "created_at",
label: i18n.t("general.date-created"),
type: "date",
},
{
name: "updated_at",
label: i18n.t("general.date-updated"),
type: "date",
},
];
</script>

View File

@@ -17,7 +17,6 @@
<v-card-text>
<CookbookEditor
v-model="editTarget"
:actions="actions"
/>
</v-card-text>
</BaseDialog>
@@ -65,90 +64,67 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useLazyRecipes } from "~/composables/recipes";
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import { useCookbookStore } from "~/composables/store/use-cookbook-store";
import { useCookbook } from "~/composables/use-group-cookbooks";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { RecipeCookBook } from "~/lib/api/types/cookbook";
import type { ReadCookBook } from "~/lib/api/types/cookbook";
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
export default defineNuxtComponent({
components: { RecipeCardSection, CookbookEditor },
setup() {
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const slug = route.params.slug as string;
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
const { actions } = useCookbookStore();
const router = useRouter();
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const slug = route.params.slug as string;
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
const { actions } = useCookbookStore();
const router = useRouter();
const tab = ref(null);
const book = getOne(slug);
const book = getOne(slug);
const isOwnHousehold = computed(() => {
if (!($auth.user.value && book.value?.householdId)) {
return false;
}
const isOwnHousehold = computed(() => {
if (!($auth.user.value && book.value?.householdId)) {
return false;
}
return $auth.user.value.householdId === book.value.householdId;
});
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
return $auth.user.value.householdId === book.value.householdId;
});
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
const dialogStates = reactive({
edit: false,
});
const dialogStates = reactive({
edit: false,
});
const editTarget = ref<RecipeCookBook | null>(null);
function handleEditCookbook() {
dialogStates.edit = true;
editTarget.value = book.value;
}
const editTarget = ref<ReadCookBook | null>(null);
function handleEditCookbook() {
dialogStates.edit = true;
editTarget.value = book.value;
}
async function editCookbook() {
if (!editTarget.value) {
return;
}
const response = await actions.updateOne(editTarget.value);
async function editCookbook() {
if (!editTarget.value) {
return;
}
const response = await actions.updateOne(editTarget.value);
if (response?.slug && book.value?.slug !== response?.slug) {
// if name changed, redirect to new slug
router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
}
else {
// otherwise reload the page, since the recipe criteria changed
router.go(0);
}
dialogStates.edit = false;
editTarget.value = null;
}
if (response?.slug && book.value?.slug !== response?.slug) {
// if name changed, redirect to new slug
router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
}
else {
// otherwise reload the page, since the recipe criteria changed
router.go(0);
}
dialogStates.edit = false;
editTarget.value = null;
}
useSeoMeta({
title: book?.value?.name || "Cookbook",
});
return {
book,
slug,
tab,
appendRecipes,
assignSorted,
recipes,
removeRecipe,
replaceRecipes,
canEdit,
dialogStates,
editTarget,
handleEditCookbook,
editCookbook,
actions,
};
},
useSeoMeta({
title: book?.value?.name || "Cookbook",
});
</script>

View File

@@ -20,45 +20,33 @@
</v-data-table>
</template>
<script lang="ts">
<script setup lang="ts">
import { parseISO, formatDistanceToNow } from "date-fns";
import type { GroupDataExport } from "~/lib/api/types/group";
export default defineNuxtComponent({
props: {
exports: {
type: Array as () => GroupDataExport[],
required: true,
},
},
setup() {
const i18n = useI18n();
defineProps<{
exports: GroupDataExport[];
}>();
const headers = [
{ title: i18n.t("export.export"), value: "name" },
{ title: i18n.t("export.file-name"), value: "filename" },
{ title: i18n.t("export.size"), value: "size" },
{ title: i18n.t("export.link-expires"), value: "expires" },
{ title: "", value: "actions" },
];
const i18n = useI18n();
function getTimeToExpire(timeString: string) {
const expiresAt = parseISO(timeString);
const headers = [
{ title: i18n.t("export.export"), value: "name" },
{ title: i18n.t("export.file-name"), value: "filename" },
{ title: i18n.t("export.size"), value: "size" },
{ title: i18n.t("export.link-expires"), value: "expires" },
{ title: "", value: "actions" },
];
return formatDistanceToNow(expiresAt, {
addSuffix: false,
});
}
function getTimeToExpire(timeString: string) {
const expiresAt = parseISO(timeString);
function downloadData(_: any) {
console.log("Downloading data...");
}
return formatDistanceToNow(expiresAt, {
addSuffix: false,
});
}
return {
downloadData,
headers,
getTimeToExpire,
};
},
});
function downloadData(_: any) {
console.log("Downloading data...");
}
</script>

View File

@@ -9,30 +9,10 @@
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
modelValue: {
type: Object,
required: true,
},
},
emits: ["update:modelValue"],
setup(props, context) {
const preferences = computed({
get() {
return props.modelValue;
},
set(val) {
context.emit("update:modelValue", val);
},
});
<script setup lang="ts">
import type { ReadGroupPreferences } from "~/lib/api/types/user";
return {
preferences,
};
},
});
const preferences = defineModel<ReadGroupPreferences>({ required: true });
</script>
<style lang="scss" scoped></style>

View File

@@ -1,91 +0,0 @@
<template>
<v-select
v-model="selected"
:items="households"
:label="label"
:hint="description"
:persistent-hint="!!description"
item-title="name"
:multiple="multiselect"
:prepend-inner-icon="$globals.icons.household"
return-object
>
<template #chip="data">
<v-chip
:key="data.index"
class="ma-1"
:input-value="data.item"
size="small"
closable
label
color="accent"
dark
@click:close="removeByIndex(data.index)"
>
{{ data.item.raw.name || data.item }}
</v-chip>
</template>
</v-select>
</template>
<script lang="ts">
import { useHouseholdStore } from "~/composables/store/use-household-store";
interface HouseholdLike {
id: string;
name: string;
}
export default defineNuxtComponent({
props: {
modelValue: {
type: Array as () => HouseholdLike[],
required: true,
},
multiselect: {
type: Boolean,
default: false,
},
description: {
type: String,
default: "",
},
},
emits: ["update:modelValue"],
setup(props, context) {
const selected = computed({
get: () => props.modelValue,
set: (val) => {
context.emit("update:modelValue", val);
},
});
onMounted(() => {
if (selected.value === undefined) {
selected.value = [];
}
});
const i18n = useI18n();
const label = computed(
() => props.multiselect ? i18n.t("household.households") : i18n.t("household.household"),
);
const { store: households } = useHouseholdStore();
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}
return {
selected,
label,
households,
removeByIndex,
};
},
});
</script>

View File

@@ -18,7 +18,7 @@
:open-on-hover="mdAndUp"
content-class="d-print-none"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-btn
:class="{ 'rounded-circle': fab }"
:size="fab ? 'small' : undefined"
@@ -26,7 +26,7 @@
:icon="!fab"
variant="text"
dark
v-bind="props"
v-bind="activatorProps"
@click.prevent
>
<v-icon>{{ icon }}</v-icon>
@@ -50,7 +50,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { Recipe } from "~/lib/api/types/recipe";
import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
import type { ShoppingListSummary } from "~/lib/api/types/household";
@@ -64,101 +64,84 @@ export interface ContextMenuItem {
isPublic: boolean;
}
export default defineNuxtComponent({
components: {
RecipeDialogAddToShoppingList,
},
props: {
recipes: {
type: Array as () => Recipe[],
default: () => [],
},
menuTop: {
type: Boolean,
default: true,
},
fab: {
type: Boolean,
default: false,
},
color: {
type: String,
default: "primary",
},
menuIcon: {
type: String,
default: null,
},
},
setup(props, context) {
const { mdAndUp } = useDisplay();
const i18n = useI18n();
const { $globals } = useNuxtApp();
const api = useUserApi();
const state = reactive({
loading: false,
shoppingListDialog: false,
menuItems: [
{
title: i18n.t("recipe.add-to-list"),
icon: $globals.icons.cartCheck,
color: undefined,
event: "shoppingList",
isPublic: false,
},
],
});
const icon = props.menuIcon || $globals.icons.dotsVertical;
const shoppingLists = ref<ShoppingListSummary[]>();
const recipesWithScales = computed(() => {
return props.recipes.map((recipe) => {
return {
scale: 1,
...recipe,
};
});
});
async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
if (data) {
shoppingLists.value = data.items as ShoppingListSummary[] ?? [];
}
}
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
shoppingList: () => {
getShoppingLists();
state.shoppingListDialog = true;
},
};
function contextMenuEventHandler(eventKey: string) {
const handler = eventHandlers[eventKey];
if (handler && typeof handler === "function") {
handler();
state.loading = false;
return;
}
context.emit(eventKey);
state.loading = false;
}
return {
...toRefs(state),
contextMenuEventHandler,
icon,
recipesWithScales,
shoppingLists,
mdAndUp,
};
},
interface Props {
recipes?: Recipe[];
menuTop?: boolean;
fab?: boolean;
color?: string;
menuIcon?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
recipes: () => [],
menuTop: true,
fab: false,
color: "primary",
menuIcon: null,
});
const emit = defineEmits<{
[key: string]: [];
}>();
const { mdAndUp } = useDisplay();
const i18n = useI18n();
const { $globals } = useNuxtApp();
const api = useUserApi();
const state = reactive({
loading: false,
shoppingListDialog: false,
menuItems: [
{
title: i18n.t("recipe.add-to-list"),
icon: $globals.icons.cartCheck,
color: undefined,
event: "shoppingList",
isPublic: false,
},
],
});
const { shoppingListDialog, menuItems } = toRefs(state);
const icon = props.menuIcon || $globals.icons.dotsVertical;
const shoppingLists = ref<ShoppingListSummary[]>();
const recipesWithScales = computed(() => {
return props.recipes.map((recipe) => {
return {
scale: 1,
...recipe,
};
});
});
async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
if (data) {
shoppingLists.value = data.items as ShoppingListSummary[] ?? [];
}
}
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
shoppingList: () => {
getShoppingLists();
state.shoppingListDialog = true;
},
};
function contextMenuEventHandler(eventKey: string) {
const handler = eventHandlers[eventKey];
if (handler && typeof handler === "function") {
handler();
state.loading = false;
return;
}
emit(eventKey);
state.loading = false;
}
</script>

View File

@@ -5,12 +5,12 @@
style="gap: 10px"
>
<v-select
v-model="inputDay"
v-model="day"
:items="MEAL_DAY_OPTIONS"
:label="$t('meal-plan.rule-day')"
/>
<v-select
v-model="inputEntryType"
v-model="entryType"
:items="MEAL_TYPE_OPTIONS"
:label="$t('meal-plan.meal-type')"
/>
@@ -19,157 +19,104 @@
<div class="mb-5">
<QueryFilterBuilder
:field-defs="fieldDefs"
:initial-query-filter="queryFilter"
:initial-query-filter="props.queryFilter"
@input="handleQueryFilterInput"
/>
</div>
<!-- TODO: proper pluralization of inputDay -->
{{ $t('meal-plan.this-rule-will-apply', {
dayCriteria: inputDay === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [inputDay]),
mealTypeCriteria: inputEntryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [inputEntryType]),
dayCriteria: day === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [day]),
mealTypeCriteria: entryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [entryType]),
}) }}
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
import { Organizer } from "~/lib/api/types/non-generated";
import type { QueryFilterJSON } from "~/lib/api/types/response";
export default defineNuxtComponent({
components: {
QueryFilterBuilder,
},
props: {
day: {
type: String,
default: "unset",
},
entryType: {
type: String,
default: "unset",
},
queryFilterString: {
type: String,
default: "",
},
queryFilter: {
type: Object as () => QueryFilterJSON,
default: null,
},
showHelp: {
type: Boolean,
default: false,
},
},
emits: ["update:day", "update:entry-type", "update:query-filter-string"],
setup(props, context) {
const i18n = useI18n();
const MEAL_TYPE_OPTIONS = [
{ title: i18n.t("meal-plan.breakfast"), value: "breakfast" },
{ title: i18n.t("meal-plan.lunch"), value: "lunch" },
{ title: i18n.t("meal-plan.dinner"), value: "dinner" },
{ title: i18n.t("meal-plan.side"), value: "side" },
{ title: i18n.t("meal-plan.type-any"), value: "unset" },
];
const MEAL_DAY_OPTIONS = [
{ title: i18n.t("general.monday"), value: "monday" },
{ title: i18n.t("general.tuesday"), value: "tuesday" },
{ title: i18n.t("general.wednesday"), value: "wednesday" },
{ title: i18n.t("general.thursday"), value: "thursday" },
{ title: i18n.t("general.friday"), value: "friday" },
{ title: i18n.t("general.saturday"), value: "saturday" },
{ title: i18n.t("general.sunday"), value: "sunday" },
{ title: i18n.t("meal-plan.day-any"), value: "unset" },
];
const inputDay = computed({
get: () => {
return props.day;
},
set: (val) => {
context.emit("update:day", val);
},
});
const inputEntryType = computed({
get: () => {
return props.entryType;
},
set: (val) => {
context.emit("update:entry-type", val);
},
});
const inputQueryFilterString = computed({
get: () => {
return props.queryFilterString;
},
set: (val) => {
context.emit("update:query-filter-string", val);
},
});
function handleQueryFilterInput(value: string | undefined) {
inputQueryFilterString.value = value || "";
};
const fieldDefs: FieldDefinition[] = [
{
name: "recipe_category.id",
label: i18n.t("category.categories"),
type: Organizer.Category,
},
{
name: "tags.id",
label: i18n.t("tag.tags"),
type: Organizer.Tag,
},
{
name: "recipe_ingredient.food.id",
label: i18n.t("recipe.ingredients"),
type: Organizer.Food,
},
{
name: "tools.id",
label: i18n.t("tool.tools"),
type: Organizer.Tool,
},
{
name: "household_id",
label: i18n.t("household.households"),
type: Organizer.Household,
},
{
name: "last_made",
label: i18n.t("general.last-made"),
type: "date",
},
{
name: "created_at",
label: i18n.t("general.date-created"),
type: "date",
},
{
name: "updated_at",
label: i18n.t("general.date-updated"),
type: "date",
},
];
return {
MEAL_TYPE_OPTIONS,
MEAL_DAY_OPTIONS,
inputDay,
inputEntryType,
inputQueryFilterString,
handleQueryFilterInput,
fieldDefs,
};
},
interface Props {
queryFilter?: QueryFilterJSON | null;
showHelp?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
queryFilter: null,
showHelp: false,
});
const day = defineModel<string>("day", { default: "unset" });
const entryType = defineModel<string>("entryType", { default: "unset" });
const queryFilterString = defineModel<string>("queryFilterString", { default: "" });
const i18n = useI18n();
const MEAL_TYPE_OPTIONS = [
{ title: i18n.t("meal-plan.breakfast"), value: "breakfast" },
{ title: i18n.t("meal-plan.lunch"), value: "lunch" },
{ title: i18n.t("meal-plan.dinner"), value: "dinner" },
{ title: i18n.t("meal-plan.side"), value: "side" },
{ title: i18n.t("meal-plan.type-any"), value: "unset" },
];
const MEAL_DAY_OPTIONS = [
{ title: i18n.t("general.monday"), value: "monday" },
{ title: i18n.t("general.tuesday"), value: "tuesday" },
{ title: i18n.t("general.wednesday"), value: "wednesday" },
{ title: i18n.t("general.thursday"), value: "thursday" },
{ title: i18n.t("general.friday"), value: "friday" },
{ title: i18n.t("general.saturday"), value: "saturday" },
{ title: i18n.t("general.sunday"), value: "sunday" },
{ title: i18n.t("meal-plan.day-any"), value: "unset" },
];
function handleQueryFilterInput(value: string | undefined) {
console.warn("handleQueryFilterInput called with value:", value);
queryFilterString.value = value || "";
}
const fieldDefs: FieldDefinition[] = [
{
name: "recipe_category.id",
label: i18n.t("category.categories"),
type: Organizer.Category,
},
{
name: "tags.id",
label: i18n.t("tag.tags"),
type: Organizer.Tag,
},
{
name: "recipe_ingredient.food.id",
label: i18n.t("recipe.ingredients"),
type: Organizer.Food,
},
{
name: "tools.id",
label: i18n.t("tool.tools"),
type: Organizer.Tool,
},
{
name: "household_id",
label: i18n.t("household.households"),
type: Organizer.Household,
},
{
name: "last_made",
label: i18n.t("general.last-made"),
type: "date",
},
{
name: "created_at",
label: i18n.t("general.date-created"),
type: "date",
},
{
name: "updated_at",
label: i18n.t("general.date-updated"),
type: "date",
},
];
</script>

View File

@@ -16,11 +16,11 @@
:label="$t('settings.webhooks.webhook-url')"
variant="underlined"
/>
<v-time-picker
<v-text-field
v-model="scheduledTime"
class="elevation-2"
ampm-in-title
format="ampm"
type="time"
clearable
variant="underlined"
/>
</v-card-text>
<v-card-actions class="py-0 justify-end">
@@ -50,52 +50,43 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { ReadWebhook } from "~/lib/api/types/household";
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
export default defineNuxtComponent({
props: {
webhook: {
type: Object as () => ReadWebhook,
required: true,
},
const props = defineProps<{
webhook: ReadWebhook;
}>();
const emit = defineEmits<{
delete: [id: string];
save: [webhook: ReadWebhook];
test: [id: string];
}>();
const i18n = useI18n();
const itemUTC = ref<string>(props.webhook.scheduledTime);
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
const scheduledTime = computed({
get() {
return itemLocal.value;
},
emits: ["delete", "save", "test"],
setup(props, { emit }) {
const i18n = useI18n();
const itemUTC = ref<string>(props.webhook.scheduledTime);
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
const scheduledTime = computed({
get() {
return itemLocal.value;
},
set(v: string) {
itemUTC.value = timeLocalToUTC(v);
itemLocal.value = v;
},
});
const webhookCopy = ref({ ...props.webhook });
function handleSave() {
webhookCopy.value.scheduledTime = itemLocal.value;
emit("save", webhookCopy.value);
}
// Set page title using useSeoMeta
useSeoMeta({
title: i18n.t("settings.webhooks.webhooks"),
});
return {
webhookCopy,
scheduledTime,
handleSave,
itemUTC,
itemLocal,
};
set(v: string) {
itemUTC.value = timeLocalToUTC(v);
itemLocal.value = v;
},
});
const webhookCopy = ref({ ...props.webhook });
function handleSave() {
webhookCopy.value.scheduledTime = itemLocal.value;
emit("save", webhookCopy.value);
}
// Set page title using useSeoMeta
useSeoMeta({
title: i18n.t("settings.webhooks.webhooks"),
});
</script>

View File

@@ -41,106 +41,76 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
export default defineNuxtComponent({
props: {
modelValue: {
type: Object,
required: true,
},
const preferences = defineModel<ReadHouseholdPreferences>({ required: true });
const i18n = useI18n();
type Preference = {
key: keyof ReadHouseholdPreferences;
label: string;
description: string;
};
const recipePreferences: Preference[] = [
{
key: "recipePublic",
label: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes"),
description: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes-description"),
},
emits: ["update:modelValue"],
setup(props, context) {
const i18n = useI18n();
type Preference = {
key: keyof ReadHouseholdPreferences;
label: string;
description: string;
};
const recipePreferences: Preference[] = [
{
key: "recipePublic",
label: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes"),
description: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes-description"),
},
{
key: "recipeShowNutrition",
label: i18n.t("group.show-nutrition-information"),
description: i18n.t("group.show-nutrition-information-description"),
},
{
key: "recipeShowAssets",
label: i18n.t("group.show-recipe-assets"),
description: i18n.t("group.show-recipe-assets-description"),
},
{
key: "recipeLandscapeView",
label: i18n.t("group.default-to-landscape-view"),
description: i18n.t("group.default-to-landscape-view-description"),
},
{
key: "recipeDisableComments",
label: i18n.t("group.disable-users-from-commenting-on-recipes"),
description: i18n.t("group.disable-users-from-commenting-on-recipes-description"),
},
{
key: "recipeDisableAmount",
label: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food"),
description: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food-description"),
},
];
const allDays = [
{
name: i18n.t("general.sunday"),
value: 0,
},
{
name: i18n.t("general.monday"),
value: 1,
},
{
name: i18n.t("general.tuesday"),
value: 2,
},
{
name: i18n.t("general.wednesday"),
value: 3,
},
{
name: i18n.t("general.thursday"),
value: 4,
},
{
name: i18n.t("general.friday"),
value: 5,
},
{
name: i18n.t("general.saturday"),
value: 6,
},
];
const preferences = computed({
get() {
return props.modelValue;
},
set(val) {
context.emit("update:modelValue", val);
},
});
return {
allDays,
preferences,
recipePreferences,
};
{
key: "recipeShowNutrition",
label: i18n.t("group.show-nutrition-information"),
description: i18n.t("group.show-nutrition-information-description"),
},
});
{
key: "recipeShowAssets",
label: i18n.t("group.show-recipe-assets"),
description: i18n.t("group.show-recipe-assets-description"),
},
{
key: "recipeLandscapeView",
label: i18n.t("group.default-to-landscape-view"),
description: i18n.t("group.default-to-landscape-view-description"),
},
{
key: "recipeDisableComments",
label: i18n.t("group.disable-users-from-commenting-on-recipes"),
description: i18n.t("group.disable-users-from-commenting-on-recipes-description"),
},
];
const allDays = [
{
name: i18n.t("general.sunday"),
value: 0,
},
{
name: i18n.t("general.monday"),
value: 1,
},
{
name: i18n.t("general.tuesday"),
value: 2,
},
{
name: i18n.t("general.wednesday"),
value: 3,
},
{
name: i18n.t("general.thursday"),
value: 4,
},
{
name: i18n.t("general.friday"),
value: 5,
},
{
name: i18n.t("general.saturday"),
value: 6,
},
];
</script>
<style lang="css">

View File

@@ -147,7 +147,7 @@
:model-value="field.value"
type="number"
variant="underlined"
@:model-value="setFieldValue(field, index, $event)"
@update:model-value="setFieldValue(field, index, $event)"
/>
<v-checkbox
v-else-if="field.type === 'boolean'"
@@ -163,14 +163,14 @@
max-width="290px"
min-width="auto"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="field.value"
persistent-hint
:prepend-icon="$globals.icons.calendar"
variant="underlined"
color="primary"
v-bind="props"
v-bind="activatorProps"
readonly
/>
</template>
@@ -184,53 +184,53 @@
</v-menu>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Category"
:model-value="field.organizers"
v-model="field.organizers"
:selector-type="Organizer.Category"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tag"
:model-value="field.organizers"
v-model="field.organizers"
:selector-type="Organizer.Tag"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tool"
:model-value="field.organizers"
v-model="field.organizers"
:selector-type="Organizer.Tool"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Food"
:model-value="field.organizers"
v-model="field.organizers"
:selector-type="Organizer.Food"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Household"
:model-value="field.organizers"
v-model="field.organizers"
:selector-type="Organizer.Household"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
</v-col>
<!-- right parenthesis -->
@@ -297,7 +297,7 @@
</v-card>
</template>
<script lang="ts">
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import { useDebounceFn } from "@vueuse/core";
import { useHouseholdSelf } from "~/composables/use-households";
@@ -307,365 +307,344 @@ import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalK
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
export default defineNuxtComponent({
components: {
VueDraggable,
RecipeOrganizerSelector,
const props = defineProps({
fieldDefs: {
type: Array as () => FieldDefinition[],
required: true,
},
props: {
fieldDefs: {
type: Array as () => FieldDefinition[],
required: true,
},
initialQueryFilter: {
type: Object as () => QueryFilterJSON | null,
default: null,
},
initialQueryFilter: {
type: Object as () => QueryFilterJSON | null,
default: null,
},
emits: ["input", "inputJSON"],
setup(props, context) {
const { household } = useHouseholdSelf();
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
});
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
const emit = defineEmits<{
(event: "input", value: string | undefined): void;
(event: "inputJSON", value: QueryFilterJSON | undefined): void;
}>();
const state = reactive({
showAdvanced: false,
qfValid: false,
datePickers: [] as boolean[],
drag: false,
});
const { household } = useHouseholdSelf();
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
const storeMap = {
[Organizer.Category]: useCategoryStore(),
[Organizer.Tag]: useTagStore(),
[Organizer.Tool]: useToolStore(),
[Organizer.Food]: useFoodStore(),
[Organizer.Household]: useHouseholdStore(),
};
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
function onDragEnd(event: any) {
state.drag = false;
const state = reactive({
showAdvanced: false,
qfValid: false,
datePickers: [] as boolean[],
drag: false,
});
const { showAdvanced, datePickers, drag } = toRefs(state);
const oldIndex: number = event.oldIndex;
const newIndex: number = event.newIndex;
state.datePickers[oldIndex] = false;
state.datePickers[newIndex] = false;
const storeMap = {
[Organizer.Category]: useCategoryStore(),
[Organizer.Tag]: useTagStore(),
[Organizer.Tool]: useToolStore(),
[Organizer.Food]: useFoodStore(),
[Organizer.Household]: useHouseholdStore(),
};
function onDragEnd(event: any) {
state.drag = false;
const oldIndex: number = event.oldIndex;
const newIndex: number = event.newIndex;
state.datePickers[oldIndex] = false;
state.datePickers[newIndex] = false;
}
// add id to fields to prevent reactivity issues
type FieldWithId = Field & { id: number };
const fields = ref<FieldWithId[]>([]);
const uid = ref(1); // init uid to pass to fields
function useUid() {
return uid.value++;
}
function addField(field: FieldDefinition) {
fields.value.push({
...getFieldFromFieldDef(field),
id: useUid(),
});
state.datePickers.push(false);
}
function setField(index: number, fieldLabel: string) {
state.datePickers[index] = false;
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.label === fieldLabel);
if (!fieldDef) {
return;
}
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
const updatedField = { ...fields.value[index], ...fieldDef };
// we have to set this explicitly since it might be undefined
updatedField.fieldOptions = fieldDef.fieldOptions;
fields.value[index] = {
...getFieldFromFieldDef(updatedField, resetValue),
id: fields.value[index].id, // keep the id
};
}
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
fields.value[index].leftParenthesis = value;
}
function setRightParenthesisValue(field: FieldWithId, index: number, value: string) {
fields.value[index].rightParenthesis = value;
}
function setLogicalOperatorValue(field: FieldWithId, index: number, value: LogicalOperator | undefined) {
if (!value) {
value = logOps.value.AND.value;
}
fields.value[index].logicalOperator = value ? logOps.value[value] : undefined;
}
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
fields.value[index].relationalOperatorValue = relOps.value[value];
}
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
state.datePickers[index] = false;
fields.value[index].value = value;
}
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
fields.value[index].values = values;
}
function setFieldOrganizers(field: FieldWithId, index: number, organizers: OrganizerBase[]) {
fields.value[index].organizers = organizers;
// Sync the values array with the organizers array
fields.value[index].values = organizers.map(org => org.id?.toString() || "").filter(id => id);
}
function removeField(index: number) {
fields.value.splice(index, 1);
state.datePickers.splice(index, 1);
}
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
/* newFields.forEach((field, index) => {
const updatedField = getFieldFromFieldDef(field);
fields.value[index] = updatedField; // recursive!!!
}); */
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
if (qf) {
console.debug(`Set query filter: ${qf}`);
}
state.qfValid = !!qf;
emit("input", qf || undefined);
emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
}, 500);
watch(fields, fieldsUpdater, { deep: true });
async function hydrateOrganizers(field: FieldWithId, _index: number) {
if (!field.values?.length || !isOrganizerType(field.type)) {
return;
}
const { store, actions } = storeMap[field.type];
if (!store.value.length) {
await actions.refresh();
}
const organizers = field.values.map((value) => {
const organizer = store.value.find(item => item?.id?.toString() === value);
if (!organizer) {
console.error(`Could not find organizer with id ${value}`);
return undefined;
}
return organizer;
});
field.organizers = organizers.filter(organizer => organizer !== undefined) as OrganizerBase[];
return field;
}
function initFieldsError(error = "") {
if (error) {
console.error(error);
}
fields.value = [];
if (props.fieldDefs.length) {
addField(props.fieldDefs[0]);
}
}
async function initializeFields() {
if (!props.initialQueryFilter?.parts?.length) {
return initFieldsError();
}
const initFields: FieldWithId[] = [];
let error = false;
for (const [index, part] of props.initialQueryFilter.parts.entries()) {
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.name === part.attributeName);
if (!fieldDef) {
error = true;
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
}
// add id to fields to prevent reactivity issues
type FieldWithId = Field & { id: number };
const fields = ref<FieldWithId[]>([]);
const field: FieldWithId = {
...getFieldFromFieldDef(fieldDef),
id: useUid(),
};
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
field.logicalOperator = part.logicalOperator
? logOps.value[part.logicalOperator]
: field.logicalOperator;
field.relationalOperatorValue = part.relationalOperator
? relOps.value[part.relationalOperator]
: field.relationalOperatorValue;
const uid = ref(1); // init uid to pass to fields
function useUid() {
return uid.value++;
}
function addField(field: FieldDefinition) {
fields.value.push({
...getFieldFromFieldDef(field),
id: useUid(),
});
state.datePickers.push(false);
};
if (field.leftParenthesis || field.rightParenthesis) {
state.showAdvanced = true;
}
function setField(index: number, fieldLabel: string) {
state.datePickers[index] = false;
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.label === fieldLabel);
if (!fieldDef) {
return;
}
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
if (typeof part.value === "string") {
field.values = part.value ? [part.value] : [];
}
else {
field.values = part.value || [];
}
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
const updatedField = { ...fields.value[index], ...fieldDef };
if (isOrganizerType(field.type)) {
await hydrateOrganizers(field, index);
}
}
else if (field.type === "boolean") {
const boolString = part.value || "false";
field.value = (
boolString[0].toLowerCase() === "t"
|| boolString[0].toLowerCase() === "y"
|| boolString[0] === "1"
);
}
else if (field.type === "number") {
field.value = Number(part.value as string || "0");
if (isNaN(field.value)) {
error = true;
return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`);
}
}
else if (field.type === "date") {
field.value = part.value as string || "";
const date = new Date(field.value);
if (isNaN(date.getTime())) {
error = true;
return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`);
}
}
else {
field.value = part.value as string || "";
}
// we have to set this explicitly since it might be undefined
updatedField.fieldOptions = fieldDef.fieldOptions;
initFields.push(field);
}
fields.value[index] = {
...getFieldFromFieldDef(updatedField, resetValue),
id: fields.value[index].id, // keep the id
};
}
if (initFields.length && !error) {
fields.value = initFields;
}
else {
initFieldsError();
}
}
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
fields.value[index].leftParenthesis = value;
}
onMounted(async () => {
try {
await initializeFields();
}
catch (error) {
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
}
});
function setRightParenthesisValue(field: FieldWithId, index: number, value: string) {
fields.value[index].rightParenthesis = value;
}
function buildQueryFilterJSON(): QueryFilterJSON {
const parts = fields.value.map((field) => {
const part: QueryFilterJSONPart = {
attributeName: field.name,
leftParenthesis: field.leftParenthesis,
rightParenthesis: field.rightParenthesis,
logicalOperator: field.logicalOperator?.value,
relationalOperator: field.relationalOperatorValue?.value,
};
function setLogicalOperatorValue(field: FieldWithId, index: number, value: LogicalOperator | undefined) {
if (!value) {
value = logOps.value.AND.value;
}
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
part.value = field.values.map(value => value.toString());
}
else if (field.type === "boolean") {
part.value = field.value ? "true" : "false";
}
else {
part.value = (field.value || "").toString();
}
fields.value[index].logicalOperator = value ? logOps.value[value] : undefined;
}
return part;
});
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
fields.value[index].relationalOperatorValue = relOps.value[value];
}
const qfJSON = { parts } as QueryFilterJSON;
console.debug(`Built query filter JSON: ${JSON.stringify(qfJSON)}`);
return qfJSON;
}
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
state.datePickers[index] = false;
fields.value[index].value = value;
}
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
fields.value[index].values = values;
}
function setOrganizerValues(field: FieldWithId, index: number, values: OrganizerBase[]) {
setFieldValues(field, index, values.map(value => value.id.toString()));
fields.value[index].organizers = values;
}
function removeField(index: number) {
fields.value.splice(index, 1);
state.datePickers.splice(index, 1);
};
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
/* newFields.forEach((field, index) => {
const updatedField = getFieldFromFieldDef(field);
fields.value[index] = updatedField; // recursive!!!
}); */
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
if (qf) {
console.debug(`Set query filter: ${qf}`);
}
state.qfValid = !!qf;
context.emit("input", qf || undefined);
context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
}, 500);
watch(fields, fieldsUpdater, { deep: true });
async function hydrateOrganizers(field: FieldWithId, index: number) {
if (!field.values?.length || !isOrganizerType(field.type)) {
return;
}
field.organizers = [];
const { store, actions } = storeMap[field.type];
if (!store.value.length) {
await actions.refresh();
}
const organizers = field.values.map((value) => {
const organizer = store.value.find(item => item?.id?.toString() === value);
if (!organizer) {
console.error(`Could not find organizer with id ${value}`);
return undefined;
}
return organizer;
});
field.organizers = organizers.filter(organizer => organizer !== undefined) as OrganizerBase[];
setOrganizerValues(field, index, field.organizers);
}
function initFieldsError(error = "") {
if (error) {
console.error(error);
}
fields.value = [];
if (props.fieldDefs.length) {
addField(props.fieldDefs[0]);
}
}
function initializeFields() {
if (!props.initialQueryFilter?.parts?.length) {
return initFieldsError();
};
const initFields: FieldWithId[] = [];
let error = false;
props.initialQueryFilter.parts.forEach((part: QueryFilterJSONPart, index: number) => {
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.name === part.attributeName);
if (!fieldDef) {
error = true;
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
}
const field: FieldWithId = {
...getFieldFromFieldDef(fieldDef),
id: useUid(),
};
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
field.logicalOperator = part.logicalOperator
? logOps.value[part.logicalOperator]
: field.logicalOperator;
field.relationalOperatorValue = part.relationalOperator
? relOps.value[part.relationalOperator]
: field.relationalOperatorValue;
if (field.leftParenthesis || field.rightParenthesis) {
state.showAdvanced = true;
}
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
if (typeof part.value === "string") {
field.values = part.value ? [part.value] : [];
}
else {
field.values = part.value || [];
}
if (isOrganizerType(field.type)) {
hydrateOrganizers(field, index);
}
}
else if (field.type === "boolean") {
const boolString = part.value || "false";
field.value = (
boolString[0].toLowerCase() === "t"
|| boolString[0].toLowerCase() === "y"
|| boolString[0] === "1"
);
}
else if (field.type === "number") {
field.value = Number(part.value as string || "0");
if (isNaN(field.value)) {
error = true;
return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`);
}
}
else if (field.type === "date") {
field.value = part.value as string || "";
const date = new Date(field.value);
if (isNaN(date.getTime())) {
error = true;
return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`);
}
}
else {
field.value = part.value as string || "";
}
initFields.push(field);
});
if (initFields.length && !error) {
fields.value = initFields;
}
else {
initFieldsError();
}
};
try {
initializeFields();
}
catch (error) {
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
}
function buildQueryFilterJSON(): QueryFilterJSON {
const parts = fields.value.map((field) => {
const part: QueryFilterJSONPart = {
attributeName: field.name,
leftParenthesis: field.leftParenthesis,
rightParenthesis: field.rightParenthesis,
logicalOperator: field.logicalOperator?.value,
relationalOperator: field.relationalOperatorValue?.value,
};
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
part.value = field.values.map(value => value.toString());
}
else if (field.type === "boolean") {
part.value = field.value ? "true" : "false";
}
else {
part.value = (field.value || "").toString();
}
return part;
});
const qfJSON = { parts } as QueryFilterJSON;
console.debug(`Built query filter JSON: ${JSON.stringify(qfJSON)}`);
return qfJSON;
}
const config = computed(() => {
const baseColMaxWidth = 55;
return {
col: {
class: "d-flex justify-center align-end field-col pa-1",
},
select: {
textClass: "d-flex justify-center text-center",
},
items: {
icon: {
cols: 1,
style: "width: fit-content;",
},
leftParens: {
cols: state.showAdvanced ? 1 : 0,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
},
logicalOperator: {
cols: 1,
style: `min-width: ${baseColMaxWidth}px;`,
},
fieldName: {
cols: state.showAdvanced ? 2 : 3,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
},
relationalOperator: {
cols: 2,
style: `min-width: ${baseColMaxWidth * 2}px;`,
},
fieldValue: {
cols: state.showAdvanced ? 3 : 4,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
},
rightParens: {
cols: state.showAdvanced ? 1 : 0,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
},
fieldActions: {
cols: 1,
style: `min-width: ${baseColMaxWidth}px;`,
},
},
};
});
return {
Organizer,
...toRefs(state),
logOps,
relOps,
config,
firstDayOfWeek,
onDragEnd,
// Fields
fields,
addField,
setField,
setLeftParenthesisValue,
setRightParenthesisValue,
setLogicalOperatorValue,
setRelationalOperatorValue,
setFieldValue,
setFieldValues,
setOrganizerValues,
removeField,
};
},
const config = computed(() => {
const baseColMaxWidth = 55;
return {
col: {
class: "d-flex justify-center align-end field-col pa-1",
},
select: {
textClass: "d-flex justify-center text-center",
},
items: {
icon: {
cols: 1,
style: "width: fit-content;",
},
leftParens: {
cols: state.showAdvanced ? 1 : 0,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
},
logicalOperator: {
cols: 1,
style: `min-width: ${baseColMaxWidth}px;`,
},
fieldName: {
cols: state.showAdvanced ? 2 : 3,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
},
relationalOperator: {
cols: 2,
style: `min-width: ${baseColMaxWidth * 2}px;`,
},
fieldValue: {
cols: state.showAdvanced ? 3 : 4,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
},
rightParens: {
cols: state.showAdvanced ? 1 : 0,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
},
fieldActions: {
cols: 1,
style: `min-width: ${baseColMaxWidth}px;`,
},
},
};
});
</script>

View File

@@ -17,8 +17,8 @@
<RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always />
<RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" />
<div v-if="loggedIn">
<v-tooltip v-if="canEdit" bottom color="info">
<template #activator="{ props }">
<v-tooltip v-if="canEdit" location="bottom" color="info">
<template #activator="{ props: tooltipProps }">
<v-btn
icon
variant="flat"
@@ -26,7 +26,7 @@
size="small"
color="info"
class="ml-1"
v-bind="props"
v-bind="tooltipProps"
@click="$emit('edit', true)"
>
<v-icon size="x-large">
@@ -86,8 +86,8 @@
</v-toolbar>
</template>
<script lang="ts">
import RecipeContextMenu from "./RecipeContextMenu.vue";
<script setup lang="ts">
import RecipeContextMenu from "./RecipeContextMenu/RecipeContextMenu.vue";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
import type { Recipe } from "~/lib/api/types/recipe";
@@ -97,103 +97,75 @@ const DELETE_EVENT = "delete";
const CLOSE_EVENT = "close";
const JSON_EVENT = "json";
export default defineNuxtComponent({
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
props: {
recipe: {
required: true,
type: Object as () => Recipe,
},
slug: {
required: true,
type: String,
},
recipeScale: {
type: Number,
default: 1,
},
open: {
required: true,
type: Boolean,
},
name: {
required: true,
type: String,
},
loggedIn: {
type: Boolean,
default: false,
},
recipeId: {
required: true,
type: String,
},
canEdit: {
type: Boolean,
default: false,
},
},
emits: ["print", "input", "delete", "close", "edit"],
setup(_, context) {
const deleteDialog = ref(false);
const i18n = useI18n();
const { $globals } = useNuxtApp();
const editorButtons = [
{
text: i18n.t("general.delete"),
icon: $globals.icons.delete,
event: DELETE_EVENT,
color: "error",
},
{
text: i18n.t("general.json"),
icon: $globals.icons.codeBraces,
event: JSON_EVENT,
color: "accent",
},
{
text: i18n.t("general.close"),
icon: $globals.icons.close,
event: CLOSE_EVENT,
color: "",
},
{
text: i18n.t("general.save"),
icon: $globals.icons.save,
event: SAVE_EVENT,
color: "success",
},
];
function emitHandler(event: string) {
switch (event) {
case CLOSE_EVENT:
context.emit(CLOSE_EVENT);
context.emit("input", false);
break;
case DELETE_EVENT:
deleteDialog.value = true;
break;
default:
context.emit(event);
break;
}
}
function emitDelete() {
context.emit(DELETE_EVENT);
context.emit("input", false);
}
return {
deleteDialog,
editorButtons,
emitHandler,
emitDelete,
};
},
interface Props {
recipe: Recipe;
slug: string;
recipeScale?: number;
open: boolean;
name: string;
loggedIn?: boolean;
recipeId: string;
canEdit?: boolean;
}
withDefaults(defineProps<Props>(), {
recipeScale: 1,
loggedIn: false,
canEdit: false,
});
const emit = defineEmits(["print", "input", "delete", "close", "edit"]);
const deleteDialog = ref(false);
const i18n = useI18n();
const { $globals } = useNuxtApp();
const editorButtons = [
{
text: i18n.t("general.delete"),
icon: $globals.icons.delete,
event: DELETE_EVENT,
color: "error",
},
{
text: i18n.t("general.json"),
icon: $globals.icons.codeBraces,
event: JSON_EVENT,
color: "accent",
},
{
text: i18n.t("general.close"),
icon: $globals.icons.close,
event: CLOSE_EVENT,
color: "",
},
{
text: i18n.t("general.save"),
icon: $globals.icons.save,
event: SAVE_EVENT,
color: "success",
},
];
function emitHandler(event: string) {
switch (event) {
case CLOSE_EVENT:
emit("close");
emit("input", false);
break;
case DELETE_EVENT:
deleteDialog.value = true;
break;
default:
emit(event as any);
break;
}
}
function emitDelete() {
emit("delete");
emit("input", false);
}
</script>
<style scoped>

View File

@@ -15,7 +15,7 @@
>
<template #prepend>
<div class="ma-auto">
<v-tooltip bottom>
<v-tooltip location="bottom">
<template #activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps">
{{ getIconDefinition(item.icon).icon }}

View File

@@ -1,13 +1,12 @@
<template>
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
<v-lazy>
<div>
<v-hover
v-slot="{ isHovering, props }"
v-slot="{ isHovering, props: hoverProps }"
:open-delay="50"
>
<v-card
v-bind="props"
v-bind="hoverProps"
:class="{ 'on-hover': isHovering }"
:style="{ cursor }"
:elevation="isHovering ? 12 : 2"
@@ -56,12 +55,9 @@
/>
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
<RecipeRating
class="ml-n2"
:value="rating"
<RecipeCardRating
:model-value="rating"
:recipe-id="recipeId"
:slug="slug"
small
/>
<v-spacer />
<RecipeChips
@@ -76,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="{
@@ -91,7 +88,7 @@
printPreferences: false,
share: true,
}"
@delete="$emit('delete', slug)"
@deleted="$emit('delete', slug)"
/>
</v-card-actions>
</slot>
@@ -99,80 +96,51 @@
</v-card>
</v-hover>
</div>
</v-lazy>
</template>
<script lang="ts">
<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";
export default defineNuxtComponent({
components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage },
props: {
name: {
type: String,
required: true,
},
slug: {
type: String,
required: true,
},
description: {
type: String,
default: null,
},
rating: {
type: Number,
required: false,
default: 0,
},
ratingColor: {
type: String,
default: "secondary",
},
image: {
type: String,
required: false,
default: "abc123",
},
tags: {
type: Array,
default: () => [],
},
recipeId: {
required: true,
type: String,
},
imageHeight: {
type: Number,
default: 200,
},
},
emits: ["click", "delete"],
setup(props) {
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
});
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
return {
isOwnGroup,
recipeRoute,
showRecipeContent,
cursor,
};
},
interface Props {
name: string;
slug: string;
description?: string | null;
rating?: number;
ratingColor?: string;
image?: string;
tags?: Array<any>;
recipeId: string;
imageHeight?: number;
}
const props = withDefaults(defineProps<Props>(), {
description: null,
rating: 0,
ratingColor: "secondary",
image: "abc123",
tags: () => [],
imageHeight: 200,
});
defineEmits<{
click: [];
delete: [slug: string];
}>();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
});
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
</script>
<style>
@@ -197,6 +165,7 @@ export default defineNuxtComponent({
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 8;
line-clamp: 8;
overflow: hidden;
}
</style>

View File

@@ -28,84 +28,60 @@
</div>
</template>
<script lang="ts">
import { useStaticRoutes, useUserApi } from "~/composables/api";
<script setup lang="ts">
import { useStaticRoutes } from "~/composables/api";
export default defineNuxtComponent({
props: {
tiny: {
type: Boolean,
default: null,
},
small: {
type: Boolean,
default: null,
},
large: {
type: Boolean,
default: null,
},
iconSize: {
type: [Number, String],
default: 100,
},
slug: {
type: String,
default: null,
},
recipeId: {
type: String,
required: true,
},
imageVersion: {
type: String,
default: null,
},
height: {
type: [Number, String],
default: "100%",
},
},
emits: ["click"],
setup(props) {
const api = useUserApi();
const { recipeImage, recipeSmallImage, recipeTinyImage } = useStaticRoutes();
const fallBackImage = ref(false);
const imageSize = computed(() => {
if (props.tiny) return "tiny";
if (props.small) return "small";
if (props.large) return "large";
return "large";
});
watch(
() => props.recipeId,
() => {
fallBackImage.value = false;
},
);
function getImage(recipeId: string) {
switch (imageSize.value) {
case "tiny":
return recipeTinyImage(recipeId, props.imageVersion);
case "small":
return recipeSmallImage(recipeId, props.imageVersion);
case "large":
return recipeImage(recipeId, props.imageVersion);
}
}
return {
api,
fallBackImage,
imageSize,
getImage,
};
},
interface Props {
tiny?: boolean | null;
small?: boolean | null;
large?: boolean | null;
iconSize?: number | string;
slug?: string | null;
recipeId: string;
imageVersion?: string | null;
height?: number | string;
}
const props = withDefaults(defineProps<Props>(), {
tiny: null,
small: null,
large: null,
iconSize: 100,
slug: null,
imageVersion: null,
height: "100%",
});
defineEmits<{
click: [];
}>();
const { recipeImage, recipeSmallImage, recipeTinyImage } = useStaticRoutes();
const fallBackImage = ref(false);
const imageSize = computed(() => {
if (props.tiny) return "tiny";
if (props.small) return "small";
if (props.large) return "large";
return "large";
});
watch(
() => props.recipeId,
() => {
fallBackImage.value = false;
},
);
function getImage(recipeId: string) {
switch (imageSize.value) {
case "tiny":
return recipeTinyImage(recipeId, props.imageVersion);
case "small":
return recipeSmallImage(recipeId, props.imageVersion);
case "large":
return recipeImage(recipeId, props.imageVersion);
}
}
</script>
<style scoped>

View File

@@ -3,7 +3,10 @@
<v-expand-transition>
<v-card
:ripple="false"
:class="isFlat ? 'mx-auto flat' : 'mx-auto'"
:class="[
isFlat ? 'mx-auto flat' : 'mx-auto',
{ 'disable-highlight': disableHighlight },
]"
:style="{ cursor }"
hover
height="100%"
@@ -84,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 -->
@@ -123,86 +124,52 @@
</div>
</template>
<script lang="ts">
<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";
export default defineNuxtComponent({
components: {
RecipeFavoriteBadge,
RecipeContextMenu,
RecipeRating,
RecipeCardImage,
RecipeChips,
},
props: {
name: {
type: String,
required: true,
},
slug: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
rating: {
type: Number,
default: 0,
},
image: {
type: String,
required: false,
default: "abc123",
},
tags: {
type: Array,
default: () => [],
},
recipeId: {
type: String,
required: true,
},
vertical: {
type: Boolean,
default: false,
},
isFlat: {
type: Boolean,
default: false,
},
height: {
type: [Number],
default: 150,
},
},
emits: ["selected", "delete"],
setup(props) {
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
});
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
return {
isOwnGroup,
recipeRoute,
showRecipeContent,
cursor,
};
},
interface Props {
name: string;
slug: string;
description: string;
rating?: number;
image?: string;
tags?: Array<any>;
recipeId: string;
vertical?: boolean;
isFlat?: boolean;
height?: number;
disableHighlight?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
rating: 0,
image: "abc123",
tags: () => [],
vertical: false,
isFlat: false,
height: 150,
disableHighlight: false,
});
defineEmits<{
selected: [];
delete: [slug: string];
}>();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
});
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
</script>
<style scoped>
@@ -241,4 +208,8 @@ export default defineNuxtComponent({
box-shadow: none !important;
background-color: transparent !important;
}
.disable-highlight :deep(.v-card__overlay) {
opacity: 0 !important;
}
</style>

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

@@ -36,11 +36,11 @@
offset-y
start
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-btn
variant="text"
:icon="$vuetify.display.xs"
v-bind="props"
v-bind="activatorProps"
:loading="sortLoading"
>
<v-icon :start="!$vuetify.display.xs">
@@ -162,7 +162,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useThrottleFn } from "@vueuse/core";
import RecipeCard from "./RecipeCard.vue";
import RecipeCardMobile from "./RecipeCardMobile.vue";
@@ -175,273 +175,243 @@ import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
const REPLACE_RECIPES_EVENT = "replaceRecipes";
const APPEND_RECIPES_EVENT = "appendRecipes";
export default defineNuxtComponent({
components: {
RecipeCard,
RecipeCardMobile,
},
props: {
disableToolbar: {
type: Boolean,
default: false,
},
disableSort: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: null,
},
title: {
type: String,
default: null,
},
singleColumn: {
type: Boolean,
default: false,
},
recipes: {
type: Array as () => Recipe[],
default: () => [],
},
query: {
type: Object as () => RecipeSearchQuery,
default: null,
},
},
setup(props, context) {
const { $vuetify } = useNuxtApp();
const preferences = useUserSortPreferences();
interface Props {
disableToolbar?: boolean;
disableSort?: boolean;
icon?: string | null;
title?: string | null;
singleColumn?: boolean;
recipes?: Recipe[];
query?: RecipeSearchQuery | null;
}
const props = withDefaults(defineProps<Props>(), {
disableToolbar: false,
disableSort: false,
icon: null,
title: null,
singleColumn: false,
recipes: () => [],
query: null,
});
const EVENTS = {
az: "az",
rating: "rating",
created: "created",
updated: "updated",
lastMade: "lastMade",
shuffle: "shuffle",
};
const emit = defineEmits<{
replaceRecipes: [recipes: Recipe[]];
appendRecipes: [recipes: Recipe[]];
}>();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => {
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
});
const display = useDisplay();
const preferences = useUserSortPreferences();
const displayTitleIcon = computed(() => {
return props.icon || $globals.icons.tags;
});
const EVENTS = {
az: "az",
rating: "rating",
created: "created",
updated: "updated",
lastMade: "lastMade",
shuffle: "shuffle",
};
const state = reactive({
sortLoading: false,
});
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => {
return display.smAndDown.value || preferences.value.useMobileCards;
});
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const displayTitleIcon = computed(() => {
return props.icon || $globals.icons.tags;
});
const page = ref(1);
const perPage = 32;
const hasMore = ref(true);
const ready = ref(false);
const loading = ref(false);
const sortLoading = ref(false);
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const router = useRouter();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const queryFilter = computed(() => {
return props.query.queryFilter || null;
const page = ref(1);
const perPage = 32;
const hasMore = ref(true);
const ready = ref(false);
const loading = ref(false);
// TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade)
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const router = useRouter();
// const orderBy = props.query?.orderBy || preferences.value.orderBy;
// const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
const queryFilter = computed(() => {
return props.query?.queryFilter || null;
// if (props.query.queryFilter && orderByFilter) {
// return `(${props.query.queryFilter}) AND ${orderByFilter}`;
// } else if (props.query.queryFilter) {
// return props.query.queryFilter;
// } else {
// return orderByFilter;
// }
});
// TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade)
async function fetchRecipes(pageCount = 1) {
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
return await fetchMore(
page.value,
perPage * pageCount,
props.query?.orderBy || preferences.value.orderBy,
orderDir,
orderByNullPosition,
props.query,
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
queryFilter.value,
);
}
// const orderBy = props.query?.orderBy || preferences.value.orderBy;
// const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
onMounted(async () => {
// if (props.query.queryFilter && orderByFilter) {
// return `(${props.query.queryFilter}) AND ${orderByFilter}`;
// } else if (props.query.queryFilter) {
// return props.query.queryFilter;
// } else {
// return orderByFilter;
// }
});
async function fetchRecipes(pageCount = 1) {
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
return await fetchMore(
page.value,
perPage * pageCount,
props.query?.orderBy || preferences.value.orderBy,
orderDir,
orderByNullPosition,
props.query,
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
queryFilter.value,
);
}
onMounted(async () => {
await initRecipes();
ready.value = true;
});
let lastQuery: string | undefined = JSON.stringify(props.query);
watch(
() => props.query,
async (newValue: RecipeSearchQuery | undefined | null) => {
const newValueString = JSON.stringify(newValue);
if (lastQuery !== newValueString) {
lastQuery = newValueString;
ready.value = false;
await initRecipes();
ready.value = true;
});
let lastQuery: string | undefined = JSON.stringify(props.query);
watch(
() => props.query,
async (newValue: RecipeSearchQuery | undefined) => {
const newValueString = JSON.stringify(newValue);
if (lastQuery !== newValueString) {
lastQuery = newValueString;
ready.value = false;
await initRecipes();
ready.value = true;
}
},
);
async function initRecipes() {
page.value = 1;
hasMore.value = true;
// we double-up the first call to avoid a bug with large screens that render
// the entire first page without scrolling, preventing additional loading
const newRecipes = await fetchRecipes(page.value + 1);
if (newRecipes.length < perPage) {
hasMore.value = false;
}
// since we doubled the first call, we also need to advance the page
page.value = page.value + 1;
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
}
const infiniteScroll = useThrottleFn(async () => {
if (!hasMore.value || loading.value) {
return;
}
loading.value = true;
page.value = page.value + 1;
const newRecipes = await fetchRecipes();
if (newRecipes.length < perPage) {
hasMore.value = false;
}
if (newRecipes.length) {
context.emit(APPEND_RECIPES_EVENT, newRecipes);
}
loading.value = false;
}, 500);
async function sortRecipes(sortType: string) {
if (state.sortLoading || loading.value) {
return;
}
function setter(
orderBy: string,
ascIcon: string,
descIcon: string,
defaultOrderDirection = "asc",
filterNull = false,
) {
if (preferences.value.orderBy !== orderBy) {
preferences.value.orderBy = orderBy;
preferences.value.orderDirection = defaultOrderDirection;
preferences.value.filterNull = filterNull;
}
else {
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
}
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
}
switch (sortType) {
case EVENTS.az:
setter(
"name",
$globals.icons.sortAlphabeticalAscending,
$globals.icons.sortAlphabeticalDescending,
"asc",
false,
);
break;
case EVENTS.rating:
setter("rating", $globals.icons.sortAscending, $globals.icons.sortDescending, "desc", true);
break;
case EVENTS.created:
setter(
"created_at",
$globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending,
"desc",
false,
);
break;
case EVENTS.updated:
setter("updated_at", $globals.icons.sortClockAscending, $globals.icons.sortClockDescending, "desc", false);
break;
case EVENTS.lastMade:
setter(
"last_made",
$globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending,
"desc",
true,
);
break;
default:
console.log("Unknown Event", sortType);
return;
}
// reset pagination
page.value = 1;
hasMore.value = true;
state.sortLoading = true;
loading.value = true;
// fetch new recipes
const newRecipes = await fetchRecipes();
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
state.sortLoading = false;
loading.value = false;
}
async function navigateRandom() {
const recipe = await getRandom(props.query, queryFilter.value);
if (!recipe?.slug) {
return;
}
router.push(`/g/${groupSlug.value}/r/${recipe.slug}`);
}
function toggleMobileCards() {
preferences.value.useMobileCards = !preferences.value.useMobileCards;
}
return {
...toRefs(state),
displayTitleIcon,
EVENTS,
infiniteScroll,
ready,
loading,
navigateRandom,
preferences,
sortRecipes,
toggleMobileCards,
useMobileCards,
};
},
});
);
async function initRecipes() {
page.value = 1;
hasMore.value = true;
// we double-up the first call to avoid a bug with large screens that render
// the entire first page without scrolling, preventing additional loading
const newRecipes = await fetchRecipes(page.value + 1);
if (newRecipes.length < perPage) {
hasMore.value = false;
}
// since we doubled the first call, we also need to advance the page
page.value = page.value + 1;
emit(REPLACE_RECIPES_EVENT, newRecipes);
}
const infiniteScroll = useThrottleFn(async () => {
if (!hasMore.value || loading.value) {
return;
}
loading.value = true;
page.value = page.value + 1;
const newRecipes = await fetchRecipes();
if (newRecipes.length < perPage) {
hasMore.value = false;
}
if (newRecipes.length) {
emit(APPEND_RECIPES_EVENT, newRecipes);
}
loading.value = false;
}, 500);
async function sortRecipes(sortType: string) {
if (sortLoading.value || loading.value) {
return;
}
function setter(
orderBy: string,
ascIcon: string,
descIcon: string,
defaultOrderDirection = "asc",
filterNull = false,
) {
if (preferences.value.orderBy !== orderBy) {
preferences.value.orderBy = orderBy;
preferences.value.orderDirection = defaultOrderDirection;
preferences.value.filterNull = filterNull;
}
else {
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
}
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
}
switch (sortType) {
case EVENTS.az:
setter(
"name",
$globals.icons.sortAlphabeticalAscending,
$globals.icons.sortAlphabeticalDescending,
"asc",
false,
);
break;
case EVENTS.rating:
setter("rating", $globals.icons.sortAscending, $globals.icons.sortDescending, "desc", true);
break;
case EVENTS.created:
setter(
"created_at",
$globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending,
"desc",
false,
);
break;
case EVENTS.updated:
setter("updated_at", $globals.icons.sortClockAscending, $globals.icons.sortClockDescending, "desc", false);
break;
case EVENTS.lastMade:
setter(
"last_made",
$globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending,
"desc",
true,
);
break;
default:
console.log("Unknown Event", sortType);
return;
}
// reset pagination
page.value = 1;
hasMore.value = true;
sortLoading.value = true;
loading.value = true;
// fetch new recipes
const newRecipes = await fetchRecipes();
emit(REPLACE_RECIPES_EVENT, newRecipes);
sortLoading.value = false;
loading.value = false;
}
async function navigateRandom() {
const recipe = await getRandom(props.query, queryFilter.value);
if (!recipe?.slug) {
return;
}
router.push(`/g/${groupSlug.value}/r/${recipe.slug}`);
}
function toggleMobileCards() {
preferences.value.useMobileCards = !preferences.value.useMobileCards;
}
</script>
<style>

View File

@@ -23,66 +23,38 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
export type UrlPrefixParam = "tags" | "categories" | "tools";
export default defineNuxtComponent({
props: {
truncate: {
type: Boolean,
default: false,
},
items: {
type: Array as () => RecipeCategory[] | RecipeTag[] | RecipeTool[],
default: () => [],
},
title: {
type: Boolean,
default: false,
},
urlPrefix: {
type: String as () => UrlPrefixParam,
default: "categories",
},
limit: {
type: Number,
default: 999,
},
small: {
type: Boolean,
default: false,
},
maxWidth: {
type: String,
default: null,
},
},
emits: ["item-selected"],
setup(props) {
const $auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const baseRecipeRoute = computed<string>(() => {
return `/g/${groupSlug.value}`;
});
function truncateText(text: string, length = 20, clamp = "...") {
if (!props.truncate) return text;
const node = document.createElement("div");
node.innerHTML = text;
const content = node.textContent || "";
return content.length > length ? content.slice(0, length) + clamp : content;
}
return {
baseRecipeRoute,
truncateText,
};
},
interface Props {
truncate?: boolean;
items?: RecipeCategory[] | RecipeTag[] | RecipeTool[];
title?: boolean;
urlPrefix?: UrlPrefixParam;
limit?: number;
small?: boolean;
maxWidth?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
truncate: false,
items: () => [],
title: false,
urlPrefix: "categories",
limit: 999,
small: false,
maxWidth: null,
});
defineEmits(["item-selected"]);
function truncateText(text: string, length = 20, clamp = "...") {
if (!props.truncate) return text;
const node = document.createElement("div");
node.innerHTML = text;
const content = node.textContent || "";
return content.length > length ? content.slice(0, length) + clamp : content;
}
</script>
<style></style>

View File

@@ -1,528 +0,0 @@
<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>
{{ $t("recipe.delete-confirmation") }}
</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 }">
<v-text-field
v-model="newMealdateString"
:label="$t('general.date')"
:prepend-icon="$globals.icons.calendar"
v-bind="props"
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 }">
<v-btn
icon
:variant="fab ? 'flat' : undefined"
:rounded="fab ? 'circle' : undefined"
:size="fab ? 'small' : undefined"
:color="fab ? 'info' : 'secondary'"
:fab="fab"
v-bind="props"
@click.prevent
>
<v-icon
:size="!fab ? undefined : 'x-large'"
:color="fab ? 'white' : 'secondary'"
>
{{ icon }}
</v-icon>
</v-btn>
</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-group @click.stop>
<template #activator="{ props }">
<v-list-item-title v-bind="props">
{{ $t("recipe.recipe-actions") }}
</v-list-item-title>
</template>
<v-list density="compact" class="ma-0 pa-0">
<v-list-item
v-for="(action, index) in recipeActions"
:key="index"
class="pl-6"
@click="executeRecipeAction(action)"
>
<v-list-item-title>
{{ action.title }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-list-group>
</div>
</v-list>
</v-menu>
</div>
</template>
<script lang="ts">
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
import RecipeDialogShare from "./RecipeDialogShare.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useUserApi } from "~/composables/api";
import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
import { useHouseholdSelf } from "~/composables/use-households";
import { alert } from "~/composables/use-toast";
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
import type { Recipe } from "~/lib/api/types/recipe";
import type { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
import type { PlanEntryType } from "~/lib/api/types/meal-plan";
import { useDownloader } from "~/composables/api/use-downloader";
export interface ContextMenuIncludes {
delete: boolean;
edit: boolean;
download: boolean;
duplicate: boolean;
mealplanner: boolean;
shoppingList: boolean;
print: boolean;
printPreferences: boolean;
share: boolean;
recipeActions: boolean;
}
export interface ContextMenuItem {
title: string;
icon: string;
color: string | undefined;
event: string;
isPublic: boolean;
}
export default defineNuxtComponent({
components: {
RecipeDialogAddToShoppingList,
RecipeDialogPrintPreferences,
RecipeDialogShare,
},
props: {
useItems: {
type: Object as () => ContextMenuIncludes,
default: () => ({
delete: true,
edit: true,
download: true,
duplicate: false,
mealplanner: true,
shoppingList: true,
print: true,
printPreferences: true,
share: true,
recipeActions: true,
}),
},
// Append items are added at the end of the useItems list
appendItems: {
type: Array as () => ContextMenuItem[],
default: () => [],
},
// Append items are added at the beginning of the useItems list
leadingItems: {
type: Array as () => ContextMenuItem[],
default: () => [],
},
menuTop: {
type: Boolean,
default: true,
},
fab: {
type: Boolean,
default: false,
},
color: {
type: String,
default: "primary",
},
slug: {
type: String,
required: true,
},
menuIcon: {
type: String,
default: null,
},
name: {
required: true,
type: String,
},
recipe: {
type: Object as () => Recipe,
default: undefined,
},
recipeId: {
required: true,
type: String,
},
recipeScale: {
type: Number,
default: 1,
},
},
emits: ["delete"],
setup(props, context) {
const api = useUserApi();
const state = reactive({
printPreferencesDialog: false,
shareDialog: false,
recipeDeleteDialog: false,
mealplannerDialog: false,
shoppingListDialog: false,
recipeDuplicateDialog: false,
recipeName: props.name,
loading: false,
menuItems: [] as ContextMenuItem[],
newMealdate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
newMealType: "dinner" as PlanEntryType,
pickerMenu: false,
});
const newMealdateString = computed(() => {
return state.newMealdate.toISOString().substring(0, 10);
});
const i18n = useI18n();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { household } = useHouseholdSelf();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
// ===========================================================================
// Context Menu Setup
const defaultItems: { [key: string]: ContextMenuItem } = {
edit: {
title: i18n.t("general.edit"),
icon: $globals.icons.edit,
color: undefined,
event: "edit",
isPublic: false,
},
delete: {
title: i18n.t("general.delete"),
icon: $globals.icons.delete,
color: undefined,
event: "delete",
isPublic: false,
},
download: {
title: i18n.t("general.download"),
icon: $globals.icons.download,
color: undefined,
event: "download",
isPublic: false,
},
duplicate: {
title: i18n.t("general.duplicate"),
icon: $globals.icons.duplicate,
color: undefined,
event: "duplicate",
isPublic: false,
},
mealplanner: {
title: i18n.t("recipe.add-to-plan"),
icon: $globals.icons.calendar,
color: undefined,
event: "mealplanner",
isPublic: false,
},
shoppingList: {
title: i18n.t("recipe.add-to-list"),
icon: $globals.icons.cartCheck,
color: undefined,
event: "shoppingList",
isPublic: false,
},
print: {
title: i18n.t("general.print"),
icon: $globals.icons.printer,
color: undefined,
event: "print",
isPublic: true,
},
printPreferences: {
title: i18n.t("general.print-preferences"),
icon: $globals.icons.printerSettings,
color: undefined,
event: "printPreferences",
isPublic: true,
},
share: {
title: i18n.t("general.share"),
icon: $globals.icons.shareVariant,
color: undefined,
event: "share",
isPublic: false,
},
};
// Get Default Menu Items Specified in Props
for (const [key, value] of Object.entries(props.useItems)) {
if (value) {
const item = defaultItems[key];
if (item && (item.isPublic || isOwnGroup.value)) {
state.menuItems.push(item);
}
}
}
// Add leading and Appending Items
state.menuItems = [...state.menuItems, ...props.leadingItems, ...props.appendItems];
const icon = props.menuIcon || $globals.icons.dotsVertical;
// ===========================================================================
// Context Menu Event Handler
const shoppingLists = ref<ShoppingListSummary[]>();
const recipeRef = ref<Recipe | undefined>(props.recipe);
const recipeRefWithScale = computed(() =>
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
);
async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
if (data) {
shoppingLists.value = data.items ?? [];
}
}
async function refreshRecipe() {
const { data } = await api.recipes.getOne(props.slug);
if (data) {
recipeRef.value = data;
}
}
const router = useRouter();
const groupRecipeActionsStore = useGroupRecipeActions();
async function executeRecipeAction(action: GroupRecipeActionOut) {
if (!props.recipe) return;
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
if (action.actionType === "post") {
if (!response?.error) {
alert.success(i18n.t("events.message-sent"));
}
else {
alert.error(i18n.t("events.something-went-wrong"));
}
}
}
async function deleteRecipe() {
const { data } = await api.recipes.deleteOne(props.slug);
if (data?.slug) {
router.push(`/g/${groupSlug.value}`);
}
context.emit("delete", props.slug);
}
const download = useDownloader();
async function handleDownloadEvent() {
const { data } = await api.recipes.getZipToken(props.slug);
if (data) {
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
}
}
async function addRecipeToPlan() {
const { response } = await api.mealplans.createOne({
date: newMealdateString.value,
entryType: state.newMealType,
title: "",
text: "",
recipeId: props.recipeId,
});
if (response?.status === 201) {
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
}
else {
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
}
}
async function duplicateRecipe() {
const { data } = await api.recipes.duplicateOne(props.slug, state.recipeName);
if (data && data.slug) {
router.push(`/g/${groupSlug.value}/r/${data.slug}`);
}
}
// Note: Print is handled as an event in the parent component
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
delete: () => {
state.recipeDeleteDialog = true;
},
edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"),
download: handleDownloadEvent,
duplicate: () => {
state.recipeDuplicateDialog = true;
},
mealplanner: () => {
state.mealplannerDialog = true;
},
printPreferences: async () => {
if (!recipeRef.value) {
await refreshRecipe();
}
state.printPreferencesDialog = true;
},
shoppingList: () => {
const promises: Promise<void>[] = [getShoppingLists()];
if (!recipeRef.value) {
promises.push(refreshRecipe());
}
Promise.allSettled(promises).then(() => {
state.shoppingListDialog = true;
});
},
share: () => {
state.shareDialog = true;
},
};
function contextMenuEventHandler(eventKey: string) {
const handler = eventHandlers[eventKey];
if (handler && typeof handler === "function") {
handler();
state.loading = false;
return;
}
context.emit(eventKey);
state.loading = false;
}
const planTypeOptions = usePlanTypeOptions();
return {
...toRefs(state),
newMealdateString,
recipeRef,
recipeRefWithScale,
executeRecipeAction,
recipeActions: groupRecipeActionsStore.recipeActions,
shoppingLists,
duplicateRecipe,
contextMenuEventHandler,
deleteRecipe,
addRecipeToPlan,
icon,
planTypeOptions,
firstDayOfWeek,
};
},
});
</script>

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

@@ -0,0 +1,461 @@
<template>
<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-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 "~/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";
import { useHouseholdSelf } from "~/composables/use-households";
import { alert } from "~/composables/use-toast";
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
import type { Recipe } from "~/lib/api/types/recipe";
import type { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
import type { PlanEntryType } from "~/lib/api/types/meal-plan";
import { useDownloader } from "~/composables/api/use-downloader";
export interface ContextMenuIncludes {
delete: boolean;
edit: boolean;
download: boolean;
duplicate: boolean;
mealplanner: boolean;
shoppingList: boolean;
print: boolean;
printPreferences: boolean;
share: boolean;
recipeActions: boolean;
}
export interface ContextMenuItem {
title: string;
icon: string;
color: string | undefined;
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,
});
const emit = defineEmits<{
[key: string]: any;
deleted: [slug: string];
}>();
const api = useUserApi();
const printPreferencesDialog = ref(false);
const shareDialog = ref(false);
const recipeDeleteDialog = ref(false);
const mealplannerDialog = ref(false);
const shoppingListDialog = ref(false);
const recipeDuplicateDialog = ref(false);
const recipeName = ref(props.name);
const loading = ref(false);
const menuItems = ref<ContextMenuItem[]>([]);
const newMealdate = ref(new Date());
const newMealType = ref<PlanEntryType>("dinner");
const pickerMenu = ref(false);
const newMealdateString = computed(() => {
// Format the date to YYYY-MM-DD in the same timezone as newMealdate
const year = newMealdate.value.getFullYear();
const month = String(newMealdate.value.getMonth() + 1).padStart(2, "0");
const day = String(newMealdate.value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
});
const i18n = useI18n();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { household } = useHouseholdSelf();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
// ===========================================================================
// Context Menu Setup
const defaultItems: { [key: string]: ContextMenuItem } = {
edit: {
title: i18n.t("general.edit"),
icon: $globals.icons.edit,
color: undefined,
event: "edit",
isPublic: false,
},
delete: {
title: i18n.t("general.delete"),
icon: $globals.icons.delete,
color: undefined,
event: "delete",
isPublic: false,
},
download: {
title: i18n.t("general.download"),
icon: $globals.icons.download,
color: undefined,
event: "download",
isPublic: false,
},
duplicate: {
title: i18n.t("general.duplicate"),
icon: $globals.icons.duplicate,
color: undefined,
event: "duplicate",
isPublic: false,
},
mealplanner: {
title: i18n.t("recipe.add-to-plan"),
icon: $globals.icons.calendar,
color: undefined,
event: "mealplanner",
isPublic: false,
},
shoppingList: {
title: i18n.t("recipe.add-to-list"),
icon: $globals.icons.cartCheck,
color: undefined,
event: "shoppingList",
isPublic: false,
},
print: {
title: i18n.t("general.print"),
icon: $globals.icons.printer,
color: undefined,
event: "print",
isPublic: true,
},
printPreferences: {
title: i18n.t("general.print-preferences"),
icon: $globals.icons.printerSettings,
color: undefined,
event: "printPreferences",
isPublic: true,
},
share: {
title: i18n.t("general.share"),
icon: $globals.icons.shareVariant,
color: undefined,
event: "share",
isPublic: false,
},
};
// Add leading and Appending Items
menuItems.value = [...menuItems.value, ...props.leadingItems, ...props.appendItems];
// ===========================================================================
// Context Menu Event Handler
const shoppingLists = ref<ShoppingListSummary[]>();
const recipeRef = ref<Recipe | undefined>(props.recipe);
const recipeRefWithScale = computed(() =>
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
);
const isAdminAndNotOwner = computed(() => {
return (
$auth.user.value?.admin
&& $auth.user.value?.id !== recipeRef.value?.userId
);
});
const canDelete = computed(() => {
const user = $auth.user.value;
const recipe = recipeRef.value;
return user && recipe && (user.admin || user.id === recipe.userId);
});
// Get Default Menu Items Specified in Props
for (const [key, value] of Object.entries(props.useItems)) {
if (!value) continue;
// Skip delete if not allowed
if (key === "delete" && !canDelete.value) continue;
const item = defaultItems[key];
if (item && (item.isPublic || isOwnGroup.value)) {
menuItems.value.push(item);
}
}
async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
if (data) {
shoppingLists.value = data.items ?? [];
}
}
async function refreshRecipe() {
const { data } = await api.recipes.getOne(props.slug);
if (data) {
recipeRef.value = data;
}
}
const router = useRouter();
const groupRecipeActionsStore = useGroupRecipeActions();
async function executeRecipeAction(action: GroupRecipeActionOut) {
if (!props.recipe) return;
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
if (action.actionType === "post") {
if (!response?.error) {
alert.success(i18n.t("events.message-sent"));
}
else {
alert.error(i18n.t("events.something-went-wrong"));
}
}
}
async function deleteRecipe() {
const { data } = await api.recipes.deleteOne(props.slug);
if (data?.slug) {
router.push(`/g/${groupSlug.value}`);
}
emit("deleted", props.slug);
}
const download = useDownloader();
async function handleDownloadEvent() {
const { data } = await api.recipes.getZipToken(props.slug);
if (data) {
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
}
}
async function addRecipeToPlan() {
const { response } = await api.mealplans.createOne({
date: newMealdateString.value,
entryType: newMealType.value,
title: "",
text: "",
recipeId: props.recipeId,
});
if (response?.status === 201) {
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
}
else {
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
}
}
async function duplicateRecipe() {
const { data } = await api.recipes.duplicateOne(props.slug, recipeName.value);
if (data && data.slug) {
router.push(`/g/${groupSlug.value}/r/${data.slug}`);
}
}
// Note: Print is handled as an event in the parent component
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
delete: () => {
recipeDeleteDialog.value = true;
},
edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"),
download: handleDownloadEvent,
duplicate: () => {
recipeDuplicateDialog.value = true;
},
mealplanner: () => {
mealplannerDialog.value = true;
},
printPreferences: async () => {
if (!recipeRef.value) {
await refreshRecipe();
}
printPreferencesDialog.value = true;
},
shoppingList: () => {
const promises: Promise<void>[] = [getShoppingLists()];
if (!recipeRef.value) {
promises.push(refreshRecipe());
}
Promise.allSettled(promises).then(() => {
shoppingListDialog.value = true;
});
},
share: () => {
shareDialog.value = true;
},
};
function contextMenuEventHandler(eventKey: string) {
const handler = eventHandlers[eventKey];
if (handler && typeof handler === "function") {
handler();
loading.value = false;
return;
}
emit(eventKey);
loading.value = false;
}
const planTypeOptions = usePlanTypeOptions();
const recipeActions = groupRecipeActionsStore.recipeActions;
</script>

View File

@@ -33,7 +33,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { whenever } from "@vueuse/core";
import { validators } from "~/composables/use-validators";
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
@@ -42,86 +42,66 @@ export interface GenericAlias {
name: string;
}
export default defineNuxtComponent({
props: {
modelValue: {
type: Boolean,
default: false,
},
data: {
type: Object as () => IngredientFood | IngredientUnit,
required: true,
},
},
emits: ["submit", "update:modelValue", "cancel"],
setup(props, context) {
// V-Model Support
const dialog = computed({
get: () => {
return props.modelValue;
},
set: (val) => {
context.emit("update:modelValue", val);
},
});
interface Props {
data: IngredientFood | IngredientUnit;
}
function createAlias() {
aliases.value.push({
name: "",
});
}
const props = defineProps<Props>();
function deleteAlias(index: number) {
aliases.value.splice(index, 1);
}
const emit = defineEmits<{
submit: [aliases: GenericAlias[]];
cancel: [];
}>();
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
function initAliases() {
aliases.value = [...props.data.aliases || []];
if (!aliases.value.length) {
createAlias();
}
}
// V-Model Support
const dialog = defineModel<boolean>({ default: false });
function createAlias() {
aliases.value.push({
name: "",
});
}
function deleteAlias(index: number) {
aliases.value.splice(index, 1);
}
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
function initAliases() {
aliases.value = [...props.data.aliases || []];
if (!aliases.value.length) {
createAlias();
}
}
initAliases();
whenever(
() => dialog.value,
() => {
initAliases();
whenever(
() => props.modelValue,
() => {
initAliases();
},
);
},
);
function saveAliases() {
const seenAliasNames: string[] = [];
const keepAliases: GenericAlias[] = [];
aliases.value.forEach((alias) => {
if (
!alias.name
|| alias.name === props.data.name
|| alias.name === props.data.pluralName
|| alias.name === props.data.abbreviation
|| alias.name === props.data.pluralAbbreviation
|| seenAliasNames.includes(alias.name)
) {
return;
}
keepAliases.push(alias);
seenAliasNames.push(alias.name);
});
aliases.value = keepAliases;
context.emit("submit", keepAliases);
function saveAliases() {
const seenAliasNames: string[] = [];
const keepAliases: GenericAlias[] = [];
aliases.value.forEach((alias) => {
if (
!alias.name
|| alias.name === props.data.name
|| alias.name === props.data.pluralName
|| alias.name === props.data.abbreviation
|| alias.name === props.data.pluralAbbreviation
|| seenAliasNames.includes(alias.name)
) {
return;
}
return {
aliases,
createAlias,
dialog,
deleteAlias,
saveAliases,
validators,
};
},
});
keepAliases.push(alias);
seenAliasNames.push(alias.name);
});
aliases.value = keepAliases;
emit("submit", keepAliases);
}
</script>

View File

@@ -3,12 +3,13 @@
v-model="selected"
item-key="id"
show-select
:sort-by="[{ key: 'dateAdded', order: 'desc' }]"
:sort-by="sortBy"
:headers="headers"
:items="recipes"
:items-per-page="15"
class="elevation-0"
:loading="loading"
return-object
>
<template #[`item.name`]="{ item }">
<a
@@ -61,7 +62,7 @@
</v-data-table>
</template>
<script lang="ts">
<script setup lang="ts">
import UserAvatar from "../User/UserAvatar.vue";
import RecipeChip from "./RecipeChips.vue";
import type { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe";
@@ -69,8 +70,6 @@ import { useUserApi } from "~/composables/api";
import type { UserSummary } from "~/lib/api/types/user";
import type { RecipeTag } from "~/lib/api/types/household";
const INPUT_EVENT = "update:modelValue";
interface ShowHeaders {
id: boolean;
owner: boolean;
@@ -83,136 +82,114 @@ interface ShowHeaders {
dateAdded: boolean;
}
export default defineNuxtComponent({
components: { RecipeChip, UserAvatar },
props: {
modelValue: {
type: Array as PropType<Recipe[]>,
required: false,
default: () => [],
},
loading: {
type: Boolean,
required: false,
default: false,
},
recipes: {
type: Array as () => Recipe[],
default: () => [],
},
showHeaders: {
type: Object as () => ShowHeaders,
required: false,
default: () => {
return {
id: true,
owner: false,
tags: true,
categories: true,
recipeServings: true,
recipeYieldQuantity: true,
recipeYield: true,
dateAdded: true,
};
},
},
},
emits: ["click"],
setup(props, context) {
const i18n = useI18n();
const $auth = useMealieAuth();
const groupSlug = $auth.user.value?.groupSlug;
const router = useRouter();
const selected = computed({
get: () => props.modelValue,
set: value => context.emit(INPUT_EVENT, value),
});
const headers = computed(() => {
const hdrs: Array<{ title: string; value: string; align?: string; sortable?: boolean }> = [];
if (props.showHeaders.id) {
hdrs.push({ title: i18n.t("general.id"), value: "id" });
}
if (props.showHeaders.owner) {
hdrs.push({ title: i18n.t("general.owner"), value: "userId", align: "center", sortable: true });
}
hdrs.push({ title: i18n.t("general.name"), value: "name", sortable: true });
if (props.showHeaders.categories) {
hdrs.push({ title: i18n.t("recipe.categories"), value: "recipeCategory", sortable: true });
}
if (props.showHeaders.tags) {
hdrs.push({ title: i18n.t("tag.tags"), value: "tags", sortable: true });
}
if (props.showHeaders.tools) {
hdrs.push({ title: i18n.t("tool.tools"), value: "tools", sortable: true });
}
if (props.showHeaders.recipeServings) {
hdrs.push({ title: i18n.t("recipe.servings"), value: "recipeServings", sortable: true });
}
if (props.showHeaders.recipeYieldQuantity) {
hdrs.push({ title: i18n.t("recipe.yield"), value: "recipeYieldQuantity", sortable: true });
}
if (props.showHeaders.recipeYield) {
hdrs.push({ title: i18n.t("recipe.yield-text"), value: "recipeYield", sortable: true });
}
if (props.showHeaders.dateAdded) {
hdrs.push({ title: i18n.t("general.date-added"), value: "dateAdded", sortable: true });
}
return hdrs;
});
function formatDate(date: string) {
try {
return i18n.d(Date.parse(date), "medium");
}
catch {
return "";
}
}
// ============
// Group Members
const api = useUserApi();
const members = ref<UserSummary[]>([]);
async function refreshMembers() {
const { data } = await api.groups.fetchMembers();
if (data) {
members.value = data.items;
}
}
function filterItems(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
if (!groupSlug || !item.id) {
return;
}
router.push(`/g/${groupSlug}?${itemType}=${item.id}`);
}
onMounted(() => {
refreshMembers();
});
function getMember(id: string) {
if (members.value[0]) {
return members.value.find(m => m.id === id)?.fullName;
}
return i18n.t("general.none");
}
return {
selected,
groupSlug,
headers,
formatDate,
members,
getMember,
filterItems,
};
},
interface Props {
loading?: boolean;
recipes?: Recipe[];
showHeaders?: ShowHeaders;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
recipes: () => [],
showHeaders: () => ({
id: true,
owner: false,
tags: true,
categories: true,
tools: true,
recipeServings: true,
recipeYieldQuantity: true,
recipeYield: true,
dateAdded: true,
}),
});
defineEmits<{
click: [];
}>();
const selected = defineModel<Recipe[]>({ default: () => [] });
const i18n = useI18n();
const $auth = useMealieAuth();
const groupSlug = $auth.user.value?.groupSlug;
const router = useRouter();
// Initialize sort state with default sorting by dateAdded descending
const sortBy = ref([{ key: "dateAdded", order: "desc" as const }]);
const headers = computed(() => {
const hdrs: Array<{ title: string; value: string; align?: "center" | "start" | "end"; sortable?: boolean }> = [];
if (props.showHeaders.id) {
hdrs.push({ title: i18n.t("general.id"), value: "id" });
}
if (props.showHeaders.owner) {
hdrs.push({ title: i18n.t("general.owner"), value: "userId", align: "center", sortable: true });
}
hdrs.push({ title: i18n.t("general.name"), value: "name", sortable: true });
if (props.showHeaders.categories) {
hdrs.push({ title: i18n.t("recipe.categories"), value: "recipeCategory", sortable: true });
}
if (props.showHeaders.tags) {
hdrs.push({ title: i18n.t("tag.tags"), value: "tags", sortable: true });
}
if (props.showHeaders.tools) {
hdrs.push({ title: i18n.t("tool.tools"), value: "tools", sortable: true });
}
if (props.showHeaders.recipeServings) {
hdrs.push({ title: i18n.t("recipe.servings"), value: "recipeServings", sortable: true });
}
if (props.showHeaders.recipeYieldQuantity) {
hdrs.push({ title: i18n.t("recipe.yield"), value: "recipeYieldQuantity", sortable: true });
}
if (props.showHeaders.recipeYield) {
hdrs.push({ title: i18n.t("recipe.yield-text"), value: "recipeYield", sortable: true });
}
if (props.showHeaders.dateAdded) {
hdrs.push({ title: i18n.t("general.date-added"), value: "dateAdded", sortable: true });
}
return hdrs;
});
function formatDate(date: string) {
try {
return i18n.d(Date.parse(date), "medium");
}
catch {
return "";
}
}
// ============
// Group Members
const api = useUserApi();
const members = ref<UserSummary[]>([]);
async function refreshMembers() {
const { data } = await api.groups.fetchMembers();
if (data) {
members.value = data.items;
}
}
function filterItems(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
if (!groupSlug || !item.id) {
return;
}
router.push(`/g/${groupSlug}?${itemType}=${item.id}`);
}
onMounted(() => {
refreshMembers();
});
function getMember(id: string) {
if (members.value[0]) {
return members.value.find(m => m.id === id)?.fullName;
}
return i18n.t("general.none");
}
</script>

View File

@@ -51,7 +51,7 @@
<BaseDialog
v-if="shoppingListIngredientDialog"
v-model="dialog"
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
:title="selectedShoppingList?.name || $t('recipe.add-to-list')"
:icon="$globals.icons.cartCheck"
width="70%"
:submit-text="$t('recipe.add-to-list')"
@@ -130,20 +130,23 @@
.ingredients[i]
.checked"
>
<v-checkbox
hide-details
:model-value="ingredientData.checked"
class="pt-0 my-auto py-auto"
color="secondary"
density="compact"
/>
<div :key="ingredientData.ingredient.quantity">
<RecipeIngredientListItem
:ingredient="ingredientData.ingredient"
:disable-amount="ingredientData.disableAmount"
:scale="recipeSection.recipeScale"
/>
</div>
<v-container class="pa-0 ma-0">
<v-row no-gutters>
<v-checkbox
hide-details
:model-value="ingredientData.checked"
class="pt-0 my-auto py-auto mr-2"
color="secondary"
density="compact"
/>
<div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
<RecipeIngredientListItem
:ingredient="ingredientData.ingredient"
:scale="recipeSection.recipeScale"
/>
</div>
</v-row>
</v-container>
</v-list-item>
</div>
</div>
@@ -172,7 +175,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { toRefs } from "@vueuse/core";
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import { useUserApi } from "~/composables/api";
@@ -188,7 +191,6 @@ export interface RecipeWithScale extends Recipe {
export interface ShoppingListIngredient {
checked: boolean;
ingredient: RecipeIngredient;
disableAmount: boolean;
}
export interface ShoppingListIngredientSection {
@@ -203,240 +205,214 @@ export interface ShoppingListRecipeIngredientSection {
ingredientSections: ShoppingListIngredientSection[];
}
export default defineNuxtComponent({
components: {
RecipeIngredientListItem,
interface Props {
recipes?: RecipeWithScale[];
shoppingLists?: ShoppingListSummary[];
}
const props = withDefaults(defineProps<Props>(), {
recipes: undefined,
shoppingLists: () => [],
});
const dialog = defineModel<boolean>({ default: false });
const i18n = useI18n();
const $auth = useMealieAuth();
const api = useUserApi();
const preferences = useShoppingListPreferences();
const ready = ref(false);
const state = reactive({
shoppingListDialog: true,
shoppingListIngredientDialog: false,
shoppingListShowAllToggled: false,
});
const { shoppingListDialog, shoppingListIngredientDialog, shoppingListShowAllToggled: _shoppingListShowAllToggled } = toRefs(state);
const userHousehold = computed(() => {
return $auth.user.value?.householdSlug || "";
});
const shoppingListChoices = computed(() => {
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
});
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
watchEffect(
() => {
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
selectedShoppingList.value = shoppingListChoices.value[0];
openShoppingListIngredientDialog(selectedShoppingList.value);
}
else {
ready.value = true;
}
},
props: {
modelValue: {
type: Boolean,
default: false,
},
recipes: {
type: Array as () => RecipeWithScale[],
default: undefined,
},
shoppingLists: {
type: Array as () => ShoppingListSummary[],
default: () => [],
},
},
emits: ["update:modelValue"],
setup(props, context) {
const i18n = useI18n();
const $auth = useMealieAuth();
const api = useUserApi();
const preferences = useShoppingListPreferences();
const ready = ref(false);
);
// v-model support
const dialog = computed({
get: () => {
return props.modelValue;
},
set: (val) => {
context.emit("update:modelValue", val);
initState();
},
watch(dialog, (val) => {
if (!val) {
initState();
}
});
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
for (const recipe of recipes) {
if (!recipe.slug) {
continue;
}
if (recipeSectionMap.has(recipe.slug)) {
const existingSection = recipeSectionMap.get(recipe.slug);
if (existingSection) {
existingSection.recipeScale += recipe.scale;
}
continue;
}
if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
const { data } = await api.recipes.getOne(recipe.slug);
if (!data?.recipeIngredient?.length) {
continue;
}
recipe.id = data.id || "";
recipe.name = data.name || "";
recipe.recipeIngredient = data.recipeIngredient;
}
else if (!recipe.recipeIngredient.length) {
continue;
}
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
return {
checked: !householdsWithFood.includes(userHousehold.value),
ingredient: ing,
};
});
const state = reactive({
shoppingListDialog: true,
shoppingListIngredientDialog: false,
shoppingListShowAllToggled: false,
});
let currentTitle = "";
const onHandIngs: ShoppingListIngredient[] = [];
const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
if (ing.ingredient.title) {
currentTitle = ing.ingredient.title;
}
const userHousehold = computed(() => {
return $auth.user.value?.householdSlug || "";
});
const shoppingListChoices = computed(() => {
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
});
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
watchEffect(
() => {
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
selectedShoppingList.value = shoppingListChoices.value[0];
openShoppingListIngredientDialog(selectedShoppingList.value);
// If this is the first item in the section, create a new section
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
if (sections.length) {
// Add the on-hand ingredients to the previous section
sections[sections.length - 1].ingredients.push(...onHandIngs);
onHandIngs.length = 0;
}
else {
ready.value = true;
sections.push({
sectionName: currentTitle,
ingredients: [],
});
}
// Store the on-hand ingredients for later
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
if (householdsWithFood.includes(userHousehold.value)) {
onHandIngs.push(ing);
return sections;
}
// Add the ingredient to previous section
sections[sections.length - 1].ingredients.push(ing);
return sections;
}, [] as ShoppingListIngredientSection[]);
// Add remaining on-hand ingredients to the previous section
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
recipeSectionMap.set(recipe.slug, {
recipeId: recipe.id,
recipeName: recipe.name,
recipeScale: recipe.scale,
ingredientSections: shoppingListIngredientSections,
});
}
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
}
function initState() {
state.shoppingListDialog = true;
state.shoppingListIngredientDialog = false;
state.shoppingListShowAllToggled = false;
recipeIngredientSections.value = [];
selectedShoppingList.value = null;
}
initState();
async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
if (!props.recipes?.length) {
return;
}
selectedShoppingList.value = list;
await consolidateRecipesIntoSections(props.recipes);
state.shoppingListDialog = false;
state.shoppingListIngredientDialog = true;
}
function setShowAllToggled() {
state.shoppingListShowAllToggled = true;
}
function bulkCheckIngredients(value = true) {
recipeIngredientSections.value.forEach((recipeSection) => {
recipeSection.ingredientSections.forEach((ingSection) => {
ingSection.ingredients.forEach((ing) => {
ing.checked = value;
});
});
});
}
async function addRecipesToList() {
if (!selectedShoppingList.value) {
return;
}
const recipeData: ShoppingListAddRecipeParamsBulk[] = [];
recipeIngredientSections.value.forEach((section) => {
const ingredients: RecipeIngredient[] = [];
section.ingredientSections.forEach((ingSection) => {
ingSection.ingredients.forEach((ing) => {
if (ing.checked) {
ingredients.push(ing.ingredient);
}
});
});
if (!ingredients.length) {
return;
}
recipeData.push(
{
recipeId: section.recipeId,
recipeIncrementQuantity: section.recipeScale,
recipeIngredients: ingredients,
},
);
});
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
for (const recipe of recipes) {
if (!recipe.slug) {
continue;
}
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
error ? alert.error(i18n.t("recipe.failed-to-add-recipes-to-list")) : alert.success(i18n.t("recipe.successfully-added-to-list"));
if (recipeSectionMap.has(recipe.slug)) {
recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
continue;
}
if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
const { data } = await api.recipes.getOne(recipe.slug);
if (!data?.recipeIngredient?.length) {
continue;
}
recipe.id = data.id || "";
recipe.name = data.name || "";
recipe.recipeIngredient = data.recipeIngredient;
}
else if (!recipe.recipeIngredient.length) {
continue;
}
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
return {
checked: !householdsWithFood.includes(userHousehold.value),
ingredient: ing,
disableAmount: recipe.settings?.disableAmount || false,
};
});
let currentTitle = "";
const onHandIngs: ShoppingListIngredient[] = [];
const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
if (ing.ingredient.title) {
currentTitle = ing.ingredient.title;
}
// If this is the first item in the section, create a new section
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
if (sections.length) {
// Add the on-hand ingredients to the previous section
sections[sections.length - 1].ingredients.push(...onHandIngs);
onHandIngs.length = 0;
}
sections.push({
sectionName: currentTitle,
ingredients: [],
});
}
// Store the on-hand ingredients for later
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
if (householdsWithFood.includes(userHousehold.value)) {
onHandIngs.push(ing);
return sections;
}
// Add the ingredient to previous section
sections[sections.length - 1].ingredients.push(ing);
return sections;
}, [] as ShoppingListIngredientSection[]);
// Add remaining on-hand ingredients to the previous section
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
recipeSectionMap.set(recipe.slug, {
recipeId: recipe.id,
recipeName: recipe.name,
recipeScale: recipe.scale,
ingredientSections: shoppingListIngredientSections,
});
}
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
}
function initState() {
state.shoppingListDialog = true;
state.shoppingListIngredientDialog = false;
state.shoppingListShowAllToggled = false;
recipeIngredientSections.value = [];
selectedShoppingList.value = null;
}
initState();
async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
if (!props.recipes?.length) {
return;
}
selectedShoppingList.value = list;
await consolidateRecipesIntoSections(props.recipes);
state.shoppingListDialog = false;
state.shoppingListIngredientDialog = true;
}
function setShowAllToggled() {
state.shoppingListShowAllToggled = true;
}
function bulkCheckIngredients(value = true) {
recipeIngredientSections.value.forEach((recipeSection) => {
recipeSection.ingredientSections.forEach((ingSection) => {
ingSection.ingredients.forEach((ing) => {
ing.checked = value;
});
});
});
}
async function addRecipesToList() {
if (!selectedShoppingList.value) {
return;
}
const recipeData: ShoppingListAddRecipeParamsBulk[] = [];
recipeIngredientSections.value.forEach((section) => {
const ingredients: RecipeIngredient[] = [];
section.ingredientSections.forEach((ingSection) => {
ingSection.ingredients.forEach((ing) => {
if (ing.checked) {
ingredients.push(ing.ingredient);
}
});
});
if (!ingredients.length) {
return;
}
recipeData.push(
{
recipeId: section.recipeId,
recipeIncrementQuantity: section.recipeScale,
recipeIngredients: ingredients,
},
);
});
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
error ? alert.error(i18n.t("recipe.failed-to-add-recipes-to-list")) : alert.success(i18n.t("recipe.successfully-added-to-list"));
state.shoppingListDialog = false;
state.shoppingListIngredientDialog = false;
dialog.value = false;
}
return {
dialog,
preferences,
ready,
shoppingListChoices,
...toRefs(state),
addRecipesToList,
bulkCheckIngredients,
openShoppingListIngredientDialog,
setShowAllToggled,
recipeIngredientSections,
selectedShoppingList,
};
},
});
state.shoppingListDialog = false;
state.shoppingListIngredientDialog = false;
dialog.value = false;
}
</script>
<style scoped lang="css">

View File

@@ -4,9 +4,9 @@
v-model="dialog"
width="800"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<BaseButton
v-bind="props"
v-bind="activatorProps"
@click="inputText = inputTextProp"
>
{{ $t("new-recipe.bulk-add") }}
@@ -89,88 +89,75 @@
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
inputTextProp: {
type: String,
required: false,
default: "",
},
},
emits: ["bulk-data"],
setup(props, context) {
const state = reactive({
dialog: false,
inputText: props.inputTextProp,
});
function splitText() {
return state.inputText.split("\n").filter(line => !(line === "\n" || !line));
}
function removeFirstCharacter() {
state.inputText = splitText()
.map(line => line.substring(1))
.join("\n");
}
const numberedLineRegex = /\d+[.):] /gm;
function splitByNumberedLine() {
// Split inputText by numberedLineRegex
const matches = state.inputText.match(numberedLineRegex);
matches?.forEach((match, idx) => {
const replaceText = idx === 0 ? "" : "\n";
state.inputText = state.inputText.replace(match, replaceText);
});
}
function trimAllLines() {
const splitLines = splitText();
splitLines.forEach((element: string, index: number) => {
splitLines[index] = element.trim();
});
state.inputText = splitLines.join("\n");
}
function save() {
context.emit("bulk-data", splitText());
state.dialog = false;
}
const i18n = useI18n();
const utilities = [
{
id: "trim-whitespace",
description: i18n.t("new-recipe.trim-whitespace-description"),
action: trimAllLines,
},
{
id: "trim-prefix",
description: i18n.t("new-recipe.trim-prefix-description"),
action: removeFirstCharacter,
},
{
id: "split-by-numbered-line",
description: i18n.t("new-recipe.split-by-numbered-line-description"),
action: splitByNumberedLine,
},
];
return {
utilities,
splitText,
trimAllLines,
removeFirstCharacter,
splitByNumberedLine,
save,
...toRefs(state),
};
},
<script setup lang="ts">
interface Props {
inputTextProp?: string;
}
const props = withDefaults(defineProps<Props>(), {
inputTextProp: "",
});
const emit = defineEmits<{
"bulk-data": [data: string[]];
}>();
const dialog = ref(false);
const inputText = ref(props.inputTextProp);
function splitText() {
return inputText.value.split("\n").filter(line => !(line === "\n" || !line));
}
function removeFirstCharacter() {
inputText.value = splitText()
.map(line => line.substring(1))
.join("\n");
}
const numberedLineRegex = /\d+[.):] /gm;
function splitByNumberedLine() {
// Split inputText by numberedLineRegex
const matches = inputText.value.match(numberedLineRegex);
matches?.forEach((match, idx) => {
const replaceText = idx === 0 ? "" : "\n";
inputText.value = inputText.value.replace(match, replaceText);
});
}
function trimAllLines() {
const splitLines = splitText();
splitLines.forEach((element: string, index: number) => {
splitLines[index] = element.trim();
});
inputText.value = splitLines.join("\n");
}
function save() {
emit("bulk-data", splitText());
dialog.value = false;
}
const i18n = useI18n();
const utilities = [
{
id: "trim-whitespace",
description: i18n.t("new-recipe.trim-whitespace-description"),
action: trimAllLines,
},
{
id: "trim-prefix",
description: i18n.t("new-recipe.trim-prefix-description"),
action: removeFirstCharacter,
},
{
id: "split-by-numbered-line",
description: i18n.t("new-recipe.split-by-numbered-line-description"),
action: splitByNumberedLine,
},
];
</script>

View File

@@ -44,6 +44,7 @@
<v-switch
v-model="preferences.showDescription"
hide-details
color="primary"
:label="$t('recipe.description')"
/>
</v-row>
@@ -51,6 +52,7 @@
<v-switch
v-model="preferences.showNotes"
hide-details
color="primary"
:label="$t('recipe.notes')"
/>
</v-row>
@@ -63,6 +65,7 @@
<v-switch
v-model="preferences.showNutrition"
hide-details
color="primary"
:label="$t('recipe.nutrition')"
/>
</v-row>
@@ -83,45 +86,19 @@
</BaseDialog>
</template>
<script lang="ts">
<script setup lang="ts">
import type { Recipe } from "~/lib/api/types/recipe";
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineNuxtComponent({
components: {
RecipePrintView,
},
props: {
modelValue: {
type: Boolean,
default: false,
},
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
default: undefined,
},
},
emits: ["update:modelValue"],
setup(props, context) {
const preferences = useUserPrintPreferences();
// V-Model Support
const dialog = computed({
get: () => {
return props.modelValue;
},
set: (val) => {
context.emit("update:modelValue", val);
},
});
return {
dialog,
ImagePosition,
preferences,
};
},
interface Props {
recipe?: NoUndefinedField<Recipe>;
}
withDefaults(defineProps<Props>(), {
recipe: undefined,
});
const dialog = defineModel<boolean>({ default: false });
const preferences = useUserPrintPreferences();
</script>

View File

@@ -52,10 +52,6 @@
<div class="mr-auto">
{{ $t("search.results") }}
</div>
<!-- <router-link
:to="advancedSearchUrl"
class="text-primary"
> {{ $t("search.advanced-search") }} </router-link> -->
</v-card-actions>
<RecipeCardMobile
@@ -76,7 +72,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeCardMobile from "./RecipeCardMobile.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { RecipeSummary } from "~/lib/api/types/recipe";
@@ -85,114 +81,104 @@ import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
import { usePublicExploreApi } from "~/composables/api/api-client";
const SELECTED_EVENT = "selected";
export default defineNuxtComponent({
components: {
RecipeCardMobile,
},
setup(_, context) {
const $auth = useMealieAuth();
const state = reactive({
loading: false,
selectedIndex: -1,
});
// Define emits
const emit = defineEmits<{
selected: [recipe: RecipeSummary];
}>();
// ===========================================================================
// Dialog State Management
const dialog = ref(false);
const $auth = useMealieAuth();
const loading = ref(false);
const selectedIndex = ref(-1);
// Reset or Grab Recipes on Change
watch(dialog, (val) => {
if (!val) {
search.query.value = "";
state.selectedIndex = -1;
search.data.value = [];
}
});
// ===========================================================================
// Dialog State Management
const dialog = ref(false);
// ===========================================================================
// Event Handlers
// Reset or Grab Recipes on Change
watch(dialog, (val) => {
if (!val) {
search.query.value = "";
selectedIndex.value = -1;
search.data.value = [];
}
});
function selectRecipe() {
const recipeCards = document.getElementsByClassName("arrow-nav");
if (recipeCards) {
if (state.selectedIndex < 0) {
state.selectedIndex = -1;
document.getElementById("arrow-search")?.focus();
return;
}
// ===========================================================================
// Event Handlers
if (state.selectedIndex >= recipeCards.length) {
state.selectedIndex = recipeCards.length - 1;
}
(recipeCards[state.selectedIndex] as HTMLElement).focus();
}
function selectRecipe() {
const recipeCards = document.getElementsByClassName("arrow-nav");
if (recipeCards) {
if (selectedIndex.value < 0) {
selectedIndex.value = -1;
document.getElementById("arrow-search")?.focus();
return;
}
function onUpDown(e: KeyboardEvent) {
if (e.key === "Enter") {
console.log(document.activeElement);
// (document.activeElement as HTMLElement).click();
}
else if (e.key === "ArrowUp") {
e.preventDefault();
state.selectedIndex--;
}
else if (e.key === "ArrowDown") {
e.preventDefault();
state.selectedIndex++;
}
else {
return;
}
selectRecipe();
if (selectedIndex.value >= recipeCards.length) {
selectedIndex.value = recipeCards.length - 1;
}
watch(dialog, (val) => {
if (!val) {
document.removeEventListener("keyup", onUpDown);
}
else {
document.addEventListener("keyup", onUpDown);
}
});
(recipeCards[selectedIndex.value] as HTMLElement).focus();
}
}
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const route = useRoute();
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`);
watch(route, close);
function onUpDown(e: KeyboardEvent) {
if (e.key === "Enter") {
console.log(document.activeElement);
// (document.activeElement as HTMLElement).click();
}
else if (e.key === "ArrowUp") {
e.preventDefault();
selectedIndex.value--;
}
else if (e.key === "ArrowDown") {
e.preventDefault();
selectedIndex.value++;
}
else {
return;
}
selectRecipe();
}
function open() {
dialog.value = true;
}
function close() {
dialog.value = false;
}
watch(dialog, (val) => {
if (!val) {
document.removeEventListener("keyup", onUpDown);
}
else {
document.addEventListener("keyup", onUpDown);
}
});
// ===========================================================================
// Basic Search
const { isOwnGroup } = useLoggedInState();
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
const search = useRecipeSearch(api);
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
watch(route, close);
// Select Handler
function open() {
dialog.value = true;
}
function close() {
dialog.value = false;
}
function handleSelect(recipe: RecipeSummary) {
close();
context.emit(SELECTED_EVENT, recipe);
}
// ===========================================================================
// Basic Search
const { isOwnGroup } = useLoggedInState();
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
const search = useRecipeSearch(api);
return {
...toRefs(state),
advancedSearchUrl,
dialog,
open,
close,
handleSelect,
search,
};
},
// Select Handler
function handleSelect(recipe: RecipeSummary) {
close();
emit(SELECTED_EVENT, recipe);
}
// Expose functions to parent components
defineExpose({
open,
close,
});
</script>

View File

@@ -14,14 +14,14 @@
max-width="290px"
min-width="auto"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="expirationDateString"
:label="$t('recipe-share.expiration-date')"
:hint="$t('recipe-share.default-30-days')"
persistent-hint
:prepend-icon="$globals.icons.calendar"
v-bind="props"
v-bind="activatorProps"
readonly
/>
</template>
@@ -92,150 +92,116 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useClipboard, useShare, whenever } from "@vueuse/core";
import type { RecipeShareToken } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api";
import { useHouseholdSelf } from "~/composables/use-households";
import { alert } from "~/composables/use-toast";
export default defineNuxtComponent({
props: {
modelValue: {
type: Boolean,
default: false,
},
recipeId: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
},
emits: ["update:modelValue"],
setup(props, context) {
// V-Model Support
const dialog = computed({
get: () => {
return props.modelValue;
},
set: (val) => {
context.emit("update:modelValue", val);
},
});
interface Props {
recipeId: string;
name: string;
}
const props = defineProps<Props>();
const state = reactive({
datePickerMenu: false,
expirationDate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
tokens: [] as RecipeShareToken[],
});
const dialog = defineModel<boolean>({ default: false });
const expirationDateString = computed(() => {
return state.expirationDate.toISOString().substring(0, 10);
});
const datePickerMenu = ref(false);
const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
const tokens = ref<RecipeShareToken[]>([]);
whenever(
() => props.modelValue,
() => {
// Set expiration date to today + 30 Days
const today = new Date();
state.expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
refreshTokens();
},
);
const i18n = useI18n();
const $auth = useMealieAuth();
const { household } = useHouseholdSelf();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
// ============================================================
// Token Actions
const userApi = useUserApi();
async function createNewToken() {
// Convert expiration date to timestamp
const { data } = await userApi.recipes.share.createOne({
recipeId: props.recipeId,
expiresAt: state.expirationDate.toISOString(),
});
if (data) {
state.tokens.push(data);
}
}
async function deleteToken(id: string) {
await userApi.recipes.share.deleteOne(id);
state.tokens = state.tokens.filter(token => token.id !== id);
}
async function refreshTokens() {
const { data } = await userApi.recipes.share.getAll(1, -1, { recipe_id: props.recipeId });
if (data) {
// @ts-expect-error - TODO: This routes doesn't have pagination, but the type are mismatched.
state.tokens = data ?? [];
}
}
const { share, isSupported: shareIsSupported } = useShare();
const { copy, copied, isSupported } = useClipboard();
function getRecipeText() {
return i18n.t("recipe.share-recipe-message", [props.name]);
}
function getTokenLink(token: string) {
return `${window.location.origin}/g/${groupSlug.value}/shared/r/${token}`;
}
async function copyTokenLink(token: string) {
if (isSupported.value) {
await copy(getTokenLink(token));
if (copied.value) {
alert.success(i18n.t("recipe-share.recipe-link-copied-message") as string);
}
else {
alert.error(i18n.t("general.clipboard-copy-failure") as string);
}
}
else {
alert.error(i18n.t("general.clipboard-not-supported") as string);
}
}
async function shareRecipe(token: string) {
if (shareIsSupported) {
share({
title: props.name,
url: getTokenLink(token),
text: getRecipeText() as string,
});
}
else {
await copyTokenLink(token);
}
}
return {
...toRefs(state),
expirationDateString,
dialog,
createNewToken,
deleteToken,
firstDayOfWeek,
shareRecipe,
copyTokenLink,
};
},
const expirationDateString = computed(() => {
return expirationDate.value.toISOString().substring(0, 10);
});
whenever(
() => dialog.value,
() => {
// Set expiration date to today + 30 Days
const today = new Date();
expirationDate.value = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
refreshTokens();
},
);
const i18n = useI18n();
const $auth = useMealieAuth();
const { household } = useHouseholdSelf();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
// ============================================================
// Token Actions
const userApi = useUserApi();
async function createNewToken() {
// Convert expiration date to timestamp
const { data } = await userApi.recipes.share.createOne({
recipeId: props.recipeId,
expiresAt: expirationDate.value.toISOString(),
});
if (data) {
tokens.value.push(data);
}
}
async function deleteToken(id: string) {
await userApi.recipes.share.deleteOne(id);
tokens.value = tokens.value.filter(token => token.id !== id);
}
async function refreshTokens() {
const { data } = await userApi.recipes.share.getAll(1, -1, { recipe_id: props.recipeId });
if (data) {
// @ts-expect-error - TODO: This routes doesn't have pagination, but the type are mismatched.
tokens.value = data ?? [];
}
}
const { share, isSupported: shareIsSupported } = useShare();
const { copy, copied, isSupported } = useClipboard();
function getRecipeText() {
return i18n.t("recipe.share-recipe-message", [props.name]);
}
function getTokenLink(token: string) {
return `${window.location.origin}/g/${groupSlug.value}/shared/r/${token}`;
}
async function copyTokenLink(token: string) {
if (isSupported.value) {
await copy(getTokenLink(token));
if (copied.value) {
alert.success(i18n.t("recipe-share.recipe-link-copied-message") as string);
}
else {
alert.error(i18n.t("general.clipboard-copy-failure") as string);
}
}
else {
alert.error(i18n.t("general.clipboard-not-supported") as string);
}
}
async function shareRecipe(token: string) {
if (shareIsSupported) {
share({
title: props.name,
url: getTokenLink(token),
text: getRecipeText() as string,
});
}
else {
await copyTokenLink(token);
}
}
</script>

View File

@@ -1,710 +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="state.orderBy = 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 } 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 { 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;
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";
}
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,
orderBy: passedQuery.value.orderBy === queryDefaults.orderBy ? undefined : passedQuery.value.orderBy,
orderDirection: passedQuery.value.orderDirection === queryDefaults.orderDirection ? undefined : passedQuery.value.orderDirection,
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;
}
if (query.orderBy?.length) {
state.value.orderBy = query.orderBy as string;
}
else {
state.value.orderBy = queryDefaults.orderBy;
}
if (query.orderDirection?.length) {
state.value.orderDirection = query.orderDirection as "asc" | "desc";
}
else {
state.value.orderDirection = queryDefaults.orderDirection;
}
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,
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

@@ -4,7 +4,7 @@
nudge-right="50"
:color="buttonStyle ? 'info' : 'secondary'"
>
<template #activator="{ props }">
<template #activator="{ props: tooltipProps }">
<v-btn
v-if="isFavorite || showAlways"
icon
@@ -13,7 +13,7 @@
size="small"
:color="buttonStyle ? 'info' : 'secondary'"
:fab="buttonStyle"
v-bind="{ ...props, ...$attrs }"
v-bind="{ ...tooltipProps, ...$attrs }"
@click.prevent="toggleFavorite"
>
<v-icon
@@ -28,47 +28,39 @@
</v-tooltip>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserSelfRatings } from "~/composables/use-users";
import { useUserApi } from "~/composables/api";
export default defineNuxtComponent({
props: {
recipeId: {
type: String,
default: "",
},
showAlways: {
type: Boolean,
default: false,
},
buttonStyle: {
type: Boolean,
default: false,
},
},
setup(props) {
const api = useUserApi();
const $auth = useMealieAuth();
const { userRatings, refreshUserRatings } = useUserSelfRatings();
const isFavorite = computed(() => {
const rating = userRatings.value.find(r => r.recipeId === props.recipeId);
return rating?.isFavorite || false;
});
async function toggleFavorite() {
if (!$auth.user.value) return;
if (!isFavorite.value) {
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
}
else {
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
}
await refreshUserRatings();
}
return { isFavorite, toggleFavorite };
},
interface Props {
recipeId?: string;
showAlways?: boolean;
buttonStyle?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
recipeId: "",
showAlways: false,
buttonStyle: false,
});
const { userRatings, refreshUserRatings } = useUserSelfRatings();
const isFavorite = computed(() => {
const rating = userRatings.value.find(r => r.recipeId === props.recipeId);
return rating?.isFavorite || false;
});
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);
}
else {
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
}
await refreshUserRatings();
}
</script>

View File

@@ -7,11 +7,11 @@
nudge-top="6"
:close-on-content-click="false"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-btn
color="accent"
dark
v-bind="props"
v-bind="activatorProps"
>
<v-icon start>
{{ $globals.icons.fileImage }}
@@ -61,52 +61,42 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
const REFRESH_EVENT = "refresh";
const UPLOAD_EVENT = "upload";
export default defineNuxtComponent({
props: {
slug: {
type: String,
required: true,
},
},
setup(props, context) {
const state = reactive({
url: "",
loading: false,
menu: false,
});
const props = defineProps<{ slug: string }>();
function uploadImage(fileObject: File) {
context.emit(UPLOAD_EVENT, fileObject);
state.menu = false;
}
const emit = defineEmits<{
refresh: [];
upload: [fileObject: File];
}>();
const api = useUserApi();
async function getImageFromURL() {
state.loading = true;
if (await api.recipes.updateImagebyURL(props.slug, state.url)) {
context.emit(REFRESH_EVENT);
}
state.loading = false;
state.menu = false;
}
const url = ref("");
const loading = ref(false);
const menu = ref(false);
const i18n = useI18n();
const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")];
function uploadImage(fileObject: File) {
emit(UPLOAD_EVENT, fileObject);
menu.value = false;
}
return {
...toRefs(state),
uploadImage,
getImageFromURL,
messages,
};
},
});
const api = useUserApi();
async function getImageFromURL() {
loading.value = true;
if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
emit(REFRESH_EVENT);
}
loading.value = false;
menu.value = false;
}
const i18n = useI18n();
const messages = computed(() =>
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
);
</script>
<style lang="scss" scoped></style>

View File

@@ -17,7 +17,6 @@
class="d-flex flex-wrap my-1"
>
<v-col
v-if="!disableAmount"
sm="12"
md="2"
cols="12"
@@ -42,7 +41,6 @@
</v-text-field>
</v-col>
<v-col
v-if="!disableAmount"
sm="12"
md="3"
cols="12"
@@ -63,6 +61,22 @@
clearable
@keyup.enter="handleUnitEnter"
>
<template #prepend>
<v-tooltip v-if="unitError" location="bottom">
<template #activator="{ props: unitTooltipProps }">
<v-icon
v-bind="unitTooltipProps"
class="ml-2 mr-n3 opacity-100"
color="primary"
>
{{ $globals.icons.alert }}
</v-icon>
</template>
<span v-if="unitErrorTooltip">
{{ unitErrorTooltip }}
</span>
</v-tooltip>
</template>
<template #no-data>
<div class="caption text-center pb-2">
{{ $t("recipe.press-enter-to-create") }}
@@ -82,7 +96,6 @@
<!-- Foods Input -->
<v-col
v-if="!disableAmount"
m="12"
md="3"
cols="12"
@@ -104,6 +117,22 @@
clearable
@keyup.enter="handleFoodEnter"
>
<template #prepend>
<v-tooltip v-if="foodError" location="bottom">
<template #activator="{ props: foodTooltipProps }">
<v-icon
v-bind="foodTooltipProps"
class="ml-2 mr-n3 opacity-100"
color="primary"
>
{{ $globals.icons.alert }}
</v-icon>
</template>
<span v-if="foodErrorTooltip">
{{ foodErrorTooltip }}
</span>
</v-tooltip>
</template>
<template #no-data>
<div class="caption text-center pb-2">
{{ $t("recipe.press-enter-to-create") }}
@@ -134,16 +163,7 @@
:placeholder="$t('recipe.notes')"
class="mb-auto"
@click="$emit('clickIngredientField', 'note')"
>
<template #prepend>
<v-icon
v-if="disableAmount && $attrs && $attrs.delete"
class="mr-n1 handle"
>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</template>
</v-text-field>
/>
<BaseButtonGroup
hover
:large="false"
@@ -153,7 +173,6 @@
@toggle-original="toggleOriginalText"
@insert-above="$emit('insert-above')"
@insert-below="$emit('insert-below')"
@insert-ingredient="$emit('insert-ingredient')"
@delete="$emit('delete')"
/>
</div>
@@ -184,22 +203,29 @@ import type { RecipeIngredient } from "~/lib/api/types/recipe";
// defineModel replaces modelValue prop
const model = defineModel<RecipeIngredient>({ required: true });
const props = defineProps({
disableAmount: {
defineProps({
unitError: {
type: Boolean,
default: false,
},
allowInsertIngredient: {
unitErrorTooltip: {
type: String,
default: "",
},
foodError: {
type: Boolean,
default: false,
},
foodErrorTooltip: {
type: String,
default: "",
},
});
defineEmits([
"clickIngredientField",
"insert-above",
"insert-below",
"insert-ingredient",
"delete",
]);
@@ -228,13 +254,6 @@ const contextMenuOptions = computed(() => {
},
];
if (props.allowInsertIngredient) {
options.push({
text: i18n.t("recipe.insert-ingredient"),
event: "insert-ingredient",
});
}
if (model.value.originalText) {
options.push({
text: i18n.t("recipe.see-original-text"),

View File

@@ -3,21 +3,13 @@
<div v-html="safeMarkup" />
</template>
<script lang="ts">
<script setup lang="ts">
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
export default defineNuxtComponent({
props: {
markup: {
type: String,
required: true,
},
},
setup(props) {
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
return {
safeMarkup,
};
},
});
interface Props {
markup: string;
}
const props = defineProps<Props>();
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
</script>

View File

@@ -28,34 +28,20 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { RecipeIngredient } from "~/lib/api/types/household";
import { useParsedIngredientText } from "~/composables/recipes";
export default defineNuxtComponent({
props: {
ingredient: {
type: Object as () => RecipeIngredient,
required: true,
},
disableAmount: {
type: Boolean,
default: false,
},
scale: {
type: Number,
default: 1,
},
},
setup(props) {
const parsedIng = computed(() => {
return useParsedIngredientText(props.ingredient, props.disableAmount, props.scale);
});
interface Props {
ingredient: RecipeIngredient;
scale?: number;
}
const props = withDefaults(defineProps<Props>(), {
scale: 1,
});
return {
parsedIng,
};
},
const parsedIng = computed(() => {
return useParsedIngredientText(props.ingredient, props.scale);
});
</script>

View File

@@ -43,7 +43,6 @@
<v-list-item-title>
<RecipeIngredientListItem
:ingredient="ingredient"
:disable-amount="disableAmount"
:scale="scale"
/>
</v-list-item-title>
@@ -53,71 +52,51 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import { parseIngredientText } from "~/composables/recipes";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
export default defineNuxtComponent({
components: { RecipeIngredientListItem },
props: {
value: {
type: Array as () => RecipeIngredient[],
default: () => [],
},
disableAmount: {
type: Boolean,
default: false,
},
scale: {
type: Number,
default: 1,
},
isCookMode: {
type: Boolean,
default: false,
},
},
setup(props) {
function validateTitle(title?: string) {
return !(title === undefined || title === "" || title === null);
}
const state = reactive({
checked: props.value.map(() => false),
showTitleEditor: computed(() => props.value.map(x => validateTitle(x.title))),
});
const ingredientCopyText = computed(() => {
const components: string[] = [];
props.value.forEach((ingredient) => {
if (ingredient.title) {
if (components.length) {
components.push("");
}
components.push(`[${ingredient.title}]`);
}
components.push(parseIngredientText(ingredient, props.disableAmount, props.scale, false));
});
return components.join("\n");
});
function toggleChecked(index: number) {
// TODO Find a better way to do this - $set is not available, and
// direct array modifications are not propagated for some reason
state.checked.splice(index, 1, !state.checked[index]);
}
return {
...toRefs(state),
ingredientCopyText,
toggleChecked,
};
},
interface Props {
value?: RecipeIngredient[];
scale?: number;
isCookMode?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
value: () => [],
scale: 1,
isCookMode: false,
});
function validateTitle(title?: string | null) {
return !(title === undefined || title === "" || title === null);
}
const checked = ref(props.value.map(() => false));
const showTitleEditor = computed(() => props.value.map(x => validateTitle(x.title)));
const ingredientCopyText = computed(() => {
const components: string[] = [];
props.value.forEach((ingredient) => {
if (ingredient.title) {
if (components.length) {
components.push("");
}
components.push(`[${ingredient.title}]`);
}
components.push(parseIngredientText(ingredient, props.scale, false));
});
return components.join("\n");
});
function toggleChecked(index: number) {
// TODO Find a better way to do this - $set is not available, and
// direct array modifications are not propagated for some reason
checked.value.splice(index, 1, !checked.value[index]);
}
</script>
<style>

View File

@@ -3,6 +3,7 @@
<div>
<BaseDialog
v-model="madeThisDialog"
:loading="madeThisFormLoading"
:icon="$globals.icons.chefHat"
:title="$t('recipe.made-this')"
:submit-text="$t('recipe.add-to-timeline')"
@@ -29,11 +30,11 @@
offset-y
max-width="290px"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="newTimelineEventTimestampString"
:prepend-icon="$globals.icons.calendar"
v-bind="props"
v-bind="activatorProps"
readonly
/>
</template>
@@ -85,13 +86,13 @@
<div>
<div v-if="lastMadeReady" class="d-flex justify-center flex-wrap">
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger">
<v-tooltip bottom>
<template #activator="{ props }">
<v-tooltip location="bottom">
<template #activator="{ props: tooltipProps }">
<v-btn
rounded
variant="outlined"
size="x-large"
v-bind="props"
v-bind="tooltipProps"
style="border-color: rgb(var(--v-theme-primary));"
@click="madeThisDialog = true"
>
@@ -116,148 +117,166 @@
</div>
</template>
<script lang="ts">
<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";
import type { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
import type { Recipe, RecipeTimelineEventIn, RecipeTimelineEventOut } from "~/lib/api/types/recipe";
import type { VForm } from "~/types/auto-forms";
export default defineNuxtComponent({
props: {
recipe: {
type: Object as () => Recipe,
required: true,
},
},
emits: ["eventCreated"],
setup(props, context) {
const madeThisDialog = ref(false);
const userApi = useUserApi();
const { household } = useHouseholdSelf();
const i18n = useI18n();
const $auth = useMealieAuth();
const domMadeThisForm = ref<VForm>();
const newTimelineEvent = ref<RecipeTimelineEventIn>({
subject: "",
eventType: "comment",
eventMessage: "",
timestamp: undefined,
recipeId: props.recipe?.id || "",
});
const newTimelineEventImage = ref<Blob | File>();
const newTimelineEventImageName = ref<string>("");
const newTimelineEventImagePreviewUrl = ref<string>();
const newTimelineEventTimestamp = ref<Date>(new Date());
const newTimelineEventTimestampString = computed(() => {
return newTimelineEventTimestamp.value.toISOString().substring(0, 10);
});
const props = defineProps<{ recipe: Recipe }>();
const emit = defineEmits<{
eventCreated: [event: RecipeTimelineEventOut];
}>();
const lastMade = ref(props.recipe.lastMade);
const lastMadeReady = ref(false);
onMounted(async () => {
if (!$auth.user?.value?.householdSlug) {
lastMade.value = props.recipe.lastMade;
}
else {
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
lastMade.value = data?.lastMade;
}
lastMadeReady.value = true;
});
whenever(
() => madeThisDialog.value,
() => {
// Set timestamp to now
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
},
);
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
function clearImage() {
newTimelineEventImage.value = undefined;
newTimelineEventImageName.value = "";
newTimelineEventImagePreviewUrl.value = undefined;
}
function uploadImage(fileObject: File) {
newTimelineEventImage.value = fileObject;
newTimelineEventImageName.value = fileObject.name;
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
function updateUploadedImage(fileObject: Blob) {
newTimelineEventImage.value = fileObject;
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
const state = reactive({ datePickerMenu: false });
async function createTimelineEvent() {
if (!(newTimelineEventTimestampString.value && props.recipe?.id && props.recipe?.slug)) {
return;
}
newTimelineEvent.value.recipeId = props.recipe.id;
// Note: $auth.user is now a ref
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
// the user only selects the date, so we set the time to end of day local time
// we choose the end of day so it always comes after "new recipe" events
newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestampString.value + "T23:59:59").toISOString();
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
const newEvent = eventResponse.data;
// we also update the recipe's last made value
if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
lastMade.value = newTimelineEvent.value.timestamp;
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
}
// update the image, if provided
if (newTimelineEventImage.value && newEvent) {
const imageResponse = await userApi.recipes.updateTimelineEventImage(
newEvent.id,
newTimelineEventImage.value,
newTimelineEventImageName.value,
);
if (imageResponse.data) {
newEvent.image = imageResponse.data.image;
}
}
// reset form
newTimelineEvent.value.eventMessage = "";
newTimelineEvent.value.timestamp = undefined;
clearImage();
madeThisDialog.value = false;
domMadeThisForm.value?.reset();
context.emit("eventCreated", newEvent);
}
return {
...toRefs(state),
domMadeThisForm,
madeThisDialog,
firstDayOfWeek,
newTimelineEvent,
newTimelineEventImage,
newTimelineEventImagePreviewUrl,
newTimelineEventTimestamp,
newTimelineEventTimestampString,
lastMade,
lastMadeReady,
createTimelineEvent,
clearImage,
uploadImage,
updateUploadedImage,
};
},
const madeThisDialog = ref(false);
const userApi = useUserApi();
const { household } = useHouseholdSelf();
const i18n = useI18n();
const $auth = useMealieAuth();
const domMadeThisForm = ref<VForm>();
const newTimelineEvent = ref<RecipeTimelineEventIn>({
subject: "",
eventType: "comment",
eventMessage: "",
timestamp: undefined,
recipeId: props.recipe?.id || "",
});
const newTimelineEventImage = ref<Blob | File>();
const newTimelineEventImageName = ref<string>("");
const newTimelineEventImagePreviewUrl = ref<string>();
const newTimelineEventTimestamp = ref<Date>(new Date());
const newTimelineEventTimestampString = computed(() => {
return formatISO(newTimelineEventTimestamp.value, { representation: "date" });
});
const lastMade = ref(props.recipe.lastMade);
const lastMadeReady = ref(false);
onMounted(async () => {
if (!$auth.user?.value?.householdSlug) {
lastMade.value = props.recipe.lastMade;
}
else {
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
lastMade.value = data?.lastMade;
}
lastMadeReady.value = true;
});
whenever(
() => madeThisDialog.value,
() => {
// Set timestamp to now
newTimelineEventTimestamp.value = new Date();
},
);
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
function clearImage() {
newTimelineEventImage.value = undefined;
newTimelineEventImageName.value = "";
newTimelineEventImagePreviewUrl.value = undefined;
}
function uploadImage(fileObject: File) {
newTimelineEventImage.value = fileObject;
newTimelineEventImageName.value = fileObject.name;
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
function updateUploadedImage(fileObject: Blob) {
newTimelineEventImage.value = fileObject;
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
const datePickerMenu = ref(false);
const madeThisFormLoading = ref(false);
function resetMadeThisForm() {
madeThisFormLoading.value = false;
newTimelineEvent.value.eventMessage = "";
newTimelineEvent.value.timestamp = undefined;
clearImage();
madeThisDialog.value = false;
domMadeThisForm.value?.reset();
}
async function createTimelineEvent() {
if (!(newTimelineEventTimestampString.value && props.recipe?.id && props.recipe?.slug)) {
return;
}
madeThisFormLoading.value = true;
newTimelineEvent.value.recipeId = props.recipe.id;
// Note: $auth.user is now a ref
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
// the user only selects the date, so we set the time to end of day local time
// we choose the end of day so it always comes after "new recipe" events
newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestampString.value + "T23:59:59").toISOString();
let newEvent: RecipeTimelineEventOut | null = null;
try {
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
newEvent = eventResponse.data;
if (!newEvent) {
throw new Error("No event created");
}
}
catch (error) {
console.error("Failed to create timeline event:", error);
alert.error(i18n.t("recipe.failed-to-add-to-timeline"));
resetMadeThisForm();
return;
}
// we also update the recipe's last made value
if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
try {
lastMade.value = newTimelineEvent.value.timestamp;
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
}
catch (error) {
console.error("Failed to update last made date:", error);
alert.error(i18n.t("recipe.failed-to-update-recipe"));
}
}
// update the image, if provided
let imageError = false;
if (newTimelineEventImage.value) {
try {
const imageResponse = await userApi.recipes.updateTimelineEventImage(
newEvent.id,
newTimelineEventImage.value,
newTimelineEventImageName.value,
);
if (imageResponse.data) {
newEvent.image = imageResponse.data.image;
}
}
catch (error) {
imageError = true;
console.error("Failed to upload image for timeline event:", error);
}
}
if (imageError) {
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
}
else {
alert.success(i18n.t("recipe.added-to-timeline"));
}
resetMadeThisForm();
emit("eventCreated", newEvent);
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<v-list :class="tile ? 'd-flex flex-wrap background' : 'background'">
<v-list :class="tile ? 'd-flex flex-wrap background' : 'background'" style="background-color: transparent;">
<v-sheet
v-for="recipe, index in recipes"
:key="recipe.id"
@@ -41,151 +41,131 @@
</v-list-item-subtitle>
</div>
<template #append>
<slot
:name="'actions-' + recipe.id"
:v-bind="{ item: recipe }"
/>
<slot
:name="'actions-' + recipe.id"
:v-bind="{ item: recipe }"
/>
</template>
</v-list-item>
</v-sheet>
</v-list>
</template>
<script lang="ts">
<script setup lang="ts">
import DOMPurify from "dompurify";
import { useFraction } from "~/composables/recipes/use-fraction";
import type { ShoppingListItemOut } from "~/lib/api/types/household";
import type { RecipeSummary } from "~/lib/api/types/recipe";
export default defineNuxtComponent({
props: {
recipes: {
type: Array as () => RecipeSummary[],
required: true,
},
listItem: {
type: Object as () => ShoppingListItemOut | undefined,
default: undefined,
},
small: {
type: Boolean,
default: false,
},
tile: {
type: Boolean,
default: false,
},
showDescription: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props) {
const $auth = useMealieAuth();
const { frac } = useFraction();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
interface Props {
recipes: RecipeSummary[];
listItem?: ShoppingListItemOut;
small?: boolean;
tile?: boolean;
showDescription?: boolean;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
listItem: undefined,
small: false,
tile: false,
showDescription: false,
disabled: false,
});
const attrs = computed(() => {
return props.small
? {
class: {
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
listItem: "px-0",
avatar: "ma-0",
icon: "ma-0 pa-0 primary",
text: "pa-0",
},
style: {
text: {
title: "font-size: small;",
subTitle: "font-size: x-small;",
},
},
}
: {
class: {
sheet: props.tile ? "mx-1 justify-center align-center" : "mb-1 justify-center align-center",
listItem: "px-4",
avatar: "",
icon: "pa-1 primary",
text: "",
},
style: {
text: {
title: "",
subTitle: "",
},
},
};
});
const $auth = useMealieAuth();
const { frac } = useFraction();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
function sanitizeHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ["strong", "sup"],
});
const attrs = computed(() => {
return props.small
? {
class: {
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
listItem: "px-0",
avatar: "ma-0",
icon: "ma-0 pa-0 primary",
text: "pa-0",
},
style: {
text: {
title: "font-size: small;",
subTitle: "font-size: x-small;",
},
},
}
: {
class: {
sheet: props.tile ? "mx-1 justify-center align-center" : "mb-1 justify-center align-center",
listItem: "px-4",
avatar: "",
icon: "pa-1 primary",
text: "",
},
style: {
text: {
title: "",
subTitle: "",
},
},
};
});
function sanitizeHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ["strong", "sup"],
});
}
const listItemDescriptions = computed<string[]>(() => {
if (
props.recipes.length === 1 // we don't need to specify details if there's only one recipe ref
|| !props.listItem?.recipeReferences
|| props.listItem.recipeReferences.length !== props.recipes.length
) {
return props.recipes.map(_ => "");
}
const listItemDescriptions: string[] = [];
for (let i = 0; i < props.recipes.length; i++) {
const itemRef = props.listItem?.recipeReferences[i];
const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1);
let listItemDescription = "";
if (props.listItem.unit?.fraction) {
const fraction = frac(quantity, 10, true);
if (fraction[0] !== undefined && fraction[0] > 0) {
listItemDescription += fraction[0];
}
if (fraction[1] > 0) {
listItemDescription += ` <sup>${fraction[1]}</sup>&frasl;<sub>${fraction[2]}</sub>`;
}
else {
listItemDescription = (quantity).toString();
}
}
else {
listItemDescription = (Math.round(quantity * 100) / 100).toString();
}
const listItemDescriptions = computed<string[]>(() => {
if (
props.recipes.length === 1 // we don't need to specify details if there's only one recipe ref
|| !props.listItem?.recipeReferences
|| props.listItem.recipeReferences.length !== props.recipes.length
) {
return props.recipes.map(_ => "");
}
if (props.listItem.unit) {
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
? props.listItem.unit.abbreviation
: props.listItem.unit.name;
const listItemDescriptions: string[] = [];
for (let i = 0; i < props.recipes.length; i++) {
const itemRef = props.listItem?.recipeReferences[i];
const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1);
listItemDescription += ` ${unitDisplay}`;
}
let listItemDescription = "";
if (props.listItem.unit?.fraction) {
const fraction = frac(quantity, 10, true);
if (fraction[0] !== undefined && fraction[0] > 0) {
listItemDescription += fraction[0];
}
if (itemRef.recipeNote) {
listItemDescription += `, ${itemRef.recipeNote}`;
}
if (fraction[1] > 0) {
listItemDescription += ` <sup>${fraction[1]}</sup>&frasl;<sub>${fraction[2]}</sub>`;
}
else {
listItemDescription = (quantity).toString();
}
}
else {
listItemDescription = (Math.round(quantity * 100) / 100).toString();
}
listItemDescriptions.push(sanitizeHTML(listItemDescription));
}
if (props.listItem.unit) {
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
? props.listItem.unit.abbreviation
: props.listItem.unit.name;
listItemDescription += ` ${unitDisplay}`;
}
if (itemRef.recipeNote) {
listItemDescription += `, ${itemRef.recipeNote}`;
}
listItemDescriptions.push(sanitizeHTML(listItemDescription));
}
return listItemDescriptions;
});
return {
attrs,
groupSlug,
listItemDescriptions,
};
},
return listItemDescriptions;
});
</script>

View File

@@ -45,62 +45,48 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useNutritionLabels } from "~/composables/recipes";
import type { Nutrition } from "~/lib/api/types/recipe";
import type { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
export default defineNuxtComponent({
props: {
modelValue: {
type: Object as () => Nutrition,
required: true,
},
edit: {
type: Boolean,
default: true,
},
},
emits: ["update:modelValue"],
setup(props, context) {
const { labels } = useNutritionLabels();
const valueNotNull = computed(() => {
let key: keyof Nutrition;
for (key in props.modelValue) {
if (props.modelValue[key] !== null) {
return true;
}
}
return false;
});
interface Props {
edit?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
edit: true,
});
const showViewer = computed(() => !props.edit && valueNotNull.value);
const modelValue = defineModel<Nutrition>({ required: true });
function updateValue(key: number | string, event: Event) {
context.emit("update:modelValue", { ...props.modelValue, [key]: event });
const { labels } = useNutritionLabels();
const valueNotNull = computed(() => {
let key: keyof Nutrition;
for (key in modelValue.value) {
if (modelValue.value[key] !== null) {
return true;
}
}
return false;
});
// Build a new list that only contains nutritional information that has a value
const renderedList = computed(() => {
return Object.entries(labels).reduce((item: NutritionLabelType, [key, label]) => {
if (props.modelValue[key]?.trim()) {
item[key] = {
...label,
value: props.modelValue[key],
};
}
return item;
}, {});
});
const showViewer = computed(() => !props.edit && valueNotNull.value);
return {
labels,
valueNotNull,
showViewer,
updateValue,
renderedList,
};
},
function updateValue(key: number | string, event: Event) {
modelValue.value = { ...modelValue.value, [key]: event };
}
// Build a new list that only contains nutritional information that has a value
const renderedList = computed(() => {
return Object.entries(labels).reduce((item: NutritionLabelType, [key, label]) => {
if (modelValue.value[key]?.trim()) {
item[key] = {
...label,
value: modelValue.value[key],
};
}
return item;
}, {});
});
</script>

View File

@@ -60,119 +60,93 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
import { type RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
const CREATED_ITEM_EVENT = "created-item";
export default defineNuxtComponent({
props: {
modelValue: {
type: Boolean,
default: false,
},
color: {
type: String,
default: null,
},
tagDialog: {
type: Boolean,
default: true,
},
itemType: {
type: String as () => RecipeOrganizer,
default: "category",
},
},
emits: ["update:modelValue"],
setup(props, context) {
const i18n = useI18n();
const state = reactive({
name: "",
onHand: false,
});
const dialog = computed({
get() {
return props.modelValue;
},
set(value) {
context.emit("update:modelValue", value);
},
});
watch(
() => props.modelValue,
(val: boolean) => {
if (!val) state.name = "";
},
);
const userApi = useUserApi();
const store = (() => {
switch (props.itemType) {
case Organizer.Tag:
return useTagStore();
case Organizer.Tool:
return useToolStore();
default:
return useCategoryStore();
}
})();
const properties = computed(() => {
switch (props.itemType) {
case Organizer.Tag:
return {
title: i18n.t("tag.create-a-tag"),
label: i18n.t("tag.tag-name"),
api: userApi.tags,
};
case Organizer.Tool:
return {
title: i18n.t("tool.create-a-tool"),
label: i18n.t("tool.tool-name"),
api: userApi.tools,
};
default:
return {
title: i18n.t("category.create-a-category"),
label: i18n.t("category.category-name"),
api: userApi.categories,
};
}
});
const rules = {
required: (val: string) => !!val || (i18n.t("general.a-name-is-required") as string),
};
async function select() {
if (store) {
// @ts-expect-error the same state is used for different organizer types, which have different requirements
await store.actions.createOne({ ...state });
}
const newItem = store.store.value.find(item => item.name === state.name);
context.emit(CREATED_ITEM_EVENT, newItem);
dialog.value = false;
}
return {
Organizer,
...toRefs(state),
dialog,
properties,
rules,
select,
};
},
interface Props {
color?: string | null;
tagDialog?: boolean;
itemType?: RecipeOrganizer;
}
const props = withDefaults(defineProps<Props>(), {
color: null,
tagDialog: true,
itemType: "category" as RecipeOrganizer,
});
const emit = defineEmits<{
"created-item": [item: any];
}>();
const dialog = defineModel<boolean>({ default: false });
const i18n = useI18n();
const name = ref("");
const onHand = ref(false);
watch(
dialog,
(val: boolean) => {
if (!val) name.value = "";
},
);
const userApi = useUserApi();
const store = (() => {
switch (props.itemType) {
case Organizer.Tag:
return useTagStore();
case Organizer.Tool:
return useToolStore();
default:
return useCategoryStore();
}
})();
const properties = computed(() => {
switch (props.itemType) {
case Organizer.Tag:
return {
title: i18n.t("tag.create-a-tag"),
label: i18n.t("tag.tag-name"),
api: userApi.tags,
};
case Organizer.Tool:
return {
title: i18n.t("tool.create-a-tool"),
label: i18n.t("tool.tool-name"),
api: userApi.tools,
};
default:
return {
title: i18n.t("category.create-a-category"),
label: i18n.t("category.category-name"),
api: userApi.categories,
};
}
});
const rules = {
required: (val: string) => !!val || (i18n.t("general.a-name-is-required") as string),
};
async function select() {
if (store) {
// @ts-expect-error the same state is used for different organizer types, which have different requirements
await store.actions.createOne({ name: name.value, onHand: onHand.value });
}
const newItem = store.store.value.find(item => item.name === name.value);
emit(CREATED_ITEM_EVENT, newItem);
dialog.value = false;
}
</script>
<style></style>

View File

@@ -122,9 +122,8 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import Fuse from "fuse.js";
import { useContextPresets } from "~/composables/use-context-presents";
import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue";
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
@@ -138,156 +137,128 @@ interface GenericItem {
onHand: boolean;
}
export default defineNuxtComponent({
components: {
RecipeOrganizerDialog,
},
props: {
items: {
type: Array as () => GenericItem[],
required: true,
},
icon: {
type: String,
required: true,
},
itemType: {
type: String as () => RecipeOrganizer,
required: true,
},
},
emits: ["update", "delete"],
setup(props, { emit }) {
const state = reactive({
// Search Options
options: {
ignoreLocation: true,
shouldSort: true,
threshold: 0.2,
location: 0,
distance: 20,
findAllMatches: true,
maxPatternLength: 32,
minMatchCharLength: 1,
keys: ["name"],
},
});
const props = defineProps<{
items: GenericItem[];
icon: string;
itemType: RecipeOrganizer;
}>();
const $auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
const emit = defineEmits<{
update: [item: GenericItem];
delete: [id: string];
}>();
// =================================================================
// Context Menu
const dialogs = ref({
organizer: false,
update: false,
delete: false,
});
const presets = useContextPresets();
const translationKey = computed<string>(() => {
const typeMap = {
categories: "category.category",
tags: "tag.tag",
tools: "tool.tool",
foods: "shopping-list.food",
households: "household.household",
};
return typeMap[props.itemType] || "";
});
const deleteTarget = ref<GenericItem | null>(null);
const updateTarget = ref<GenericItem | null>(null);
function confirmDelete(item: GenericItem) {
deleteTarget.value = item;
dialogs.value.delete = true;
}
function deleteOne() {
if (!deleteTarget.value) {
return;
}
emit("delete", deleteTarget.value.id);
}
function openUpdateDialog(item: GenericItem) {
updateTarget.value = deepCopy(item);
dialogs.value.update = true;
}
function updateOne() {
if (!updateTarget.value) {
return;
}
emit("update", updateTarget.value);
}
// ================================================================
// Search Functions
const searchString = useRouteQuery("q", "");
const fuse = computed(() => {
return new Fuse(props.items, state.options);
});
const fuzzyItems = computed<GenericItem[]>(() => {
if (searchString.value.trim() === "") {
return props.items;
}
const result = fuse.value.search(searchString.value.trim() as string);
return result.map(x => x.item);
});
// =================================================================
// Sorted Items
const itemsSorted = computed(() => {
const byLetter: { [key: string]: Array<GenericItem> } = {};
if (!fuzzyItems.value) {
return byLetter;
}
[...fuzzyItems.value]
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((item) => {
const letter = item.name[0].toUpperCase();
if (!byLetter[letter]) {
byLetter[letter] = [];
}
byLetter[letter].push(item);
});
return byLetter;
});
function isTitle(str: number | string) {
return typeof str === "string" && str.length === 1;
}
return {
groupSlug,
isTitle,
dialogs,
confirmDelete,
openUpdateDialog,
updateOne,
updateTarget,
deleteOne,
deleteTarget,
Organizer,
presets,
itemsSorted,
searchString,
translationKey,
};
const state = reactive({
// Search Options
options: {
ignoreLocation: true,
shouldSort: true,
threshold: 0.2,
location: 0,
distance: 20,
findAllMatches: true,
maxPatternLength: 32,
minMatchCharLength: 1,
keys: ["name"],
},
});
const $auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
// =================================================================
// Context Menu
const dialogs = ref({
organizer: false,
update: false,
delete: false,
});
const presets = useContextPresets();
const translationKey = computed<string>(() => {
const typeMap = {
categories: "category.category",
tags: "tag.tag",
tools: "tool.tool",
foods: "shopping-list.food",
households: "household.household",
};
return typeMap[props.itemType] || "";
});
const deleteTarget = ref<GenericItem | null>(null);
const updateTarget = ref<GenericItem | null>(null);
function confirmDelete(item: GenericItem) {
deleteTarget.value = item;
dialogs.value.delete = true;
}
function deleteOne() {
if (!deleteTarget.value) {
return;
}
emit("delete", deleteTarget.value.id);
}
function openUpdateDialog(item: GenericItem) {
updateTarget.value = deepCopy(item);
dialogs.value.update = true;
}
function updateOne() {
if (!updateTarget.value) {
return;
}
emit("update", updateTarget.value);
}
// ================================================================
// Search Functions
const searchString = useRouteQuery("q", "");
const fuse = computed(() => {
return new Fuse(props.items, state.options);
});
const fuzzyItems = computed<GenericItem[]>(() => {
if (searchString.value.trim() === "") {
return props.items;
}
const result = fuse.value.search(searchString.value.trim() as string);
return result.map(x => x.item);
});
// =================================================================
// Sorted Items
const itemsSorted = computed(() => {
const byLetter: { [key: string]: Array<GenericItem> } = {};
if (!fuzzyItems.value) {
return byLetter;
}
[...fuzzyItems.value]
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((item) => {
const letter = item.name[0].toUpperCase();
if (!byLetter[letter]) {
byLetter[letter] = [];
}
byLetter[letter].push(item);
});
return byLetter;
});
function isTitle(str: number | string) {
return typeof str === "string" && str.length === 1;
}
</script>

View File

@@ -3,7 +3,7 @@
v-model="selected"
v-bind="inputAttrs"
v-model:search="searchInput"
:items="storeItem"
:items="items"
:label="label"
chips
closable-chips
@@ -46,180 +46,138 @@
</v-autocomplete>
</template>
<script lang="ts">
<script setup lang="ts">
import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
import type { RecipeTool } from "~/lib/api/types/admin";
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
import type { HouseholdSummary } from "~/lib/api/types/household";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
export default defineNuxtComponent({
props: {
modelValue: {
type: Array as () => (
| HouseholdSummary
| RecipeTag
| RecipeCategory
| RecipeTool
| IngredientFood
| string
)[] | undefined,
required: true,
},
/**
* The type of organizer to use.
*/
selectorType: {
type: String as () => RecipeOrganizer,
required: true,
},
inputAttrs: {
type: Object as () => Record<string, any>,
default: () => ({}),
},
returnObject: {
type: Boolean,
default: true,
},
showAdd: {
type: Boolean,
default: true,
},
showLabel: {
type: Boolean,
default: true,
},
showIcon: {
type: Boolean,
default: true,
},
variant: {
type: String as () => "filled" | "underlined" | "outlined" | "plain" | "solo" | "solo-inverted" | "solo-filled",
default: "outlined",
},
},
emits: ["update:modelValue"],
interface Props {
selectorType: RecipeOrganizer;
inputAttrs?: Record<string, any>;
returnObject?: boolean;
showAdd?: boolean;
showLabel?: boolean;
showIcon?: boolean;
variant?: "filled" | "underlined" | "outlined" | "plain" | "solo" | "solo-inverted" | "solo-filled";
}
setup(props, context) {
const selected = computed({
get: () => props.modelValue,
set: (val) => {
context.emit("update:modelValue", val);
},
});
onMounted(() => {
if (selected.value === undefined) {
selected.value = [];
}
});
const i18n = useI18n();
const { $globals } = useNuxtApp();
const label = computed(() => {
if (!props.showLabel) {
return "";
}
switch (props.selectorType) {
case Organizer.Tag:
return i18n.t("tag.tags");
case Organizer.Category:
return i18n.t("category.categories");
case Organizer.Tool:
return i18n.t("tool.tools");
case Organizer.Food:
return i18n.t("general.foods");
case Organizer.Household:
return i18n.t("household.households");
default:
return i18n.t("general.organizer");
}
});
const icon = computed(() => {
if (!props.showIcon) {
return "";
}
switch (props.selectorType) {
case Organizer.Tag:
return $globals.icons.tags;
case Organizer.Category:
return $globals.icons.categories;
case Organizer.Tool:
return $globals.icons.tools;
case Organizer.Food:
return $globals.icons.foods;
case Organizer.Household:
return $globals.icons.household;
default:
return $globals.icons.tags;
}
});
// ===========================================================================
// Store & Items Setup
const storeMap = {
[Organizer.Category]: useCategoryStore(),
[Organizer.Tag]: useTagStore(),
[Organizer.Tool]: useToolStore(),
[Organizer.Food]: useFoodStore(),
[Organizer.Household]: useHouseholdStore(),
};
const store = computed(() => {
const { store } = storeMap[props.selectorType];
return store.value;
});
const items = computed(() => {
if (!props.returnObject) {
return store.value.map(item => item.name);
}
return store.value;
});
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}
function appendCreated(item: any) {
if (selected.value === undefined) {
return;
}
selected.value = [...selected.value, item];
}
const dialog = ref(false);
const searchInput = ref("");
function resetSearchInput() {
searchInput.value = "";
}
return {
Organizer,
appendCreated,
dialog,
storeItem: items,
label,
icon,
selected,
removeByIndex,
searchInput,
resetSearchInput,
};
},
const props = withDefaults(defineProps<Props>(), {
inputAttrs: () => ({}),
returnObject: true,
showAdd: true,
showLabel: true,
showIcon: true,
variant: "outlined",
});
const selected = defineModel<(
| HouseholdSummary
| RecipeTag
| RecipeCategory
| RecipeTool
| IngredientFood
| string
)[] | undefined>({ required: true });
onMounted(() => {
if (selected.value === undefined) {
selected.value = [];
}
});
const i18n = useI18n();
const { $globals } = useNuxtApp();
const label = computed(() => {
if (!props.showLabel) {
return "";
}
switch (props.selectorType) {
case Organizer.Tag:
return i18n.t("tag.tags");
case Organizer.Category:
return i18n.t("category.categories");
case Organizer.Tool:
return i18n.t("tool.tools");
case Organizer.Food:
return i18n.t("general.foods");
case Organizer.Household:
return i18n.t("household.households");
default:
return i18n.t("general.organizer");
}
});
const icon = computed(() => {
if (!props.showIcon) {
return "";
}
switch (props.selectorType) {
case Organizer.Tag:
return $globals.icons.tags;
case Organizer.Category:
return $globals.icons.categories;
case Organizer.Tool:
return $globals.icons.tools;
case Organizer.Food:
return $globals.icons.foods;
case Organizer.Household:
return $globals.icons.household;
default:
return $globals.icons.tags;
}
});
// ===========================================================================
// Store & Items Setup
const storeMap = {
[Organizer.Category]: useCategoryStore(),
[Organizer.Tag]: useTagStore(),
[Organizer.Tool]: useToolStore(),
[Organizer.Food]: useFoodStore(),
[Organizer.Household]: useHouseholdStore(),
};
const store = computed(() => {
const { store } = storeMap[props.selectorType];
return store.value;
});
const items = computed(() => {
if (!props.returnObject) {
return store.value.map(item => item.name);
}
return store.value;
});
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}
function appendCreated(item: any) {
if (selected.value === undefined) {
return;
}
selected.value = [...selected.value, item];
}
const dialog = ref(false);
const searchInput = ref("");
function resetSearchInput() {
searchInput.value = "";
}
</script>
<style scoped>

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"
@@ -37,7 +37,7 @@
<RecipePageIngredientEditor v-if="isEditForm" v-model="recipe" />
</div>
<div>
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
<RecipePageScale v-model="scale" :recipe="recipe" />
</div>
<!--
@@ -81,7 +81,7 @@
</v-card>
<WakelockSwitch />
<RecipePageComments
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
v-if="!recipe.settings?.disableComments && !isEditForm && !isCookMode"
v-model="recipe"
class="px-1 my-4 d-print-none"
/>
@@ -96,7 +96,7 @@
<v-row style="height: 100%" no-gutters class="overflow-hidden">
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%">
<div class="d-flex align-center">
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
<RecipePageScale v-model="scale" :recipe="recipe" />
</div>
<RecipePageIngredientToolsView
v-if="!isEditForm"
@@ -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') }}
@@ -124,7 +124,7 @@
</v-sheet>
<v-sheet v-show="isCookMode && hasLinkedIngredients">
<div class="mt-2 px-2 px-md-4">
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
<RecipePageScale v-model="scale" :recipe="recipe" />
</div>
<RecipePageInstructions
v-model="recipe.recipeInstructions"
@@ -141,7 +141,6 @@
<RecipeIngredients
:value="notLinkedIngredients"
:scale="scale"
:disable-amount="recipe.settings.disableAmount"
:is-cook-mode="isCookMode"
/>
</v-card>
@@ -189,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,8 +277,8 @@ async function deleteRecipe() {
* View Preferences
*/
const landscape = computed(() => {
const preferLandscape = recipe.value.settings.landscapeView;
const smallScreen = !$vuetify.display.smAndUp.value;
const preferLandscape = recipe.value.settings?.landscapeView;
const smallScreen = !display.smAndUp.value;
if (preferLandscape) {
return true;

View File

@@ -26,7 +26,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useRecipePermissions } from "~/composables/recipes";
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
@@ -35,82 +35,48 @@ import { useStaticRoutes, useUserApi } from "~/composables/api";
import type { HouseholdSummary } from "~/lib/api/types/household";
import type { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
import { usePageState, usePageUser, PageMode } from "~/composables/recipe-page/shared-state";
export default defineNuxtComponent({
components: {
RecipePageInfoCard,
RecipeActionMenu,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
recipeScale: {
type: Number,
default: 1,
},
landscape: {
type: Boolean,
default: false,
},
},
emits: ["save", "delete"],
setup(props) {
const { $vuetify } = useNuxtApp();
const { recipeImage } = useStaticRoutes();
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const { isOwnGroup } = useLoggedInState();
const recipeHousehold = ref<HouseholdSummary>();
if (user) {
const userApi = useUserApi();
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
recipeHousehold.value = data || undefined;
});
}
const { canEditRecipe } = useRecipePermissions(props.recipe, recipeHousehold, user);
function printRecipe() {
window.print();
}
const hideImage = ref(false);
const imageHeight = computed(() => {
return $vuetify.display.xs.value ? "200" : "400";
});
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
watch(
() => recipeImageUrl.value,
() => {
hideImage.value = false;
},
);
return {
isOwnGroup,
setMode,
toggleEditMode,
recipeImage,
canEditRecipe,
imageKey,
user,
PageMode,
pageMode,
EditorMode,
editMode,
printRecipe,
imageHeight,
hideImage,
isEditMode,
recipeImageUrl,
};
},
interface Props {
recipe: NoUndefinedField<Recipe>;
recipeScale?: number;
landscape?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
recipeScale: 1,
landscape: false,
});
defineEmits(["save", "delete"]);
const { recipeImage } = useStaticRoutes();
const { imageKey, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const { isOwnGroup } = useLoggedInState();
const recipeHousehold = ref<HouseholdSummary>();
if (user) {
const userApi = useUserApi();
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
recipeHousehold.value = data || undefined;
});
}
const { canEditRecipe } = useRecipePermissions(props.recipe, recipeHousehold, user);
function printRecipe() {
window.print();
}
const hideImage = ref(false);
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
watch(
() => recipeImageUrl.value,
() => {
hideImage.value = false;
},
);
</script>

View File

@@ -35,7 +35,7 @@
>
<RecipeYield
:yield-quantity="recipe.recipeYieldQuantity"
:yield="recipe.recipeYield"
:yield-text="recipe.recipeYield"
:scale="recipeScale"
class="mb-4"
/>
@@ -76,7 +76,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
@@ -86,34 +86,15 @@ import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/Recip
import type { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineNuxtComponent({
components: {
RecipeRating,
RecipeLastMade,
RecipeTimeCard,
RecipeYield,
RecipePageInfoCardImage,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
recipeScale: {
type: Number,
default: 1,
},
landscape: {
type: Boolean,
required: true,
},
},
setup() {
const { isOwnGroup } = useLoggedInState();
interface Props {
recipe: NoUndefinedField<Recipe>;
recipeScale?: number;
landscape: boolean;
}
return {
isOwnGroup,
};
},
withDefaults(defineProps<Props>(), {
recipeScale: 1,
});
const { isOwnGroup } = useLoggedInState();
</script>

View File

@@ -12,60 +12,47 @@
/>
</template>
<script lang="ts">
<script setup lang="ts">
import { useStaticRoutes, useUserApi } from "~/composables/api";
import type { HouseholdSummary } from "~/lib/api/types/household";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import type { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineNuxtComponent({
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
maxWidth: {
type: String,
default: undefined,
},
},
setup(props) {
const { $vuetify } = useNuxtApp();
const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const recipeHousehold = ref<HouseholdSummary>();
if (user) {
const userApi = useUserApi();
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
recipeHousehold.value = data || undefined;
});
}
const hideImage = ref(false);
const imageHeight = computed(() => {
return $vuetify.display.xs.value ? "200" : "400";
});
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
watch(
() => recipeImageUrl.value,
() => {
hideImage.value = false;
},
);
return {
recipeImageUrl,
imageKey,
hideImage,
imageHeight,
};
},
interface Props {
recipe: NoUndefinedField<Recipe>;
maxWidth?: string;
}
const props = withDefaults(defineProps<Props>(), {
maxWidth: undefined,
});
const display = useDisplay();
const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const recipeHousehold = ref<HouseholdSummary>();
if (user) {
const userApi = useUserApi();
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
recipeHousehold.value = data || undefined;
});
}
const hideImage = ref(false);
const imageHeight = computed(() => {
return display.xs.value ? "200" : "400";
});
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
watch(
() => recipeImageUrl.value,
() => {
hideImage.value = false;
},
);
</script>

View File

@@ -1,9 +1,14 @@
<!-- eslint-disable vue/no-mutating-props -->
<template>
<div>
<h2 class="mb-4 text-h5 font-weight-medium opacity-80">
{{ $t("recipe.ingredients") }}
</h2>
<div class="mb-4">
<h2 class="mb-4 text-h5 font-weight-medium opacity-80">
{{ $t("recipe.ingredients") }}
</h2>
<BannerWarning v-if="!hasFoodOrUnit">
{{ $t("recipe.ingredients-not-parsed-description", { parse: $t('recipe.parse') }) }}
</BannerWarning>
</div>
<VueDraggable
v-if="recipe.recipeIngredient.length > 0"
v-model="recipe.recipeIngredient"
@@ -27,7 +32,6 @@
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
class="list-group-item"
:disable-amount="recipe.settings.disableAmount"
@delete="recipe.recipeIngredient.splice(index, 1)"
@insert-above="insertNewIngredient(index)"
@insert-below="insertNewIngredient(index + 1)"
@@ -42,14 +46,14 @@
/>
<div class="d-flex flex-wrap justify-center justify-sm-end mt-3">
<v-tooltip
top
location="top"
color="accent"
>
<template #activator="{ props }">
<span>
<BaseButton
class="mb-1"
:disabled="recipe.settings.disableAmount || hasFoodOrUnit"
:disabled="hasFoodOrUnit"
color="accent"
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
v-bind="props"
@@ -109,10 +113,7 @@ const hasFoodOrUnit = computed(() => {
});
const parserToolTip = computed(() => {
if (recipe.value.settings.disableAmount) {
return i18n.t("recipe.enable-ingredient-amounts-to-use-this-feature");
}
else if (hasFoodOrUnit.value) {
if (hasFoodOrUnit.value) {
return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed");
}
return i18n.t("recipe.parse-ingredients");
@@ -127,7 +128,6 @@ function addIngredient(ingredients: Array<string> | null = null) {
note: x,
unit: undefined,
food: undefined,
disableAmount: true,
quantity: 1,
};
});
@@ -146,7 +146,6 @@ function addIngredient(ingredients: Array<string> | null = null) {
unit: undefined,
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
food: undefined,
disableAmount: true,
quantity: 1,
});
}
@@ -161,7 +160,6 @@ function insertNewIngredient(dest: number) {
unit: undefined,
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
food: undefined,
disableAmount: true,
quantity: 1,
});
}

View File

@@ -3,7 +3,6 @@
<RecipeIngredients
:value="recipe.recipeIngredient"
:scale="scale"
:disable-amount="recipe.settings.disableAmount"
:is-cook-mode="isCookMode"
/>
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
@@ -36,7 +35,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { useToolStore } from "~/composables/store";
@@ -48,71 +47,52 @@ interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
export default defineNuxtComponent({
components: {
RecipeIngredients,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
scale: {
type: Number,
required: true,
},
isCookMode: {
type: Boolean,
default: false,
},
},
setup(props) {
const { isOwnGroup } = useLoggedInState();
interface Props {
recipe: NoUndefinedField<Recipe>;
scale: number;
isCookMode?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isCookMode: false,
});
const toolStore = isOwnGroup.value ? useToolStore() : null;
const { user } = usePageUser();
const { isEditMode } = usePageState(props.recipe.slug);
const { isOwnGroup } = useLoggedInState();
const recipeTools = computed(() => {
if (!(user.householdSlug && toolStore)) {
return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
}
else {
return props.recipe.tools.map((tool) => {
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
return { ...tool, onHand } as RecipeToolWithOnHand;
});
}
const toolStore = isOwnGroup.value ? useToolStore() : null;
const { user } = usePageUser();
const { isEditMode } = usePageState(props.recipe.slug);
const recipeTools = computed(() => {
if (!(user.householdSlug && toolStore)) {
return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
}
else {
return props.recipe.tools.map((tool) => {
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
return { ...tool, onHand } as RecipeToolWithOnHand;
});
}
});
function updateTool(index: number) {
if (user.id && user.householdSlug && toolStore) {
const tool = recipeTools.value[index];
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
if (!tool.householdsWithTool) {
tool.householdsWithTool = [user.householdSlug];
}
else {
tool.householdsWithTool.push(user.householdSlug);
}
}
else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== user.householdSlug);
}
toolStore.actions.updateOne(tool);
function updateTool(index: number) {
if (user.id && user.householdSlug && toolStore) {
const tool = recipeTools.value[index];
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
if (!tool.householdsWithTool) {
tool.householdsWithTool = [user.householdSlug];
}
else {
console.log("no user, skipping server update");
tool.householdsWithTool.push(user.householdSlug);
}
}
else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== user.householdSlug);
}
return {
toolStore,
recipeTools,
isEditMode,
updateTool,
};
},
});
toolStore.actions.updateOne(tool);
}
else {
console.log("no user, skipping server update");
}
}
</script>

View File

@@ -29,33 +29,48 @@
{{ activeText }}
</p>
<v-divider class="mb-4" />
<v-checkbox
v-for="ing in unusedIngredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
class="mb-n2 mt-n2"
>
<template #label>
<RecipeIngredientHtml :markup="parseIngredientText(ing, recipe.settings.disableAmount)" />
</template>
</v-checkbox>
<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>
<v-checkbox
v-for="ing in usedIngredients"
<template v-for="(ingredients, title) in groupedUnusedIngredients" :key="title">
<h4 v-if="title" class="py-3 ml-1 pl-4">
{{ title }}
</h4>
<v-checkbox-btn
v-for="ing in ingredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
class="mb-n2 mt-n2"
class="ml-4"
>
<template #label>
<RecipeIngredientHtml :markup="parseIngredientText(ing, recipe.settings.disableAmount)" />
<RecipeIngredientHtml :markup="parseIngredientText(ing)" />
</template>
</v-checkbox>
</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>
@@ -325,7 +340,6 @@
return step.ingredientReferences.map((ref) => ref.referenceId).includes(ing.referenceId || '')
})"
:scale="scale"
:disable-amount="recipe.settings.disableAmount"
:is-cook-mode="isCookMode"
/>
</div>
@@ -393,8 +407,6 @@ const props = defineProps({
const emit = defineEmits(["click-instruction-field", "update:assets"]);
const BASE_URL = useRequestURL().origin;
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
const dialog = ref(false);
@@ -554,7 +566,6 @@ function autoSetReferences() {
props.recipe.recipeIngredient,
activeRefs.value,
activeText.value,
props.recipe.settings.disableAmount,
).forEach((ingredient: string) => activeRefs.value.push(ingredient));
}
@@ -569,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 "";
@@ -576,7 +652,7 @@ function getIngredientByRefId(refId: string | undefined) {
const ing = ingredientLookup.value[refId];
if (!ing) return "";
return parseIngredientText(ing, props.recipe.settings.disableAmount, props.scale);
return parseIngredientText(ing, props.scale);
}
// ===============================================================
@@ -699,7 +775,7 @@ async function handleImageDrop(index: number, files: File[]) {
}
emit("update:assets", [...assets.value, data]);
const assetUrl = BASE_URL + recipeAssetPath(props.recipe.id, data.fileName as string);
const assetUrl = recipeAssetPath(props.recipe.id, data.fileName as string);
const text = `<img src="${assetUrl}" height="100%" width="100%"/>`;
instructionList.value[index].text += text;
}

View File

@@ -2,55 +2,37 @@
<div class="d-flex justify-space-between align-center pt-2 pb-3">
<RecipeScaleEditButton
v-if="!isEditMode"
v-model.number="scaleValue"
v-model.number="scale"
:recipe-servings="recipeServings"
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
:edit-scale="hasFoodOrUnit && !isEditMode"
/>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe";
import { usePageState } from "~/composables/recipe-page/shared-state";
export default defineNuxtComponent({
components: {
RecipeScaleEditButton,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
scale: {
type: Number,
default: 1,
},
},
emits: ["update:scale"],
setup(props, { emit }) {
const { isEditMode } = usePageState(props.recipe.slug);
const props = defineProps<{ recipe: NoUndefinedField<Recipe> }>();
const recipeServings = computed<number>(() => {
return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
});
const scale = defineModel<number>({ default: 1 });
const scaleValue = computed<number>({
get() {
return props.scale;
},
set(val) {
emit("update:scale", val);
},
});
const { isEditMode } = usePageState(props.recipe.slug);
return {
recipeServings,
scaleValue,
isEditMode,
};
},
const recipeServings = computed<number>(() => {
return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
});
const hasFoodOrUnit = computed(() => {
if (props.recipe.recipeIngredient) {
for (const ingredient of props.recipe.recipeIngredient) {
if (ingredient.food || ingredient.unit) {
return true;
}
}
}
return false;
});
</script>

View File

@@ -8,24 +8,17 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import type { Recipe } from "~/lib/api/types/recipe";
export default defineNuxtComponent({
components: {
RecipePrintView,
},
props: {
recipe: {
type: Object as () => Recipe,
required: true,
},
scale: {
type: Number,
default: 1,
},
},
interface Props {
recipe: Recipe;
scale?: number;
}
withDefaults(defineProps<Props>(), {
scale: 1,
});
</script>

View File

@@ -166,7 +166,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import DOMPurify from "dompurify";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import { useStaticRoutes } from "~/composables/api";
@@ -188,167 +188,141 @@ type InstructionSection = {
instructions: RecipeStep[];
};
export default defineNuxtComponent({
components: {
RecipeTimeCard,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
scale: {
type: Number,
default: 1,
},
dense: {
type: Boolean,
default: false,
},
},
setup(props) {
const i18n = useI18n();
const preferences = useUserPrintPreferences();
const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { labels } = useNutritionLabels();
function sanitizeHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ["strong", "sup"],
});
}
const servingsDisplay = computed(() => {
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
return scaledAmountDisplay
? i18n.t("recipe.yields-amount-with-text", {
amount: scaledAmountDisplay,
text: props.recipe.recipeYield,
}) as string
: "";
});
const yieldDisplay = computed(() => {
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
return scaledAmountDisplay ? i18n.t("recipe.serves-amount", { amount: scaledAmountDisplay }) as string : "";
});
const recipeYield = computed(() => {
if (servingsDisplay.value && yieldDisplay.value) {
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
}
else {
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
}
});
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
// Group ingredients by section so we can style them independently
const ingredientSections = computed<IngredientSection[]>(() => {
if (!props.recipe.recipeIngredient) {
return [];
}
return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
// if title append new section to the end of the array
if (ingredient.title) {
sections.push({
sectionName: ingredient.title,
ingredients: [ingredient],
});
return sections;
}
// append new section if first
if (sections.length === 0) {
sections.push({
sectionName: "",
ingredients: [ingredient],
});
return sections;
}
// otherwise add ingredient to last section in the array
sections[sections.length - 1].ingredients.push(ingredient);
return sections;
}, [] as IngredientSection[]);
});
// Group instructions by section so we can style them independently
const instructionSections = computed<InstructionSection[]>(() => {
if (!props.recipe.recipeInstructions) {
return [];
}
return props.recipe.recipeInstructions.reduce((sections, step) => {
const offset = (() => {
if (sections.length === 0) {
return 0;
}
const lastOffset = sections[sections.length - 1].stepOffset;
const lastNumSteps = sections[sections.length - 1].instructions.length;
return lastOffset + lastNumSteps;
})();
// if title append new section to the end of the array
if (step.title) {
sections.push({
sectionName: step.title,
stepOffset: offset,
instructions: [step],
});
return sections;
}
// append if first element
if (sections.length === 0) {
sections.push({
sectionName: "",
stepOffset: offset,
instructions: [step],
});
return sections;
}
// otherwise add step to last section in the array
sections[sections.length - 1].instructions.push(step);
return sections;
}, [] as InstructionSection[]);
});
const hasNotes = computed(() => {
return props.recipe.notes && props.recipe.notes.length > 0;
});
function parseText(ingredient: RecipeIngredient) {
return parseIngredientText(ingredient, props.recipe.settings?.disableAmount || false, props.scale);
}
return {
labels,
hasNotes,
imageKey,
ImagePosition,
parseText,
parseIngredientText,
preferences,
recipeImageUrl,
recipeYield,
ingredientSections,
instructionSections,
};
},
interface Props {
recipe: NoUndefinedField<Recipe>;
scale?: number;
dense?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
scale: 1,
dense: false,
});
const i18n = useI18n();
const preferences = useUserPrintPreferences();
const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { labels } = useNutritionLabels();
function sanitizeHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ["strong", "sup"],
});
}
const servingsDisplay = computed(() => {
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
return scaledAmountDisplay || props.recipe.recipeYield
? i18n.t("recipe.yields-amount-with-text", {
amount: scaledAmountDisplay,
text: props.recipe.recipeYield,
}) as string
: "";
});
const yieldDisplay = computed(() => {
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
return scaledAmountDisplay ? i18n.t("recipe.serves-amount", { amount: scaledAmountDisplay }) as string : "";
});
const recipeYield = computed(() => {
if (servingsDisplay.value && yieldDisplay.value) {
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
}
else {
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
}
});
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
// Group ingredients by section so we can style them independently
const ingredientSections = computed<IngredientSection[]>(() => {
if (!props.recipe.recipeIngredient) {
return [];
}
return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
// if title append new section to the end of the array
if (ingredient.title) {
sections.push({
sectionName: ingredient.title,
ingredients: [ingredient],
});
return sections;
}
// append new section if first
if (sections.length === 0) {
sections.push({
sectionName: "",
ingredients: [ingredient],
});
return sections;
}
// otherwise add ingredient to last section in the array
sections[sections.length - 1].ingredients.push(ingredient);
return sections;
}, [] as IngredientSection[]);
});
// Group instructions by section so we can style them independently
const instructionSections = computed<InstructionSection[]>(() => {
if (!props.recipe.recipeInstructions) {
return [];
}
return props.recipe.recipeInstructions.reduce((sections, step) => {
const offset = (() => {
if (sections.length === 0) {
return 0;
}
const lastOffset = sections[sections.length - 1].stepOffset;
const lastNumSteps = sections[sections.length - 1].instructions.length;
return lastOffset + lastNumSteps;
})();
// if title append new section to the end of the array
if (step.title) {
sections.push({
sectionName: step.title,
stepOffset: offset,
instructions: [step],
});
return sections;
}
// append if first element
if (sections.length === 0) {
sections.push({
sectionName: "",
stepOffset: offset,
instructions: [step],
});
return sections;
}
// otherwise add step to last section in the array
sections[sections.length - 1].instructions.push(step);
return sections;
}, [] as InstructionSection[]);
});
const hasNotes = computed(() => {
return props.recipe.notes && props.recipe.notes.length > 0;
});
function parseText(ingredient: RecipeIngredient) {
return parseIngredientText(ingredient, props.scale);
}
</script>
<style scoped>

View File

@@ -10,11 +10,11 @@
nudge-top="6"
:close-on-content-click="false"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-tooltip
v-if="canEditScale"
size="small"
top
location="top"
color="secondary-darken-1"
>
<template #activator="{ props: tooltipProps }">
@@ -23,7 +23,7 @@
dark
color="secondary-darken-1"
size="small"
v-bind="{ ...props, ...tooltipProps }"
v-bind="{ ...activatorProps, ...tooltipProps }"
:style="{ cursor: canEditScale ? '' : 'default' }"
>
<v-icon
@@ -45,7 +45,7 @@
dark
color="secondary-darken-1"
size="small"
v-bind="props"
v-bind="activatorProps"
:style="{ cursor: canEditScale ? '' : 'default' }"
>
<v-icon
@@ -66,21 +66,22 @@
<v-card-text class="mt-n5">
<div class="mt-4 d-flex align-center">
<v-text-field
:model-value="yieldQuantityEditorValue"
:model-value="yieldQuantity"
type="number"
:min="0"
variant="underlined"
hide-spin-buttons
@update:model-value="recalculateScale(yieldQuantityEditorValue)"
@update:model-value="recalculateScale(parseFloat($event) || 0)"
/>
<v-tooltip
end
location="end"
color="secondary-darken-1"
>
<template #activator="{ props }">
<template #activator="{ props: resetTooltipProps }">
<v-btn
v-bind="props"
v-bind="resetTooltipProps"
icon
flat
class="mx-1"
size="small"
@click="scale = 1"
@@ -121,90 +122,50 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
export default defineNuxtComponent({
props: {
modelValue: {
type: Number,
required: true,
},
recipeServings: {
type: Number,
default: 0,
},
editScale: {
type: Boolean,
default: false,
},
},
emits: ["update:modelValue"],
setup(props, { emit }) {
const i18n = useI18n();
const menu = ref<boolean>(false);
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
interface Props {
recipeServings?: number;
editScale?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
recipeServings: 0,
editScale: false,
});
const scale = computed({
get: () => props.modelValue,
set: (value) => {
const newScaleNumber = parseFloat(`${value}`);
emit("update:modelValue", isNaN(newScaleNumber) ? 0 : newScaleNumber);
},
});
const scale = defineModel<number>({ required: true });
function recalculateScale(newYield: number) {
if (isNaN(newYield) || newYield <= 0) {
return;
}
const i18n = useI18n();
const menu = ref<boolean>(false);
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
if (props.recipeServings <= 0) {
scale.value = 1;
}
else {
scale.value = newYield / props.recipeServings;
}
}
function recalculateScale(newYield: number) {
if (isNaN(newYield) || newYield <= 0) {
return;
}
const recipeYieldAmount = computed(() => {
return useScaledAmount(props.recipeServings, scale.value);
});
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
const yieldDisplay = computed(() => {
return yieldQuantity.value
? i18n.t(
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay },
) as string
: "";
});
if (props.recipeServings <= 0) {
scale.value = 1;
}
else {
scale.value = newYield / props.recipeServings;
}
}
// only update yield quantity when the menu opens, so we don't override the user's input
const yieldQuantityEditorValue = ref(recipeYieldAmount.value.scaledAmount);
watch(
() => menu.value,
() => {
if (!menu.value) {
return;
}
const recipeYieldAmount = computed(() => {
return useScaledAmount(props.recipeServings, scale.value);
});
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
const yieldDisplay = computed(() => {
return yieldQuantity.value
? i18n.t(
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay },
) as string
: "";
});
yieldQuantityEditorValue.value = recipeYieldAmount.value.scaledAmount;
},
);
const disableDecrement = computed(() => {
return recipeYieldAmount.value.scaledAmount <= 1;
});
return {
menu,
canEditScale,
scale,
recalculateScale,
yieldDisplay,
yieldQuantity,
yieldQuantityEditorValue,
disableDecrement,
};
},
const disableDecrement = computed(() => {
return yieldQuantity.value <= 1;
});
</script>

View File

@@ -1,54 +0,0 @@
<template>
<div class="d-flex justify-center align-center">
<v-btn-toggle v-model="selected" tile group color="primary accent-3" mandatory="force" @change="emitMulti">
<v-btn size="small" :value="false">
{{ $t("search.include") }}
</v-btn>
<v-btn size="small" :value="true">
{{ $t("search.exclude") }}
</v-btn>
</v-btn-toggle>
<v-btn-toggle v-model="match" tile group color="primary accent-3" mandatory="force" @change="emitMulti">
<v-btn size="small" :value="false" class="text-uppercase">
{{ $t("search.and") }}
</v-btn>
<v-btn size="small" :value="true" class="text-uppercase">
{{ $t("search.or") }}
</v-btn>
</v-btn-toggle>
</div>
</template>
<script lang="ts">
type SelectionValue = "include" | "exclude" | "any";
export default defineNuxtComponent({
props: {
modelValue: {
type: String as () => SelectionValue,
default: "include",
},
},
emits: ["update:modelValue", "update"],
data() {
return {
selected: false,
match: false,
};
},
methods: {
emitChange() {
this.$emit("update:modelValue", this.selected);
},
emitMulti() {
const updateData = {
exclude: this.selected,
matchAny: this.match,
};
this.$emit("update", updateData);
},
},
});
</script>
<style lang="scss" scoped></style>

View File

@@ -31,7 +31,6 @@ const labels: Record<keyof RecipeSettings, string> = {
showAssets: i18n.t("asset.show-assets"),
landscapeView: i18n.t("recipe.landscape-view-coming-soon"),
disableComments: i18n.t("recipe.disable-comments"),
disableAmount: i18n.t("recipe.disable-amount"),
locked: i18n.t("recipe.locked"),
};
</script>

View File

@@ -14,9 +14,7 @@
<div v-for="(organizer, idx) in missingOrganizers" :key="idx">
<v-col v-if="organizer.show" cols="12">
<div class="d-flex flex-row flex-wrap align-center pt-2">
<v-icon class="ma-0 pa-0">
{{ organizer.icon }}
</v-icon>
<v-icon class="ma-0 pa-0" />
<v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content">
{{ $t("recipe-finder.missing") }}:
</v-card-text>
@@ -41,7 +39,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeCardMobile from "./RecipeCardMobile.vue";
import type { IngredientFood, RecipeSummary, RecipeTool } from "~/lib/api/types/recipe";
@@ -51,71 +49,72 @@ interface Organizer {
selected: boolean;
}
export default defineNuxtComponent({
components: { RecipeCardMobile },
props: {
recipe: {
type: Object as () => RecipeSummary,
required: true,
},
missingFoods: {
type: Array as () => IngredientFood[] | null,
default: null,
},
missingTools: {
type: Array as () => RecipeTool[] | null,
default: null,
},
disableCheckbox: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const { $globals } = useNuxtApp();
const missingOrganizers = computed(() => [
{
type: "food",
show: props.missingFoods?.length,
icon: $globals.icons.foods,
items: props.missingFoods
? props.missingFoods.map((food) => {
return reactive({ type: "food", item: food, selected: false } as Organizer);
})
: [],
getLabel: (item: IngredientFood) => item.pluralName || item.name,
},
{
type: "tool",
show: props.missingTools?.length,
icon: $globals.icons.tools,
items: props.missingTools
? props.missingTools.map((tool) => {
return reactive({ type: "tool", item: tool, selected: false } as Organizer);
})
: [],
getLabel: (item: RecipeTool) => item.name,
},
]);
function handleCheckbox(organizer: Organizer) {
if (props.disableCheckbox) {
return;
}
organizer.selected = !organizer.selected;
if (organizer.selected) {
context.emit(`add-${organizer.type}`, organizer.item);
}
else {
context.emit(`remove-${organizer.type}`, organizer.item);
}
}
return {
missingOrganizers,
handleCheckbox,
};
},
interface Props {
recipe: RecipeSummary;
missingFoods?: IngredientFood[] | null;
missingTools?: RecipeTool[] | null;
disableCheckbox?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
missingFoods: null,
missingTools: null,
disableCheckbox: false,
});
const emit = defineEmits<{
"add-food": [food: IngredientFood];
"remove-food": [food: IngredientFood];
"add-tool": [tool: RecipeTool];
"remove-tool": [tool: RecipeTool];
}>();
const { $globals } = useNuxtApp();
const missingOrganizers = computed(() => [
{
type: "food",
show: props.missingFoods?.length,
icon: $globals.icons.foods,
items: props.missingFoods
? props.missingFoods.map((food) => {
return reactive({ type: "food", item: food, selected: false } as Organizer);
})
: [],
getLabel: (item: IngredientFood) => item.pluralName || item.name,
},
{
type: "tool",
show: props.missingTools?.length,
icon: $globals.icons.tools,
items: props.missingTools
? props.missingTools.map((tool) => {
return reactive({ type: "tool", item: tool, selected: false } as Organizer);
})
: [],
getLabel: (item: RecipeTool) => item.name,
},
]);
function handleCheckbox(organizer: Organizer) {
if (props.disableCheckbox) {
return;
}
organizer.selected = !organizer.selected;
if (organizer.selected) {
if (organizer.type === "food") {
emit("add-food", organizer.item as IngredientFood);
}
else {
emit("add-tool", organizer.item as RecipeTool);
}
}
else {
if (organizer.type === "food") {
emit("remove-food", organizer.item as IngredientFood);
}
else {
emit("remove-tool", organizer.item as RecipeTool);
}
}
}
</script>

View File

@@ -1,4 +1,4 @@
<template v-if="showCards">
<template v-if="_showCards">
<div class="text-center">
<!-- Total Time -->
<div
@@ -78,65 +78,46 @@
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
prepTime: {
type: String,
default: null,
},
totalTime: {
type: String,
default: null,
},
performTime: {
type: String,
default: null,
},
color: {
type: String,
default: "accent custom-transparent",
},
small: {
type: Boolean,
default: false,
},
},
setup(props) {
const i18n = useI18n();
<script setup lang="ts">
interface Props {
prepTime?: string | null;
totalTime?: string | null;
performTime?: string | null;
color?: string;
small?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
prepTime: null,
totalTime: null,
performTime: null,
color: "accent custom-transparent",
small: false,
});
function isEmpty(str: string | null) {
return !str || str.length === 0;
}
const i18n = useI18n();
const showCards = computed(() => {
return [props.prepTime, props.totalTime, props.performTime].some(x => !isEmpty(x));
});
function isEmpty(str: string | null) {
return !str || str.length === 0;
}
const validateTotalTime = computed(() => {
return !isEmpty(props.totalTime) ? { name: i18n.t("recipe.total-time"), value: props.totalTime } : null;
});
const _showCards = computed(() => {
return [props.prepTime, props.totalTime, props.performTime].some(x => !isEmpty(x));
});
const validatePrepTime = computed(() => {
return !isEmpty(props.prepTime) ? { name: i18n.t("recipe.prep-time"), value: props.prepTime } : null;
});
const validateTotalTime = computed(() => {
return !isEmpty(props.totalTime) ? { name: i18n.t("recipe.total-time"), value: props.totalTime } : null;
});
const validatePerformTime = computed(() => {
return !isEmpty(props.performTime) ? { name: i18n.t("recipe.perform-time"), value: props.performTime } : null;
});
const validatePrepTime = computed(() => {
return !isEmpty(props.prepTime) ? { name: i18n.t("recipe.prep-time"), value: props.prepTime } : null;
});
const fontSize = computed(() => {
return props.small ? { fontSize: "smaller" } : { fontSize: "larger" };
});
const validatePerformTime = computed(() => {
return !isEmpty(props.performTime) ? { name: i18n.t("recipe.perform-time"), value: props.performTime } : null;
});
return {
showCards,
validateTotalTime,
validatePrepTime,
validatePerformTime,
fontSize,
};
},
const fontSize = computed(() => {
return props.small ? { fontSize: "smaller" } : { fontSize: "larger" };
});
</script>

View File

@@ -11,7 +11,7 @@
nudge-bottom="3"
:close-on-content-click="false"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-badge
:content="filterBadgeCount"
:model-value="filterBadgeCount > 0"
@@ -21,7 +21,7 @@
class="rounded-circle"
size="small"
color="info"
v-bind="props"
v-bind="activatorProps"
icon
>
<v-icon> {{ $globals.icons.filter }} </v-icon>
@@ -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>
@@ -105,7 +105,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useThrottleFn, whenever } from "@vueuse/core";
import RecipeTimelineItem from "./RecipeTimelineItem.vue";
import { useTimelinePreferences } from "~/composables/use-users/preferences";
@@ -115,252 +115,205 @@ import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api";
import type { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe";
export default defineNuxtComponent({
components: { RecipeTimelineItem },
interface Props {
modelValue?: boolean;
queryFilter: string;
maxHeight?: number | string;
showRecipeCards?: boolean;
}
props: {
modelValue: {
type: Boolean,
default: false,
},
queryFilter: {
type: String,
required: true,
},
maxHeight: {
type: [Number, String],
default: undefined,
},
showRecipeCards: {
type: Boolean,
default: false,
},
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
maxHeight: undefined,
showRecipeCards: false,
});
const api = useUserApi();
const i18n = useI18n();
const preferences = useTimelinePreferences();
const { eventTypeOptions } = useTimelineEventTypes();
const loading = ref(true);
const ready = ref(false);
const page = ref(1);
const perPage = 32;
const hasMore = ref(true);
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
const recipes = new Map<string, Recipe>();
const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length);
const eventTypeFilterState = computed(() => {
return eventTypeOptions.value.map((option) => {
return {
...option,
checked: preferences.value.types.includes(option.value),
};
});
});
const screenBuffer = 4;
whenever(
() => props.modelValue,
() => {
initializeTimelineEvents();
},
);
setup(props) {
const api = useUserApi();
const i18n = useI18n();
const preferences = useTimelinePreferences();
const { eventTypeOptions } = useTimelineEventTypes();
const loading = ref(true);
const ready = ref(false);
// Preferences
function reverseSort() {
if (loading.value) {
return;
}
const page = ref(1);
const perPage = 32;
const hasMore = ref(true);
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
initializeTimelineEvents();
}
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
const recipes = new Map<string, Recipe>();
const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length);
const eventTypeFilterState = computed(() => {
return eventTypeOptions.value.map((option) => {
return {
...option,
checked: preferences.value.types.includes(option.value),
};
});
});
function toggleEventTypeOption(option: TimelineEventType) {
if (loading.value) {
return;
}
interface ScrollEvent extends Event {
target: HTMLInputElement;
const index = preferences.value.types.indexOf(option);
if (index === -1) {
preferences.value.types.push(option);
}
else {
preferences.value.types.splice(index, 1);
}
initializeTimelineEvents();
}
// Timeline Actions
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);
}
async function deleteTimelineEvent(index: number) {
const { response } = await api.recipes.deleteTimelineEvent(timelineEvents.value[index].id);
if (response?.status !== 200) {
alert.error(i18n.t("events.something-went-wrong") as string);
return;
}
timelineEvents.value.splice(index, 1);
alert.success(i18n.t("events.event-deleted") as string);
}
async function getRecipes(recipeIds: string[]): Promise<Recipe[]> {
const qf = "id IN [" + recipeIds.map(id => `"${id}"`).join(", ") + "]";
const { data } = await api.recipes.getAll(1, -1, { queryFilter: qf });
return data?.items || [];
}
async function updateRecipes(events: RecipeTimelineEventOut[]) {
const recipeIds: string[] = [];
events.forEach((event) => {
if (recipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
return;
}
const screenBuffer = 4;
const onScroll = (event: ScrollEvent) => {
if (!event.target) {
return;
recipeIds.push(event.recipeId);
});
const results = await getRecipes(recipeIds);
results.forEach((result) => {
if (!result?.id) {
return;
}
recipes.set(result.id, result);
});
}
async function scrollTimelineEvents() {
const orderBy = "timestamp";
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
const eventTypeValue = `["${preferences.value.types.join("\", \"")}"]`;
const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`;
const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter });
page.value += 1;
if (!response?.data) {
return;
}
const events = response.data.items;
if (events.length < perPage) {
hasMore.value = false;
if (!events.length) {
return;
}
}
// fetch recipes
if (props.showRecipeCards) {
await updateRecipes(events);
}
// this is set last so Vue knows to re-render
timelineEvents.value.push(...events);
}
async function initializeTimelineEvents() {
loading.value = true;
ready.value = false;
page.value = 1;
hasMore.value = true;
timelineEvents.value = [];
await scrollTimelineEvents();
ready.value = true;
loading.value = false;
}
const infiniteScroll = useThrottleFn(() => {
useAsyncData(useAsyncKey(), async () => {
if (!hasMore.value || loading.value) {
return;
}
loading.value = true;
await scrollTimelineEvents();
loading.value = false;
});
}, 500);
// preload events
initializeTimelineEvents();
onMounted(
() => {
document.onscroll = () => {
// if the inner element is scrollable, let its scroll event handle the infiniteScroll
const timelineContainerElement = document.getElementById("timeline-container");
if (timelineContainerElement) {
const { clientHeight, scrollHeight } = timelineContainerElement;
// if scrollHeight == clientHeight, the element is not scrollable, so we need to look at the global position
// if scrollHeight > clientHeight, it is scrollable and we don't need to do anything here
if (scrollHeight > clientHeight) {
return;
}
}
const { scrollTop, offsetHeight, scrollHeight } = event.target;
// trigger when the user is getting close to the bottom
const bottomOfElement = scrollTop + offsetHeight >= scrollHeight - (offsetHeight * screenBuffer);
if (bottomOfElement) {
const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight * screenBuffer);
if (bottomOfWindow) {
infiniteScroll();
}
};
whenever(
() => props.modelValue,
() => {
initializeTimelineEvents();
},
);
// Preferences
function reverseSort() {
if (loading.value) {
return;
}
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
initializeTimelineEvents();
}
function toggleEventTypeOption(option: TimelineEventType) {
if (loading.value) {
return;
}
const index = preferences.value.types.indexOf(option);
if (index === -1) {
preferences.value.types.push(option);
}
else {
preferences.value.types.splice(index, 1);
}
initializeTimelineEvents();
}
// 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);
if (response?.status !== 200) {
alert.error(i18n.t("events.something-went-wrong") as string);
return;
}
alert.success(i18n.t("events.event-updated") as string);
};
async function deleteTimelineEvent(index: number) {
const { response } = await api.recipes.deleteTimelineEvent(timelineEvents.value[index].id);
if (response?.status !== 200) {
alert.error(i18n.t("events.something-went-wrong") as string);
return;
}
timelineEvents.value.splice(index, 1);
alert.success(i18n.t("events.event-deleted") as string);
};
async function getRecipe(recipeId: string): Promise<Recipe | null> {
const { data } = await api.recipes.getOne(recipeId);
return data;
};
async function updateRecipes(events: RecipeTimelineEventOut[]) {
const recipePromises: Promise<Recipe | null>[] = [];
const seenRecipeIds: string[] = [];
events.forEach((event) => {
if (seenRecipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
return;
}
seenRecipeIds.push(event.recipeId);
recipePromises.push(getRecipe(event.recipeId));
});
const results = await Promise.all(recipePromises);
results.forEach((result) => {
if (result && result.id) {
recipes.set(result.id, result);
}
});
}
async function scrollTimelineEvents() {
const orderBy = "timestamp";
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
const eventTypeValue = `["${preferences.value.types.join("\", \"")}"]`;
const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`;
const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter });
page.value += 1;
if (!response?.data) {
return;
}
const events = response.data.items;
if (events.length < perPage) {
hasMore.value = false;
if (!events.length) {
return;
}
}
// fetch recipes
if (props.showRecipeCards) {
await updateRecipes(events);
}
// this is set last so Vue knows to re-render
timelineEvents.value.push(...events);
};
async function initializeTimelineEvents() {
loading.value = true;
ready.value = false;
page.value = 1;
hasMore.value = true;
timelineEvents.value = [];
await scrollTimelineEvents();
ready.value = true;
loading.value = false;
}
const infiniteScroll = useThrottleFn(() => {
useAsyncData(useAsyncKey(), async () => {
if (!hasMore.value || loading.value) {
return;
}
loading.value = true;
await scrollTimelineEvents();
loading.value = false;
});
}, 500);
// preload events
initializeTimelineEvents();
onMounted(
() => {
document.onscroll = () => {
// if the inner element is scrollable, let its scroll event handle the infiniteScroll
const timelineContainerElement = document.getElementById("timeline-container");
if (timelineContainerElement) {
const { clientHeight, scrollHeight } = timelineContainerElement;
// if scrollHeight == clientHeight, the element is not scrollable, so we need to look at the global position
// if scrollHeight > clientHeight, it is scrollable and we don't need to do anything here
if (scrollHeight > clientHeight) {
return;
}
}
const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight * screenBuffer);
if (bottomOfWindow) {
infiniteScroll();
}
};
},
);
return {
deleteTimelineEvent,
filterBadgeCount,
loading,
onScroll,
preferences,
eventTypeFilterState,
recipes,
reverseSort,
toggleEventTypeOption,
timelineEvents,
updateTimelineEvent,
};
},
});
);
</script>

View File

@@ -1,10 +1,10 @@
<template>
<v-tooltip
bottom
location="bottom"
nudge-right="50"
:color="buttonStyle ? 'info' : 'secondary'"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-btn
icon
:variant="buttonStyle ? 'flat' : undefined"
@@ -12,7 +12,7 @@
size="small"
:color="buttonStyle ? 'info' : 'secondary'"
:fab="buttonStyle"
v-bind="{ ...props, ...$attrs }"
v-bind="{ ...activatorProps, ...$attrs }"
@click.prevent="toggleTimeline"
>
<v-icon
@@ -39,48 +39,37 @@
</v-tooltip>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeTimeline from "./RecipeTimeline.vue";
export default defineNuxtComponent({
components: { RecipeTimeline },
interface Props {
buttonStyle?: boolean;
slug?: string;
recipeName?: string;
}
const props = withDefaults(defineProps<Props>(), {
buttonStyle: false,
slug: "",
recipeName: "",
});
props: {
buttonStyle: {
type: Boolean,
default: false,
},
slug: {
type: String,
default: "",
},
recipeName: {
type: String,
default: "",
},
},
const i18n = useI18n();
const { smAndDown } = useDisplay();
const showTimeline = ref(false);
setup(props) {
const i18n = useI18n();
const { smAndDown } = useDisplay();
const showTimeline = ref(false);
function toggleTimeline() {
showTimeline.value = !showTimeline.value;
}
function toggleTimeline() {
showTimeline.value = !showTimeline.value;
}
const timelineAttrs = computed(() => {
let title = i18n.t("recipe.timeline");
if (smAndDown.value) {
title += ` ${props.recipeName}`;
}
const timelineAttrs = computed(() => {
let title = i18n.t("recipe.timeline");
if (smAndDown.value) {
title += ` ${props.recipeName}`;
}
return {
title,
queryFilter: `recipe.slug="${props.slug}"`,
};
});
return { showTimeline, timelineAttrs, toggleTimeline };
},
return {
title,
queryFilter: `recipe.slug="${props.slug}"`,
};
});
</script>

View File

@@ -43,7 +43,7 @@
edit: true,
delete: true,
}"
@update="$emit('update')"
@update="$emit('update', $event)"
@delete="$emit('delete')"
/>
</v-col>
@@ -53,6 +53,7 @@
<v-row :class="useMobileFormat ? 'py-3 mx-0' : 'py-3 mx-0'" style="max-width: 100%">
<v-col align-self="center" class="pa-0">
<RecipeCardMobile
disable-highlight
:vertical="useMobileFormat"
:name="recipe.name"
:slug="recipe.slug"
@@ -90,105 +91,89 @@
</v-timeline-item>
</template>
<script lang="ts">
<script setup lang="ts">
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";
export default defineNuxtComponent({
components: { RecipeCardMobile, RecipeTimelineContextMenu, UserAvatar, SafeMarkdown },
interface Props {
event: RecipeTimelineEventOut;
recipe?: Recipe;
showRecipeCards?: boolean;
}
props: {
event: {
type: Object as () => RecipeTimelineEventOut,
required: true,
},
recipe: {
type: Object as () => Recipe,
default: undefined,
},
showRecipeCards: {
type: Boolean,
default: false,
},
},
emits: ["selected", "update", "delete"],
const props = withDefaults(defineProps<Props>(), {
recipe: undefined,
showRecipeCards: false,
});
setup(props) {
const { $vuetify, $globals } = useNuxtApp();
const { recipeTimelineEventImage } = useStaticRoutes();
const { eventTypeOptions } = useTimelineEventTypes();
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
defineEmits<{
selected: [];
update: [event: RecipeTimelineEventUpdate];
delete: [];
}>();
const { user: currentUser } = useMealieAuth();
const { $globals } = useNuxtApp();
const display = useDisplay();
const { recipeTimelineEventImage } = useStaticRoutes();
const { eventTypeOptions } = useTimelineEventTypes();
const route = useRoute();
const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
const { user: currentUser } = useMealieAuth();
const useMobileFormat = computed(() => {
return $vuetify.display.smAndDown.value;
});
const route = useRoute();
const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
const attrs = computed(() => {
if (useMobileFormat.value) {
return {
class: "px-0",
small: false,
avatar: {
size: "30px",
class: "pr-0",
},
image: {
maxHeight: "250",
class: "my-3",
},
};
}
else {
return {
class: "px-3",
small: false,
avatar: {
size: "42px",
class: "",
},
image: {
maxHeight: "300",
class: "mb-5",
},
};
}
});
const icon = computed(() => {
const option = eventTypeOptions.value.find(option => option.value === props.event.eventType);
return option ? option.icon : $globals.icons.informationVariant;
});
const hideImage = ref(false);
const eventImageUrl = computed<string>(() => {
if (props.event.image !== "has image") {
return "";
}
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
});
const useMobileFormat = computed(() => {
return display.smAndDown.value;
});
const attrs = computed(() => {
if (useMobileFormat.value) {
return {
attrs,
groupSlug,
icon,
eventImageUrl,
hideImage,
timelineEvents,
useMobileFormat,
currentUser,
class: "px-0",
small: false,
avatar: {
size: "30px",
class: "pr-0",
},
image: {
maxHeight: "250",
class: "my-3",
},
};
},
}
else {
return {
class: "px-3",
small: false,
avatar: {
size: "42px",
class: "",
},
image: {
maxHeight: "300",
class: "mb-5",
},
};
}
});
const icon = computed(() => {
const option = eventTypeOptions.value.find(option => option.value === props.event.eventType);
return option ? option.icon : $globals.icons.informationVariant;
});
const hideImage = ref(false);
const eventImageUrl = computed<string>(() => {
if (props.event.image !== "has image") {
return "";
}
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
});
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
v-if="scaledAmount"
v-if="yieldDisplay"
class="d-flex align-center"
>
<v-row
@@ -18,53 +18,49 @@
<p class="my-0 opacity-80">
<span class="font-weight-bold">{{ $t("recipe.yield") }}</span><br>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="scaledAmount" /> {{ text }}
<span v-html="yieldDisplay" />
</p>
</v-row>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import DOMPurify from "dompurify";
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
export default defineNuxtComponent({
props: {
yieldQuantity: {
type: Number,
default: 0,
},
yield: {
type: String,
default: "",
},
scale: {
type: Number,
default: 1,
},
color: {
type: String,
default: "accent custom-transparent",
},
},
setup(props) {
function sanitizeHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ["strong", "sup"],
});
}
interface Props {
yieldQuantity?: number;
yieldText?: string;
scale?: number;
color?: string;
}
const props = withDefaults(defineProps<Props>(), {
yieldQuantity: 0,
yieldText: "",
scale: 1,
color: "accent custom-transparent",
});
const scaledAmount = computed(() => {
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
return scaledAmountDisplay;
});
const text = sanitizeHTML(props.yield);
function sanitizeHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ["strong", "sup"],
});
}
return {
scaledAmount,
text,
};
},
const yieldDisplay = computed<string>(() => {
const components: string[] = [];
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
if (scaledAmountDisplay) {
components.push(scaledAmountDisplay);
}
const text = props.yieldText;
if (text) {
components.push(text);
}
return sanitizeHTML(components.join(" "));
});
</script>

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"
@@ -40,11 +42,10 @@
v-if="requireAll != undefined"
v-model="requireAllValue"
density="compact"
size="small"
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
@@ -74,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"
>
@@ -102,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"
>
@@ -135,6 +138,8 @@
</template>
<script lang="ts">
import { watchDebounced } from "@vueuse/core";
export interface SelectableItem {
id: string;
name: string;
@@ -166,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) => {
@@ -173,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) => {
@@ -187,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];
}
};
@@ -222,6 +250,8 @@ export default defineNuxtComponent({
state,
selected,
selectedRadio,
selectedCount,
selectedIds,
filtered,
handleCheckboxClick,
handleRadioClick,

View File

@@ -20,6 +20,7 @@
<template #activator="{ props }">
<v-btn
size="small"
variant="text"
class="ml-2 handle"
icon
v-bind="props"

View File

@@ -8,36 +8,25 @@
class="flex-nowrap align-center"
>
<v-col :cols="itemLabelCols">
<v-checkbox
v-model="listItem.checked"
class="mt-0"
color="null"
hide-details
density="compact"
:label="listItem.note!"
@change="$emit('checked', listItem)"
>
<template #label>
<div :class="listItem.checked ? 'strike-through' : ''">
<RecipeIngredientListItem
:ingredient="listItem"
:disable-amount="!(listItem.isFood || listItem.quantity !== 1)"
/>
</div>
</template>
</v-checkbox>
<div class="d-flex align-center flex-nowrap">
<v-checkbox
v-model="listItem.checked"
hide-details
density="compact"
class="mt-0 flex-shrink-0"
color="null"
@change="$emit('checked', listItem)"
/>
<div
class="ml-2 text-truncate"
:class="listItem.checked ? 'strike-through' : ''"
style="min-width: 0;"
>
<RecipeIngredientListItem :ingredient="listItem" />
</div>
</div>
</v-col>
<v-spacer />
<v-col
v-if="label && showLabel"
cols="3"
class="text-right"
>
<MultiPurposeLabel
:label="label"
size="small"
/>
</v-col>
<v-col
cols="auto"
class="text-right"
@@ -57,7 +46,7 @@
open-delay="200"
transition="slide-x-reverse-transition"
density="compact"
right
location="end"
content-class="text-caption"
>
<template #activator="{ props: tooltipProps }">
@@ -76,27 +65,6 @@
</template>
<span>Toggle Recipes</span>
</v-tooltip>
<!-- Dummy button so the spacing is consistent when labels are enabled -->
<v-btn
v-else
size="small"
variant="text"
class="ml-2"
icon
disabled
/>
<v-btn
size="small"
variant="text"
class="ml-2 handle"
icon
v-bind="props"
>
<v-icon>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-btn>
<v-btn
size="small"
variant="text"
@@ -108,6 +76,17 @@
{{ $globals.icons.edit }}
</v-icon>
</v-btn>
<v-btn
size="small"
variant="text"
class="handle"
icon
v-bind="props"
>
<v-icon>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item
@@ -170,7 +149,6 @@
@save="save"
@cancel="toggleEdit(false)"
@delete="$emit('delete')"
@toggle-foods="localListItem.isFood = !localListItem.isFood"
/>
</div>
</template>
@@ -179,7 +157,6 @@
import { useOnline } from "@vueuse/core";
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
import MultiPurposeLabel from "./MultiPurposeLabel.vue";
import type { ShoppingListItemOut } from "~/lib/api/types/household";
import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels";
import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe";
@@ -191,16 +168,12 @@ interface actions {
}
export default defineNuxtComponent({
components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeList, RecipeIngredientListItem },
components: { ShoppingListItemEditor, RecipeList, RecipeIngredientListItem },
props: {
modelValue: {
type: Object as () => ShoppingListItemOut,
required: true,
},
showLabel: {
type: Boolean,
default: false,
},
labels: {
type: Array as () => MultiPurposeLabelOut[],
required: true,
@@ -222,7 +195,7 @@ export default defineNuxtComponent({
setup(props, context) {
const i18n = useI18n();
const displayRecipeRefs = ref(false);
const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : props.showLabel ? "4" : "6");
const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : "6");
const isOffline = computed(() => useOnline().value === false);
const contextMenu: actions[] = [
@@ -307,7 +280,7 @@ export default defineNuxtComponent({
}
listItem.value.recipeReferences.forEach((ref) => {
const recipe = props.recipes.get(ref.recipeId);
const recipe = props.recipes?.get(ref.recipeId);
if (recipe) {
recipeList.push(recipe);
}

View File

@@ -2,7 +2,7 @@
<div>
<v-card variant="outlined">
<v-card-text class="pb-3 pt-1">
<div v-if="listItem.isFood" class="d-md-flex align-center mb-2" style="gap: 20px">
<div class="d-md-flex align-center mb-2" style="gap: 20px">
<div>
<InputQuantity v-model="listItem.quantity" />
</div>
@@ -26,9 +26,6 @@
/>
</div>
<div class="d-md-flex align-center" style="gap: 20px">
<div v-if="!listItem.isFood">
<InputQuantity v-model="listItem.quantity" />
</div>
<v-textarea
v-model="listItem.note"
hide-details
@@ -72,7 +69,7 @@
</div>
<BaseButton
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
size="small"
small
color="info"
:icon="$globals.icons.tagArrowRight"
:text="$t('shopping-list.save-label')"
@@ -99,11 +96,6 @@
text: $t('general.cancel'),
event: 'cancel',
},
{
icon: $globals.icons.foods,
text: $t('shopping-list.toggle-food'),
event: 'toggle-foods',
},
{
icon: $globals.icons.save,
text: $t('general.save'),
@@ -113,7 +105,6 @@
@save="$emit('save')"
@cancel="$emit('cancel')"
@delete="$emit('delete')"
@toggle-foods="listItem.isFood = !listItem.isFood"
/>
</v-card-actions>
</v-card>

View File

@@ -2,7 +2,7 @@
<v-tooltip
v-if="userId"
:disabled="!user || !tooltip"
right
location="end"
>
<template #activator="{ props }">
<v-avatar

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

@@ -17,7 +17,7 @@
<v-btn
color="white"
icon
href="https://github.com/hay-kot/mealie"
href="https://github.com/mealie-recipes/mealie"
target="_blank"
>
<v-icon>

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

@@ -2,7 +2,7 @@
<v-tooltip
ref="copyToolTip"
v-model="show"
top
location="top"
:open-on-hover="false"
:open-on-click="true"
close-delay="500"

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,26 +33,28 @@
<!-- 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="auto"
:hide-details="!inputField.hint"
:persistent-hint="!!inputField.hint"
density="comfortable"
@change="emitBlur">
<template #label>
<span class="ml-4">
{{ inputField.label }}
</span>
</template>
</v-checkbox>
</template>
</v-checkbox>
<!-- 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
@@ -61,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"
/>
@@ -69,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"
@@ -80,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"
/>
@@ -88,31 +90,23 @@
<!-- 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"
:item-title="inputField.itemText"
:item-value="inputField.itemValue"
item-title="text"
item-value="text"
:return-object="false"
:hint="inputField.hint"
density="comfortable"
persistent-hint
lazy-validation
@blur="emitBlur"
>
<template #item="{ item }">
<div>
<v-list-item-title>{{ item.raw.text }}</v-list-item-title>
<v-list-item-subtitle>{{ item.raw.description }}</v-list-item-subtitle>
</div>
</template>
</v-select>
/>
<!-- Color Picker -->
<div
@@ -125,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"
>
@@ -133,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
@@ -144,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>
@@ -156,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>
@@ -166,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>
@@ -182,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>
@@ -203,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: {
@@ -244,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

@@ -48,7 +48,7 @@
open-delay="200"
transition="slide-y-reverse-transition"
density="compact"
bottom
location="bottom"
content-class="text-caption"
>
<template #activator="{ props }">

View File

@@ -82,7 +82,7 @@
<BaseButton
v-if="canSubmit"
type="submit"
:disabled="submitDisabled"
:disabled="submitDisabled || loading"
@click="submitEvent"
>
{{ submitText }}

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",
};
});

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