Compare commits

..

203 Commits

Author SHA1 Message Date
Michael Genson
b7b191a5ee fix: Truncate Long Passwords (>72 bytes) (#6335) 2025-10-09 23:46:06 +00:00
Hayden
5620370ade chore(l10n): New Crowdin updates (#6320) 2025-10-09 16:30:31 +00:00
renovate[bot]
d333d47e34 chore(deps): update dependency ruff to ^0.14.0 (#6334)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-07 18:49:52 +00:00
Brian Choromanski
b34b1c9be3 feat: Added url to current version release (#6308) 2025-10-07 09:31:55 +00:00
Michael Genson
8c5010148d fix: Translate log-out string (#6332) 2025-10-06 17:14:51 -05:00
Michael Genson
a17b0e329e fix: No Redirect On Valid Token (#6327) 2025-10-06 13:02:25 -05:00
Arsène Reymond
8ab69a7d7a fix: Remove unused next-auth dependency (#6328) 2025-10-06 12:43:13 -05:00
github-actions[bot]
f4ecf74b91 chore(auto): Update pre-commit hooks (#6324)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-10-06 15:09:13 +00:00
renovate[bot]
ba9d816f64 chore(deps): update dependency pylint to v3.3.9 (#6321)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 09:57:17 -05:00
Michael Genson
6895b49543 fix: Re-write Nuxt auth backend and get rid of sidebase auth (#6322) 2025-10-05 20:43:38 -05:00
github-actions[bot]
fffe7b05e0 chore(l10n): Crowdin locale sync (#6318)
Co-authored-by: GitHub Action <action@github.com>
2025-10-05 02:56:21 +00:00
Hayden
1271e0e49b chore(l10n): New Crowdin updates (#6317) 2025-10-04 21:45:57 -05:00
Hayden
478054b724 chore(l10n): New Crowdin updates (#6313) 2025-10-04 11:30:21 -05:00
Hayden
57d259a7a3 chore(l10n): New Crowdin updates (#6309) 2025-10-04 21:34:09 +10:00
Hayden
a4a6d4dfb1 chore(l10n): New Crowdin updates (#6273) 2025-10-03 17:24:33 +00:00
github-actions[bot]
f7b4f79312 chore(l10n): Crowdin locale sync (#6268)
Co-authored-by: GitHub Action <action@github.com>
2025-10-03 17:03:14 +00:00
renovate[bot]
434d312f7c chore(deps): update dependency openai to v2.1.0 (#6302)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-03 11:50:06 -05:00
renovate[bot]
bda460b49e chore(deps): update dependency ruff to v0.13.3 (#6301)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-03 16:17:53 +00:00
renovate[bot]
d3e1c48655 chore(deps): update dependency authlib to v1.6.5 (#6299)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-03 11:05:13 -05:00
github-actions[bot]
b2a3430f2c docs(auto): Update image tag, for release v3.3.1 (#6300)
Co-authored-by: michael-genson <71845777+michael-genson@users.noreply.github.com>
2025-10-02 18:26:51 +00:00
renovate[bot]
3d792d9333 chore(deps): update dependency openai to v2.0.1 (#6296)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 17:22:38 +00:00
renovate[bot]
2e028d7e12 chore(deps): update node.js to 2bb201f (#6295)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 12:11:00 -05:00
Brian Choromanski
c63932e8b3 fix: Updated pwa orientation to any (#6298) 2025-10-01 20:51:15 -05:00
renovate[bot]
3ba2227bc7 chore(deps): update dependency mkdocs-material to v9.6.21 (#6293)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-01 04:45:41 +00:00
renovate[bot]
67af391c6b chore(deps): update dependency pillow-heif to v1.1.1 (#6291)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 23:33:14 -05:00
renovate[bot]
70ae0dac25 chore(deps): update node.js to d367fd3 (#6292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-01 03:19:44 +00:00
renovate[bot]
e15a9c3c9f chore(deps): update dependency apprise to v1.9.5 (#6290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 22:07:31 -05:00
renovate[bot]
9d40d60b3b fix(deps): update dependency openai to v2 (#6294)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 21:40:14 -05:00
renovate[bot]
e2760f7247 chore(deps): update dependency tzdata to v2025 (#6287) 2025-09-29 17:18:19 -05:00
Michael Genson
83bf21b947 fix: Restore recipe meta for non-logged-in users (#6286) 2025-09-29 10:33:18 -05:00
Michael Genson
d1824affff fix: Default to "0" qty when creating ingredients everywhere (#6285) 2025-09-29 10:19:37 -05:00
renovate[bot]
4827e1092f chore(deps): update dependency beautifulsoup4 to v4.14.2 (#6283)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 09:49:54 -05:00
github-actions[bot]
7db767b075 chore(auto): Update pre-commit hooks (#6282)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-09-29 14:36:01 +00:00
renovate[bot]
afdd0b15dc fix(deps): update dependency fastapi to ^0.118.0 (#6281)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 09:24:37 -05:00
Michael Genson
37c9166a77 docs: Update TOKEN_TIME docs to include max (#6279) 2025-09-28 22:05:15 -05:00
github-actions[bot]
ba0b9d4cd9 docs(auto): Update image tag, for release v3.3.0 (#6267)
Co-authored-by: michael-genson <71845777+michael-genson@users.noreply.github.com>
2025-09-28 01:13:15 +00:00
renovate[bot]
9fd99a86b8 chore(deps): update dependency beautifulsoup4 to v4.14.0 (#6260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-27 20:02:31 -05:00
Michael Genson
824603a578 fix: Stores Not Populating Sometimes (#6266) 2025-09-27 19:17:08 -05:00
Hayden
e3f120c680 chore(l10n): New Crowdin updates (#6264) 2025-09-27 16:23:01 -05:00
Michael Genson
d16a10440d chore: Add Stricter Frontend Formatting (#6262) 2025-09-27 13:57:53 -05:00
Michael Genson
ecdf7de386 chore: Upgrade Node and Nuxt (#6240) 2025-09-27 12:26:02 -05:00
Hayden
0e10ed8461 chore(l10n): New Crowdin updates (#6257) 2025-09-27 15:50:58 +02:00
Michael Genson
1684169e7b fix: Check for non-hid properties when injetcing SPA meta (#6256) 2025-09-26 16:07:13 -05:00
Hayden
3d9f2bef82 chore(l10n): New Crowdin updates (#6254) 2025-09-26 17:21:43 +00:00
Michael Genson
a722b05fb5 fix: Make Ingredient Parser Dialog Use Full Space (#6253) 2025-09-26 11:45:41 -05:00
Michael Genson
187e83eeb5 fix: Misc Issues with Ingredient Parser (#6250) 2025-09-26 11:25:15 -05:00
renovate[bot]
f3cc51190c fix(deps): update dependency bcrypt to v5 (#6246)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-26 15:05:42 +00:00
renovate[bot]
33aedd6904 chore(deps): update dependency pyyaml to v6.0.3 (#6245)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-26 09:52:20 -05:00
renovate[bot]
ea9a25a891 chore(deps): update dependency pydantic-settings to v2.11.0 (#6233)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-26 09:30:37 -05:00
Hayden
3a237258a1 chore(l10n): New Crowdin updates (#6241) 2025-09-26 06:00:25 +00:00
Michael Genson
d29de8e679 feat: Simplify Default Layout Logic and Add Household.name To Cookbooks API (#6243) 2025-09-25 18:01:10 -05:00
Michael Genson
79367872ac fix: Remove Double Cookie Refresh (#6242) 2025-09-25 14:55:07 -05:00
renovate[bot]
f058dec27b chore(deps): update dependency lxml to v6.0.2 (#6219)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-25 16:14:49 +00:00
renovate[bot]
c87acf54db chore(deps): update dependency coverage to v7.10.7 (#6216)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-25 16:02:08 +00:00
renovate[bot]
84c144e40f fix(deps): update dependency fastapi to ^0.117.0 (#6205)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-25 15:49:22 +00:00
renovate[bot]
474cf299cd fix(deps): update dependency uvicorn to ^0.37.0 (#6200)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-25 15:32:35 +00:00
renovate[bot]
1cababc5a5 chore(deps): update dependency ruff to v0.13.2 (#6239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-25 15:14:28 +00:00
renovate[bot]
8705bcf195 chore(deps): update dependency openai to v1.109.1 (#6196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-25 14:59:21 +00:00
Hayden
bdb511c1c8 chore(l10n): New Crowdin updates (#6237) 2025-09-25 09:44:03 -05:00
Carter
c9f3f65f36 fix: Remove constraint on unhashed password being 'LDAP' (#6236) 2025-09-24 23:32:28 -05:00
Hayden
3ec55f0e48 chore(l10n): New Crowdin updates (#6234) 2025-09-24 16:33:34 +00:00
Patrick Lehner (he/him)
7d43c7c7a2 docs: Improve formatting in 'Automating Backups with n8n' community guide (#6221)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-09-24 15:16:56 +00:00
Michael Genson
c710e9d3f5 fix: Enabled Using Mealie In iframe If Served Over HTTPS (#6128) 2025-09-24 09:58:17 -05:00
Hayden
0313e6b3b8 chore(l10n): New Crowdin updates (#6231) 2025-09-24 08:38:03 +02:00
Michael Genson
24b890136d fix: Workflow Issues with Deleting Ingredient In Parser (#6230) 2025-09-23 17:36:49 -05:00
Patrick Lehner (he/him)
4b67554b36 docs: Update navigation instructions for (admin) settings pages (#6220) 2025-09-23 22:14:19 +00:00
Michael Genson
679a42a7cc feat: Ingredient Parser Enhancements (#6228) 2025-09-23 17:03:35 -05:00
Hayden
4dfc32a314 chore(l10n): New Crowdin updates (#6225) 2025-09-23 21:06:34 +00:00
Michael Genson
96acc6fc4b fix: Remove explicit timeout from OpenAI image API Call (#6227) 2025-09-23 12:39:23 -05:00
Hayden
249c9e8f23 chore(l10n): New Crowdin updates (#6224) 2025-09-23 03:18:17 +00:00
Hayden
7413185300 chore(l10n): New Crowdin updates (#6218) 2025-09-22 15:35:33 +00:00
github-actions[bot]
6168ea0150 chore(auto): Update pre-commit hooks (#6222)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-09-22 15:21:36 +00:00
Patrick Lehner (he/him)
f7ba7862d4 docs: Fix formatting in some community guides (#6223) 2025-09-22 10:11:15 -05:00
Michael Genson
cec6d2c5ec fix: Actually Fix Token Time (#6215) 2025-09-21 19:51:19 -05:00
renovate[bot]
b27977fbdf chore(deps): update dependency mypy to v1.18.2 (#6193)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-21 16:14:29 +00:00
github-actions[bot]
2a60b330ac chore(l10n): Crowdin locale sync (#6206)
Co-authored-by: GitHub Action <action@github.com>
2025-09-21 16:03:16 +00:00
Hayden
72ec5bd13e chore(l10n): New Crowdin updates (#6213) 2025-09-21 10:53:10 -05:00
Hayden
bb45cbb0a2 chore(l10n): New Crowdin updates (#6176) 2025-09-21 05:12:32 +00:00
Michael Genson
c929a03b57 feat: Upgraded Ingredient Parsing Workflow (#6151) 2025-09-21 04:37:14 +00:00
Michael Genson
9e5a54477f docs: Add Info Regarding Theme Settings Config (#6198) 2025-09-20 12:58:59 -05:00
Arsène Reymond
078b4563b3 fix: multiple RecipeRating backend calls (#6194)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-09-20 15:27:17 +00:00
Michael Genson
a9090bc2bd feat: Manually calculate OpenAI Parsing Confidence (#6141) 2025-09-19 23:09:34 -05:00
renovate[bot]
cb8c1423c5 chore(deps): update dependency ruff to v0.13.1 (#6191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-19 16:35:33 -05:00
Arsène Reymond
f6a1b5f4eb fix: ingredient linker and instructions titles (#6146)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-09-19 17:38:29 +00:00
Michael Genson
7623b72c4c fix: Print Button Does Nothing (#6178) 2025-09-18 12:48:36 -05:00
renovate[bot]
17d40e34df fix(deps): update dependency openai to v1.108.0 (#6185)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-17 22:14:45 -05:00
renovate[bot]
bade6968a3 fix(deps): update dependency authlib to v1.6.4 (#6182)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-17 14:50:42 -05:00
renovate[bot]
92a142125f fix(deps): update dependency fastapi to v0.116.2 (#6181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-17 00:19:17 +00:00
renovate[bot]
d39c2a2874 chore(deps): update dependency mkdocs-material to v9.6.20 (#6179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-17 00:07:10 +00:00
renovate[bot]
324de7fb10 chore(deps): update dependency pytest-asyncio to v1.2.0 (#6162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 23:55:05 +00:00
renovate[bot]
c4544ea042 chore(deps): update dependency mypy to v1.18.1 (#6161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 18:41:31 -05:00
renovate[bot]
a5dda74812 fix(deps): update dependency pydantic to v2.11.9 (#6159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 23:15:24 +00:00
renovate[bot]
fd7e58e40c fix(deps): update dependency openai to v1.107.3 (#6147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 23:01:40 +00:00
renovate[bot]
5e42841a7d chore(deps): update node.js to abcf9c9 (#6138)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 22:45:31 +00:00
github-actions[bot]
ae9306b8c2 chore(auto): Update pre-commit hooks (#6174)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-09-16 22:34:13 +00:00
renovate[bot]
7f0c5cbcc4 chore(deps): update dependency ruff to ^0.13.0 (#6148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 17:23:32 -05:00
github-actions[bot]
a7d8bcc6ba docs(auto): Update image tag, for release v3.2.1 (#6172)
Co-authored-by: michael-genson <71845777+michael-genson@users.noreply.github.com>
2025-09-15 19:45:07 +00:00
Hayden
b94ef78a12 chore(l10n): New Crowdin updates (#6145) 2025-09-15 04:19:55 +00:00
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
334 changed files with 22154 additions and 20460 deletions

View File

@@ -11,7 +11,7 @@
// Use -bullseye variants on local on arm64/Apple Silicon.
"VARIANT": "3.12-bullseye",
// Options
"NODE_VERSION": "20"
"NODE_VERSION": "22"
}
},
"mounts": [

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup node env 🏗
uses: actions/setup-node@v4.0.0
with:
node-version: 20
node-version: 22
check-latest: true
- name: Get yarn cache directory path 🛠

View File

@@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: 'yarn'
cache-dependency-path: ./tests/e2e/yarn.lock
- name: Set up Docker Buildx

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

@@ -77,7 +77,7 @@ jobs:
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/sqlite.md
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/postgres.md
sed -i 's/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' pyproject.toml
sed -i 's/^\s*"version": "[^"]*"/"version": "${{ env.VERSION_NUM }}"/' frontend/package.json
sed -i 's/\("version": "\)[^"]*"/\1${{ env.VERSION_NUM }}"/' frontend/package.json
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6

View File

@@ -14,7 +14,7 @@ jobs:
- name: Setup node env 🏗
uses: actions/setup-node@v4.0.0
with:
node-version: 20
node-version: 22
check-latest: true
- name: Get yarn cache directory path 🛠

View File

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

View File

@@ -59,8 +59,11 @@
"netlify.toml": "runtime.txt",
"README.md": "LICENSE, SECURITY.md"
},
"[typescript]": {
"editor.formatOnSave": true
},
"[vue]": {
"editor.formatOnSave": false
"editor.formatOnSave": true
},
"[python]": {
"editor.formatOnSave": true,

View File

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

View File

@@ -1,3 +1,4 @@
import subprocess
from dataclasses import dataclass
from pathlib import Path
@@ -105,12 +106,16 @@ def main():
# Flatten list of lists
all_children = [item for sublist in all_children for item in sublist]
out_path = GENERATED / "__init__.py"
render_python_template(
TEMPLATE,
GENERATED / "__init__.py",
out_path,
{"children": all_children},
)
subprocess.run(["poetry", "run", "ruff", "check", str(out_path), "--fix"])
subprocess.run(["poetry", "run", "ruff", "format", str(out_path)])
if __name__ == "__main__":
main()

View File

@@ -1,5 +1,6 @@
import pathlib
import re
import subprocess
from dataclasses import dataclass, field
from utils import PROJECT_DIR, log, render_python_template
@@ -84,16 +85,23 @@ def find_modules(root: pathlib.Path) -> list[Modules]:
return modules
def main():
def main() -> None:
modules = find_modules(SCHEMA_PATH)
template_paths: list[pathlib.Path] = []
for module in modules:
log.debug(f"Module: {module.directory.name}")
for file in module.files:
log.debug(f" File: {file.import_path}")
log.debug(f" Classes: [{', '.join(file.classes)}]")
render_python_template(template, module.directory / "__init__.py", {"module": module})
template_path = module.directory / "__init__.py"
template_paths.append(template_path)
render_python_template(template, template_path, {"module": module})
path_args = (str(p) for p in template_paths)
subprocess.run(["poetry", "run", "ruff", "check", *path_args, "--fix"])
subprocess.run(["poetry", "run", "ruff", "format", *path_args])
if __name__ == "__main__":

View File

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

View File

@@ -1,4 +1,5 @@
import re
import subprocess
from pathlib import Path
from jinja2 import Template
@@ -189,6 +190,7 @@ def generate_typescript_types() -> None: # noqa: C901
skipped_dirs: list[Path] = []
failed_modules: list[Path] = []
out_paths: list[Path] = []
for module in schema_path.iterdir():
if module.is_dir() and module.stem in ignore_dirs:
skipped_dirs.append(module)
@@ -205,10 +207,18 @@ def generate_typescript_types() -> None: # noqa: C901
path_as_module = path_to_module(module)
generate_typescript_defs(path_as_module, str(out_path), exclude=("MealieModel")) # type: ignore
clean_output_file(out_path)
out_paths.append(out_path)
except Exception:
failed_modules.append(module)
log.exception(f"Module Error: {module}")
# Run ESLint --fix on the files to clean up any formatting issues
subprocess.run(
["yarn", "lint", "--fix", *(str(path) for path in out_paths)],
check=True,
cwd=PROJECT_DIR / "frontend",
)
log.debug("\n📁 Skipped Directories:")
for skipped_dir in skipped_dirs:
log.debug(f" 📁 {skipped_dir.name}")

View File

@@ -1,5 +1,4 @@
import logging
import subprocess
from dataclasses import dataclass
from pathlib import Path
@@ -23,11 +22,6 @@ def render_python_template(template_file: Path | str, dest: Path, data: dict):
dest.write_text(text)
# lint/format file with Ruff
log.info(f"Formatting {dest}")
subprocess.run(["poetry", "run", "ruff", "check", str(dest), "--fix"])
subprocess.run(["poetry", "run", "ruff", "format", str(dest)])
@dataclass
class CodeSlicer:
@@ -37,7 +31,7 @@ class CodeSlicer:
indentation: str | None
text: list[str]
_next_line = None
_next_line: int | None = None
def purge_lines(self) -> None:
start = self.start + 1

View File

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

View File

@@ -34,7 +34,7 @@ Make sure the VSCode Dev Containers extension is installed, then select "Dev Con
- [Python 3.12](https://www.python.org/downloads/)
- [Poetry](https://python-poetry.org/docs/#installation)
- [Node v16.x](https://nodejs.org/en/)
- [Node](https://nodejs.org/en/)
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)
- [task](https://taskfile.dev/#/installation)
@@ -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

@@ -1,5 +1,5 @@
!!! info
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
Mealie supports adding the ingredients of a recipe to your [Bring](https://www.getbring.com/) shopping list, as you can
see [here](https://docs.mealie.io/documentation/getting-started/features/#recipe-actions).

View File

@@ -1,21 +1,21 @@
!!! info
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
In a lot of ways, Home Assistant is why this project exists! Since Mealie has a robust API it makes it a great fit for interacting with Home Assistant and pulling information into your dashboard.
### Display Today's Meal in Lovelace
## Display Today's Meal in Lovelace
You can use the Mealie API to get access to meal plans in Home Assistant like in the image below.
![api-extras-gif](../../assets/img/home-assistant-card.png)
Steps:
## Steps:
#### 1. Get your API Token
### 1. Get your API Token
Create an API token from Mealie's User Settings page (https://docs.mealie.io/documentation/getting-started/api-usage/#getting-a-token)
Create an API token from Mealie's User Settings page (see [this page](https://docs.mealie.io/documentation/getting-started/api-usage/#getting-a-token) to learn how).
#### 2. Create Home Assistant Sensors
### 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.
@@ -40,7 +40,7 @@ rest:
unique_id: mealie_todays_meal_id
```
#### 3. Create a Camera Entity
### 3. Create a Camera Entity
We will create a camera entity to display the image of today's meal in Lovelace.
@@ -52,7 +52,7 @@ In the still image url field put in:
Under the entity page for the new camera, rename it.
e.g. `camera.mealie_todays_meal_image`
#### 4. Create a Lovelace Card
### 4. Create a Lovelace Card
Create a picture entity card and set the entity to `mealie_todays_meal` and the camera entity to `camera.mealie_todays_meal_image` or set in the yaml directly.
@@ -76,4 +76,4 @@ card_mod:
```
!!! tip
Due to how Home Assistant works with images, I had to include the additional styling to get the images to not appear distorted. This requires an [additional installation](https://github.com/thomasloven/lovelace-card-mod) from HACS.
Due to how Home Assistant works with images, I had to include the additional styling to get the images to not appear distorted. This requires an [additional installation](https://github.com/thomasloven/lovelace-card-mod) from HACS.

View File

@@ -12,12 +12,10 @@ var url = document.URL.endsWith('/') ?
document.URL;
var mealie = "http://localhost:8080";
var group_slug = "home" // Change this to your group slug. You can obtain this from your URL after logging-in to Mealie
var use_keywords= "&use_keywords=1" // Optional - use keywords from recipe - update to "" if you don't want that
var edity = "&edit=1" // Optional - keep in edit mode - update to "" if you don't want that
if (mealie.slice(-1) === "/") {
mealie = mealie.slice(0, -1)
}
var dest = mealie + "/g/" + group_slug + "/r/create/url?recipe_import_url=" + url + use_keywords + edity;
var dest = mealie + "/g/" + group_slug + "/r/create/url?recipe_import_url=" + url;
window.open(dest, "_blank");
```

View File

@@ -1,9 +1,10 @@
!!! info
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
An easy way to add recipes to Mealie from an Apple device is via an Apple Shortcut. This is a short guide to install an configure a shortcut able to add recipes via a link or image(s).
*Note: if adding via images make sure to enable [Mealie's openai integration](https://docs.mealie.io/documentation/getting-started/installation/open-ai/)*
!!! note
If adding via images make sure to enable [Mealie's OpenAI Integration](https://docs.mealie.io/documentation/getting-started/installation/open-ai/)
## Javascript can only be run via Shortcuts on the Safari browser on MacOS and iOS. If you do not use Safari you may skip this section
Some sites have begun blocking AI scraping bots, inadvertently blocking the recipe scraping library Mealie uses as well. To circumvent this, the shortcut uses javascript to capture the raw html loaded in the browser and sends that to mealie when possible.
@@ -16,12 +17,13 @@ Settings app -> apps -> Shortcuts -> Advanced -> Allow Running Scripts
Shortcuts app -> Settings (CMD ,) -> Advanced -> Allow Running Scripts
## Initial setup
## Initial Setup
An API key is needed to authenticate with mealie. To create an api key for a user, navigate to http://YOUR_MEALIE_URL/user/profile/api-tokens. Alternatively you can create a key via the mealie home page by clicking the user's profile pic in the top left -> Api Tokens
The shortcut can be installed via **[This link](https://www.icloud.com/shortcuts/52834724050b42aebe0f2efd8d067360)**. Upon install, replace "MEALIE_API_KEY" with the API key generated previously and "MEALIE_URI" with the full URL used to access your mealie instance e.g. "http://10.0.0.5:9000" or "https://mealie.domain.com".
## Using the shortcut
## Using the Shortcut
Once installed, the shortcut will automatically appear as an option when sharing an image or webpage. It can also be useful to add the shortcut to the home screen of your device. If selected from the home screen or shortcuts app, a menu will appear with prompts to import via **taking photo(s)**, **selecting photo(s)**, **scanning a URL**, or **pasting a URL**.
*Note: despite the mealie API being able to accept multiple recipe images for import it is currently impossible to send multiple files in 1 web request via Shortcuts. Instead, the shortcut combines the images into a singular, vertically-concatenated image to send to mealie. This can result in slightly less-accurate text recognition.*
!!! note
Despite the Mealie API being able to accept multiple recipe images for import it is currently impossible to send multiple files in 1 web request via Shortcuts. Instead, the shortcut combines the images into a singular, vertically-concatenated image to send to mealie. This can result in slightly less-accurate text recognition.

View File

@@ -1,71 +1,77 @@
# Automating Backups with n8n
!!! info
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
> [n8n](https://github.com/n8n-io/n8n) is a free and source-available fair-code licensed workflow automation tool. Alternative to Zapier or Make, allowing you to use a UI to create automated workflows.
[n8n](https://github.com/n8n-io/n8n) is a free and source-available fair-code licensed workflow automation tool. It's an alternative to tools like Zapier or Make, allowing you to use a UI to create automated workflows.
This example workflow:
1. Backups Mealie every morning via an API call
2. Deletes all but the last 7 backups
1. Creates a Mealie backup every morning via an API call
2. Keeps the last 7 backups, deleting older ones
> [!CAUTION]
> This only automates the backup function, this does not backup your data to anywhere except your local instance. Please make sure you are backing up your data to an external source.
!!! warning "Important"
This only automates the backup function, this does not backup your data to anywhere except your local instance. Please make sure you are backing up your data to an external source.
---
![screenshot](../../assets/img/n8n/n8n-mealie-backup.png)
# Setup
## Setup
## Deploying n8n
### Deploying n8n
Follow the relevant guide in the [n8n Documentation](https://docs.n8n.io/)
## Importing n8n workflow
### Importing n8n workflow
1. In n8n, add a new workflow
2. In the top right hit the 3 dot menu and select 'Import from URL...'
![screenshot](../../assets/img/n8n/n8n-workflow-import.png)
![screenshot](../../assets/img/n8n/n8n-workflow-import.png)
3. Paste `https://github.com/mealie-recipes/mealie/blob/mealie-next/docs/docs/assets/other/n8n/n8n-mealie-backup.json` and click Import
3. Paste `https://github.com/mealie-recipes/mealie/blob/mealie-next/docs/docs/assets/other/n8n/n8n-mealie-backup.json` and click 'Import'
4. Click through the nodes and update the URLs for your environment
## API Credentials
### API Credentials
#### Generate Mealie API Token
1. Head to https://mealie.example.com/user/profile/api-tokens
> If you dont see this screen make sure that "Show advanced features" is checked under https://mealie.example.com/user/profile/edit
2. Under token name, enter the name of the token i.e. 'n8n' and hit Generate
1. Head to `<YOUR MEALIE INSTANCE>/user/profile/api-tokens`
!!! tip
If you dont see this screen make sure that "Show advanced features" is checked under `<YOUR MEALIE INSTANCE>/user/profile/edit`
2. Under token name, enter the name of the token (for example, 'n8n') and hit 'Generate'
3. Copy and keep this API Token somewhere safe, this is like your password!
> You can use your normal user for this, but assuming you're an admin you could also choose to create a user named n8n and generate the API key against that user.
!!! tip
You can use your normal user for this, but assuming you're an admin you could also choose to create a user named n8n and generate the API key against that user.
#### Setup Credentials in n8n
> [n8n Docs](https://docs.n8n.io/credentials/add-edit-credentials/)
See also [n8n Docs](https://docs.n8n.io/credentials/add-edit-credentials/).
1. Create a new "Header Auth" Credential
![screenshot](../../assets/img/n8n/n8n-cred-app.png)
![screenshot](../../assets/img/n8n/n8n-cred-app.png)
2. In the connection screen set - Name as `Authorization` - Value as `Bearer {INSERT MEALIE API KEY}`
![screenshot](../../assets/img/n8n/n8n-cred-connection.png)
![screenshot](../../assets/img/n8n/n8n-cred-connection.png)
3. In the workflow you created, for the "Run Backup", "Get All backups", and "Delete Oldies" nodes, update:
- Authentication to `Generic Credential Type`
- Generic Auth Type to `Header Auth`
- Header Auth to `Mealie API` or whatever you named your credentials
![screenshot](../../assets/img/n8n/n8n-workflow-auth.png)
- Authentication to `Generic Credential Type`
- Generic Auth Type to `Header Auth`
- Header Auth to `Mealie API` or whatever you named your credentials
## Notification Node
![screenshot](../../assets/img/n8n/n8n-workflow-auth.png)
> Please use error notifications of some kind. It's very easy to set and forget an automation, then have the worst happen and lose data.
### Notification Node
!!! warning "Important"
Please use error notifications of some kind. It's very easy to set and forget an automation, then have the worst happen and lose data.
[ntfy](https://github.com/binwiederhier/ntfy) is a great open source, self-hostable tool for sending notifications.

View File

@@ -1,11 +1,10 @@
# Using SWAG as Reverse Proxy
!!! info
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
To make the setup of a Reverse Proxy much easier, Linuxserver.io developed [SWAG](https://github.com/linuxserver/docker-swag).
To make the setup of a Reverse Proxy much easier, Linuxserver.io developed [SWAG](https://github.com/linuxserver/docker-swag)
SWAG - Secure Web Application Gateway (formerly known as letsencrypt, no relation to Let's Encrypt™) sets up an Nginx web server and reverse proxy with PHP support and a built-in certbot client that automates free SSL server certificate generation and renewal processes (Let's Encrypt and ZeroSSL). It also contains fail2ban for intrusion prevention.
## Step 1: Get a domain

View File

@@ -72,7 +72,7 @@
Mealie allows you to link ingredients to specific steps in a recipe, ensuring you know exactly when to add each ingredient during the cooking process.
**Link Ingredients to Steps in a Recipe**
1. Go to a recipe
2. Click the Edit button/icon
3. Scroll down to the step you want to link ingredients to
@@ -82,7 +82,7 @@
7. Click 'Save' on the Recipe
You can optionally link the same ingredient to multiple steps, which is useful for prepping an ingredient in one step and using it in another.
??? question "What is fuzzy search and how do I use it?"
### What is fuzzy search and how do I use it?
@@ -111,7 +111,7 @@
You can change the theme by settings the environment variables.
- [Backend Config - Themeing](./installation/backend-config.md#themeing)
- [Backend Config - Theming](./installation/backend-config.md#theming)
??? question "How can I change the login session timeout?"
@@ -233,7 +233,7 @@
### How can I use Mealie externally
Exposing Mealie or any service to the internet can pose significant security risks. Before proceeding, carefully evaluate the potential impacts on your system. Due to the unique nature of each network, we cannot provide specific steps for your setup.
Exposing Mealie or any service to the internet can pose significant security risks. Before proceeding, carefully evaluate the potential impacts on your system. Due to the unique nature of each network, we cannot provide specific steps for your setup.
There is a community guide available for one way to potentially set this up, and you could reach out on Discord for further discussion on what may be best for your network.
@@ -267,7 +267,7 @@
### Why setup Email?
Mealie uses email to send account invites and password resets. If you don't use these features, you don't need to set up email. There are also other methods to perform these actions that do not require the setup of Email.
Mealie uses email to send account invites and password resets. If you don't use these features, you don't need to set up email. There are also other methods to perform these actions that do not require the setup of Email.
Email settings can be adjusted via environment variables on the backend container:

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

@@ -11,7 +11,7 @@
| DEFAULT_GROUP | Home | The default group for users |
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
| BASE_URL | http://localhost:8080 | Used for Notifications |
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid |
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid. Must be <= 87600 (10 years, in hours). |
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
| API_DOCS | True | Turns on/off access to the API documentation locally |
| TZ | UTC | Must be set to get correct date/time on the server |
@@ -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,12 +132,19 @@ 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
Setting the following environmental variables will change the theme of the frontend. Note that the themes are the same for all users. This is a break-change when migration from v0.x.x -> 1.x.x.
!!! info
If you're setting these variables but not seeing these changes persist, try removing the `#` character. Also, depending on which syntax you're using, double-check you're using quotes correctly.
If using YAML mapping syntax, be sure to include quotes around these values, otherwise they will be treated as comments in your YAML file:<br>`THEME_LIGHT_PRIMARY: '#E58325'` or `THEME_LIGHT_PRIMARY: 'E58325'`
If using YAML sequence syntax, don't include any quotes:<br>`THEME_LIGHT_PRIMARY=#E58325` or `THEME_LIGHT_PRIMARY=E58325`
| Variables | Default | Description |
| --------------------- | :-----: | --------------------------- |
| THEME_LIGHT_PRIMARY | #E58325 | Light Theme Config Variable |

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
# Backups and Restores
Mealie provides an integrated mechanic for doing full installation backups of the database.
Mealie provides an integrated mechanic for doing full installation backups of the database.
Navigate to Settings > Backups or manually by adding `/admin/backups` to your instance URL.
Navigate to Settings > Admin Settings > Backups or manually by adding `/admin/backups` to your instance URL.
From this page, you will be able to:
From this page, you will be able to:
- See a list of available backups
- Create a backup
@@ -39,7 +39,7 @@ Restoring the Database when using Postgres requires Mealie to be configured with
```sql
ALTER USER mealie WITH SUPERUSER;
# Run restore from Mealie
-- Run restore from Mealie
ALTER USER mealie WITH NOSUPERUSER;
```

View File

@@ -1,6 +1,7 @@
# Permissions and Public Access
Mealie provides various levels of user access and permissions. This includes:
- Authentication and registration ([LDAP](../authentication/ldap.md) and [OpenID Connect](../authentication/oidc.md) are both supported)
- Customizable user permissions
- Fine-tuned public access for non-users
@@ -8,12 +9,12 @@ Mealie provides various levels of user access and permissions. This includes:
## Customizable User Permissions
Each user can be configured to have varying levels of access. Some of these permissions include:
- Access to Administrator tools
- Access to inviting other users
- Access to manage their group and group data
Administrators can navigate to the Settings page and access the User Management page to configure these settings.
Administrators can configure these settings on the User Management page (navigate to Settings > Admin Settings > Users or append `/admin/manage/users` to your instance URL).
[User Management Demo](https://demo.mealie.io/admin/manage/users){ .md-button .md-button--primary }
@@ -22,8 +23,8 @@ Administrators can navigate to the Settings page and access the User Management
By default, groups and households are set to private, meaning only logged-in users may access the group/household. In order for a recipe to be viewable by public (not logged-in) users, three criteria must be met:
1. The group must not be private
2. The household must not be private, *and* the household setting for allowing users outside of your group to see your recipes must be enabled. These can be toggled on the Household Settings page
2. The recipe must be set to public. This can be toggled for each recipe individually, or in bulk using the Recipe Data Management page
2. The household must not be private, _and_ the household setting for allowing users outside of your group to see your recipes must be enabled. These can be toggled on the Household Management page (navigate to Settings > Admin Settings > Households or append `/admin/manage/households` to your instance URL)
3. The recipe must be set to public. This can be toggled for each recipe individually, or in bulk using the Recipe Data Management page
Additionally, if the group is not private, public users can view all public group data (public recipes, public cookbooks, etc.) from the home page ([e.g. the demo home page](https://demo.mealie.io/g/home)).

View File

File diff suppressed because one or more lines are too long

View File

@@ -86,7 +86,7 @@ nav:
- Community Guides:
- Bring API without internet exposure: "documentation/community-guide/bring-api.md"
- Automate Backups with n8n: "documentation/community-guide/n8n-backup-automation.md"
- Automating Backups with n8n: "documentation/community-guide/n8n-backup-automation.md"
- Bulk Url Import: "documentation/community-guide/bulk-url-import.md"
- Home Assistant: "documentation/community-guide/home-assistant.md"
- Import Bookmarklet: "documentation/community-guide/import-recipe-bookmarklet.md"

View File

@@ -37,7 +37,7 @@
}
.handle {
cursor: grab;
cursor: grab !important;
}
.hidden {

View File

@@ -32,9 +32,9 @@
>
<div class="d-flex align-center w-100 mb-2">
<v-toolbar-title class="headline mb-0">
<v-icon size="large" class="mr-3">
{{ $globals.icons.pages }}
</v-icon>
<v-icon size="large" class="mr-3">
{{ $globals.icons.pages }}
</v-icon>
{{ book.name }}
</v-toolbar-title>
<BaseButton

View File

@@ -1,44 +1,44 @@
<template>
<div v-if="preferences">
<BaseCardSectionTitle :title="$t('household.household-preferences')" />
<div class="mb-6">
<v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
<div class="ml-8">
<p class="text-subtitle-2 my-0 py-0">
{{ $t("household.private-household-description") }}
</p>
<DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
</div>
</div>
<div class="mb-6">
<v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
<div class="ml-8">
<p class="text-subtitle-2 my-0 py-0">
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
</p>
</div>
</div>
<v-select
v-model="preferences.firstDayOfWeek"
:prepend-icon="$globals.icons.calendarWeekBegin"
:items="allDays"
item-title="name"
item-value="value"
:label="$t('settings.first-day-of-week')"
variant="underlined"
flat
/>
<div v-if="preferences">
<BaseCardSectionTitle :title="$t('household.household-preferences')" />
<div class="mb-6">
<v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
<div class="ml-8">
<p class="text-subtitle-2 my-0 py-0">
{{ $t("household.private-household-description") }}
</p>
<DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
</div>
</div>
<div class="mb-6">
<v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
<div class="ml-8">
<p class="text-subtitle-2 my-0 py-0">
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
</p>
</div>
</div>
<v-select
v-model="preferences.firstDayOfWeek"
:prepend-icon="$globals.icons.calendarWeekBegin"
:items="allDays"
item-title="name"
item-value="value"
:label="$t('settings.first-day-of-week')"
variant="underlined"
flat
/>
<BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')" />
<div class="preference-container">
<div v-for="p in recipePreferences" :key="p.key">
<v-checkbox v-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
<p class="ml-8 text-subtitle-2 my-0 py-0">
{{ p.description }}
</p>
</div>
</div>
</div>
<BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')" />
<div class="preference-container">
<div v-for="p in recipePreferences" :key="p.key">
<v-checkbox v-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
<p class="ml-8 text-subtitle-2 my-0 py-0">
{{ p.description }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">

View File

@@ -432,9 +432,9 @@ function removeField(index: number) {
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
/* newFields.forEach((field, index) => {
const updatedField = getFieldFromFieldDef(field);
fields.value[index] = updatedField; // recursive!!!
}); */
const updatedField = getFieldFromFieldDef(field);
fields.value[index] = updatedField; // recursive!!!
}); */
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
if (qf) {

View File

@@ -5,8 +5,14 @@
density="compact"
elevation="0"
>
<BaseDialog v-model="deleteDialog" :title="$t('recipe.delete-recipe')" color="error"
:icon="$globals.icons.alertCircle" can-confirm @confirm="emitDelete()">
<BaseDialog
v-model="deleteDialog"
:title="$t('recipe.delete-recipe')"
color="error"
:icon="$globals.icons.alertCircle"
can-confirm
@confirm="emitDelete()"
>
<v-card-text>
{{ $t("recipe.delete-confirmation") }}
</v-card-text>
@@ -15,7 +21,14 @@
<v-spacer />
<div v-if="!open" class="custom-btn-group ma-1">
<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!" />
<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" location="bottom" color="info">
<template #activator="{ props: tooltipProps }">
@@ -87,7 +100,7 @@
</template>
<script setup lang="ts">
import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeContextMenu from "./RecipeContextMenu/RecipeContextMenu.vue";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
import type { Recipe } from "~/lib/api/types/recipe";

View File

@@ -1,111 +1,109 @@
<template>
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
<div>
<v-hover
v-slot="{ isHovering, props: hoverProps }"
:open-delay="50"
<div>
<v-hover
v-slot="{ isHovering, props: hoverProps }"
:open-delay="50"
>
<v-card
v-bind="hoverProps"
:class="{ 'on-hover': isHovering }"
:style="{ cursor }"
:elevation="isHovering ? 12 : 2"
:to="recipeRoute"
:min-height="imageHeight + 75"
@click.self="$emit('click')"
>
<v-card
v-bind="hoverProps"
:class="{ 'on-hover': isHovering }"
:style="{ cursor }"
:elevation="isHovering ? 12 : 2"
:to="recipeRoute"
:min-height="imageHeight + 75"
@click.self="$emit('click')"
<RecipeCardImage
:icon-size="imageHeight"
:height="imageHeight"
:slug="slug"
:recipe-id="recipeId"
size="small"
:image-version="image"
>
<RecipeCardImage
:icon-size="imageHeight"
:height="imageHeight"
:slug="slug"
:recipe-id="recipeId"
size="small"
:image-version="image"
>
<v-expand-transition v-if="description">
<div
v-if="isHovering"
class="d-flex transition-fast-in-fast-out bg-secondary v-card--reveal"
style="height: 100%"
>
<v-card-text class="v-card--text-show white--text">
<div class="descriptionWrapper">
<SafeMarkdown :source="description" />
</div>
</v-card-text>
</div>
</v-expand-transition>
</RecipeCardImage>
<v-card-title class="mb-n3 px-4">
<div class="headerClass">
{{ name }}
</div>
</v-card-title>
<slot name="actions">
<v-card-actions
v-if="showRecipeContent"
class="px-1"
<v-expand-transition v-if="description">
<div
v-if="isHovering"
class="d-flex transition-fast-in-fast-out bg-secondary v-card--reveal"
style="height: 100%"
>
<RecipeFavoriteBadge
v-if="isOwnGroup"
class="absolute"
:recipe-id="recipeId"
show-always
/>
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
<v-card-text class="v-card--text-show white--text">
<div class="descriptionWrapper">
<SafeMarkdown :source="description" />
</div>
</v-card-text>
</div>
</v-expand-transition>
</RecipeCardImage>
<v-card-title class="mb-n3 px-4">
<div class="headerClass">
{{ name }}
</div>
</v-card-title>
<RecipeRating
class="ml-n2"
:model-value="rating"
:recipe-id="recipeId"
:slug="slug"
small
/>
<v-spacer />
<RecipeChips
:truncate="true"
:items="tags"
:title="false"
:limit="2"
small
url-prefix="tags"
v-bind="$attrs"
/>
<slot name="actions">
<v-card-actions
v-if="showRecipeContent"
class="px-1"
>
<RecipeFavoriteBadge
v-if="isOwnGroup"
class="absolute"
:recipe-id="recipeId"
show-always
/>
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
<!-- If we're not logged-in, no items display, so we hide this menu -->
<RecipeContextMenu
v-if="isOwnGroup"
color="grey-darken-2"
:slug="slug"
:name="name"
:recipe-id="recipeId"
:use-items="{
delete: false,
edit: false,
download: true,
mealplanner: true,
shoppingList: true,
print: false,
printPreferences: false,
share: true,
}"
@delete="$emit('delete', slug)"
/>
</v-card-actions>
</slot>
<slot />
</v-card>
</v-hover>
</div>
<RecipeCardRating
:model-value="rating"
:recipe-id="recipeId"
/>
<v-spacer />
<RecipeChips
:truncate="true"
:items="tags"
:title="false"
:limit="2"
small
url-prefix="tags"
v-bind="$attrs"
/>
<!-- If we're not logged-in, no items display, so we hide this menu -->
<RecipeContextMenu
v-if="isOwnGroup && showRecipeContent"
color="grey-darken-2"
:slug="slug"
:menu-icon="$globals.icons.dotsVertical"
:name="name"
:recipe-id="recipeId"
:use-items="{
delete: false,
edit: false,
download: true,
mealplanner: true,
shoppingList: true,
print: false,
printPreferences: false,
share: true,
}"
@deleted="$emit('delete', slug)"
/>
</v-card-actions>
</slot>
<slot />
</v-card>
</v-hover>
</div>
</template>
<script setup lang="ts">
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeChips from "./RecipeChips.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeContextMenu from "./RecipeContextMenu/RecipeContextMenu.vue";
import RecipeCardImage from "./RecipeCardImage.vue";
import RecipeRating from "./RecipeRating.vue";
import RecipeCardRating from "./RecipeCardRating.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
interface Props {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,145 @@
<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"
@print="$emit('print')"
@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;
print: [];
deleted: [slug: string];
}>();
const { $globals } = useNuxtApp();
const isMenuContentLoaded = ref(false);
const icon = computed(() => {
return props.menuIcon || $globals.icons.dotsVertical;
});
// Props to pass to the content component (excluding internal wrapper props)
const contentProps = computed(() => {
const { ...rest } = props;
return rest;
});
function onHover() {
if (!isMenuContentLoaded.value) {
isMenuContentLoaded.value = true;
}
}
function onMenuToggle(isOpen: boolean) {
if (isOpen && !isMenuContentLoaded.value) {
isMenuContentLoaded.value = true;
}
}
const RecipeContextMenuContent = defineAsyncComponent(
() => import("./RecipeContextMenuContent.vue"),
);
</script>

View File

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

View File

@@ -1,8 +1,15 @@
<template>
<div>
<BaseDialog v-model="dialog" :title="$t('data-pages.manage-aliases')" :icon="$globals.icons.edit"
:submit-icon="$globals.icons.check" :submit-text="$t('general.confirm')" can-submit @submit="saveAliases"
@cancel="$emit('cancel')">
<BaseDialog
v-model="dialog"
:title="$t('data-pages.manage-aliases')"
:icon="$globals.icons.edit"
:submit-icon="$globals.icons.check"
:submit-text="$t('general.confirm')"
can-submit
@submit="saveAliases"
@cancel="$emit('cancel')"
>
<v-card-text>
<v-container>
<v-row v-for="alias, i in aliases" :key="i">
@@ -10,13 +17,16 @@
<v-text-field v-model="alias.name" :label="$t('general.name')" :rules="[validators.required]" />
</v-col>
<v-col cols="2">
<BaseButtonGroup :buttons="[
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
},
]" @delete="deleteAlias(i)" />
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
},
]"
@delete="deleteAlias(i)"
/>
</v-col>
</v-row>
</v-container>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,7 @@
:placeholder="$t('recipe.quantity')"
@keypress="quantityFilter"
>
<template #prepend>
<template v-if="enableDragHandle" #prepend>
<v-icon
class="mr-n1 handle"
>
@@ -59,6 +59,7 @@
class="mx-1"
:placeholder="$t('recipe.choose-unit')"
clearable
:menu-props="{ attach: props.menuAttachTarget, maxHeight: '250px' }"
@keyup.enter="handleUnitEnter"
>
<template #prepend>
@@ -115,6 +116,7 @@
class="mx-1 py-0"
:placeholder="$t('recipe.choose-food')"
clearable
:menu-props="{ attach: props.menuAttachTarget, maxHeight: '250px' }"
@keyup.enter="handleFoodEnter"
>
<template #prepend>
@@ -165,12 +167,12 @@
@click="$emit('clickIngredientField', 'note')"
/>
<BaseButtonGroup
v-if="enableContextMenu"
hover
:large="false"
class="my-auto d-flex"
:buttons="btns"
@toggle-section="toggleTitle"
@toggle-original="toggleOriginalText"
@insert-above="$emit('insert-above')"
@insert-below="$emit('insert-below')"
@delete="$emit('delete')"
@@ -178,13 +180,7 @@
</div>
</v-col>
</v-row>
<p
v-if="showOriginalText"
class="text-caption"
>
{{ $t("recipe.original-text-with-value", { originalText: model.originalText }) }}
</p>
<slot name="before-divider" />
<v-divider
v-if="!mdAndUp"
class="my-4"
@@ -203,7 +199,11 @@ import type { RecipeIngredient } from "~/lib/api/types/recipe";
// defineModel replaces modelValue prop
const model = defineModel<RecipeIngredient>({ required: true });
defineProps({
const props = defineProps({
menuAttachTarget: {
type: String,
default: "body",
},
unitError: {
type: Boolean,
default: false,
@@ -220,6 +220,18 @@ defineProps({
type: String,
default: "",
},
enableContextMenu: {
type: Boolean,
default: false,
},
enableDragHandle: {
type: Boolean,
default: false,
},
deleteDisabled: {
type: Boolean,
default: false,
},
});
defineEmits([
@@ -235,7 +247,6 @@ const { $globals } = useNuxtApp();
const state = reactive({
showTitle: false,
showOriginalText: false,
});
const contextMenuOptions = computed(() => {
@@ -254,13 +265,6 @@ const contextMenuOptions = computed(() => {
},
];
if (model.value.originalText) {
options.push({
text: i18n.t("recipe.see-original-text"),
event: "toggle-original",
});
}
return options;
});
@@ -281,8 +285,8 @@ const btns = computed(() => {
text: i18n.t("general.delete"),
event: "delete",
children: undefined,
disabled: props.deleteDisabled,
});
return out;
});
@@ -319,10 +323,6 @@ function toggleTitle() {
state.showTitle = !state.showTitle;
}
function toggleOriginalText() {
state.showOriginalText = !state.showOriginalText;
}
function handleUnitEnter() {
if (
model.value.unit === undefined
@@ -349,7 +349,7 @@ function quantityFilter(e: KeyboardEvent) {
}
}
const { showTitle, showOriginalText } = toRefs(state);
const { showTitle } = toRefs(state);
const foods = foodStore.store;
const units = unitStore.store;

View File

@@ -1,15 +1,44 @@
<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="safeMarkup" />
<div class="ingredient-link-label links-disabled">
<SafeMarkdown v-if="baseText" :source="baseText" />
<SafeMarkdown
v-if="ingredient?.note"
class="d-inline"
:source="` ${ingredient.note}`"
/>
</div>
</template>
<script setup lang="ts">
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
import { computed } from "vue";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { useParsedIngredientText } from "~/composables/recipes";
interface Props {
markup: string;
ingredient?: RecipeIngredient;
scale?: number;
}
const props = defineProps<Props>();
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
const { ingredient, scale = 1 } = defineProps<Props>();
const baseText = computed(() => {
if (!ingredient) return "";
const parsed = useParsedIngredientText(ingredient, scale);
return [parsed.quantity, parsed.unit, parsed.name].filter(Boolean).join(" ").trim();
});
</script>
<style scoped>
.ingredient-link-label {
display: block;
line-height: 1.25;
word-break: break-word;
font-size: 0.95rem;
}
.links-disabled :deep(a) {
pointer-events: none;
cursor: default;
color: var(--v-theme-primary);
text-decoration: none;
}
</style>

View File

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

View File

@@ -1,7 +1,14 @@
<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">
<RecipePageParseDialog
:model-value="isParsing"
:ingredients="recipe.recipeIngredient"
:width="$vuetify.display.smAndDown ? '100%' : '80%'"
@update:model-value="toggleIsParsing"
@save="saveParsedIngredients"
/>
<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"
@@ -106,9 +113,13 @@
/>
<v-divider />
</v-col>
<v-col class="overflow-y-auto"
:class="$vuetify.display.smAndDown.value ? 'py-2': 'py-6'"
style="height: 100%" cols="12" sm="7">
<v-col
class="overflow-y-auto"
: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') }}
</h2>
@@ -168,6 +179,7 @@ import RecipePageIngredientEditor from "./RecipePageParts/RecipePageIngredientEd
import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredientToolsView.vue";
import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue";
import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
import RecipePageParseDialog from "./RecipePageParts/RecipePageParseDialog.vue";
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue";
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
@@ -178,26 +190,28 @@ import {
usePageState,
} from "~/composables/recipe-page/shared-state";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import type { Recipe, RecipeCategory, RecipeIngredient, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { useRouteQuery } from "~/composables/use-router";
import { useUserApi } from "~/composables/api";
import { uuid4, deepCopy } from "~/composables/use-utils";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
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();
const { isOwnGroup } = useLoggedInState();
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
const router = useRouter();
const api = useUserApi();
const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode }
const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, isParsing, toggleCookMode, toggleIsParsing }
= usePageState(recipe.value.slug);
const { deactivateNavigationWarning } = useNavigationWarning();
const notLinkedIngredients = computed(() => {
@@ -246,12 +260,29 @@ const hasLinkedIngredients = computed(() => {
type BooleanString = "true" | "false" | "";
const edit = useRouteQuery<BooleanString>("edit", "");
const paramsEdit = useRouteQuery<BooleanString>("edit", "");
const paramsParse = useRouteQuery<BooleanString>("parse", "");
onMounted(() => {
if (edit.value === "true") {
if (paramsEdit.value === "true" && isOwnGroup.value) {
setMode(PageMode.EDIT);
}
if (paramsParse.value === "true" && isOwnGroup.value) {
toggleIsParsing(true);
}
});
watch(isEditMode, (newVal) => {
if (!newVal) {
paramsEdit.value = undefined;
}
});
watch(isParsing, () => {
if (!isParsing.value) {
paramsParse.value = undefined;
}
});
/** =============================================================
@@ -266,6 +297,12 @@ async function saveRecipe() {
}
}
async function saveParsedIngredients(ingredients: NoUndefinedField<RecipeIngredient[]>) {
recipe.value.recipeIngredient = ingredients;
await saveRecipe();
toggleIsParsing(false);
}
async function deleteRecipe() {
const { data } = await api.recipes.deleteOne(recipe.value.slug);
if (data?.slug) {
@@ -278,7 +315,7 @@ async function deleteRecipe() {
*/
const landscape = computed(() => {
const preferLandscape = recipe.value.settings?.landscapeView;
const smallScreen = !$vuetify.display.smAndUp.value;
const smallScreen = !display.smAndUp.value;
if (preferLandscape) {
return true;
@@ -302,7 +339,7 @@ function addStep(steps: Array<string> | null = null) {
if (steps) {
const cleanedSteps = steps.map((step) => {
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
return { id: uuid4(), text: step, title: "", summary: "", ingredientReferences: [] };
});
recipe.value.recipeInstructions.push(...cleanedSteps);

View File

@@ -13,25 +13,25 @@
@upload="uploadImage"
/>
<v-spacer />
<v-select
v-model="recipe.userId"
class="my-2"
max-width="300"
:items="allUsers"
:item-props="itemsProps"
:label="$t('general.owner')"
:disabled="!canEditOwner"
variant="outlined"
density="compact"
>
<template #prepend>
<UserAvatar
:user-id="recipe.userId"
:tooltip="false"
/>
</template>
</v-select>
</div>
<v-select
v-model="recipe.userId"
class="my-2"
max-width="300"
:items="allUsers"
:item-props="itemsProps"
:label="$t('general.owner')"
:disabled="!canEditOwner"
variant="outlined"
density="compact"
>
<template #prepend>
<UserAvatar
:user-id="recipe.userId"
:tooltip="false"
/>
</template>
</v-select>
</div>
</template>
<script setup lang="ts">

View File

@@ -29,8 +29,8 @@
class="mb-2 mx-n2"
>
<v-card-title class="text-h5 font-weight-medium opacity-80">
{{ $t('recipe.api-extras') }}
</v-card-title>
{{ $t('recipe.api-extras') }}
</v-card-title>
<v-divider class="ml-4" />
<v-card-text>
{{ $t('recipe.api-extras-description') }}

View File

@@ -12,10 +12,10 @@
>
<v-card-text class="w-100">
<div class="d-flex flex-column align-center">
<v-card-title class="text-h5 font-weight-regular pa-0 text-wrap text-center opacity-80">
{{ recipe.name }}
</v-card-title>
<RecipeRating
<v-card-title class="text-h5 font-weight-regular pa-0 text-wrap text-center opacity-80">
{{ recipe.name }}
</v-card-title>
<RecipeRating
:key="recipe.slug"
:value="recipe.rating"
:recipe-id="recipe.id"

View File

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

View File

@@ -1,4 +1,3 @@
<!-- eslint-disable vue/no-mutating-props -->
<template>
<div>
<div class="mb-4">
@@ -31,6 +30,8 @@
v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
enable-drag-handle
enable-context-menu
class="list-group-item"
@delete="recipe.recipeIngredient.splice(index, 1)"
@insert-above="insertNewIngredient(index)"
@@ -55,8 +56,8 @@
class="mb-1"
:disabled="hasFoodOrUnit"
color="accent"
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
v-bind="props"
@click="toggleIsParsing(true)"
>
<template #icon>
{{ $globals.icons.foods }}
@@ -87,16 +88,14 @@ import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
import { usePageState } from "~/composables/recipe-page/shared-state";
import { uuid4 } from "~/composables/use-utils";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const i18n = useI18n();
const $auth = useMealieAuth();
const drag = ref(false);
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { toggleIsParsing } = usePageState(recipe.value.slug);
const hasFoodOrUnit = computed(() => {
if (!recipe.value) {
@@ -128,7 +127,7 @@ function addIngredient(ingredients: Array<string> | null = null) {
note: x,
unit: undefined,
food: undefined,
quantity: 1,
quantity: 0,
};
});
@@ -146,7 +145,7 @@ 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,
quantity: 1,
quantity: 0,
});
}
}
@@ -160,7 +159,7 @@ function insertNewIngredient(dest: number) {
unit: undefined,
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
food: undefined,
quantity: 1,
quantity: 0,
});
}
</script>

View File

@@ -29,31 +29,48 @@
{{ activeText }}
</p>
<v-divider class="mb-4" />
<v-checkbox-btn
v-for="ing in unusedIngredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
>
<template #label>
<RecipeIngredientHtml :markup="parseIngredientText(ing)" />
<template v-if="Object.keys(groupedUnusedIngredients).length > 0">
<h4 class="py-3 ml-1">
{{ $t("recipe.unlinked") }}
</h4>
<template v-for="(ingredients, title) in groupedUnusedIngredients" :key="title">
<h4 v-if="title" class="py-3 ml-1 pl-4">
{{ title }}
</h4>
<v-checkbox-btn
v-for="ing in ingredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
class="ml-4"
>
<template #label>
<RecipeIngredientHtml :ingredient="ing" :scale="scale" />
</template>
</v-checkbox-btn>
</template>
</v-checkbox-btn>
</template>
<template v-if="usedIngredients.length > 0">
<template v-if="Object.keys(groupedUsedIngredients).length > 0">
<h4 class="py-3 ml-1">
{{ $t("recipe.linked-to-other-step") }}
</h4>
<v-checkbox-btn
v-for="ing in usedIngredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
>
<template #label>
<RecipeIngredientHtml :markup="parseIngredientText(ing)" />
</template>
</v-checkbox-btn>
<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 :ingredient="ing" :scale="scale" />
</template>
</v-checkbox-btn>
</template>
</template>
</v-card-text>
@@ -167,17 +184,17 @@
<v-hover v-slot="{ isHovering }">
<v-card
class="my-3"
:class="[{ 'on-hover': isHovering }, isChecked(index)]"
:class="[{ 'on-hover': isHovering }, { 'cursor-default': isEditForm }, isChecked(index)]"
:elevation="isHovering ? 12 : 2"
:ripple="false"
@click="toggleDisabled(index)"
>
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
<div class="d-flex align-center">
<v-card-title class="recipe-step-title pt-3" :class="!isChecked(index) ? 'pb-0' : 'pb-3'">
<div class="d-flex align-center w-100">
<v-text-field
v-if="isEditForm"
v-model="step.summary"
class="headline handle"
class="headline"
hide-details
density="compact"
variant="solo"
@@ -185,14 +202,27 @@
:placeholder="$t('recipe.step-index', { step: index + 1 })"
>
<template #prepend>
<v-icon size="26">
<v-icon size="26" class="handle">
{{ $globals.icons.arrowUpDown }}
</v-icon>
</template>
</v-text-field>
<span v-else>
{{ step.summary ? step.summary : $t("recipe.step-index", { step: index + 1 }) }}
</span>
<div
v-else
class="summary-wrapper"
>
<template v-if="step.summary">
<SafeMarkdown
class="pr-2"
:source="step.summary"
/>
</template>
<template v-else>
<span>
{{ $t('recipe.step-index', { step: index + 1 }) }}
</span>
</template>
</div>
<template v-if="isEditForm">
<div class="ml-auto">
<BaseButtonGroup
@@ -297,11 +327,22 @@
persistentHint: true,
}"
/>
<RecipeIngredientHtml
v-for="ing in step.ingredientReferences"
:key="ing.referenceId!"
:markup="getIngredientByRefId(ing.referenceId!)"
/>
<div
v-if="step.ingredientReferences && step.ingredientReferences.length"
class="linked-ingredients-editor"
>
<div
v-for="(linkRef, i) in step.ingredientReferences"
:key="linkRef.referenceId ?? i"
class="mb-1"
>
<RecipeIngredientHtml
v-if="linkRef.referenceId && ingredientLookup[linkRef.referenceId]"
:ingredient="ingredientLookup[linkRef.referenceId]"
:scale="scale"
/>
</div>
</div>
</v-card-text>
</DropZone>
<v-expand-transition>
@@ -356,9 +397,7 @@
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import RecipeIngredientHtml from "../../RecipeIngredientHtml.vue";
import type { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe";
import { parseIngredientText } from "~/composables/recipes";
import { uuid4 } from "~/composables/use-utils";
import { useUserApi, useStaticRoutes } from "~/composables/api";
import { usePageState } from "~/composables/recipe-page/shared-state";
@@ -366,6 +405,7 @@ import { useExtractIngredientReferences } from "~/composables/recipe-page/use-ex
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import DropZone from "~/components/global/DropZone.vue";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
import RecipeIngredientHtml from "~/components/Domain/Recipe/RecipeIngredientHtml.vue";
interface MergerHistory {
target: number;
@@ -483,10 +523,9 @@ function openDialog(idx: number, text: string, refs?: IngredientReferences[]) {
instructionList.value[idx].ingredientReferences = [];
refs = instructionList.value[idx].ingredientReferences as IngredientReferences[];
}
setUsedIngredients();
activeText.value = text;
activeIndex.value = idx;
activeText.value = text;
setUsedIngredients();
dialog.value = true;
activeRefs.value = refs.map(ref => ref.referenceId ?? "");
}
@@ -527,29 +566,26 @@ function saveAndOpenNextLinkIngredients() {
function setUsedIngredients() {
const usedRefs: { [key: string]: boolean } = {};
instructionList.value.forEach((element) => {
instructionList.value.forEach((element, idx) => {
if (idx === activeIndex.value) return;
element.ingredientReferences?.forEach((ref) => {
if (ref.referenceId !== undefined) {
usedRefs[ref.referenceId!] = true;
}
if (ref.referenceId) usedRefs[ref.referenceId] = true;
});
});
usedIngredients.value = props.recipe.recipeIngredient.filter((ing) => {
return ing.referenceId !== undefined && ing.referenceId in usedRefs;
});
usedIngredients.value = props.recipe.recipeIngredient.filter(ing => !!ing.referenceId && ing.referenceId in usedRefs);
unusedIngredients.value = props.recipe.recipeIngredient.filter((ing) => {
return !(ing.referenceId !== undefined && ing.referenceId in usedRefs);
});
unusedIngredients.value = props.recipe.recipeIngredient.filter(ing => !!ing.referenceId && !(ing.referenceId in usedRefs));
}
watch(activeRefs, () => setUsedIngredients());
function autoSetReferences() {
useExtractIngredientReferences(
props.recipe.recipeIngredient,
activeRefs.value,
activeText.value,
).forEach((ingredient: string) => activeRefs.value.push(ingredient));
).forEach(ingredient => activeRefs.value.push(ingredient));
}
const ingredientLookup = computed(() => {
@@ -563,15 +599,60 @@ const ingredientLookup = computed(() => {
}, results);
});
function getIngredientByRefId(refId: string | undefined) {
if (refId === undefined) {
return "";
}
// Map each ingredient's referenceId to its section title
const ingredientSectionTitles = computed(() => {
const titleMap: { [key: string]: string } = {};
let currentTitle = "";
const ing = ingredientLookup.value[refId];
if (!ing) return "";
return parseIngredientText(ing, props.scale);
}
// 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((): Record<string, RecipeIngredient[]> => {
const groups: Record<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 || "";
(groups[title] ||= []).push(ingredient);
});
return groups;
});
const groupedUsedIngredients = computed((): Record<string, RecipeIngredient[]> => {
const groups: Record<string, RecipeIngredient[]> = {};
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 || "";
(groups[title] ||= []).push(ingredient);
});
return groups;
});
// ===============================================================
// Instruction Merger
@@ -765,7 +846,21 @@ function openImageUpload(index: number) {
z-index: 1;
}
.v-text-field >>> input {
.v-text-field :deep(input) {
font-size: 1.5rem;
}
.recipe-step-title {
/* Multiline display */
white-space: normal;
line-height: 1.25;
word-break: break-word;
}
.summary-wrapper {
flex: 1 1 auto;
min-width: 0; /* wrapping in flex container */
white-space: normal;
overflow-wrap: anywhere;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,538 @@
<template>
<BaseDialog
:model-value="modelValue"
:title="$t('recipe.parse-ingredients')"
:icon="$globals.icons.fileSign"
disable-submit-on-enter
@update:model-value="emit('update:modelValue', $event)"
>
<v-container fluid class="pa-2 ma-0" style="background-color: rgb(var(--v-theme-background));">
<div v-if="state.loading.parser" class="my-6">
<AppLoader waiting-text="" class="my-6" />
</div>
<div v-else>
<BaseCardSectionTitle :title="$t('recipe.parser.ingredient-parser')">
<div v-if="!state.allReviewed" class="mb-4">
<p>{{ $t("recipe.parser.ingredient-parser-description") }}</p>
<p>{{ $t("recipe.parser.ingredient-parser-final-review-description") }}</p>
</div>
<div class="d-flex flex-wrap align-center">
<div class="text-body-2 mr-2">
{{ $t("recipe.parser.select-parser") }}
</div>
<div class="d-flex align-center">
<BaseOverflowButton
v-model="parser"
:disabled="state.loading.parser"
btn-class="mx-2"
:items="availableParsers"
/>
<v-btn
icon
size="40"
color="info"
:disabled="state.loading.parser"
@click="parseIngredients"
>
<v-icon>{{ $globals.icons.refresh }}</v-icon>
</v-btn>
</div>
</div>
</BaseCardSectionTitle>
<v-card v-if="!state.allReviewed && currentIng">
<v-card-text class="pb-0 mb-0">
<div class="text-center px-8 py-4 mb-6">
<p class="text-h5 font-italic">
{{ currentIng.input }}
</p>
</div>
<div class="d-flex align-center pa-0 ma-0">
<v-icon
:color="(currentIng.confidence?.average || 0) < confidenceThreshold ? 'error' : 'success'"
>
{{ (currentIng.confidence?.average || 0) < confidenceThreshold ? $globals.icons.alert : $globals.icons.check }}
</v-icon>
<span
class="ml-2"
:color="currentIngHasError ? 'error-text' : 'success-text'"
>
{{ $t("recipe.parser.confidence-score") }}: {{ currentIng.confidence ? asPercentage(currentIng.confidence?.average!) : "" }}
</span>
</div>
<RecipeIngredientEditor
v-model="currentIng.ingredient"
:unit-error="!!currentMissingUnit"
:unit-error-tooltip="$t('recipe.parser.this-unit-could-not-be-parsed-automatically')"
:food-error="!!currentMissingFood"
:food-error-tooltip="$t('recipe.parser.this-food-could-not-be-parsed-automatically')"
/>
<v-card-actions>
<v-spacer />
<BaseButton
v-if="currentMissingUnit && !currentIng.ingredient.unit?.id"
color="warning"
size="small"
@click="createMissingUnit"
>
{{ i18n.t("recipe.parser.missing-unit", { unit: currentMissingUnit }) }}
</BaseButton>
<BaseButton
v-if="
currentMissingUnit
&& currentIng.ingredient.unit?.id
&& currentMissingUnit.toLowerCase() != currentIng.ingredient.unit?.name.toLowerCase()
"
color="warning"
size="small"
@click="addMissingUnitAsAlias"
>
{{ i18n.t("recipe.parser.add-text-as-alias-for-item", { text: currentMissingUnit, item: currentIng.ingredient.unit.name }) }}
</BaseButton>
<BaseButton
v-if="currentMissingFood && !currentIng.ingredient.food?.id"
color="warning"
size="small"
@click="createMissingFood"
>
{{ i18n.t("recipe.parser.missing-food", { food: currentMissingFood }) }}
</BaseButton>
<BaseButton
v-if="
currentMissingFood
&& currentIng.ingredient.food?.id
&& currentMissingFood.toLowerCase() != currentIng.ingredient.food?.name.toLowerCase()
"
color="warning"
size="small"
@click="addMissingFoodAsAlias"
>
{{ i18n.t("recipe.parser.add-text-as-alias-for-item", { text: currentMissingFood, item: currentIng.ingredient.food.name }) }}
</BaseButton>
</v-card-actions>
</v-card-text>
</v-card>
<div v-else>
<v-card-title class="text-center pt-0 pb-8">
{{ $t("recipe.parser.review-parsed-ingredients") }}
</v-card-title>
<v-card-text style="max-height: 60vh; overflow-y: auto;">
<VueDraggable
v-model="parsedIngs"
handle=".handle"
:delay="250"
:delay-on-touch-only="true"
v-bind="{
animation: 200,
group: 'recipe-ingredients',
disabled: false,
ghostClass: 'ghost',
}"
class="px-6"
@start="drag = true"
@end="drag = false"
>
<TransitionGroup
type="transition"
>
<v-lazy v-for="(ingredient, index) in parsedIngs" :key="index">
<RecipeIngredientEditor
v-model="ingredient.ingredient"
enable-drag-handle
enable-context-menu
class="list-group-item pb-8"
:delete-disabled="parsedIngs.length <= 1"
@delete="parsedIngs.splice(index, 1)"
@insert-above="insertNewIngredient(index)"
@insert-below="insertNewIngredient(index + 1)"
>
<template #before-divider>
<p v-if="ingredient.input" class="py-0 my-0 text-caption">
{{ $t("recipe.original-text-with-value", { originalText: ingredient.input }) }}
</p>
</template>
</RecipeIngredientEditor>
</v-lazy>
</TransitionGroup>
</VueDraggable>
</v-card-text>
</div>
</div>
</v-container>
<template v-if="!state.loading.parser" #custom-card-action>
<!-- Parse -->
<div v-if="!state.allReviewed" class="d-flex justify-space-between align-center">
<v-checkbox
v-model="currentIngShouldDelete"
color="error"
hide-details
density="compact"
:label="i18n.t('recipe.parser.delete-item')"
class="mr-4"
/>
<BaseButton
:color="currentIngShouldDelete ? 'error' : 'info'"
:icon="currentIngShouldDelete ? $globals.icons.delete : $globals.icons.arrowRightBold"
:icon-right="!currentIngShouldDelete"
:text="$t(currentIngShouldDelete ? 'recipe.parser.delete-item' : 'general.next')"
@click="nextIngredient"
/>
</div>
<!-- Review -->
<div v-else>
<BaseButton
create
:text="$t('general.save')"
:icon="$globals.icons.save"
:loading="state.loading.save"
@click="saveIngs"
/>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient } from "~/lib/api/types/recipe";
import type { Parser } from "~/lib/api/user/recipes/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useAppInfo, useUserApi } from "~/composables/api";
import { parseIngredientText } from "~/composables/recipes";
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
import { useGlobalI18n } from "~/composables/use-global-i18n";
import { alert } from "~/composables/use-toast";
import { useParsingPreferences } from "~/composables/use-users/preferences";
const props = defineProps<{
modelValue: boolean;
ingredients: NoUndefinedField<RecipeIngredient[]>;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
}>();
const i18n = useGlobalI18n();
const api = useUserApi();
const appInfo = useAppInfo();
const drag = ref(false);
const unitStore = useUnitStore();
const unitData = useUnitData();
const foodStore = useFoodStore();
const foodData = useFoodData();
const parserPreferences = useParsingPreferences();
const parser = ref<Parser>(parserPreferences.value.parser || "nlp");
const availableParsers = computed(() => {
return [
{
text: i18n.t("recipe.parser.natural-language-processor"),
value: "nlp",
},
{
text: i18n.t("recipe.parser.brute-parser"),
value: "brute",
},
{
text: i18n.t("recipe.parser.openai-parser"),
value: "openai",
hide: !appInfo.value?.enableOpenai,
},
];
});
/**
* If confidence of parsing is below this threshold,
* we will prompt the user to review the parsed ingredient.
*/
const confidenceThreshold = 0.85;
const parsedIngs = ref<ParsedIngredient[]>([]);
const currentIng = ref<ParsedIngredient | null>(null);
const currentMissingUnit = ref("");
const currentMissingFood = ref("");
const currentIngHasError = computed(() => currentMissingUnit.value || currentMissingFood.value);
const currentIngShouldDelete = ref(false);
const state = reactive({
currentParsedIndex: -1,
allReviewed: false,
loading: {
parser: false,
save: false,
},
});
function shouldReview(ing: ParsedIngredient): boolean {
console.debug(`Checking if ingredient needs review (input="${ing.input})":`, ing);
if ((ing.confidence?.average || 0) < confidenceThreshold) {
console.debug("Needs review due to low confidence:", ing.confidence?.average);
return true;
}
const food = ing.ingredient.food;
if (food && !food.id) {
console.debug("Needs review due to missing food ID:", food);
return true;
}
const unit = ing.ingredient.unit;
if (unit && !unit.id) {
console.debug("Needs review due to missing unit ID:", unit);
return true;
}
console.debug("No review needed");
return false;
}
function checkUnit(ing: ParsedIngredient) {
const unit = ing.ingredient.unit?.name;
if (!unit || ing.ingredient.unit?.id) {
currentMissingUnit.value = "";
return;
}
const potentialMatch = createdUnits.get(unit.toLowerCase());
if (potentialMatch) {
ing.ingredient.unit = potentialMatch;
currentMissingUnit.value = "";
return;
}
currentMissingUnit.value = unit;
ing.ingredient.unit = undefined;
}
function checkFood(ing: ParsedIngredient) {
const food = ing.ingredient.food?.name;
if (!food || ing.ingredient.food?.id) {
currentMissingFood.value = "";
return;
}
const potentialMatch = createdFoods.get(food.toLowerCase());
if (potentialMatch) {
ing.ingredient.food = potentialMatch;
currentMissingFood.value = "";
return;
}
currentMissingFood.value = food;
ing.ingredient.food = undefined;
}
function nextIngredient() {
let nextIndex = state.currentParsedIndex;
if (currentIngShouldDelete.value) {
parsedIngs.value.splice(state.currentParsedIndex, 1);
currentIngShouldDelete.value = false;
}
else {
nextIndex += 1;
}
while (nextIndex < parsedIngs.value.length) {
const current = parsedIngs.value[nextIndex];
if (shouldReview(current)) {
state.currentParsedIndex = nextIndex;
currentIng.value = current;
currentIngShouldDelete.value = false;
checkUnit(current);
checkFood(current);
return;
}
nextIndex += 1;
}
// No more to review
state.allReviewed = true;
}
async function parseIngredients() {
if (state.loading.parser) {
return;
}
if (!props.ingredients || props.ingredients.length === 0) {
state.loading.parser = false;
return;
}
state.loading.parser = true;
try {
const ingsAsString = props.ingredients.map(ing => parseIngredientText(ing, 1, false) ?? "");
const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString);
if (error || !data) {
throw new Error("Failed to parse ingredients");
}
parsedIngs.value = data;
state.currentParsedIndex = -1;
state.allReviewed = false;
createdUnits.clear();
createdFoods.clear();
currentIngShouldDelete.value = false;
nextIngredient();
}
catch (error) {
console.error("Error parsing ingredients:", error);
alert.error(i18n.t("events.something-went-wrong"));
}
finally {
state.loading.parser = false;
}
}
/** Cache of lowercased created units to avoid duplicate creations */
const createdUnits = new Map<string, IngredientUnit>();
/** Cache of lowercased created foods to avoid duplicate creations */
const createdFoods = new Map<string, IngredientFood>();
async function createMissingUnit() {
if (!currentMissingUnit.value) {
return;
}
unitData.reset();
unitData.data.name = currentMissingUnit.value;
let newUnit: IngredientUnit | null = null;
if (createdUnits.has(unitData.data.name)) {
newUnit = createdUnits.get(unitData.data.name)!;
}
else {
newUnit = await unitStore.actions.createOne(unitData.data);
}
if (!newUnit) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
currentIng.value!.ingredient.unit = newUnit;
createdUnits.set(newUnit.name.toLowerCase(), newUnit);
currentMissingUnit.value = "";
}
async function createMissingFood() {
if (!currentMissingFood.value) {
return;
}
foodData.reset();
foodData.data.name = currentMissingFood.value;
let newFood: IngredientFood | null = null;
if (createdFoods.has(foodData.data.name)) {
newFood = createdFoods.get(foodData.data.name)!;
}
else {
newFood = await foodStore.actions.createOne(foodData.data);
}
if (!newFood) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
currentIng.value!.ingredient.food = newFood;
createdFoods.set(newFood.name.toLowerCase(), newFood);
currentMissingFood.value = "";
}
async function addMissingUnitAsAlias() {
const unit = currentIng.value?.ingredient.unit as IngredientUnit | undefined;
if (!currentMissingUnit.value || !unit?.id) {
return;
}
unit.aliases = unit.aliases || [];
if (unit.aliases.map(a => a.name).includes(currentMissingUnit.value)) {
return;
}
unit.aliases.push({ name: currentMissingUnit.value });
const updated = await unitStore.actions.updateOne(unit);
if (!updated) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
currentIng.value!.ingredient.unit = updated;
currentMissingUnit.value = "";
}
async function addMissingFoodAsAlias() {
const food = currentIng.value?.ingredient.food as IngredientFood | undefined;
if (!currentMissingFood.value || !food?.id) {
return;
}
food.aliases = food.aliases || [];
if (food.aliases.map(a => a.name).includes(currentMissingFood.value)) {
return;
}
food.aliases.push({ name: currentMissingFood.value });
const updated = await foodStore.actions.updateOne(food);
if (!updated) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
currentIng.value!.ingredient.food = updated;
currentMissingFood.value = "";
}
watch(() => props.modelValue, () => {
if (!props.modelValue) {
return;
}
parseIngredients();
});
watch(parser, () => {
parserPreferences.value.parser = parser.value;
parseIngredients();
});
watch([parsedIngs, () => state.allReviewed], () => {
if (!state.allReviewed) {
return;
}
if (!parsedIngs.value.length) {
insertNewIngredient(0);
}
}, { immediate: true, deep: true });
function asPercentage(num: number | undefined): string {
if (!num) {
return "0%";
}
return Math.round(num * 100).toFixed(2) + "%";
}
function insertNewIngredient(index: number) {
const ing = {
input: "",
confidence: {},
ingredient: {
quantity: 0,
referenceId: uuid4(),
},
} as ParsedIngredient;
parsedIngs.value.splice(index, 0, ing);
}
function saveIngs() {
emit("save", parsedIngs.value.map(x => x.ingredient as NoUndefinedField<RecipeIngredient>));
state.loading.save = true;
}
</script>

View File

@@ -4,20 +4,23 @@
<section>
<v-container class="ma-0 pa-0">
<v-row>
<v-col v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
cols="4"
align-self="center"
<v-col
v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
cols="4"
align-self="center"
>
<img :key="imageKey"
:src="recipeImageUrl"
style="min-height: 50; max-width: 100%;"
<img
:key="imageKey"
:src="recipeImageUrl"
style="min-height: 50; max-width: 100%;"
>
</v-col>
<v-col order="0">
<v-card-title class="headline pl-0">
<v-icon start
color="primary"
<v-icon
start
color="primary"
>
{{ $globals.icons.primary }}
</v-icon>
@@ -36,17 +39,19 @@
</div>
</div>
<v-row class="d-flex justify-start">
<RecipeTimeCard :prep-time="recipe.prepTime"
:total-time="recipe.totalTime"
:perform-time="recipe.performTime"
small
color="white"
class="ml-4"
<RecipeTimeCard
:prep-time="recipe.prepTime"
:total-time="recipe.totalTime"
:perform-time="recipe.performTime"
small
color="white"
class="ml-4"
/>
</v-row>
<v-card-text v-if="preferences.showDescription"
class="px-0"
<v-card-text
v-if="preferences.showDescription"
class="px-0"
>
<SafeMarkdown :source="recipe.description" />
</v-card-text>
@@ -60,24 +65,29 @@
<v-card-title class="headline pl-0">
{{ $t("recipe.ingredients") }}
</v-card-title>
<div v-for="(ingredientSection, sectionIndex) in ingredientSections"
:key="`ingredient-section-${sectionIndex}`"
class="print-section"
<div
v-for="(ingredientSection, sectionIndex) in ingredientSections"
:key="`ingredient-section-${sectionIndex}`"
class="print-section"
>
<h4 v-if="ingredientSection.ingredients[0].title"
class="ingredient-title mt-2"
<h4
v-if="ingredientSection.ingredients[0].title"
class="ingredient-title mt-2"
>
{{ ingredientSection.ingredients[0].title }}
</h4>
<div class="ingredient-grid"
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
<div
class="ingredient-grid"
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
>
<template v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients"
:key="`ingredient-${ingredientIndex}`"
<template
v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients"
:key="`ingredient-${ingredientIndex}`"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<p class="ingredient-body"
v-html="parseText(ingredient)"
<p
class="ingredient-body"
v-html="parseText(ingredient)"
/>
</template>
</div>
@@ -86,22 +96,26 @@
<!-- Instructions -->
<section>
<div v-for="(instructionSection, sectionIndex) in instructionSections"
:key="`instruction-section-${sectionIndex}`"
:class="{ 'print-section': instructionSection.sectionName }"
<div
v-for="(instructionSection, sectionIndex) in instructionSections"
:key="`instruction-section-${sectionIndex}`"
:class="{ 'print-section': instructionSection.sectionName }"
>
<v-card-title v-if="!sectionIndex"
class="headline pl-0"
<v-card-title
v-if="!sectionIndex"
class="headline pl-0"
>
{{ $t("recipe.instructions") }}
</v-card-title>
<div v-for="(step, stepIndex) in instructionSection.instructions"
:key="`instruction-${stepIndex}`"
<div
v-for="(step, stepIndex) in instructionSection.instructions"
:key="`instruction-${stepIndex}`"
>
<div class="print-section">
<h4 v-if="step.title"
:key="`instruction-title-${stepIndex}`"
class="instruction-title mb-2"
<h4
v-if="step.title"
:key="`instruction-title-${stepIndex}`"
class="instruction-title mb-2"
>
{{ step.title }}
</h4>
@@ -112,8 +126,9 @@
+ 1,
}) }}
</h5>
<SafeMarkdown :source="step.text"
class="recipe-step-body"
<SafeMarkdown
:source="step.text"
class="recipe-step-body"
/>
</div>
</div>
@@ -122,18 +137,21 @@
<!-- Notes -->
<div v-if="preferences.showNotes">
<v-divider v-if="hasNotes"
class="grey my-4"
<v-divider
v-if="hasNotes"
class="grey my-4"
/>
<section>
<div v-for="(note, index) in recipe.notes"
:key="index + 'note'"
<div
v-for="(note, index) in recipe.notes"
:key="index + 'note'"
>
<div class="print-section">
<h4>{{ note.title }}</h4>
<SafeMarkdown :source="note.text"
class="note-body"
<SafeMarkdown
:source="note.text"
class="note-body"
/>
</div>
</div>
@@ -150,8 +168,9 @@
<div class="print-section">
<table class="nutrition-table">
<tbody>
<tr v-for="(value, key) in recipe.nutrition"
:key="key"
<tr
v-for="(value, key) in recipe.nutrition"
:key="key"
>
<template v-if="value">
<td>{{ labels[key].label }}</td>

View File

@@ -2,7 +2,8 @@
<div @click.prevent>
<!-- User Rating -->
<v-hover v-slot="{ isHovering, props }">
<v-rating v-if="isOwnGroup && (userRating || isHovering || !ratingsLoaded)"
<v-rating
v-if="isOwnGroup && (userRating || isHovering || !ratingsLoaded)"
v-bind="props"
:model-value="userRating"
active-color="secondary"
@@ -13,10 +14,10 @@
hover
clearable
@update:model-value="updateRating(+$event)"
@click="updateRating"
/>
<!-- Group Rating -->
<v-rating v-else
<v-rating
v-else
v-bind="props"
:model-value="groupRating"
:half-increments="true"
@@ -83,7 +84,7 @@ export default defineNuxtComponent({
});
function updateRating(val?: number) {
if (!isOwnGroup.value) {
if (!isOwnGroup.value || !val) {
return;
}

View File

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

View File

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

View File

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

View File

@@ -69,7 +69,7 @@
</div>
<BaseButton
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
size="small"
small
color="info"
:icon="$globals.icons.tagArrowRight"
:text="$t('shopping-list.save-label')"
@@ -84,12 +84,12 @@
:buttons="[
...(allowDelete
? [
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
},
]
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
},
]
: []),
{
icon: $globals.icons.close,

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
@@ -80,30 +78,11 @@
<v-list-item-subtitle class="font-weight-medium" style="font-size: small;">
{{ item.subtitle }}
</v-list-item-subtitle>
</v-list-item>
</v-list-item>
</div>
</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>
@@ -121,29 +100,22 @@ import type { SideBarLink } from "~/types/application-types";
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 || "");
const cookbookPreferences = useCookbookPreferences();
const ownCookbookStore = useCookbookStore(i18n);
const ownHouseholdStore = useHouseholdStore(i18n);
const publicCookbookStoreCache = ref<Record<string, ReturnType<typeof usePublicCookbookStore>>>({});
const publicHouseholdStoreCache = ref<Record<string, ReturnType<typeof usePublicHouseholdStore>>>({});
function getPublicCookbookStore(slug: string) {
if (!publicCookbookStoreCache.value[slug]) {
@@ -152,13 +124,6 @@ export default defineNuxtComponent({
return publicCookbookStoreCache.value[slug];
}
function getPublicHouseholdStore(slug: string) {
if (!publicHouseholdStoreCache.value[slug]) {
publicHouseholdStoreCache.value[slug] = usePublicHouseholdStore(slug, i18n);
}
return publicHouseholdStoreCache.value[slug];
}
const cookbooks = computed(() => {
if (isOwnGroup.value) {
return ownCookbookStore.store.value;
@@ -170,34 +135,14 @@ export default defineNuxtComponent({
return [];
});
const households = computed(() => {
if (isOwnGroup.value) {
return ownHouseholdStore.store.value;
}
else if (groupSlug.value) {
const publicStore = getPublicHouseholdStore(groupSlug.value);
return unref(publicStore.store);
}
return [];
});
const householdsById = computed(() => {
return households.value.reduce((acc, household) => {
acc[household.id] = household;
return acc;
}, {} as { [key: string]: HouseholdSummary });
});
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 {
@@ -221,11 +166,8 @@ export default defineNuxtComponent({
const ownLinks: SideBarLink[] = [];
const links: SideBarLink[] = [];
const cookbooksByHousehold = sortedCookbooks.reduce((acc, cookbook) => {
const householdName = householdsById.value[cookbook.householdId]?.name || "";
if (!acc[householdName]) {
acc[householdName] = [];
}
acc[householdName].push(cookbook);
const householdName = cookbook.household?.name || "";
(acc[householdName] ||= []).push(cookbook);
return acc;
}, {} as Record<string, ReadCookBook[]>);
@@ -286,19 +228,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 +296,9 @@ export default defineNuxtComponent({
groupSlug,
cookbookLinks,
createLinks,
bottomLinks,
topLinks,
isOwnGroup,
languageDialog,
toggleDark,
sidebar,
};
},

View File

@@ -128,7 +128,7 @@ export default defineNuxtComponent({
async function logout() {
try {
await $auth.signOut({ callbackUrl: "/login?direct=1" });
await $auth.signOut("/login?direct=1");
}
catch (e) {
console.error(e);

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>
@@ -32,20 +33,39 @@
<template v-for="nav in topLink">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
<!-- Multi Items -->
<v-list-group v-if="nav.children" :key="(nav.key || nav.title) + 'multi-item'"
v-model="dropDowns[nav.title]" color="primary" :prepend-icon="nav.icon" :fluid="true">
<v-list-group
v-if="nav.children"
:key="(nav.key || nav.title) + 'multi-item'"
v-model="dropDowns[nav.title]"
color="primary"
:prepend-icon="nav.icon"
:fluid="true"
>
<template #activator="{ props }">
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
</template>
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to"
:prepend-icon="child.icon" :title="child.title" class="ml-4" />
<v-list-item
v-for="child in nav.children"
:key="child.key || child.title"
exact
:to="child.to"
:prepend-icon="child.icon"
:title="child.title"
class="ml-4"
/>
</v-list-group>
<!-- Single Item -->
<template v-else>
<v-list-item :key="(nav.key || nav.title) + 'single-item'" exact link :to="nav.to"
:prepend-icon="nav.icon" :title="nav.title" />
<v-list-item
:key="(nav.key || nav.title) + 'single-item'"
exact
link
:to="nav.to"
:prepend-icon="nav.icon"
:title="nav.title"
/>
</template>
</div>
</template>
@@ -59,14 +79,27 @@
<template v-for="nav in secondaryLinks">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
<!-- Multi Items -->
<v-list-group v-if="nav.children" :key="(nav.key || nav.title) + 'multi-item'"
v-model="dropDowns[nav.title]" color="primary" :prepend-icon="nav.icon" fluid>
<v-list-group
v-if="nav.children"
:key="(nav.key || nav.title) + 'multi-item'"
v-model="dropDowns[nav.title]"
color="primary"
:prepend-icon="nav.icon"
fluid
>
<template #activator="{ props }">
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
</template>
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to"
class="ml-2" :prepend-icon="child.icon" :title="child.title" />
<v-list-item
v-for="child in nav.children"
:key="child.key || child.title"
exact
:to="child.to"
class="ml-2"
:prepend-icon="child.icon"
:title="child.title"
/>
</v-list-group>
<!-- Single Item -->
@@ -82,30 +115,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 +165,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 +214,11 @@ export default defineNuxtComponent({
userProfileLink,
showDrawer,
loggedIn,
isAdmin,
canManage,
isOwnGroup,
sessionUser: $auth.user,
toggleDark,
};
},
});

View File

@@ -0,0 +1,48 @@
<template>
<div class="icon-container">
<v-divider class="icon-divider" />
<v-avatar
:class="['pa-2', 'icon-avatar']"
color="primary"
:size="size"
>
<slot>
<svg
class="icon-white"
viewBox="0 0 24 24"
:style="{ width: size + 'px', height: size + 'px' }"
>
<path
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
/>
</svg>
</slot>
</v-avatar>
</div>
</template>
<script setup lang="ts">
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,14 +33,16 @@
<!-- Check Box -->
<v-checkbox
v-if="inputField.type === fieldTypes.BOOLEAN"
v-model="modelValue[inputField.varName]"
v-model="model[inputField.varName]"
:name="inputField.varName"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
:hint="inputField.hint"
:hide-details="!inputField.hint"
:persistent-hint="!!inputField.hint"
density="comfortable"
@change="emitBlur">
@change="emitBlur"
>
<template #label>
<span class="ml-4">
{{ inputField.label }}
@@ -51,9 +53,9 @@
<!-- Text Field -->
<v-text-field
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
v-model="modelValue[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
variant="solo-filled"
flat
@@ -62,7 +64,7 @@
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules), ...defaultRules] : []"
:rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules as any), ...defaultRules] : []"
lazy-validation
@blur="emitBlur"
/>
@@ -70,9 +72,9 @@
<!-- Text Area -->
<v-textarea
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
v-model="modelValue[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled"
flat
rows="3"
@@ -81,7 +83,7 @@
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:rules="[...rulesByKey(inputField.rules), ...defaultRules]"
:rules="[...rulesByKey(inputField.rules as any), ...defaultRules]"
lazy-validation
@blur="emitBlur"
/>
@@ -89,12 +91,11 @@
<!-- Option Select -->
<v-select
v-else-if="inputField.type === fieldTypes.SELECT"
v-model="modelValue[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled"
flat
:prepend-icon="inputField.icons ? modelValue[inputField.varName] : null"
:label="inputField.label"
:name="inputField.varName"
:items="inputField.options"
@@ -106,15 +107,7 @@
persistent-hint
lazy-validation
@blur="emitBlur"
>
<template #item="{ item }">
<v-list-item
v-bind="props"
:title="item.raw.text"
:subtitle="item.raw.description"
/>
</template>
</v-select>
/>
<!-- Color Picker -->
<div
@@ -127,7 +120,7 @@
<v-btn
class="my-2 ml-auto"
style="min-width: 200px"
:color="modelValue[inputField.varName]"
:color="model[inputField.varName]"
dark
v-bind="templateProps"
>
@@ -135,7 +128,7 @@
</v-btn>
</template>
<v-color-picker
v-model="modelValue[inputField.varName]"
v-model="model[inputField.varName]"
value="#7417BE"
hide-canvas
hide-inputs
@@ -146,11 +139,12 @@
</v-menu>
</div>
<!-- Object Type -->
<div v-else-if="inputField.type === fieldTypes.OBJECT">
<auto-form
v-model="modelValue[inputField.varName]"
v-model="model[inputField.varName]"
:color="color"
:items="inputField.items"
:items="(inputField as any).items"
@blur="emitBlur"
/>
</div>
@@ -158,7 +152,7 @@
<!-- List Type -->
<div v-else-if="inputField.type === fieldTypes.LIST">
<div
v-for="(item, idx) in modelValue[inputField.varName]"
v-for="(item, idx) in model[inputField.varName]"
:key="idx"
>
<p>
@@ -168,15 +162,15 @@
class="ml-5"
x-small
delete
@click="removeByIndex(modelValue[inputField.varName], idx)"
@click="removeByIndex(model[inputField.varName], idx)"
/>
</span>
</p>
<v-divider class="mb-5 mx-2" />
<auto-form
v-model="modelValue[inputField.varName][idx]"
v-model="model[inputField.varName][idx]"
:color="color"
:items="inputField.items"
:items="(inputField as any).items"
@blur="emitBlur"
/>
</div>
@@ -184,7 +178,7 @@
<v-spacer />
<BaseButton
small
@click="modelValue[inputField.varName].push(getTemplate(inputField.items))"
@click="model[inputField.varName].push(getTemplate((inputField as any).items))"
>
{{ $t("general.new") }}
</BaseButton>
@@ -205,7 +199,13 @@ const BLUR_EVENT = "blur";
type ValidatorKey = keyof typeof validators;
// Use defineModel for v-model
const modelValue = defineModel<[object, Array<any>]>();
const modelValue = defineModel<Record<string, any> | any[]>({
type: [Object, Array],
required: true,
});
// alias to avoid template TS complaining about possible undefined
const model = modelValue as any;
const props = defineProps({
updateMode: {
@@ -246,26 +246,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

@@ -10,9 +10,7 @@
:max-width="maxWidth ?? undefined"
:content-class="top ? 'top-dialog' : undefined"
:fullscreen="$vuetify.display.xs"
@keydown.enter="() => {
emit('submit'); dialog = false;
}"
@keydown.enter="submitOnEnter"
@click:outside="emit('cancel')"
@keydown.esc="emit('cancel')"
>
@@ -127,6 +125,7 @@ interface DialogProps {
canDelete?: boolean;
canConfirm?: boolean;
canSubmit?: boolean;
disableSubmitOnEnter?: boolean;
}
interface DialogEmits {
@@ -150,6 +149,7 @@ const props = withDefaults(defineProps<DialogProps>(), {
canDelete: false,
canConfirm: false,
canSubmit: false,
disableSubmitOnEnter: false,
});
const emit = defineEmits<DialogEmits>();
@@ -181,6 +181,14 @@ function submitEvent() {
submitted.value = true;
}
function submitOnEnter() {
if (props.disableSubmitOnEnter) {
return;
}
submitEvent();
}
function deleteEvent() {
emit("delete");
submitted.value = true;
@@ -192,8 +200,8 @@ function open() {
}
/* function close() {
dialog.value = false;
logDeprecatedProp("close");
dialog.value = false;
logDeprecatedProp("close");
} */
function logDeprecatedProp(val: string) {

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<template>
<!-- eslint-disable-next-line vue/no-v-html is safe here because all HTML is sanitized with DOMPurify in setup() -->
<div v-html="value" />
<!-- eslint-disable-next-line vue/no-v-html is safe here because all HTML is sanitized with DOMPurify in setup() -->
<div v-html="value" />
</template>
<script lang="ts">
@@ -39,7 +39,7 @@ export default defineNuxtComponent({
}
const value = computed(() => {
const rawHtml = marked.parse(props.source || "", { async: false });
const rawHtml = marked.parse(props.source || "", { async: false, breaks: true });
return sanitizeMarkdown(rawHtml);
});

View File

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

View File

@@ -1,16 +1,13 @@
import { useAsyncKey } from "../use-utils";
import type { AppInfo } from "~/lib/api/types/admin";
export function useAppInfo(): Ref<AppInfo | null> {
const appInfo = ref<null | AppInfo>(null);
const i18n = useI18n();
const { $axios } = useNuxtApp();
$axios.defaults.headers.common["Accept-Language"] = i18n.locale.value;
useAsyncData(useAsyncKey(), async () => {
const { data: appInfo } = useAsyncData("app-info", async () => {
const data = await $axios.get<AppInfo>("/api/app/about");
appInfo.value = data.data;
return data.data;
});
return appInfo;

View File

@@ -1,10 +1,10 @@
import { useAsyncKey } from "../use-utils";
import type { AsyncData, NuxtError } from "#app";
import type { BoundT } from "./types";
import type { BaseCRUDAPI, BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
import type { QueryValue } from "~/lib/api/base/route";
interface ReadOnlyStoreActions<T extends BoundT> {
getAll(page?: number, perPage?: number, params?: any): Ref<T[] | null>;
getAll(page?: number, perPage?: number, params?: any): AsyncData<T[] | null, NuxtError<unknown> | null>;
refresh(page?: number, perPage?: number, params?: any): Promise<void>;
}
@@ -21,6 +21,7 @@ interface StoreActions<T extends BoundT> extends ReadOnlyStoreActions<T> {
* a lot of refreshing hooks to be called on operations
*/
export function useReadOnlyActions<T extends BoundT>(
storeKey: string,
api: BaseCRUDAPIReadOnly<T>,
allRef: Ref<T[] | null> | null,
loading: Ref<boolean>,
@@ -29,20 +30,24 @@ export function useReadOnlyActions<T extends BoundT>(
params.orderBy ??= "name";
params.orderDirection ??= "asc";
loading.value = true;
const allItems = useAsyncData(useAsyncKey(), async () => {
const { data } = await api.getAll(page, perPage, params);
loading.value = false;
const allItems = useAsyncData(storeKey, async () => {
loading.value = true;
try {
const { data } = await api.getAll(page, perPage, params);
if (data && allRef) {
allRef.value = data.items;
}
if (data && allRef) {
allRef.value = data.items;
}
if (data) {
return data.items ?? [];
if (data) {
return data.items ?? [];
}
else {
return [];
}
}
else {
return [];
finally {
loading.value = false;
}
});
@@ -76,6 +81,7 @@ export function useReadOnlyActions<T extends BoundT>(
* a lot of refreshing hooks to be called on operations
*/
export function useStoreActions<T extends BoundT>(
storeKey: string,
api: BaseCRUDAPI<unknown, T, unknown>,
allRef: Ref<T[] | null> | null,
loading: Ref<boolean>,
@@ -84,20 +90,24 @@ export function useStoreActions<T extends BoundT>(
params.orderBy ??= "name";
params.orderDirection ??= "asc";
loading.value = true;
const allItems = useAsyncData(useAsyncKey(), async () => {
const { data } = await api.getAll(page, perPage, params);
loading.value = false;
const allItems = useAsyncData(storeKey, async () => {
loading.value = true;
try {
const { data } = await api.getAll(page, perPage, params);
if (data && allRef) {
allRef.value = data.items;
}
if (data && allRef) {
allRef.value = data.items;
}
if (data) {
return data.items ?? [];
if (data) {
return data.items ?? [];
}
else {
return [];
}
}
else {
return [];
finally {
loading.value = false;
}
});

View File

@@ -13,12 +13,13 @@ export const useData = function <T extends BoundT>(defaultObject: T) {
};
export const useReadOnlyStore = function <T extends BoundT>(
storeKey: string,
store: Ref<T[]>,
loading: Ref<boolean>,
api: BaseCRUDAPIReadOnly<T>,
params = {} as Record<string, QueryValue>,
) {
const storeActions = useReadOnlyActions(api, store, loading);
const storeActions = useReadOnlyActions(`${storeKey}-store-readonly`, api, store, loading);
const actions = {
...storeActions,
async refresh() {
@@ -29,21 +30,22 @@ export const useReadOnlyStore = function <T extends BoundT>(
},
};
if (!loading.value && (!store.value || store.value.length === 0)) {
const result = actions.getAll(1, -1, params);
store.value = result.value || [];
// initial hydration
if (!loading.value && !store.value.length) {
actions.refresh();
}
return { store, actions };
};
export const useStore = function <T extends BoundT>(
storeKey: string,
store: Ref<T[]>,
loading: Ref<boolean>,
api: BaseCRUDAPI<unknown, T, unknown>,
params = {} as Record<string, QueryValue>,
) {
const storeActions = useStoreActions(api, store, loading);
const storeActions = useStoreActions(`${storeKey}-store`, api, store, loading);
const actions = {
...storeActions,
async refresh() {
@@ -54,9 +56,9 @@ export const useStore = function <T extends BoundT>(
},
};
if (!loading.value && (!store.value || store.value.length === 0)) {
const result = actions.getAll(1, -1, params);
store.value = result.value || [];
// initial hydration
if (!loading.value && !store.value.length) {
actions.refresh();
}
return { store, actions };

View File

@@ -29,26 +29,31 @@ interface PageState {
editMode: ComputedRef<EditorMode>;
/**
* true is the page is in edit mode and the edit mode is in form mode.
*/
* true is the page is in edit mode and the edit mode is in form mode.
*/
isEditForm: ComputedRef<boolean>;
/**
* true is the page is in edit mode and the edit mode is in json mode.
*/
* true is the page is in edit mode and the edit mode is in json mode.
*/
isEditJSON: ComputedRef<boolean>;
/**
* true is the page is in view mode.
*/
* true is the page is in view mode.
*/
isEditMode: ComputedRef<boolean>;
/**
* true is the page is in cook mode.
*/
* true is the page is in cook mode.
*/
isCookMode: ComputedRef<boolean>;
/**
* true if the recipe is currently being parsed.
*/
isParsing: ComputedRef<boolean>;
setMode: (v: PageMode) => void;
setEditMode: (v: EditorMode) => void;
toggleEditMode: () => void;
toggleCookMode: () => void;
toggleIsParsing: (v?: boolean) => void;
}
type PageRefs = ReturnType<typeof pageRefs>;
@@ -60,11 +65,12 @@ function pageRefs(slug: string) {
slugRef: ref(slug),
pageModeRef: ref(PageMode.VIEW),
editModeRef: ref(EditorMode.FORM),
isParsingRef: ref(false),
imageKey: ref(1),
};
}
function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): PageState {
function pageState({ slugRef, pageModeRef, editModeRef, isParsingRef, imageKey }: PageRefs): PageState {
const { activateNavigationWarning, deactivateNavigationWarning } = useNavigationWarning();
const toggleEditMode = () => {
@@ -83,6 +89,14 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P
pageModeRef.value = PageMode.COOK;
};
const toggleIsParsing = (v: boolean | null = null) => {
if (v === null) {
v = !isParsingRef.value;
}
isParsingRef.value = v;
};
const setEditMode = (v: EditorMode) => {
editModeRef.value = v;
};
@@ -113,6 +127,7 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P
setMode,
setEditMode,
toggleCookMode,
toggleIsParsing,
isEditForm: computed(() => {
return pageModeRef.value === PageMode.EDIT && editModeRef.value === EditorMode.FORM;
@@ -126,6 +141,9 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P
isCookMode: computed(() => {
return pageModeRef.value === PageMode.COOK;
}),
isParsing: computed(() => {
return isParsingRef.value;
}),
};
}

View File

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

View File

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

View File

@@ -17,10 +17,10 @@ export const useCategoryData = function () {
export const useCategoryStore = function (i18n?: Composer) {
const api = useUserApi(i18n);
return useStore<RecipeCategory>(store, loading, api.categories);
return useStore<RecipeCategory>("category", store, loading, api.categories);
};
export const usePublicCategoryStore = function (groupSlug: string, i18n?: Composer) {
const api = usePublicExploreApi(groupSlug, i18n).explore;
return useReadOnlyStore<RecipeCategory>(store, publicLoading, api.categories);
return useReadOnlyStore<RecipeCategory>("category", store, publicLoading, api.categories);
};

View File

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

View File

@@ -18,10 +18,10 @@ export const useFoodData = function () {
export const useFoodStore = function (i18n?: Composer) {
const api = useUserApi(i18n);
return useStore<IngredientFood>(store, loading, api.foods);
return useStore<IngredientFood>("food", store, loading, api.foods);
};
export const usePublicFoodStore = function (groupSlug: string, i18n?: Composer) {
const api = usePublicExploreApi(groupSlug, i18n).explore;
return useReadOnlyStore<IngredientFood>(store, publicLoading, api.foods);
return useReadOnlyStore<IngredientFood>("food", store, publicLoading, api.foods);
};

View File

@@ -9,10 +9,10 @@ const publicLoading = ref(false);
export const useHouseholdStore = function (i18n?: Composer) {
const api = useUserApi(i18n);
return useReadOnlyStore<HouseholdSummary>(store, loading, api.households);
return useReadOnlyStore<HouseholdSummary>("household", store, loading, api.households);
};
export const usePublicHouseholdStore = function (groupSlug: string, i18n?: Composer) {
const api = usePublicExploreApi(groupSlug, i18n).explore;
return useReadOnlyStore<HouseholdSummary>(store, publicLoading, api.households);
return useReadOnlyStore<HouseholdSummary>("household-public", store, publicLoading, api.households);
};

View File

@@ -17,5 +17,5 @@ export const useLabelData = function () {
export const useLabelStore = function (i18n?: Composer) {
const api = useUserApi(i18n);
return useStore<MultiPurposeLabelOut>(store, loading, api.multiPurposeLabels);
return useStore<MultiPurposeLabelOut>("label", store, loading, api.multiPurposeLabels);
};

View File

@@ -17,10 +17,10 @@ export const useTagData = function () {
export const useTagStore = function (i18n?: Composer) {
const api = useUserApi(i18n);
return useStore<RecipeTag>(store, loading, api.tags);
return useStore<RecipeTag>("tag", store, loading, api.tags);
};
export const usePublicTagStore = function (groupSlug: string, i18n?: Composer) {
const api = usePublicExploreApi(groupSlug, i18n).explore;
return useReadOnlyStore<RecipeTag>(store, publicLoading, api.tags);
return useReadOnlyStore<RecipeTag>("tag", store, publicLoading, api.tags);
};

View File

@@ -23,10 +23,10 @@ export const useToolData = function () {
export const useToolStore = function (i18n?: Composer) {
const api = useUserApi(i18n);
return useStore<RecipeTool>(store, loading, api.tools);
return useStore<RecipeTool>("tool", store, loading, api.tools);
};
export const usePublicToolStore = function (groupSlug: string, i18n?: Composer) {
const api = usePublicExploreApi(groupSlug, i18n).explore;
return useReadOnlyStore<RecipeTool>(store, publicLoading, api.tools);
return useReadOnlyStore<RecipeTool>("tool", store, publicLoading, api.tools);
};

View File

@@ -18,5 +18,5 @@ export const useUnitData = function () {
export const useUnitStore = function (i18n?: Composer) {
const api = useUserApi(i18n);
return useStore<IngredientUnit>(store, loading, api.units);
return useStore<IngredientUnit>("unit", store, loading, api.units);
};

View File

@@ -16,5 +16,5 @@ export const useUserStore = function (i18n?: Composer) {
const requests = useRequests(i18n);
const api = new GroupUserAPIReadOnly(requests);
return useReadOnlyStore<UserSummary>(store, loading, api, { orderBy: "full_name" });
return useReadOnlyStore<UserSummary>("user", store, loading, api, { orderBy: "full_name" });
};

View File

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

View File

@@ -29,8 +29,8 @@ export function useGroupRecipeActionData() {
}
export const useGroupRecipeActions = function (
orderBy: string | null = "title",
orderDirection: string | null = "asc",
orderBy: string | null = "title",
orderDirection: string | null = "asc",
) {
const api = useUserApi();
@@ -78,7 +78,7 @@ export const useGroupRecipeActions = function (
};
const actions = {
...useStoreActions<GroupRecipeActionOut>(api.groupRecipeActions, groupRecipeActions, loading),
...useStoreActions<GroupRecipeActionOut>("group-recipe-actions", api.groupRecipeActions, groupRecipeActions, loading),
flushStore() {
groupRecipeActions.value = [];
},

View File

@@ -9,7 +9,7 @@ export const LOCALES = [
{
name: "简体中文 (Chinese simplified)",
value: "zh-CN",
progress: 35,
progress: 38,
dir: "ltr",
},
{
@@ -21,7 +21,7 @@ export const LOCALES = [
{
name: "Українська (Ukrainian)",
value: "uk-UA",
progress: 37,
progress: 55,
dir: "ltr",
},
{
@@ -33,7 +33,7 @@ export const LOCALES = [
{
name: "Svenska (Swedish)",
value: "sv-SE",
progress: 52,
progress: 53,
dir: "ltr",
},
{
@@ -45,19 +45,19 @@ export const LOCALES = [
{
name: "Slovenščina (Slovenian)",
value: "sl-SI",
progress: 39,
progress: 40,
dir: "ltr",
},
{
name: "Slovenčina (Slovak)",
value: "sk-SK",
progress: 37,
progress: 46,
dir: "ltr",
},
{
name: "Pусский (Russian)",
value: "ru-RU",
progress: 40,
progress: 41,
dir: "ltr",
},
{
@@ -75,25 +75,25 @@ export const LOCALES = [
{
name: "Português do Brasil (Brazilian Portuguese)",
value: "pt-BR",
progress: 41,
progress: 46,
dir: "ltr",
},
{
name: "Polski (Polish)",
value: "pl-PL",
progress: 40,
progress: 42,
dir: "ltr",
},
{
name: "Norsk (Norwegian)",
value: "no-NO",
progress: 39,
progress: 40,
dir: "ltr",
},
{
name: "Nederlands (Dutch)",
value: "nl-NL",
progress: 45,
progress: 52,
dir: "ltr",
},
{
@@ -105,7 +105,7 @@ export const LOCALES = [
{
name: "Lietuvių (Lithuanian)",
value: "lt-LT",
progress: 26,
progress: 27,
dir: "ltr",
},
{
@@ -123,25 +123,25 @@ export const LOCALES = [
{
name: "Italiano (Italian)",
value: "it-IT",
progress: 39,
progress: 43,
dir: "ltr",
},
{
name: "Íslenska (Icelandic)",
value: "is-IS",
progress: 2,
progress: 10,
dir: "ltr",
},
{
name: "Magyar (Hungarian)",
value: "hu-HU",
progress: 40,
progress: 45,
dir: "ltr",
},
{
name: "Hrvatski (Croatian)",
value: "hr-HR",
progress: 27,
progress: 28,
dir: "ltr",
},
{
@@ -159,7 +159,7 @@ export const LOCALES = [
{
name: "Français (French)",
value: "fr-FR",
progress: 52,
progress: 67,
dir: "ltr",
},
{
@@ -171,7 +171,7 @@ export const LOCALES = [
{
name: "Belge (Belgian)",
value: "fr-BE",
progress: 36,
progress: 41,
dir: "ltr",
},
{
@@ -189,7 +189,7 @@ export const LOCALES = [
{
name: "Español (Spanish)",
value: "es-ES",
progress: 41,
progress: 45,
dir: "ltr",
},
{
@@ -201,31 +201,31 @@ export const LOCALES = [
{
name: "British English",
value: "en-GB",
progress: 23,
progress: 43,
dir: "ltr",
},
{
name: "Ελληνικά (Greek)",
value: "el-GR",
progress: 39,
progress: 41,
dir: "ltr",
},
{
name: "Deutsch (German)",
value: "de-DE",
progress: 66,
progress: 80,
dir: "ltr",
},
{
name: "Dansk (Danish)",
value: "da-DK",
progress: 40,
progress: 43,
dir: "ltr",
},
{
name: "Čeština (Czech)",
value: "cs-CZ",
progress: 39,
progress: 42,
dir: "ltr",
},
{
@@ -237,7 +237,7 @@ export const LOCALES = [
{
name: "Български (Bulgarian)",
value: "bg-BG",
progress: 31,
progress: 44,
dir: "ltr",
},
{

View File

@@ -0,0 +1,85 @@
import { useRecipeCreatePreferences } from "~/composables/use-users/preferences";
export interface UseNewRecipeOptionsProps {
enableImportKeywords?: boolean;
enableStayInEditMode?: boolean;
enableParseRecipe?: boolean;
}
export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
const {
enableImportKeywords = true,
enableStayInEditMode = true,
enableParseRecipe = true,
} = props;
const router = useRouter();
const recipeCreatePreferences = useRecipeCreatePreferences();
const importKeywordsAsTags = computed({
get() {
if (!enableImportKeywords) return false;
return recipeCreatePreferences.value.importKeywordsAsTags;
},
set(v: boolean) {
if (!enableImportKeywords) return;
recipeCreatePreferences.value.importKeywordsAsTags = v;
},
});
const stayInEditMode = computed({
get() {
if (!enableStayInEditMode) return false;
return recipeCreatePreferences.value.stayInEditMode;
},
set(v: boolean) {
if (!enableStayInEditMode) return;
recipeCreatePreferences.value.stayInEditMode = v;
},
});
const parseRecipe = computed({
get() {
if (!enableParseRecipe) return false;
return recipeCreatePreferences.value.parseRecipe;
},
set(v: boolean) {
if (!enableParseRecipe) return;
recipeCreatePreferences.value.parseRecipe = v;
},
});
function navigateToRecipe(recipeSlug: string, groupSlug: string, createPagePath: string) {
const editParam = enableStayInEditMode ? stayInEditMode.value : false;
const parseParam = enableParseRecipe ? parseRecipe.value : false;
const queryParams = new URLSearchParams();
if (editParam) {
queryParams.set("edit", "true");
}
if (parseParam) {
queryParams.set("parse", "true");
}
const queryString = queryParams.toString();
const recipeUrl = `/g/${groupSlug}/r/${recipeSlug}${queryString ? `?${queryString}` : ""}`;
// Replace current entry to prevent re-import on back navigation
router.replace(createPagePath).then(() => router.push(recipeUrl));
}
return {
// Computed properties for the checkboxes
importKeywordsAsTags,
stayInEditMode,
parseRecipe,
// Helper functions
navigateToRecipe,
// Props for conditional rendering
enableImportKeywords,
enableStayInEditMode,
enableParseRecipe,
};
}

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