Compare commits

...

471 Commits

Author SHA1 Message Date
Michael Genson
e4f38685b3 fix(deps): Bump various frontend deps (#6774) 2025-12-23 17:12:52 -06:00
Michael Genson
d02023e12c fix: Only fetch recipes with a household id (#6773) 2025-12-23 16:48:27 -06:00
Michael Genson
64d8786d8f fix: Improve recipe bulk deletion (#6772) 2025-12-23 12:31:53 -06:00
Michael Genson
0971d59fa6 fix: Can't remove organizer (#6771) 2025-12-23 11:32:14 -06:00
Hayden
9b799ca441 chore(l10n): New Crowdin updates (#6768) 2025-12-22 23:36:50 -06:00
Michael Genson
193b823688 feat: Replace number inputs with new v-number-input compontent (#6767) 2025-12-22 18:45:52 -06:00
Michael Genson
c64c2d25e7 fix: Add resiliency to LDAP admin filter (#6766) 2025-12-22 15:37:15 -06:00
onemustpersist
8b4111d68f fix: Imported API keys not working on a new server #6477 (#6496)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
2025-12-22 12:08:44 -06:00
miah
9d601ea4b5 feat: Animate shopping list and increase touch target (#6569)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-22 11:55:25 -06:00
gitolicious
95e1bbce2b feat: persist selected dates in meal planner (#6512)
Co-authored-by: Michael Genson <genson.michael@gmail.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-22 11:10:23 -06:00
Aurelien
7b32508201 fix: the add_pagination_to_query now always returns the correct count (#6505)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-22 10:39:35 -06:00
Hayden
6ed85d72d7 chore(l10n): New Crowdin updates (#6765) 2025-12-22 15:59:19 +00:00
github-actions[bot]
cd2a522f25 chore(auto): Update pre-commit hooks (#6760)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-12-22 15:48:53 +00:00
Arsène Reymond
6bd6400aba fix: add loader for create backup button (#6763) 2025-12-22 09:38:44 -06:00
Michael Genson
8b92d6ee04 fix: Backup selection doesn't work sometimes (#6759) 2025-12-21 20:56:51 -06:00
Michael Genson
7cc2ed75e5 fix: Consistant Shopping List Recipe State (#6758) 2025-12-21 20:27:00 -06:00
renovate[bot]
cb7f46c0ad fix(deps): update dependency fastapi to v0.127.0 (#6756)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-21 13:52:34 -06:00
renovate[bot]
cb12aedf72 fix(deps): update dependency openai to v2.14.0 (#6745) 2025-12-21 12:48:06 -06:00
renovate[bot]
8c35a26ab0 chore(deps): update dependency mkdocs-material to v9.7.1 (#6741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-21 18:05:50 +00:00
github-actions[bot]
b2d0f46dd2 chore(l10n): Crowdin locale sync (#6754)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-21 11:53:25 -06:00
Hayden
2c4b7bf611 chore(l10n): New Crowdin updates (#6748)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-21 11:52:47 -06:00
renovate[bot]
38e542bcd3 fix(deps): update dependency python-multipart to v0.0.21 (#6737)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-21 11:52:15 -06:00
renovate[bot]
e53452c19c fix(deps): update dependency uvicorn to v0.40.0 (#6757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-21 11:35:48 -06:00
Hayden
13213476d8 docs: add --no-project flags to skip mealie installation (#6755) 2025-12-20 21:23:11 -06:00
Hayden
9925450173 docs: isolate docs dependencies to avoid python-ldap build (#6753) 2025-12-20 20:45:59 -06:00
Hayden
efb9dae681 docs: add GitHub Actions workflow for docs deployment (#6752) 2025-12-20 20:26:14 -06:00
renovate[bot]
cee93d2a87 fix(deps): update dependency fastapi to v0.126.0 (#6750)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-20 16:06:03 -06:00
Arsène Reymond
0d4a8654c1 fix: PWA maskable android icons & enctype shared_target (#6731)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-19 05:06:27 +00:00
Hayden
95b1be07bb chore(l10n): New Crowdin updates (#6744)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-19 04:53:09 +00:00
mealie-commit-bot[bot]
a6fc98fc82 chore: bump version to v3.8.0 2025-12-19 01:37:16 +00:00
Michael Genson
6f03010f6c fix: Security Patches (#6743) 2025-12-18 22:54:16 +00:00
renovate[bot]
69397c91b8 fix(deps): update dependency openai to v2.13.0 (#6726)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-18 21:45:42 +00:00
Hayden
798792dcdc chore(l10n): New Crowdin updates (#6736)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-18 15:32:22 -06:00
renovate[bot]
cc32dd9fa6 chore(deps): update dependency ruff to v0.14.10 (#6742)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-18 15:32:00 -06:00
renovate[bot]
0c64eb29f9 fix(deps): update dependency fastapi to v0.125.0 (#6740)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 17:37:43 -06:00
renovate[bot]
8baa5cc315 chore(deps): update dependency pre-commit to v4.5.1 (#6734)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 00:07:49 +00:00
Hayden
6f3a5c6c8f chore(l10n): New Crowdin updates (#6733) 2025-12-16 17:42:13 +00:00
Hayden
778078590b chore(l10n): New Crowdin updates (#6729) 2025-12-15 22:54:37 -06:00
github-actions[bot]
53c82e5491 chore(auto): Update pre-commit hooks (#6724) 2025-12-15 18:16:59 +00:00
Hayden
fef114d97f chore(l10n): New Crowdin updates (#6725) 2025-12-15 08:55:48 -06:00
Hayden
e80cbfad7f chore(l10n): New Crowdin updates (#6722)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-14 23:31:48 -06:00
renovate[bot]
99527ce738 chore(deps): update dependency mypy to v1.19.1 (#6723)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-14 23:31:34 -06:00
Arsène Reymond
08ccced734 fix: localize text validators message (#6719) 2025-12-14 09:56:11 -06:00
github-actions[bot]
43c2c9552b chore(l10n): Crowdin locale sync (#6716)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-13 22:10:43 -06:00
Hayden
db5741c7ee chore(l10n): New Crowdin updates (#6710) 2025-12-13 22:10:23 -06:00
renovate[bot]
a1e394cf36 fix(deps): update dependency tzdata to v2025.3 (#6713) 2025-12-13 15:21:42 -06:00
Michael Genson
bdbef1ab9e fix: More lenient postgres override parsing (#6712) 2025-12-13 14:21:54 -06:00
Michael Genson
e5276f6c20 fix: Put tooltips behind app bar (#6711) 2025-12-13 10:56:18 -06:00
Michael Genson
20a6e71b31 feat: Optionally include URL when importing via HTML/JSON (#6709) 2025-12-12 23:20:26 -06:00
Michael Genson
24c111af7b chore: Miscellaneous cleanup (#6708) 2025-12-12 22:48:49 -06:00
davidschinkel
ab4559319e fix: Improved bulk deletion by reducing refreshs (#6634)
Co-authored-by: David Schinkel <david@zollsoft.de>
Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
2025-12-13 04:08:04 +00:00
Michael Genson
2f8625ac44 fix: Disable submit on enter when editing timeline events (#6707) 2025-12-12 21:56:36 -06:00
Hayden
dd146afa57 chore(l10n): New Crowdin updates (#6706) 2025-12-12 21:20:34 -06:00
renovate[bot]
91d15f671e fix(deps): update dependency authlib to v1.6.6 (#6700)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 01:54:41 +00:00
renovate[bot]
7008b13246 fix(deps): update dependency fastapi to v0.124.4 (#6702)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 19:43:35 -06:00
mealie-commit-bot[bot]
1a1798cd88 chore: bump version to v3.7.0 2025-12-13 01:21:07 +00:00
Michael Genson
64f47c1589 fix: Reprocess script UUID handling for postgres (#6705) 2025-12-12 19:18:52 -06:00
Michael Genson
326bb1eb8e feat: Reprocess image user script (#6704) 2025-12-12 18:30:49 -06:00
Hayden
80dc2ecfb7 chore(l10n): New Crowdin updates (#6701) 2025-12-12 13:53:06 +00:00
renovate[bot]
b72082663f chore(deps): update node.js to 20988bc (#6698) 2025-12-12 05:24:05 +00:00
renovate[bot]
f46ae423d3 chore(deps): update dependency ruff to v0.14.9 (#6699) 2025-12-11 23:13:36 -06:00
Hayden
05cdff8ae7 chore(l10n): New Crowdin updates (#6697) 2025-12-12 03:57:19 +01:00
Hayden
0facdf73be chore(l10n): New Crowdin updates (#6694) 2025-12-11 21:38:11 +00:00
renovate[bot]
cbad569134 fix(deps): update dependency openai to v2.11.0 (#6696)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 15:27:38 -06:00
Hayden
1063433aa9 chore(l10n): New Crowdin updates (#6693) 2025-12-10 19:47:30 -06:00
renovate[bot]
0ba22c81e7 fix(deps): update dependency fastapi to v0.124.2 (#6688) 2025-12-10 18:59:49 +00:00
renovate[bot]
0667177a2e fix(deps): update dependency recipe-scrapers to v15.11.0 (#6691) 2025-12-10 18:48:05 +00:00
Hayden
6fcf22869b chore(l10n): New Crowdin updates (#6689) 2025-12-10 12:37:02 -06:00
renovate[bot]
20b45e57e0 fix(deps): update dependency sqlalchemy to v2.0.45 (#6687)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 20:53:09 -06:00
Hayden
7a38a52158 chore(l10n): New Crowdin updates (#6686) 2025-12-09 19:45:24 -06:00
renovate[bot]
e27eca5571 chore(deps): update node.js to 9a2ed90 (#6684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-09 12:53:03 -06:00
renovate[bot]
a90b2ccafd chore(deps): update dependency coverage to v7.13.0 (#6683)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 12:52:45 -06:00
Michael Genson
e0d8104643 feat: Suggest HTML importer on URL importer failure (#6685) 2025-12-09 11:05:34 -06:00
Hayden
53ee64828b chore(l10n): New Crowdin updates (#6678)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-09 10:27:21 -06:00
Arsène Reymond
6f7fba5ac1 feat: Add user QueryFilter and improve UI on mobile (#6235)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
2025-12-09 09:49:12 -06:00
github-actions[bot]
89aed15905 chore(auto): Update pre-commit hooks (#6680)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-12-08 14:55:38 +00:00
renovate[bot]
aac48287a4 fix(deps): update dependency apprise to v1.9.6 (#6677)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 08:45:21 -06:00
Hayden
34daaa0476 chore(l10n): New Crowdin updates (#6675) 2025-12-07 09:43:47 -06:00
Henri Cook
af56a3e69d fix: improve password manager autofill compatibility on login page (#6662)
Co-authored-by: Henri Cook <henri.cook@linklaters.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-06 23:35:16 -06:00
github-actions[bot]
0908812b47 chore(l10n): Crowdin locale sync (#6672)
Co-authored-by: GitHub Action <action@github.com>
2025-12-06 22:58:22 -06:00
renovate[bot]
d910fbafe8 chore(deps): update dependency pytest to v9.0.2 (#6670) 2025-12-07 00:15:15 +00:00
renovate[bot]
c7692426d5 fix(deps): update dependency orjson to v3.11.5 (#6667) 2025-12-07 00:04:01 +00:00
renovate[bot]
b7a615add9 fix(deps): update dependency fastapi to v0.124.0 (#6664) 2025-12-06 17:52:57 -06:00
Hayden
3167e23b6b chore(l10n): New Crowdin updates (#6671) 2025-12-06 17:21:05 -06:00
Hayden
8b582f8682 feat: autofill default credentials on first login (#6666) 2025-12-06 17:47:33 +00:00
Hayden
05f648d7fb fix: clear cached store data on logout to prevent user data leakage (#6665) 2025-12-06 11:36:39 -06:00
Nathan Winspear
1f19133870 docs: add theming examples to backend configuration guide (#6443)
Co-authored-by: Michael Genson <genson.michael@gmail.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-05 19:35:33 -06:00
Hayden
98273da16e chore(l10n): New Crowdin updates (#6661)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-05 16:29:47 -06:00
miah
f857ca18da feat: Improve startup workflow UI (#6342)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-05 22:18:32 +00:00
renovate[bot]
22a0e6d608 fix(deps): update dependency fastapi to v0.123.10 (#6660)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-05 15:58:01 -06:00
Tempest
ed806b9fec feat: Improve Image Minification Logic and Efficiency (#5883)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
2025-12-05 15:45:53 -06:00
Michael Genson
ae8b489f97 dev: Add copilot-instructions.md (#6659) 2025-12-05 13:00:45 -06:00
Noneangel
71732d4766 feat: frontend autocomplete is diacritics/ligatures insensitive (#6169)
Co-authored-by: Pierre <pierre@debian.zabi.ovh>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-05 12:44:37 -06:00
Cash Prokop-Weaver
6695314588 feat: Add snack, drink, and dessert (#6149)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
2025-12-05 11:54:00 -06:00
Nico Hirsch
c115e6d83f feat: Put calendar directly in the date picker dialogs (#6110)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
2025-12-05 10:05:47 -06:00
renovate[bot]
e3e970213c fix(deps): update dependency fastapi to v0.123.9 (#6657)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 23:29:52 -06:00
Hayden
7fe358e5e7 chore(l10n): New Crowdin updates (#6653) 2025-12-04 21:54:37 +00:00
renovate[bot]
c7f3334479 fix(deps): update dependency openai to v2.9.0 (#6656)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 15:43:47 -06:00
renovate[bot]
d4467f65fb fix(deps): update dependency fastapi to v0.123.8 (#6640)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 21:23:23 +00:00
renovate[bot]
27e61ec6b1 chore(deps): update dependency ruff to v0.14.8 (#6655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 15:12:20 -06:00
Hayden
6c6dc8103d chore(l10n): New Crowdin updates (#6649) 2025-12-03 10:25:28 +01:00
Hayden
35963dad2e fix: change log rotation size from 10kb to 10mb (#6648) 2025-12-02 19:06:51 -06:00
mealie-commit-bot[bot]
acd0c2cb3e chore: bump version to v3.6.1 2025-12-02 23:08:41 +00:00
Michael Genson
28d00f7dd5 fix: Bump version before building release (#6647) 2025-12-02 16:51:46 -06:00
Michael Genson
fdd3d4b37a fix: Remove Auth Refresh (#6646) 2025-12-02 15:55:01 -06:00
Hayden
b09a85dfab chore(l10n): New Crowdin updates (#6643) 2025-12-02 10:39:36 -06:00
github-actions[bot]
b6ceece901 docs(auto): Update image tag, for release v3.6.0 (#6639) 2025-12-01 23:35:45 -06:00
renovate[bot]
54b8760d15 fix(deps): update dependency fastapi to v0.123.1 (#6638)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 05:00:40 +00:00
davidschinkel
187e0300a0 fix: Enabled newlines in timeline comment (#5905) (#6620)
Co-authored-by: David Schinkel <david@zollsoft.de>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-01 22:48:24 -06:00
github-actions[bot]
c398316b55 chore(auto): Update pre-commit hooks (#6632)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-12-02 04:46:26 +00:00
Hayden
eb093a755b chore(l10n): New Crowdin updates (#6637) 2025-12-02 04:35:15 +00:00
Hayden
2e982fad82 chore(l10n): New Crowdin updates (#6631) 2025-11-30 20:54:05 -06:00
renovate[bot]
f5570bf9b2 fix(deps): update dependency beautifulsoup4 to v4.14.3 (#6629)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 10:46:27 -06:00
renovate[bot]
ddd7ee0696 fix(deps): update dependency fastapi to v0.123.0 (#6627)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 16:06:01 +00:00
Hayden
f1b5b999b9 chore(l10n): New Crowdin updates (#6628) 2025-11-30 15:55:01 +00:00
renovate[bot]
47892f84be chore(deps): update dependency pylint to v4.0.4 (#6626)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 09:44:26 -06:00
github-actions[bot]
18002351b6 chore(l10n): Crowdin locale sync (#6625)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-30 03:13:02 +00:00
Hayden
9605c448e7 chore(l10n): New Crowdin updates (#6624) 2025-11-30 02:46:27 +00:00
renovate[bot]
9499c2942c fix(deps): update dependency pydantic-settings to v2.12.0 (#6617)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 23:40:05 +00:00
renovate[bot]
f04bd7b777 fix(deps): update dependency openai to v2.8.1 (#6616)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 23:26:16 +00:00
renovate[bot]
710708ea68 fix(deps): update dependency fastapi to v0.122.0 (#6615)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 23:14:55 +00:00
Michael Genson
bb196da83b fix: Bump Pydantic to v2.12.5 (#6622) 2025-11-29 17:04:13 -06:00
renovate[bot]
d500fbf0b4 chore(deps): update dependency pre-commit to v4.5.0 (#6614)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 16:47:05 -06:00
renovate[bot]
ca94ca973c chore(deps): update dependency mypy to v1.19.0 (#6613) 2025-11-29 22:23:35 +00:00
renovate[bot]
454d1eff1c chore(deps): update dependency mkdocs-material to v9.7.0 (#6612) 2025-11-29 16:11:58 -06:00
renovate[bot]
280be88fc5 chore(deps): update dependency coverage to v7.12.0 (#6611) 2025-11-29 15:27:39 -06:00
renovate[bot]
e24c37957b fix(deps): update dependency rapidfuzz to v3.14.3 (#6610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 20:59:37 +00:00
renovate[bot]
46b069ba71 fix(deps): update dependency alembic to v1.17.2 (#6608)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 14:48:10 -06:00
renovate[bot]
2caed5e192 chore(deps): update dependency pylint to v4.0.3 (#6605) 2025-11-29 13:16:27 -06:00
renovate[bot]
406f44e6a7 chore(deps): update dependency types-python-dateutil to v2.9.0.20251115 (#6607)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 18:07:56 +00:00
renovate[bot]
f6787f18ba chore(deps): update dependency ruff to v0.14.7 (#6606)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 17:56:01 +00:00
Michael Genson
1d64f428db chore: Fail frontend lint if there are warnings (#6619) 2025-11-29 11:33:17 -06:00
renovate[bot]
77906da9f1 fix(deps): update dependency recipe-scrapers to v15.10.0 (#6618)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 17:32:40 +00:00
renovate[bot]
35d470f5ea fix(deps): pin dependencies (#6604)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 17:04:14 +00:00
Hayden
d7cdcfa734 chore(l10n): New Crowdin updates (#6594) 2025-11-29 16:53:03 +00:00
Michael Genson
bfbdf76c2d chore: Update Renovate config to pin versions in pyproject.toml (#6603) 2025-11-29 10:42:01 -06:00
github-actions[bot]
7cc0fafbaa chore(auto): Update pre-commit hooks (#6558)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-11-29 16:23:16 +00:00
Simon Lam
5b65ceda93 fix: Asset type selector dropdown #6413; asset entry layout; asset download content disposition (#6595)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-29 10:13:26 -06:00
Michael Genson
07ecd88685 feat: Remove backend cookie and use frontend for auth (#6601) 2025-11-28 19:29:16 -06:00
gpotter@gmail.com
8f1ce1a1c3 fix: recipe recursion false positive (#6591)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
2025-11-28 11:28:18 -06:00
cordlord
3146e99b03 fix: PWA follows OS screen rotation/lock settings (#6573) 2025-11-25 21:42:38 +00:00
Simon Lam
fe53cc28ba fix: Tool management bug #6447 - correct mismatch between event fired vs event handler (#6590)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-23 17:14:59 -06:00
github-actions[bot]
d85635997b chore(l10n): Crowdin locale sync (#6589)
Co-authored-by: GitHub Action <action@github.com>
2025-11-23 23:13:26 +00:00
Hayden
1ca29df52e chore(l10n): New Crowdin updates (#6565)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-11-23 02:59:59 +00:00
renovate[bot]
ee5de10ffb chore(deps): update node.js to aa648b3 (#6568)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-22 12:41:51 -06:00
Michael Genson
201ab4b8ac chore: lint (#6582) 2025-11-21 23:37:59 -06:00
miah
45af609161 dev: Allow dev server to be accessed on local network (#6581)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-21 23:19:37 -06:00
Michael Genson
c4a3068492 fix: Set maxAge on frontend auth cookie (#6576) 2025-11-20 15:18:27 -06:00
ithabi
6d4f573526 fix: Favorites page fails to load when sorted by random (#6517)
Co-authored-by: “a24ithay” <“abilan.ithayakumar@imt-atlantique.net”>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-20 15:04:49 +00:00
ithabi
3c14df453e fix : Can't edit extra long category name depending on resolution (#6536)
Co-authored-by: “a24ithay” <“abilan.ithayakumar@imt-atlantique.net”>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-20 14:54:23 +00:00
Hayden
9826f3483e chore(l10n): New Crowdin updates (#6563) 2025-11-18 09:30:53 +01:00
Hayden
caf0f5f441 chore(l10n): New Crowdin updates (#6561) 2025-11-17 14:36:42 -06:00
github-actions[bot]
b599de9c22 chore(l10n): Crowdin locale sync (#6553)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-11-17 12:15:00 +00:00
Hayden
fd7aa44c13 chore(l10n): New Crowdin updates (#6559) 2025-11-17 12:20:54 +01:00
Hayden
82b7bacdb7 chore(l10n): New Crowdin updates (#6557) 2025-11-16 20:32:32 +01:00
Hayden
84f86c2682 chore(l10n): New Crowdin updates (#6554) 2025-11-16 09:39:17 +01:00
Hayden
527edb1a92 chore(l10n): New Crowdin updates (#6552) 2025-11-15 19:22:47 +00:00
Hayden
6e11b92e74 chore(l10n): New Crowdin updates (#6548) 2025-11-15 10:12:28 +01:00
Hayden
3f5b25a30e chore(l10n): New Crowdin updates (#6547) 2025-11-14 18:41:48 +00:00
github-actions[bot]
662d06b5a8 docs(auto): Update image tag, for release v3.5.0 (#6542) 2025-11-14 18:22:32 +00:00
Hayden
9003d0f1d1 chore(l10n): New Crowdin updates (#6513)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-11-14 12:12:35 -06:00
Lory
1cf7e37ada fix: prevent URL encoding in postgres placeholder display (#6438) 2025-11-14 16:17:17 +00:00
Arsène Reymond
930c92365d fix: Improve recipe ingredient selection (#6518)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-14 10:05:54 -06:00
Aurelien
6f1fee5511 fix: Make Ingredients and Instructions independently scrollable in cook mode (#6358)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-14 09:45:36 -06:00
renovate[bot]
f5de126d86 chore(deps): update node.js to 42ce5b9 (#6539)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-13 05:15:20 +00:00
renovate[bot]
725dae41b1 chore(deps): update dependency pytest to v9 (#6525)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-12 23:04:56 -06:00
github-actions[bot]
39e919526a chore(auto): Update pre-commit hooks (#6528)
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-11-12 17:19:19 +00:00
renovate[bot]
1978ad2c96 chore(deps): update node.js to e5bbac0 (#6507)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-12 11:04:49 -06:00
github-actions[bot]
23e8dc1941 chore(l10n): Crowdin locale sync (#6524) 2025-11-08 22:15:51 -06:00
Hayden
96b408a661 chore(l10n): New Crowdin updates (#6508) 2025-11-05 09:39:12 +01:00
Hayden
20a9a94770 chore(l10n): New Crowdin updates (#6506) 2025-11-04 21:30:09 +01:00
renovate[bot]
b280e2d1a0 chore(deps): update node.js to 55b6bbe (#6503)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 11:09:32 -06:00
Hayden
735162d042 chore(l10n): New Crowdin updates (#6502) 2025-11-04 09:03:03 -06:00
gpotter@gmail.com
60d9294861 feat: Add recipe as ingredient (#4800)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-03 23:57:57 -06:00
Michael Genson
ff42964537 fix: Brute parser fails if unit or food is empty (#6500) 2025-11-03 23:44:13 -06:00
Christian Hollinger
bb67d993a0 feat: Add DELETE /{slug}/image (#6259)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-03 19:41:54 -06:00
Michael Genson
7bb0f0801a fix: Stabilize shopping list queuing (#6498) 2025-11-03 18:38:33 -06:00
Hayden
3a4875a54f chore(l10n): New Crowdin updates (#6495) 2025-11-03 20:53:45 +00:00
Michael Genson
0371874670 fix: Refactor Recipe Zip File Flow (#6170) 2025-11-03 14:43:22 -06:00
Tom Strange
3d177566ed fix: Include contents of purpose field when parsing ingredients (#6494) 2025-11-03 18:09:00 +00:00
github-actions[bot]
14e87918fb chore(auto): Update pre-commit hooks (#6493)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-11-03 16:15:38 +00:00
Hayden
ac75b0254d chore(l10n): New Crowdin updates (#6492) 2025-11-03 09:12:57 +01:00
Michael Genson
7f2927600b chore: Update some frontend deps (#6490) 2025-11-02 22:42:19 -06:00
aliyyanWijaya
5e8c4a6cee fix: Update the random button flow (#6248)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-03 02:52:17 +00:00
Arsène Reymond
a460c32674 fix: Locale dates format (#6211)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-02 20:39:33 -06:00
Hayden
973cd5ab02 chore(l10n): New Crowdin updates (#6487) 2025-11-02 20:08:02 +01:00
Hayden
ac355c1071 chore(l10n): New Crowdin updates (#6486) 2025-11-02 01:07:56 -05:00
github-actions[bot]
3a617cd3c3 chore(l10n): Crowdin locale sync (#6485)
Co-authored-by: GitHub Action <action@github.com>
2025-11-01 23:05:26 -05:00
Hayden
3c874c2f85 chore(l10n): New Crowdin updates (#6478) 2025-11-01 20:11:17 +00:00
renovate[bot]
fb3be73163 chore(deps): update dependency types-python-slugify to v8 (#6480)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-01 20:00:36 +00:00
renovate[bot]
14b783852e fix(deps): update dependency tzdata to v2025 (#6481)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-01 19:49:51 +00:00
Michael Genson
75616d66b8 dev: Migrate to uv (#6470) 2025-11-01 14:36:40 -05:00
github-actions[bot]
01713b0416 docs(auto): Update image tag, for release v3.4.0 (#6471)
Co-authored-by: michael-genson <71845777+michael-genson@users.noreply.github.com>
2025-10-31 19:29:05 +00:00
Hayden
123a8b99f8 chore(l10n): New Crowdin updates (#6469) 2025-10-31 19:15:33 +00:00
renovate[bot]
6732fcd696 chore(deps): update dependency fastapi to v0.120.3 (#6465)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-31 11:08:41 -05:00
Hayden
5fcbfbf361 chore(l10n): New Crowdin updates (#6464) 2025-10-31 01:06:46 +05:45
Hayden
1318998bc9 chore(l10n): New Crowdin updates (#6462) 2025-10-30 12:59:57 +05:45
renovate[bot]
0947212271 chore(deps): update dependency fastapi to v0.120.2 (#6457) 2025-10-29 14:39:33 +00:00
renovate[bot]
92ac5c6253 chore(deps): update node.js to v24 (#6451) 2025-10-29 09:27:00 -05:00
renovate[bot]
5f96f4b47f chore(deps): update dependency fastapi to v0.120.1 (#6450)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-29 08:49:32 -05:00
renovate[bot]
dbcd430425 chore(deps): update dependency alembic to v1.17.1 (#6456)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-29 04:52:13 +00:00
renovate[bot]
4c9164594b chore(deps): update dependency python-dotenv to v1.2.1 (#6442)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 23:39:37 -05:00
renovate[bot]
e5a13f8b43 chore(deps): update dependency ingredient-parser-nlp to v2.4.0 (#6448)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-29 01:34:43 +00:00
github-actions[bot]
726ad10c7e chore(auto): Update pre-commit hooks (#6445)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-10-29 01:22:51 +00:00
Hayden
df53310f2e chore(l10n): New Crowdin updates (#6455) 2025-10-28 22:12:48 +05:45
Hayden
82bf5c1bae chore(l10n): New Crowdin updates (#6446) 2025-10-28 10:32:51 +05:45
Hayden
c70a63f0ff chore(l10n): New Crowdin updates (#6444) 2025-10-27 12:16:52 +05:45
Hayden
14bfa6bcae chore(l10n): New Crowdin updates (#6441) 2025-10-26 09:52:59 -05:00
github-actions[bot]
adbafef157 chore(l10n): Crowdin locale sync (#6440)
Co-authored-by: GitHub Action <action@github.com>
2025-10-26 04:06:16 +00:00
Hayden
62d52f53e4 chore(l10n): New Crowdin updates (#6439) 2025-10-25 22:55:47 -05:00
Florian Fischer
4370319fec fix: Food seed only works for American English (#6204) (#6436)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-10-25 12:40:02 -05:00
Hayden
15908d190d chore(l10n): New Crowdin updates (#6435) 2025-10-25 10:07:21 -05:00
Hayden
fcb909e072 chore(l10n): New Crowdin updates (#6434) 2025-10-25 01:50:15 +00:00
Aurelien
8e532af4d9 fix: Heart and Ranking Stars overlap each other (#6359)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-10-24 17:35:07 -05:00
Richard vL
831cb6dd17 fix: Changed sorting icons (#6354)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-10-24 21:51:11 +00:00
Michael Genson
089bb24c0f fix: Make docs:gen consistent regardless of timestamp (again) (#6432) 2025-10-24 16:34:44 -05:00
Fernando Muñoz Paredes
107dfc34de fix: dash slug names (#5709)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-10-24 16:01:55 -05:00
renovate[bot]
144d4caea6 chore(deps): update dependency orjson to v3.11.4 (#6431)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-24 14:02:45 -05:00
renovate[bot]
b3db81b9a4 chore(deps): update dependency openai to v2.6.1 (#6429)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-24 18:29:53 +00:00
renovate[bot]
dc2bbdc494 fix(deps): update dependency fastapi to ^0.120.0 (#6426)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-24 13:17:47 -05:00
Hayden
8f17a08923 chore(l10n): New Crowdin updates (#6396) 2025-10-24 15:44:12 +00:00
renovate[bot]
f6209bff54 chore(deps): update node.js to 23c24e8 (#6424)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-24 15:31:16 +00:00
Michael Genson
33865285d1 fix: Use crossorigin: "use-credentials" with PWA manifest (#6430) 2025-10-24 10:20:04 -05:00
renovate[bot]
e226b9b1d5 fix(deps): update dependency vite to v7 [security] (#6412)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-24 14:55:04 +00:00
miah
201c63d1e4 feat: Improve shopping list label sections (#6345)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-10-24 09:43:55 -05:00
renovate[bot]
a242f567ad chore(deps): update dependency ruff to v0.14.2 (#6425)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 23:08:29 -05:00
renovate[bot]
67ead2e8a1 chore(deps): update node.js to a2a7dcc (#6422)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-22 13:54:33 +00:00
renovate[bot]
7b273b77e2 chore(deps): update node.js to 58644f2 (#6418)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-22 08:42:55 -05:00
renovate[bot]
b4cd095360 chore(deps): update dependency pylint to v4.0.2 (#6409)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 18:22:02 +00:00
renovate[bot]
a9bb27c782 chore(deps): update dependency fastapi to v0.119.1 (#6408)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 13:08:10 -05:00
renovate[bot]
9df1523911 fix(deps): update dependency uvicorn to ^0.38.0 (#6400)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 12:47:15 -05:00
renovate[bot]
0c8a1ae608 chore(deps): update dependency openai to v2.6.0 (#6398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 17:26:57 +00:00
renovate[bot]
7d54404bf0 chore(deps): update dependency ruff to v0.14.1 (#6397)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 12:04:13 -05:00
github-actions[bot]
8bbe70d245 chore(auto): Update pre-commit hooks (#6407)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-10-21 16:16:07 +00:00
renovate[bot]
6c87f7fe33 chore(deps): update dependency pydantic to v2.12.3 (#6377)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 11:04:10 -05:00
miah
7e168eb75b feat: Support User-Level Default Activities (#5125) 2025-10-15 21:30:08 -05:00
Hayden
64d481b4fc chore(l10n): New Crowdin updates (#6395) 2025-10-15 21:08:22 +00:00
renovate[bot]
a9926557bc fix(deps): update dependency pillow to v12 (#6394)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-15 15:55:25 -05:00
renovate[bot]
2a908c0dd2 chore(deps): update dependency coverage to v7.11.0 (#6392) 2025-10-15 14:50:55 -05:00
renovate[bot]
c64a0dc769 chore(deps): update dependency mkdocs-material to v9.6.22 (#6391) 2025-10-15 10:46:58 -05:00
renovate[bot]
7ce9c35ef5 chore(deps): update dependency pylint to v4.0.1 (#6389) 2025-10-15 13:17:11 +00:00
Hayden
0acca2021d chore(l10n): New Crowdin updates (#6388) 2025-10-15 08:05:19 -05:00
Michael Genson
5de0b48aa9 fix: Upgrade Pydantic and remove manual Postgres URL parsing (#6385) 2025-10-14 15:52:50 -05:00
Hayden
ffe199c083 chore(l10n): New Crowdin updates (#6384) 2025-10-14 20:25:34 +00:00
Michael Genson
215a18be42 fix: Check x-forwarded-proto header when determining auth cookie samesite attribute (#6383) 2025-10-14 12:38:03 -05:00
github-actions[bot]
a1b065e5d1 chore(auto): Update pre-commit hooks (#6370)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-10-14 16:22:22 +00:00
Hayden
d660d89a1b chore(l10n): New Crowdin updates (#6381) 2025-10-14 10:22:03 -05:00
Hayden
ade1f797a9 chore(l10n): New Crowdin updates (#6376) 2025-10-13 19:45:53 +00:00
Ritoban Dutta
192872b9ec fix: Change 'Units' to 'Unit' in shopping list item editor (#6372) 2025-10-13 17:41:29 +00:00
renovate[bot]
25ebcb1a05 chore(deps): update dependency pylint to v4 (#6366)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 15:21:26 +00:00
Hayden
89d95ca5e1 chore(l10n): New Crowdin updates (#6371) 2025-10-13 10:09:53 -05:00
Hayden
b705652af3 chore(l10n): New Crowdin updates (#6367) 2025-10-12 14:28:54 -05:00
github-actions[bot]
b0c78de2da chore(l10n): Crowdin locale sync (#6364)
Co-authored-by: GitHub Action <action@github.com>
2025-10-12 04:15:09 +00:00
renovate[bot]
c4b1f9fd01 fix(deps): update dependency fastapi to ^0.119.0 (#6362)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-11 23:01:45 -05:00
renovate[bot]
2b0d8227f4 chore(deps): update dependency alembic to v1.17.0 (#6361)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-11 19:14:31 +00:00
Hayden
42517e9f8a chore(l10n): New Crowdin updates (#6357) 2025-10-11 14:01:21 -05:00
renovate[bot]
37c97c8aba chore(deps): update dependency python-ldap to v3.4.5 [security] (#6356)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-10 22:52:36 -05:00
Hayden
64d36a2608 chore(l10n): New Crowdin updates (#6353) 2025-10-10 12:49:24 -05:00
renovate[bot]
563defe074 chore(deps): update dependency sqlalchemy to v2.0.44 (#6352)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-10 12:14:59 -05:00
renovate[bot]
f5ffb760d3 chore(deps): update dependency psycopg2-binary to v2.9.11 (#6351)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-10 16:34:05 +00:00
renovate[bot]
3118a0c0cf fix(deps): update dependency aiofiles to v25 (#6344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-10 11:19:52 -05:00
renovate[bot]
444beb68f9 chore(deps): update dependency rich to v14.2.0 (#6341)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-10 16:03:34 +00:00
renovate[bot]
49a97ebc0e chore(deps): update dependency fastapi to v0.118.3 (#6336)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-10 10:46:29 -05:00
renovate[bot]
6f682b742e chore(deps): update dependency pydantic to v2.12.0 (#6310)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-10 10:24:12 -05:00
Hayden
32d4d22bb8 chore(l10n): New Crowdin updates (#6347)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-10-10 05:33:04 +00:00
Dallin Miner
71d86489f4 feat: Add new migration for DVO Cook'n (#5085)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-10-10 00:22:51 -05:00
github-actions[bot]
a95eaf3d2e docs(auto): Update image tag, for release v3.3.2 (#6346)
Co-authored-by: michael-genson <71845777+michael-genson@users.noreply.github.com>
2025-10-10 04:14:06 +00:00
renovate[bot]
414af989e7 chore(deps): update dependency openai to v2.3.0 (#6330)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-09 23:02:07 -05:00
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
477 changed files with 48397 additions and 44278 deletions

View File

@@ -8,28 +8,14 @@ FROM mcr.microsoft.com/devcontainers/python:${VARIANT}
ARG NODE_VERSION="none"
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
# install poetry - respects $POETRY_VERSION & $POETRY_HOME
RUN echo "export PROMPT_COMMAND='history -a'" >> /home/vscode/.bashrc \
&& echo "export HISTFILE=~/commandhistory/.bash_history" >> /home/vscode/.bashrc \
&& chown vscode:vscode -R /home/vscode/
RUN npm install -g @go-task/cli
RUN npm install -g json-schema-to-typescript
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=off \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 \
POETRY_HOME="/opt/poetry" \
POETRY_VIRTUALENVS_IN_PROJECT=true
# prepend poetry and venv to path
ENV PATH="$POETRY_HOME/bin:$PATH"
RUN curl -sSL https://install.python-poetry.org | python3 -
# RUN poetry config virtualenvs.create false
# Install additional OS packages
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
curl \
@@ -39,5 +25,9 @@ RUN apt-get update \
libsasl2-dev libldap2-dev libssl-dev \
gnupg gnupg2 gnupg1
# create directory used for Docker Secrets
# Install uv
RUN pip install uv
ENV UV_LINK_MODE=copy
# Create directory for Docker Secrets
RUN mkdir -p /run/secrets

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": [
@@ -23,7 +23,6 @@
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python",
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
"mypy.runUsingActiveInterpreter": true
},
@@ -31,10 +30,10 @@
"charliermarsh.ruff",
"dbaeumer.vscode-eslint",
"matangover.mypy",
"ms-python.black-formatter",
"ms-python.pylint",
"ms-python.python",
"ms-python.vscode-pylance",
"streetsidesoftware.code-spell-checker-cspell-bundled-dictionaries",
"Vue.volar"
]
}
@@ -42,13 +41,14 @@
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
3000,
8000, // used by mkdocs
9000,
9091, // used by docker production
24678 // used by nuxt when hot-reloading using polling
],
// Use 'onCreateCommand' to run commands at the end of container creation.
// Use 'postCreateCommand' to run commands after the container is created.
"onCreateCommand": "sudo chown -R vscode:vscode /workspaces/mealie/frontend/node_modules /home/vscode/commandhistory && task setup",
"onCreateCommand": "sudo chown -R vscode:vscode /workspaces/mealie/frontend/node_modules /home/vscode/commandhistory && task setup --force",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
"features": {
@@ -56,5 +56,8 @@
"dockerDashComposeVersion": "v2"
}
},
"appPort": 3000
"appPort": [
"3000:3000",
"9000:9000"
]
}

240
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,240 @@
# Mealie Development Guide for AI Agents
## Project Overview
Mealie is a self-hosted recipe manager, meal planner, and shopping list application with a FastAPI backend (Python 3.12) and Nuxt 3 frontend (Vue 3 + TypeScript). It uses SQLAlchemy ORM with support for SQLite and PostgreSQL databases.
**Development vs Production:**
- **Development:** Frontend (port 3000) and backend (port 9000) run as separate processes
- **Production:** Frontend is statically generated and served via FastAPI's SPA module (`mealie/routes/spa/`) in a single container
## Architecture & Key Patterns
### Backend Architecture (mealie/)
**Repository-Service-Controller Pattern:**
- **Controllers** (`mealie/routes/**/controller_*.py`): Inherit from `BaseUserController` or `BaseAdminController`, handle HTTP concerns, delegate to services
- **Services** (`mealie/services/`): Business logic layer, inherit from `BaseService`, coordinate repos and external dependencies
- **Repositories** (`mealie/repos/`): Data access layer using SQLAlchemy, accessed via `AllRepositories` factory
- Get repos via dependency injection: `repos: AllRepositories = Depends(get_repositories)`
- All repos scoped to group/household context automatically
**Route Organization:**
- Routes in `mealie/routes/` organized by domain (auth, recipe, groups, households, admin)
- Use `APIRouter` with FastAPI dependency injection
- Apply `@router.get/post/put/delete` decorators with Pydantic response models
- Route controllers use `HttpRepo` mixin for common CRUD operations (see `mealie/routes/_base/mixins.py`)
**Schemas & Type Generation:**
- Pydantic schemas in `mealie/schema/` with strict separation: `*In`, `*Out`, `*Create`, `*Update` suffixes
- Auto-exported from submodules via `__init__.py` files (generated by `task dev:generate`)
- TypeScript types auto-generated from Pydantic schemas - **never manually edit** `frontend/lib/api/types/`
**Database & Sessions:**
- Session management via `Depends(generate_session)` in FastAPI routes
- Use `session_context()` context manager in services/scripts
- SQLAlchemy models in `mealie/db/models/`, migrations in `mealie/alembic/`
- Create migrations: `task py:migrate -- "description"`
### Frontend Architecture (frontend/)
**Component Organization (strict naming conventions):**
- **Domain Components** (`components/Domain/`): Feature-specific, prefix with domain (e.g., `AdminDashboard`)
- **Global Components** (`components/global/`): Reusable primitives, prefix with `Base` (e.g., `BaseButton`)
- **Layout Components** (`components/Layout/`): Layout-only, prefix with `App` if props or `The` if singleton
- **Page Components** (`components/` with page prefix): Last resort for breaking up complex pages
**API Client Pattern:**
- API clients in `frontend/lib/api/` extend `BaseAPI`, `BaseCRUDAPI`, or `BaseCRUDAPIReadOnly`
- Types imported from auto-generated `frontend/lib/api/types/` (DO NOT EDIT MANUALLY)
- Composables in `frontend/composables/` for shared state and API logic (e.g., `use-mealie-auth.ts`)
- Use `useAuthBackend()` for authentication state, `useMealieAuth()` for user management
**State Management:**
- Nuxt 3 composables for state (no Vuex)
- Auth state via `use-mealie-auth.ts` composable
- Prefer composables over global state stores
## Essential Commands (via Task/Taskfile.yml)
**Development workflow:**
```bash
task setup # Install all dependencies (Python + Node)
task dev:services # Start Postgres & Mailpit containers
task py # Start FastAPI backend (port 9000)
task ui # Start Nuxt frontend (port 3000)
task docs # Start MkDocs documentation server
```
**Code generation (REQUIRED after schema changes):**
```bash
task dev:generate # Generate TypeScript types, schema exports, test helpers
```
**Testing & Quality:**
```bash
task py:test # Run pytest (supports args: task py:test -- -k test_name)
task py:check # Format + lint + type-check + test (full validation)
task py:format # Ruff format
task py:lint # Ruff check
task py:mypy # Type checking
task ui:test # Vitest frontend tests
task ui:check # Frontend lint + test
```
**Database:**
```bash
task py:migrate -- "description" # Generate Alembic migration
task py:postgres # Run backend with PostgreSQL config
```
**Docker:**
```bash
task docker:prod # Build and run production Docker compose
```
## Critical Development Practices
### Python Backend
1. **Always use `uv` for Python commands** (not `python` or `pip`):
```bash
uv run python mealie/app.py
uv run pytest tests/
```
2. **Type hints are mandatory:** Use mypy-compatible annotations, handle Optional types explicitly
3. **Dependency injection pattern:**
```python
from fastapi import Depends
from mealie.repos.all_repositories import get_repositories, AllRepositories
def my_route(
repos: AllRepositories = Depends(get_repositories),
user: PrivateUser = Depends(get_current_user)
):
recipe = repos.recipes.get_one(recipe_id)
```
4. **Settings & Configuration:**
- Get settings: `settings = get_app_settings()` (cached singleton)
- Get directories: `dirs = get_app_dirs()`
- Never instantiate `AppSettings()` directly
5. **Testing:**
- Fixtures in `tests/fixtures/`
- Use `api_client` fixture for integration tests
- Follow existing patterns in `tests/integration_tests/` and `tests/unit_tests/`
### Frontend
1. **Run code generation after backend schema changes:** `task dev:generate`
2. **TypeScript strict mode:** All code must pass type checking
3. **Component naming:** Follow strict conventions (see Architecture section above)
4. **API calls pattern:**
```typescript
const api = useUserApi();
const recipe = await api.recipes.getOne(recipeId);
```
5. **Composables for shared logic:** Prefer composables in `composables/` over inline code duplication
6. **Translations:** Only modify `en-US` locale files when adding new translation strings - other locales are managed via Crowdin and **must never be modified** (PRs modifying non-English locales will be rejected)
### Cross-Cutting Concerns
1. **Code generation is source of truth:** After Pydantic schema changes, run `task dev:generate` to update:
- TypeScript types (`frontend/lib/api/types/`)
- Schema exports (`mealie/schema/*/__init__.py`)
- Test data paths and routes
2. **Multi-tenancy:** All data scoped to **groups** and **households**:
- Groups contain multiple households
- Households contain recipes, meal plans, shopping lists
- Repositories automatically filter by group/household context
3. **Pre-commit hooks:** Install via `task setup:py`, enforces Ruff formatting/linting
4. **Testing before PRs:** Run `task py:check` and `task ui:check` before submitting PRs
## Pull Request Best Practices
### Before Submitting a PR
1. **Draft PRs are optional:** Create a draft PR early if you want feedback while working, or open directly as ready when complete
2. **Verify code generation:** If you modified Pydantic schemas, ensure `task dev:generate` was run
3. **Follow Conventional Commits:** Title your PR according to the conventional commits format (see PR template)
4. **Add release notes:** Include user-facing changes in the PR description
### What to Review
**Architecture & Patterns:**
- Does the code follow the repository-service-controller pattern?
- Are controllers delegating business logic to services?
- Are services coordinating repositories, not accessing the database directly?
- Is dependency injection used properly (`Depends(get_repositories)`, `Depends(get_current_user)`)?
**Data Scoping:**
- Are repositories correctly scoped to group/household context?
- Do route handlers properly validate group/household ownership before operations?
- Are multi-tenant boundaries enforced (users can't access other groups' data)?
**Type Safety:**
- Are type hints present on all functions and methods?
- Are Pydantic schemas using correct suffixes (`*In`, `*Out`, `*Create`, `*Update`)?
- For frontend, does TypeScript code pass strict type checking?
**Generated Files:**
- Verify `frontend/lib/api/types/` files weren't manually edited (they're auto-generated)
- Check that `mealie/schema/*/__init__.py` exports match actual schema files (auto-generated)
- If schemas changed, confirm generated files were updated via `task dev:generate`
**Code Quality:**
- Is the code readable and well-organized?
- Are complex operations documented with clear comments?
- Do component names follow the strict naming conventions (Domain/Global/Layout/Page prefixes)?
- Are composables used for shared frontend logic instead of duplication?
**Translations:**
- Were only `en-US` locale files modified for new translation strings?
- Verify no other locale files (managed by Crowdin) were touched
**Database Changes:**
- Are Alembic migrations included for schema changes?
- Are migrations tested against both SQLite and PostgreSQL?
### Review Etiquette
- Be constructive and specific in feedback
- Suggest code examples when proposing changes
- Focus on architecture and logic - formatting/linting is handled by CI
- Use "Approve" when ready to merge, "Request Changes" for blocking issues, "Comment" for non-blocking suggestions
## Common Gotchas
- **Don't manually edit generated files:** `frontend/lib/api/types/`, schema `__init__.py` files
- **Repository context:** Repos are group/household-scoped - passing wrong IDs causes 404s
- **Session handling:** Don't create sessions manually, use dependency injection or `session_context()`
- **Schema changes require codegen:** After changing Pydantic models, run `task dev:generate`
- **Translation files:** Only modify `en-US` locale files - all other locales are managed by Crowdin
- **Dev containers:** This project uses VS Code dev containers - leverage the pre-configured environment
- **Task commands:** Use `task` commands instead of direct tool invocation for consistency
## Key Files to Reference
- `Taskfile.yml` - All development commands and workflows
- `mealie/routes/_base/base_controllers.py` - Controller base classes and patterns
- `mealie/repos/repository_factory.py` - Repository factory and available repos
- `frontend/lib/api/base/base-clients.ts` - API client base classes
- `tests/conftest.py` - Test fixtures and setup
- `dev/code-generation/main.py` - Code generation entry point
## Additional Resources
- [Documentation](https://docs.mealie.io/)
- [Contributors Guide](https://nightly.mealie.io/contributors/developers-guide/code-contributions/)
- [Discord](https://discord.gg/QuStdQGSGK)

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 🛠
@@ -70,13 +70,8 @@ jobs:
with:
python-version: "3.12"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
plugins: |
poetry-plugin-export
- name: Install uv
run: pip install uv
- name: Retrieve built frontend
uses: actions/download-artifact@v4

50
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Deploy Documentation
on:
push:
branches: [mealie-next]
paths:
- 'docs/**'
- '.github/workflows/docs.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --only-group docs --no-install-project
- name: Build docs
run: uv run --no-project mkdocs build -d site
working-directory: docs
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

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

@@ -25,24 +25,21 @@ jobs:
with:
python-version: "3.12"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install uv
run: pip install uv
- name: Load cached venv
id: cached-poetry-dependencies
id: cached-python-dependencies
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
- name: Check venv cache
id: cache-validate
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
if: steps.cached-python-dependencies.outputs.cache-hit == 'true'
run: |
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
echo "import fastapi;print('venv good?')" > test.py && uv run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
rm test.py
continue-on-error: true
@@ -50,13 +47,13 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
poetry install
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
uv sync --group dev
if: steps.cached-python-dependencies.outputs.cache-hit != 'true'
- name: Run locale generation
run: |
cd dev/code-generation
poetry run python main.py locales
uv run python main.py locales
env:
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
@@ -86,7 +83,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 +93,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

@@ -5,17 +5,73 @@ on:
types: [published]
jobs:
commit-version-bump:
name: Commit version bump to repository
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
commit-sha: ${{ steps.commit.outputs.commit-sha }}
steps:
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
- name: Checkout 🛎
uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
- name: Extract Version From Tag Name
run: echo "VERSION_NUM=$(echo ${{ github.event.release.tag_name }} | sed 's/^v//')" >> $GITHUB_ENV
- name: Configure Git
run: |
git config user.name "mealie-commit-bot[bot]"
git config user.email "mealie-commit-bot[bot]@users.noreply.github.com"
- name: Update all version strings
run: |
sed -i 's/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' pyproject.toml
sed -i '/^name = "mealie"$/,/^version = / s/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' uv.lock
sed -i 's/\("version": "\)[^"]*"/\1${{ env.VERSION_NUM }}"/' frontend/package.json
sed -i 's/:v[0-9]*\.[0-9]*\.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/installation-checklist.md
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
- name: Commit and push changes
id: commit
run: |
git add pyproject.toml frontend/package.json uv.lock docs/
git commit -m "chore: bump version to ${{ github.event.release.tag_name }}"
git push origin HEAD:${{ github.event.repository.default_branch }}
echo "commit-sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Move release tag to new commit
run: |
git tag -f ${{ github.event.release.tag_name }}
git push -f origin ${{ github.event.release.tag_name }}
backend-tests:
name: "Backend Server Tests"
uses: ./.github/workflows/test-backend.yml
needs:
- commit-version-bump
frontend-tests:
name: "Frontend Tests"
uses: ./.github/workflows/test-frontend.yml
needs:
- commit-version-bump
build-package:
name: Build Package
uses: ./.github/workflows/build-package.yml
needs:
- commit-version-bump
with:
tag: ${{ github.event.release.tag_name }}
@@ -43,10 +99,48 @@ jobs:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
rollback-on-failure:
name: Rollback version commit if deployment fails
needs:
- commit-version-bump
- publish
if: always() && needs.publish.result == 'failure'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
- name: Checkout 🛎
uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "mealie-commit-bot[bot]"
git config user.email "mealie-commit-bot[bot]@users.noreply.github.com"
- name: Delete release tag
run: |
git push --delete origin ${{ github.event.release.tag_name }}
- name: Revert version bump commit
run: |
git revert --no-edit ${{ needs.commit-version-bump.outputs.commit-sha }}
git push origin HEAD:${{ github.event.repository.default_branch }}
notify-discord:
name: Notify Discord
needs:
- publish
if: success()
runs-on: ubuntu-latest
steps:
- name: Discord notification
@@ -55,41 +149,3 @@ jobs:
uses: Ilshidur/action-discord@0.3.2
with:
args: "🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of Mealie has been released. See the release notes https://github.com/mealie-recipes/mealie/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}"
update-image-tags:
name: Update image tag in sample docker-compose files
needs:
- publish
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout 🛎
uses: actions/checkout@v4
- name: Extract Version From Tag Name
run: echo "VERSION_NUM=$(echo ${{ github.event.release.tag_name }} | sed 's/^v//')" >> $GITHUB_ENV
- name: Modify version strings
run: |
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/installation-checklist.md
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
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
# This doesn't currently work for us because it creates the PR but the workflows don't run.
# TODO: Provide a personal access token as a parameter here, that solves that problem.
# https://github.com/peter-evans/create-pull-request
with:
commit-message: "Update image tag, for release ${{ github.event.release.tag_name }}"
branch: "docs/newrelease-update-version-${{ github.event.release.tag_name }}"
labels: |
documentation
delete-branch: true
base: mealie-next
title: "docs(auto): Update image tag, for release ${{ github.event.release.tag_name }}"
body: "Auto-generated by `.github/workflows/release.yml`, on publish of release ${{ github.event.release.tag_name }}"

View File

@@ -49,24 +49,21 @@ jobs:
with:
python-version: "3.12"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install uv
run: pip install uv
- name: Load cached venv
id: cached-poetry-dependencies
id: cached-python-dependencies
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
- name: Check venv cache
id: cache-validate
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
if: steps.cached-python-dependencies.outputs.cache-hit == 'true'
run: |
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
echo "import fastapi;print('venv good?')" > test.py && uv run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
rm test.py
continue-on-error: true
@@ -74,13 +71,12 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
poetry install
poetry add "psycopg2-binary==2.9.9"
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' || steps.cache-validate.outputs.cache-hit-success != 'true'
uv sync --group dev --extra pgsql
if: steps.cached-python-dependencies.outputs.cache-hit != 'true' || steps.cache-validate.outputs.cache-hit-success != 'true'
- name: Formatting (Ruff)
run: |
poetry run ruff format . --check
uv run ruff format . --check
- name: Lint (Ruff)
run: |

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 🛠
@@ -39,7 +39,7 @@ jobs:
working-directory: "frontend"
- name: Run linter 👀
run: yarn lint
run: yarn lint --max-warnings=0
working-directory: "frontend"
- name: Run tests 🧪

1
.gitignore vendored
View File

@@ -20,6 +20,7 @@ dev/data/backups/*
dev/data/debug/*
dev/data/img/*
dev/data/migration/*
dev/data/templates/*
dev/data/users/*
dev/data/groups/*

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.14.10
hooks:
- id: ruff
- id: ruff-format

View File

@@ -55,12 +55,15 @@
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig",
"pyproject.toml": "poetry.lock, alembic.ini, .pylintrc",
"pyproject.toml": "uv.lock, alembic.ini, .pylintrc",
"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

@@ -28,7 +28,7 @@ tasks:
docs:gen:
desc: runs the API documentation generator
cmds:
- poetry run python dev/code-generation/gen_docs_api.py
- uv run python dev/code-generation/gen_docs_api.py
docs:
desc: runs the documentation server
@@ -36,7 +36,7 @@ tasks:
deps:
- docs:gen
cmds:
- poetry run python -m mkdocs serve
- uv run python -m mkdocs serve
setup:ui:
desc: setup frontend dependencies
@@ -54,10 +54,10 @@ tasks:
desc: setup python dependencies
run: once
cmds:
- poetry install --with main,dev,postgres
- poetry run pre-commit install
- uv sync --extra pgsql --group dev
- uv run pre-commit install
sources:
- poetry.lock
- uv.lock
- pyproject.toml
- .pre-commit-config.yaml
@@ -70,7 +70,7 @@ tasks:
dev:generate:
desc: run code generators
cmds:
- poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
- uv run python dev/code-generation/main.py {{ .CLI_ARGS }}
- task: docs:gen
- task: py:format
@@ -88,28 +88,30 @@ 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
py:mypy:
desc: runs python type checking
cmds:
- poetry run mypy mealie
- uv run mypy mealie
py:test:
desc: runs python tests (support args after '--')
cmds:
- poetry run pytest {{ .CLI_ARGS }}
- uv run pytest {{ .CLI_ARGS }}
py:format:
desc: runs python code formatter
cmds:
- poetry run ruff format .
- uv run ruff format .
py:lint:
desc: runs python linter
cmds:
- poetry run ruff check mealie
- uv run ruff check mealie
py:check:
desc: runs all linters, type checkers, and formatters
@@ -122,10 +124,10 @@ tasks:
py:coverage:
desc: runs python coverage and generates html report
cmds:
- poetry run pytest
- poetry run coverage report -m
- poetry run coveragepy-lcov
- poetry run coverage html
- uv run pytest
- uv run coverage report -m
- uv run coveragepy-lcov
- uv run coverage html
- open htmlcov/index.html
py:package:copy-frontend:
@@ -145,17 +147,17 @@ tasks:
desc: Generate requirements file to pin all packages, effectively a "pip freeze" before installation begins
internal: true
cmds:
- poetry export -n --only=main --extras=pgsql --output=dist/requirements.txt
- uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file dist/requirements.txt
# Include mealie in the requirements, hashing the package that was just built to ensure it's the one installed
- echo "mealie[pgsql]=={{.MEALIE_VERSION}} \\" >> dist/requirements.txt
- poetry run pip hash dist/mealie-{{.MEALIE_VERSION}}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
- pip hash dist/mealie-{{.MEALIE_VERSION}}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
- echo " \\" >> dist/requirements.txt
- poetry run pip hash dist/mealie-{{.MEALIE_VERSION}}.tar.gz | tail -n1 >> dist/requirements.txt
- pip hash dist/mealie-{{.MEALIE_VERSION}}.tar.gz | tail -n1 >> dist/requirements.txt
vars:
MEALIE_VERSION:
sh: poetry version --short
sh: python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])"
sources:
- poetry.lock
- uv.lock
- pyproject.toml
- dist/mealie-*.whl
- dist/mealie-*.tar.gz
@@ -182,13 +184,13 @@ tasks:
deps:
- py:package:deps
cmds:
- poetry build -n --output=dist
- uv build --out-dir dist
- task: py:package:generate-requirements
py:
desc: runs the backend server
cmds:
- poetry run python mealie/app.py
- uv run python mealie/app.py
py:postgres:
desc: runs the backend server configured for containerized postgres
@@ -200,12 +202,12 @@ tasks:
POSTGRES_PORT: 5432
POSTGRES_DB: mealie
cmds:
- poetry run python mealie/app.py
- uv run python mealie/app.py
py:migrate:
desc: generates a new database migration file e.g. task py:migrate -- "add new column"
cmds:
- poetry run alembic --config mealie/alembic/alembic.ini revision --autogenerate -m "{{ .CLI_ARGS }}"
- uv run alembic --config mealie/alembic/alembic.ini revision --autogenerate -m "{{ .CLI_ARGS }}"
- task: py:format
ui:build:
@@ -226,7 +228,7 @@ tasks:
desc: runs the frontend linter
dir: frontend
cmds:
- yarn lint
- yarn lint --max-warnings=0
ui:test:
desc: runs the frontend tests

View File

@@ -1,7 +1,8 @@
import json
from datetime import UTC, datetime
from typing import Any
from fastapi import FastAPI
from freezegun import freeze_time
from mealie.app import app
from mealie.core.config import determine_data_dir
@@ -37,14 +38,43 @@ HTML_TEMPLATE = """<!-- Custom HTML site displayed as the Home chapter -->
"""
HTML_PATH = DATA_DIR.parent.parent.joinpath("docs/docs/overrides/api.html")
CONSTANT_DT = datetime(2025, 10, 24, 15, 53, 0, 0, tzinfo=UTC)
def normalize_timestamps(s: dict[str, Any]) -> dict[str, Any]:
field_format = s.get("format")
is_timestamp = field_format in ["date-time", "date", "time"]
has_default = s.get("default")
if not is_timestamp:
for k, v in s.items():
if isinstance(v, dict):
s[k] = normalize_timestamps(v)
elif isinstance(v, list):
s[k] = [normalize_timestamps(i) if isinstance(i, dict) else i for i in v]
return s
elif not has_default:
return s
if field_format == "date-time":
s["default"] = CONSTANT_DT.isoformat()
elif field_format == "date":
s["default"] = CONSTANT_DT.date().isoformat()
elif field_format == "time":
s["default"] = CONSTANT_DT.time().isoformat()
return s
def generate_api_docs(my_app: FastAPI):
openapi_schema = my_app.openapi()
openapi_schema = normalize_timestamps(openapi_schema)
with open(HTML_PATH, "w") as fd:
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(my_app.openapi()))
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(openapi_schema))
fd.write(text)
if __name__ == "__main__":
with freeze_time("2024-01-20T17:00:55Z"):
generate_api_docs(app)
generate_api_docs(app)

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(["uv", "run", "ruff", "check", str(out_path), "--fix"])
subprocess.run(["uv", "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(["uv", "run", "ruff", "check", *path_args, "--fix"])
subprocess.run(["uv", "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:24@sha256:20988bcdc6dc76690023eb2505dd273bdeefddcd0bde4bfd1efe4ebf8707f747 \
AS frontend-builder
WORKDIR /frontend
@@ -50,40 +50,29 @@ RUN apt-get update \
curl \
&& rm -rf /var/lib/apt/lists/*
ENV POETRY_HOME="/opt/poetry" \
POETRY_NO_INTERACTION=1
# prepend poetry to path
ENV PATH="$POETRY_HOME/bin:$PATH"
# install poetry - respects $POETRY_VERSION & $POETRY_HOME
ENV POETRY_VERSION=2.0.1
RUN curl -sSL https://install.python-poetry.org | python3 -
# install poetry plugins needed to build the package
RUN poetry self add "poetry-plugin-export>=1.9"
RUN pip install uv
WORKDIR /mealie
# copy project files here to ensure they will be cached.
COPY poetry.lock pyproject.toml ./
COPY uv.lock pyproject.toml ./
COPY mealie ./mealie
# Copy frontend to package it into the wheel
COPY --from=frontend-builder /frontend/dist ./mealie/frontend
# Build the source and binary package
RUN poetry build --output=dist
RUN uv build --out-dir dist
# Create the requirements file, which is used to install the built package and
# its pinned dependencies later. mealie is included to ensure the built one is
# what's installed.
RUN export MEALIE_VERSION=$(poetry version --short) \
&& poetry export --only=main --extras=pgsql --output=dist/requirements.txt \
&& echo "mealie[pgsql]==$MEALIE_VERSION \\" >> dist/requirements.txt \
&& poetry run pip hash dist/mealie-$MEALIE_VERSION-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt \
RUN uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file dist/requirements.txt \
&& MEALIE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") \
&& echo "mealie[pgsql]==${MEALIE_VERSION} \\" >> dist/requirements.txt \
&& pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt \
&& echo " \\" >> dist/requirements.txt \
&& poetry run pip hash dist/mealie-$MEALIE_VERSION.tar.gz | tail -n1 >> dist/requirements.txt
&& pip hash dist/mealie-${MEALIE_VERSION}.tar.gz | tail -n1 >> dist/requirements.txt
###############################################
# Package Container

View File

@@ -12,13 +12,13 @@ yarnpkg generate
popd
rm -r mealie/frontend
cp -a frontend/dist mealie/frontend
poetry build
poetry export -n --only=main --extras=pgsql --output=dist/requirements.txt
MEALIE_VERSION=$(poetry version --short)
uv build --out-dir dist
uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file dist/requirements.txt
MEALIE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
echo "mealie[pgsql]==${MEALIE_VERSION} \\" >> dist/requirements.txt
poetry run pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
echo " \\" >> dist/requirements.txt
poetry run pip hash dist/mealie-${MEALIE_VERSION}.tar.gz | tail -n1 >> dist/requirements.txt
pip hash dist/mealie-${MEALIE_VERSION}.tar.gz | tail -n1 >> dist/requirements.txt
```
The Python package can be installed with all of its dependencies pinned to the versions tested by the developers with:

View File

@@ -33,8 +33,8 @@ Make sure the VSCode Dev Containers extension is installed, then select "Dev Con
### Prerequisites
- [Python 3.12](https://www.python.org/downloads/)
- [Poetry](https://python-poetry.org/docs/#installation)
- [Node v16.x](https://nodejs.org/en/)
- [uv](https://docs.astral.sh/uv/)
- [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

@@ -22,6 +22,7 @@ Mealie supports importing recipes from a few other sources besides websites. Cur
- Recipe Keeper
- Copy Me That
- My Recipe Box
- DVO Cook'n X3
You can access these options on your installation at the `/group/migrations` page on your installation. If you'd like to see another source added, feel free to request so on Github.
@@ -87,6 +88,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 +119,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 +201,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 +210,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

@@ -4,22 +4,22 @@
### General
| Variables | Default | Description |
| ----------------------------- | :-------------------: | -------------------------------------------------------------------------------------------------- |
| PUID | 911 | UserID permissions between host OS and container |
| PGID | 911 | GroupID permissions between host OS and container |
| 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 |
| 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 |
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
| ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication |
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
| Variables | Default | Description |
| ----------------------------- | :-------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PUID | 911 | UserID permissions between host OS and container |
| PGID | 911 | GroupID permissions between host OS and container |
| 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. Must be <= 9600 (400 days, 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 |
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
| ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication |
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
<super>\*</super> Starting in v1.4.0 this was changed to default to `false` as part of a security review of the application.
@@ -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,28 +132,108 @@ 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.
| Variables | Default | Description |
| --------------------- | :-----: | --------------------------- |
| THEME_LIGHT_PRIMARY | #E58325 | Light Theme Config Variable |
| THEME_LIGHT_ACCENT | #007A99 | Light Theme Config Variable |
| THEME_LIGHT_SECONDARY | #973542 | Light Theme Config Variable |
| THEME_LIGHT_SUCCESS | #43A047 | Light Theme Config Variable |
| THEME_LIGHT_INFO | #1976D2 | Light Theme Config Variable |
| THEME_LIGHT_WARNING | #FF6D00 | Light Theme Config Variable |
| THEME_LIGHT_ERROR | #EF5350 | Light Theme Config Variable |
| THEME_DARK_PRIMARY | #E58325 | Dark Theme Config Variable |
| THEME_DARK_ACCENT | #007A99 | Dark Theme Config Variable |
| THEME_DARK_SECONDARY | #973542 | Dark Theme Config Variable |
| THEME_DARK_SUCCESS | #43A047 | Dark Theme Config Variable |
| THEME_DARK_INFO | #1976D2 | Dark Theme Config Variable |
| THEME_DARK_WARNING | #FF6D00 | Dark Theme Config Variable |
| THEME_DARK_ERROR | #EF5350 | Dark Theme Config Variable |
!!! 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 | Main brand color and headers |
| THEME_LIGHT_ACCENT | #007A99 | Buttons and interactive elements |
| THEME_LIGHT_SECONDARY | #973542 | Navigation and sidebar backgrounds |
| THEME_LIGHT_SUCCESS | #43A047 | Success messages and confirmations |
| THEME_LIGHT_INFO | #1976D2 | Information alerts and tooltips |
| THEME_LIGHT_WARNING | #FF6D00 | Warning notifications |
| THEME_LIGHT_ERROR | #EF5350 | Error messages and alerts |
| THEME_DARK_PRIMARY | #E58325 | Main brand color and headers |
| THEME_DARK_ACCENT | #007A99 | Buttons and interactive elements |
| THEME_DARK_SECONDARY | #973542 | Navigation and sidebar backgrounds |
| THEME_DARK_SUCCESS | #43A047 | Success messages and confirmations |
| THEME_DARK_INFO | #1976D2 | Information alerts and tooltips |
| THEME_DARK_WARNING | #FF6D00 | Warning notifications |
| THEME_DARK_ERROR | #EF5350 | Error messages and alerts |
#### Theming Examples
The examples below provide copy-ready Docker Compose environment configurations for three different color palettes. Copy and paste the desired theme into your `docker-compose.yml` file's environment section.
!!! info
These themes are functional and ready to use, but they are provided primarily as examples. The color palettes can be adjusted or refined to better suit your preferences.
=== "Blue Theme"
```yaml
environment:
# Light mode colors
THEME_LIGHT_PRIMARY: '#5E9BD1'
THEME_LIGHT_ACCENT: '#A3C9E8'
THEME_LIGHT_SECONDARY: '#4F89BA'
THEME_LIGHT_SUCCESS: '#4CAF50'
THEME_LIGHT_INFO: '#4A9ED8'
THEME_LIGHT_WARNING: '#EAC46B'
THEME_LIGHT_ERROR: '#E57373'
# Dark mode colors
THEME_DARK_PRIMARY: '#5A8FBF'
THEME_DARK_ACCENT: '#90B8D9'
THEME_DARK_SECONDARY: '#406D96'
THEME_DARK_SUCCESS: '#81C784'
THEME_DARK_INFO: '#78B2C0'
THEME_DARK_WARNING: '#EBC86E'
THEME_DARK_ERROR: '#E57373'
```
=== "Green Theme"
```yaml
environment:
# Light mode colors
THEME_LIGHT_PRIMARY: '#75A86C'
THEME_LIGHT_ACCENT: '#A8D0A6'
THEME_LIGHT_SECONDARY: '#638E5E'
THEME_LIGHT_SUCCESS: '#4CAF50'
THEME_LIGHT_INFO: '#4A9ED8'
THEME_LIGHT_WARNING: '#EAC46B'
THEME_LIGHT_ERROR: '#E57373'
# Dark mode colors
THEME_DARK_PRIMARY: '#739B7A'
THEME_DARK_ACCENT: '#9FBE9D'
THEME_DARK_SECONDARY: '#56775E'
THEME_DARK_SUCCESS: '#81C784'
THEME_DARK_INFO: '#78B2C0'
THEME_DARK_WARNING: '#EBC86E'
THEME_DARK_ERROR: '#E57373'
```
=== "Pink Theme"
```yaml
environment:
# Light mode colors
THEME_LIGHT_PRIMARY: '#D97C96'
THEME_LIGHT_ACCENT: '#E891A7'
THEME_LIGHT_SECONDARY: '#C86C88'
THEME_LIGHT_SUCCESS: '#4CAF50'
THEME_LIGHT_INFO: '#2196F3'
THEME_LIGHT_WARNING: '#FFC107'
THEME_LIGHT_ERROR: '#E57373'
# Dark mode colors
THEME_DARK_PRIMARY: '#C2185B'
THEME_DARK_ACCENT: '#FF80AB'
THEME_DARK_SECONDARY: '#AD1457'
THEME_DARK_SUCCESS: '#81C784'
THEME_DARK_INFO: '#64B5F6'
THEME_DARK_WARNING: '#FFD54F'
THEME_DARK_ERROR: '#E57373'
```
### Docker Secrets

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.8.0`
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
4. Restart the container
@@ -60,7 +60,7 @@ The following steps were tested on a Ubuntu 20.04 server, but should work for mo
## Step 3: Customizing The `docker-compose.yaml` files.
After you've decided setup the files it's important to set a few ENV variables to ensure that you can use all the features of Mealie. I recommend that you verify and check that:
After you've decided how to set up your files, it's important to set a few ENV variables to ensure that you can use all the features of Mealie. Verify that:
- [x] You've configured the relevant ENV variables for your database selection in the `docker-compose.yaml` files.
- [x] You've configured the [SMTP server settings](./backend-config.md#email) (used for invitations, password resets, etc). You can setup a [google app password](https://support.google.com/accounts/answer/185833?hl=en) if you want to send email via gmail.
@@ -117,7 +117,7 @@ The latest tag provides the latest released image of Mealie.
---
**These tags no are long updated**
**These tags are no longer updated**
`mealie:frontend-v1.0.0beta-x` **and** `mealie:api-v1.0.0beta-x`

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ Mealie is a self hosted recipe manager and meal planner with a RestAPI backend a
- Copy Me That
- Paprika
- Tandoor Recipes
- DVO Cook'n X3
- Random Meal Plan generation
- Advanced rule configuration to fine tune random recipes

View File

@@ -4,11 +4,28 @@
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
!!! info "Improved Image Processing"
Starting with :octicons-tag-24: v3.7.0, we updated our image processing algorithm to improve image quality and compression. New image processing can be up to 40%-50% smaller on disk while providing higher resolution thumbnails. To take advantage of these improvements on older recipes, you can run our image-processing script:
```shell
docker exec -it mealie bash
python /opt/mealie/lib64/python3.12/site-packages/mealie/scripts/reprocess_images.py
```
### Options
- `--workers N`: Number of worker threads (default: 2, safe for low-powered devices)
- `--force-all`: Reprocess all recipes regardless of current image state
### Example
```shell
python /opt/mealie/lib64/python3.12/site-packages/mealie/scripts/reprocess_images.py --workers 8
```
## Upgrading to Mealie v1 or later
If you are upgrading from pre-v1.0.0 to v1.0.0 or later (v2.0.0, etc.), make sure you read [Migrating to Mealie v1](./migrating-to-mealie-v1.md)!

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

@@ -32,8 +32,8 @@ theme:
markdown_extensions:
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- def_list
- pymdownx.highlight
- pymdownx.superfences
@@ -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

@@ -0,0 +1,121 @@
<template>
<v-container max-width="880" class="end-page-content">
<div class="d-flex flex-column ga-6">
<div>
<v-card-title class="text-h4 justify-center">
{{ $t('admin.setup.setup-complete') }}
</v-card-title>
<v-card-subtitle class="justify-center">
{{ $t('admin.setup.here-are-a-few-things-to-help-you-get-started') }}
</v-card-subtitle>
</div>
<div
v-for="section, idx in sections"
:key="idx"
class="d-flex flex-column ga-3"
>
<v-card-title class="text-h6 pl-0">
{{ section.title }}
</v-card-title>
<div class="sections d-flex flex-column ga-2">
<v-card
v-for="link, linkIdx in section.links"
:key="linkIdx"
clas="link-card"
:href="link.to"
:title="link.text"
:subtitle="link.description"
:append-icon="$globals.icons.chevronRight"
>
<template #prepend>
<v-avatar :icon="link.icon || undefined" variant="tonal" :color="section.color" />
</template>
</v-card>
</div>
</div>
</div>
</v-container>
</template>
<script lang="ts">
export default defineNuxtComponent({
setup() {
const i18n = useI18n();
const $auth = useMealieAuth();
const groupSlug = computed(() => $auth.user.value?.groupSlug);
const { $globals } = useNuxtApp();
const sections = ref([
{
title: i18n.t("profile.data-migrations"),
color: "info",
links: [
{
icon: $globals.icons.backupRestore,
to: "/admin/backups",
text: i18n.t("settings.backup.backup-restore"),
description: i18n.t("admin.setup.restore-from-v1-backup"),
},
{
icon: $globals.icons.import,
to: "/group/migrations",
text: i18n.t("migration.recipe-migration"),
description: i18n.t("migration.coming-from-another-application-or-an-even-older-version-of-mealie"),
},
],
},
{
title: i18n.t("recipe.create-recipes"),
color: "success",
links: [
{
icon: $globals.icons.createAlt,
to: computed(() => `/g/${groupSlug.value || ""}/r/create/new`),
text: i18n.t("recipe.create-recipe"),
description: i18n.t("recipe.create-recipe-description"),
},
{
icon: $globals.icons.link,
to: computed(() => `/g/${groupSlug.value || ""}/r/create/url`),
text: i18n.t("recipe.import-with-url"),
description: i18n.t("recipe.scrape-recipe-description"),
},
],
},
{
title: i18n.t("user.manage-users"),
color: "primary",
links: [
{
icon: $globals.icons.group,
to: "/admin/manage/users",
text: i18n.t("user.manage-users"),
description: i18n.t("user.manage-users-description"),
},
{
icon: $globals.icons.user,
to: "/user/profile",
text: i18n.t("profile.manage-user-profile"),
description: i18n.t("admin.setup.manage-profile-or-get-invite-link"),
},
],
},
]);
return { sections };
},
});
</script>
<style>
.v-container {
.v-card-title,
.v-card-subtitle {
padding: 0;
white-space: unset;
}
.v-card-item {
gap: 0.5rem;
}
}
</style>

View File

@@ -83,6 +83,11 @@ const fieldDefs: FieldDefinition[] = [
label: i18n.t("household.households"),
type: Organizer.Household,
},
{
name: "user_id",
label: i18n.t("user.users"),
type: Organizer.User,
},
{
name: "created_at",
label: i18n.t("general.date-created"),

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

@@ -14,7 +14,7 @@
<BaseButton
download
size="small"
:download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"
:download-url="`/api/recipes/bulk-actions/export/${item.id}/download`"
/>
</template>
</v-data-table>

View File

@@ -58,6 +58,9 @@ const MEAL_TYPE_OPTIONS = [
{ title: i18n.t("meal-plan.lunch"), value: "lunch" },
{ title: i18n.t("meal-plan.dinner"), value: "dinner" },
{ title: i18n.t("meal-plan.side"), value: "side" },
{ title: i18n.t("meal-plan.snack"), value: "snack" },
{ title: i18n.t("meal-plan.drink"), value: "drink" },
{ title: i18n.t("meal-plan.dessert"), value: "dessert" },
{ title: i18n.t("meal-plan.type-any"), value: "unset" },
];
@@ -103,6 +106,11 @@ const fieldDefs: FieldDefinition[] = [
label: i18n.t("household.households"),
type: Organizer.Household,
},
{
name: "user_id",
label: i18n.t("user.users"),
type: Organizer.User,
},
{
name: "last_made",
label: i18n.t("general.last-made"),

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

@@ -1,283 +1,299 @@
<template>
<v-card class="ma-0" style="overflow-x: auto;">
<v-card class="ma-0" flat fluid>
<v-card-text class="ma-0 pa-0">
<v-container fluid class="ma-0 pa-0">
<VueDraggable
v-model="fields"
handle=".handle"
:delay="250"
:delay-on-touch-only="true"
v-bind="{
animation: 200,
group: 'recipe-instructions',
ghostClass: 'ghost',
}"
@start="drag = true"
@end="onDragEnd"
<VueDraggable
v-model="fields"
handle=".handle"
:delay="250"
:delay-on-touch-only="true"
v-bind="{
animation: 200,
group: 'recipe-instructions',
ghostClass: 'ghost',
}"
@start="drag = true"
@end="onDragEnd"
>
<v-row
v-for="(field, index) in fields"
:key="field.id"
class="d-flex flex-row flex-wrap mx-auto pb-2"
:class="$vuetify.display.xs ? (Math.floor(index / 1) % 2 === 0 ? 'bg-dark' : 'bg-light') : ''"
style="max-width: 100%;"
>
<v-row
v-for="(field, index) in fields"
:key="field.id"
class="d-flex flex-nowrap"
style="max-width: 100%;"
<!-- drag handle -->
<v-col
:cols="config.items.icon.cols(index)"
:sm="config.items.icon.sm(index)"
:class="$vuetify.display.smAndDown ? 'd-flex pa-0' : 'd-flex justify-end pr-6'"
>
<!-- drag handle -->
<v-col
:cols="config.items.icon.cols"
:class="config.col.class"
:style="config.items.icon.style"
<v-icon class="handle my-auto" :size="28" style="cursor: move;">
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-col>
<!-- and / or -->
<v-col
v-if="index != 0 || $vuetify.display.smAndUp"
:cols="config.items.logicalOperator.cols(index)"
:sm="config.items.logicalOperator.sm(index)"
:class="config.col.class"
>
<v-select
v-if="index"
:model-value="field.logicalOperator"
:items="[logOps.AND, logOps.OR]"
item-title="label"
item-value="value"
variant="underlined"
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
>
<v-icon
class="handle"
:size="24"
style="cursor: move;margin: auto;"
>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-col>
<!-- and / or -->
<v-col
:cols="config.items.logicalOperator.cols"
:class="config.col.class"
:style="config.items.logicalOperator.style"
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
</v-col>
<!-- left parenthesis -->
<v-col
v-if="showAdvanced"
:cols="config.items.leftParens.cols(index)"
:sm="config.items.leftParens.sm(index)"
:class="config.col.class"
>
<v-select
:model-value="field.leftParenthesis"
:items="['', '(', '((', '(((']"
variant="underlined"
@update:model-value="setLeftParenthesisValue(field, index, $event)"
>
<v-select
v-if="index"
:model-value="field.logicalOperator"
:items="[logOps.AND, logOps.OR]"
item-title="label"
item-value="value"
variant="underlined"
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
</v-col>
<!-- left parenthesis -->
<v-col
v-if="showAdvanced"
:cols="config.items.leftParens.cols"
:class="config.col.class"
:style="config.items.leftParens.style"
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw }}
</span>
</template>
</v-select>
</v-col>
<!-- field name -->
<v-col
:cols="config.items.fieldName.cols(index)"
:sm="config.items.fieldName.sm(index)"
:class="config.col.class"
>
<v-select
chips
:model-value="field.label"
:items="fieldDefs"
variant="underlined"
item-title="label"
@update:model-value="setField(index, $event)"
>
<v-select
:model-value="field.leftParenthesis"
:items="['', '(', '((', '(((']"
variant="underlined"
@update:model-value="setLeftParenthesisValue(field, index, $event)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw }}
</span>
</template>
</v-select>
</v-col>
<!-- field name -->
<v-col
:cols="config.items.fieldName.cols"
:class="config.col.class"
:style="config.items.fieldName.style"
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
</v-col>
<!-- relational operator -->
<v-col
:cols="config.items.relationalOperator.cols(index)"
:sm="config.items.relationalOperator.sm(index)"
:class="config.col.class"
>
<v-select
v-if="field.type !== 'boolean'"
:model-value="field.relationalOperatorValue"
:items="field.relationalOperatorOptions"
item-title="label"
item-value="value"
variant="underlined"
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
>
<v-select
chips
:model-value="field.label"
:items="fieldDefs"
variant="underlined"
item-title="label"
@update:model-value="setField(index, $event)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
</v-col>
<!-- relational operator -->
<v-col
:cols="config.items.relationalOperator.cols"
:class="config.col.class"
:style="config.items.relationalOperator.style"
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
</v-col>
<!-- field value -->
<v-col
:cols="config.items.fieldValue.cols(index)"
:sm="config.items.fieldValue.sm(index)"
:class="config.col.class"
>
<v-select
v-if="field.fieldOptions"
:model-value="field.values"
:items="field.fieldOptions"
item-title="label"
item-value="value"
multiple
variant="underlined"
@update:model-value="setFieldValues(field, index, $event)"
/>
<v-text-field
v-else-if="field.type === 'string'"
:model-value="field.value"
variant="underlined"
@update:model-value="setFieldValue(field, index, $event)"
/>
<v-number-input
v-else-if="field.type === 'number'"
:model-value="field.value"
variant="underlined"
control-variant="stacked"
inset
:precision="null"
@update:model-value="setFieldValue(field, index, $event)"
/>
<v-checkbox
v-else-if="field.type === 'boolean'"
:model-value="field.value"
@update:model-value="setFieldValue(field, index, $event!)"
/>
<v-menu
v-else-if="field.type === 'date'"
v-model="datePickers[index]"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="auto"
>
<v-select
v-if="field.type !== 'boolean'"
:model-value="field.relationalOperatorValue"
:items="field.relationalOperatorOptions"
item-title="label"
item-value="value"
variant="underlined"
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
</v-col>
<!-- field value -->
<v-col
:cols="config.items.fieldValue.cols"
:class="config.col.class"
:style="config.items.fieldValue.style"
>
<v-select
v-if="field.fieldOptions"
:model-value="field.values"
:items="field.fieldOptions"
item-title="label"
item-value="value"
multiple
variant="underlined"
@update:model-value="setFieldValues(field, index, $event)"
/>
<v-text-field
v-else-if="field.type === 'string'"
:model-value="field.value"
variant="underlined"
@update:model-value="setFieldValue(field, index, $event)"
/>
<v-text-field
v-else-if="field.type === 'number'"
:model-value="field.value"
type="number"
variant="underlined"
@update:model-value="setFieldValue(field, index, $event)"
/>
<v-checkbox
v-else-if="field.type === 'boolean'"
:model-value="field.value"
@update:model-value="setFieldValue(field, index, $event!)"
/>
<v-menu
v-else-if="field.type === 'date'"
v-model="datePickers[index]"
: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="field.value"
persistent-hint
:prepend-icon="$globals.icons.calendar"
variant="underlined"
color="primary"
v-bind="activatorProps"
readonly
/>
</template>
<v-date-picker
:model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
hide-header
:first-day-of-week="firstDayOfWeek"
:local="$i18n.locale"
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
<template #activator="{ props: activatorProps }">
<v-text-field
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null"
persistent-hint
:prepend-icon="$globals.icons.calendar"
variant="underlined"
color="primary"
v-bind="activatorProps"
readonly
/>
</v-menu>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Category"
v-model="field.organizers"
:selector-type="Organizer.Category"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)"
</template>
<v-date-picker
:model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
hide-header
:first-day-of-week="firstDayOfWeek"
:local="$i18n.locale"
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tag"
v-model="field.organizers"
:selector-type="Organizer.Tag"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tool"
v-model="field.organizers"
:selector-type="Organizer.Tool"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Food"
v-model="field.organizers"
:selector-type="Organizer.Food"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Household"
v-model="field.organizers"
:selector-type="Organizer.Household"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
</v-col>
<!-- right parenthesis -->
<v-col
v-if="showAdvanced"
:cols="config.items.rightParens.cols"
:class="config.col.class"
:style="config.items.rightParens.style"
</v-menu>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Category"
v-model="field.organizers"
:selector-type="Organizer.Category"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tag"
v-model="field.organizers"
:selector-type="Organizer.Tag"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tool"
v-model="field.organizers"
:selector-type="Organizer.Tool"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Food"
v-model="field.organizers"
:selector-type="Organizer.Food"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Household"
v-model="field.organizers"
:selector-type="Organizer.Household"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.User"
v-model="field.organizers"
:selector-type="Organizer.User"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/>
</v-col>
<!-- right parenthesis -->
<v-col
v-if="showAdvanced"
:cols="config.items.rightParens.cols(index)"
:sm="config.items.rightParens.sm(index)"
:class="config.col.class"
>
<v-select
:model-value="field.rightParenthesis"
:items="['', ')', '))', ')))']"
variant="underlined"
@update:model-value="setRightParenthesisValue(field, index, $event)"
>
<v-select
:model-value="field.rightParenthesis"
:items="['', ')', '))', ')))']"
variant="underlined"
@update:model-value="setRightParenthesisValue(field, index, $event)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw }}
</span>
</template>
</v-select>
</v-col>
<!-- field actions -->
<v-col
:cols="config.items.fieldActions.cols"
:class="config.col.class"
:style="config.items.fieldActions.style"
>
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
disabled: fields.length === 1,
},
]"
class="my-auto"
@delete="removeField(index)"
/>
</v-col>
</v-row>
</VueDraggable>
</v-container>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw }}
</span>
</template>
</v-select>
</v-col>
<!-- field actions -->
<v-col
v-if="!$vuetify.display.smAndDown || index === fields.length - 1"
:cols="config.items.fieldActions.cols(index)"
:sm="config.items.fieldActions.sm(index)"
:class="config.col.class"
>
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
disabled: fields.length === 1,
},
]"
class="my-auto"
@delete="removeField(index)"
/>
</v-col>
</v-row>
</VueDraggable>
</v-card-text>
<v-card-actions>
<v-row fluid class="d-flex justify-end pa-0 mx-2">
<v-row fluid class="d-flex justify-end ma-2">
<v-spacer />
<v-checkbox
v-model="showAdvanced"
@@ -305,6 +321,7 @@ import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerS
import { Organizer } from "~/lib/api/types/non-generated";
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { useUserStore } from "~/composables/store/use-user-store";
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
const props = defineProps({
@@ -344,6 +361,7 @@ const storeMap = {
[Organizer.Tool]: useToolStore(),
[Organizer.Food]: useFoodStore(),
[Organizer.Household]: useHouseholdStore(),
[Organizer.User]: useUserStore(),
};
function onDragEnd(event: any) {
@@ -432,9 +450,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) {
@@ -602,46 +620,56 @@ function buildQueryFilterJSON(): QueryFilterJSON {
}
const config = computed(() => {
const baseColMaxWidth = 55;
const multiple = fields.value.length > 1;
const adv = state.showAdvanced;
return {
col: {
class: "d-flex justify-center align-end field-col pa-1",
class: "d-flex justify-center align-end py-0",
},
select: {
textClass: "d-flex justify-center text-center",
},
items: {
icon: {
cols: 1,
cols: (_index: number) => 2,
sm: (_index: number) => 1,
style: "width: fit-content;",
},
leftParens: {
cols: state.showAdvanced ? 1 : 0,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
cols: (index: number) => (adv ? (index === 0 ? 2 : 0) : 0),
sm: (_index: number) => (adv ? 1 : 0),
},
logicalOperator: {
cols: 1,
style: `min-width: ${baseColMaxWidth}px;`,
cols: (_index: number) => 0,
sm: (_index: number) => (multiple ? 1 : 0),
},
fieldName: {
cols: state.showAdvanced ? 2 : 3,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
cols: (index: number) => {
if (adv) return index === 0 ? 8 : 12;
return index === 0 ? 10 : 12;
},
sm: (_index: number) => (adv ? 2 : 3),
},
relationalOperator: {
cols: 2,
style: `min-width: ${baseColMaxWidth * 2}px;`,
cols: (_index: number) => 12,
sm: (_index: number) => 2,
},
fieldValue: {
cols: state.showAdvanced ? 3 : 4,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
cols: (index: number) => {
const last = index === fields.value.length - 1;
if (adv) return last ? 8 : 10;
return last ? 10 : 12;
},
sm: (_index: number) => (adv ? 3 : 4),
},
rightParens: {
cols: state.showAdvanced ? 1 : 0,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
cols: (index: number) => (adv ? (index === fields.value.length - 1 ? 2 : 0) : 0),
sm: (_index: number) => (adv ? 1 : 0),
},
fieldActions: {
cols: 1,
style: `min-width: ${baseColMaxWidth}px;`,
cols: (index: number) => (index === fields.value.length - 1 ? 2 : 0),
sm: (_index: number) => 1,
},
},
};
@@ -651,5 +679,14 @@ const config = computed(() => {
<style scoped>
* {
font-size: 1em;
--bg-opactity: calc(var(--v-hover-opacity) * var(--v-theme-overlay-multiplier));
}
.bg-dark {
background-color: rgba(0, 0, 0, var(--bg-opactity));
}
.bg-light {
background-color: rgba(255, 255, 255, var(--bg-opactity));
}
</style>

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";
@@ -113,7 +126,7 @@ withDefaults(defineProps<Props>(), {
canEdit: false,
});
const emit = defineEmits(["print", "input", "delete", "close", "edit"]);
const emit = defineEmits(["print", "input", "save", "delete", "close", "json", "edit"]);
const deleteDialog = ref(false);

View File

@@ -28,11 +28,12 @@
<v-list-item-title class="pl-2">
{{ item.name }}
</v-list-item-title>
<v-list-item-action>
<template #append>
<v-btn
v-if="!edit"
color="primary"
icon
size="small"
:href="assetURL(item.fileName ?? '')"
target="_blank"
top
@@ -43,6 +44,7 @@
<v-btn
color="error"
icon
size="small"
top
@click="model.splice(i, 1)"
>
@@ -53,7 +55,7 @@
:copy-text="assetEmbed(item.fileName ?? '')"
/>
</div>
</v-list-item-action>
</template>
</v-list-item>
</v-list>
</v-card>
@@ -90,13 +92,12 @@
item-value="name"
class="mr-2"
>
<template #item="{ item }">
<v-avatar>
<v-icon class="mr-auto">
{{ item.raw.icon }}
</v-icon>
</v-avatar>
{{ item.title }}
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps">
<template #prepend>
<v-icon>{{ item.raw.icon }}</v-icon>
</template>
</v-list-item>
</template>
</v-select>
<AppButtonUpload

View File

@@ -1,111 +1,108 @@
<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
small
:icon-size="imageHeight"
:height="imageHeight"
:slug="slug"
:recipe-id="recipeId"
: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"
: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

@@ -19,10 +19,10 @@
cover
>
<RecipeCardImage
tiny
:icon-size="100"
:slug="slug"
:recipe-id="recipeId"
size="small"
:image-version="image"
:height="height"
/>
@@ -41,11 +41,11 @@
name="avatar"
>
<RecipeCardImage
tiny
:icon-size="100"
:slug="slug"
:recipe-id="recipeId"
:image-version="image"
size="small"
width="125"
:height="height"
/>
@@ -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

@@ -90,6 +90,14 @@
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
</div>
</v-list-item>
<v-list-item @click="sortRecipes(EVENTS.shuffle)">
<div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
{{ $globals.icons.diceMultiple }}
</v-icon>
<v-list-item-title>{{ $t("general.random") }}</v-list-item-title>
</div>
</v-list-item>
</v-list>
</v-menu>
<ContextMenu
@@ -199,7 +207,7 @@ const emit = defineEmits<{
appendRecipes: [recipes: Recipe[]];
}>();
const { $vuetify } = useNuxtApp();
const display = useDisplay();
const preferences = useUserSortPreferences();
const EVENTS = {
@@ -215,7 +223,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(() => {
@@ -223,6 +231,7 @@ const displayTitleIcon = computed(() => {
});
const sortLoading = ref(false);
const randomSeed = ref(Date.now().toString());
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
@@ -256,13 +265,18 @@ const queryFilter = computed(() => {
async function fetchRecipes(pageCount = 1) {
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
const orderBy = props.query?.orderBy || preferences.value.orderBy;
const localQuery = { ...props.query };
if (orderBy === "random") {
localQuery._searchSeed = randomSeed.value;
}
return await fetchMore(
page.value,
perPage * pageCount,
props.query?.orderBy || preferences.value.orderBy,
orderBy,
orderDir,
orderByNullPosition,
props.query,
localQuery,
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
queryFilter.value,
);
@@ -288,6 +302,9 @@ watch(
);
async function initRecipes() {
if (preferences.value.orderBy === "random") {
randomSeed.value = Date.now().toString();
}
page.value = 1;
hasMore.value = true;
@@ -380,6 +397,15 @@ async function sortRecipes(sortType: string) {
true,
);
break;
case EVENTS.shuffle:
setter(
"random",
$globals.icons.diceMultiple,
$globals.icons.diceMultiple, // icon in asc and desc is the same for random
);
// We update the seed value to have a different order
randomSeed.value = Date.now().toString();
break;
default:
console.log("Unknown Event", sortType);
return;

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,109 @@
<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-date-picker
v-model="newMealdate"
class="mx-auto mb-3"
hide-header
show-adjacent-months
color="primary"
:first-day-of-week="firstDayOfWeek"
:local="$i18n.locale"
/>
<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 +175,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
[key: string]: any;
delete: [slug: string];
deleted: [slug: string];
}>();
const api = useUserApi();
@@ -241,7 +191,6 @@ const loading = ref(false);
const menuItems = ref<ContextMenuItem[]>([]);
const newMealdate = ref(new Date());
const newMealType = ref<PlanEntryType>("dinner");
const pickerMenu = ref(false);
const newMealdateString = computed(() => {
// Format the date to YYYY-MM-DD in the same timezone as newMealdate
@@ -336,8 +285,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,17 +354,20 @@ async function deleteRecipe() {
if (data?.slug) {
router.push(`/g/${groupSlug.value}`);
}
emit("delete", props.slug);
emit("deleted", props.slug);
}
const download = useDownloader();
async function handleDownloadEvent() {
const { data } = await api.recipes.getZipToken(props.slug);
if (data) {
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
const { data: shareToken } = await api.recipes.share.createOne({ recipeId: props.recipeId });
if (!shareToken) {
console.error("No share token received");
alert.error(i18n.t("events.something-went-wrong"));
return;
}
download(api.recipes.share.getZipRedirectUrl(shareToken.id), `${props.slug}.zip`);
}
async function addRecipeToPlan() {

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

@@ -57,7 +57,7 @@
</div>
</template>
<template #[`item.dateAdded`]="{ item }">
{{ formatDate(item.dateAdded!) }}
{{ item.dateAdded ? $d(new Date(item.dateAdded)) : '' }}
</template>
</v-data-table>
</template>
@@ -153,15 +153,6 @@ const headers = computed(() => {
return hdrs;
});
function formatDate(date: string) {
try {
return i18n.d(Date.parse(date), "medium");
}
catch {
return "";
}
}
// ============
// Group Members
const api = useUserApi();

View File

@@ -6,7 +6,7 @@
:title="$t('recipe.add-to-list')"
:icon="$globals.icons.cartCheck"
>
<v-container v-if="!shoppingListChoices.length">
<v-container v-if="!filteredShoppingLists.length">
<BasePageTitle>
<template #title>
{{ $t('shopping-list.no-shopping-lists-found') }}
@@ -15,7 +15,7 @@
</v-container>
<v-card-text>
<v-card
v-for="list in shoppingListChoices"
v-for="list in filteredShoppingLists"
:key="list.id"
hover
class="my-2 left-border"
@@ -139,7 +139,7 @@
color="secondary"
density="compact"
/>
<div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
<div :key="`${ingredientData.ingredient?.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
<RecipeIngredientListItem
:ingredient="ingredientData.ingredient"
:scale="recipeSection.recipeScale"
@@ -222,6 +222,10 @@ const api = useUserApi();
const preferences = useShoppingListPreferences();
const ready = ref(false);
// Capture values at initialization to avoid reactive updates
const currentHouseholdSlug = ref("");
const filteredShoppingLists = ref<ShoppingListSummary[]>([]);
const state = reactive({
shoppingListDialog: true,
shoppingListIngredientDialog: false,
@@ -230,31 +234,25 @@ const state = reactive({
const { shoppingListDialog, shoppingListIngredientDialog, shoppingListShowAllToggled: _shoppingListShowAllToggled } = toRefs(state);
const userHousehold = computed(() => {
return $auth.user.value?.householdSlug || "";
});
const shoppingListChoices = computed(() => {
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
});
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
watchEffect(
() => {
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
selectedShoppingList.value = shoppingListChoices.value[0];
watch(dialog, (newVal, oldVal) => {
if (newVal && !oldVal) {
currentHouseholdSlug.value = $auth.user.value?.householdSlug || "";
filteredShoppingLists.value = props.shoppingLists.filter(
list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id,
);
if (filteredShoppingLists.value.length === 1 && !state.shoppingListShowAllToggled) {
selectedShoppingList.value = filteredShoppingLists.value[0];
openShoppingListIngredientDialog(selectedShoppingList.value);
}
else {
ready.value = true;
}
},
);
watch(dialog, (val) => {
if (!val) {
}
else if (!newVal) {
initState();
}
});
@@ -274,25 +272,53 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
continue;
}
if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
const { data } = await api.recipes.getOne(recipe.slug);
// Create a local copy to avoid mutating props
let recipeData = { ...recipe };
if (!(recipeData.id && recipeData.name && recipeData.recipeIngredient)) {
const { data } = await api.recipes.getOne(recipeData.slug);
if (!data?.recipeIngredient?.length) {
continue;
}
recipe.id = data.id || "";
recipe.name = data.name || "";
recipe.recipeIngredient = data.recipeIngredient;
recipeData = {
...recipeData,
id: data.id || "",
name: data.name || "",
recipeIngredient: data.recipeIngredient,
};
}
else if (!recipe.recipeIngredient.length) {
else if (!recipeData.recipeIngredient.length) {
continue;
}
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
return {
checked: !householdsWithFood.includes(userHousehold.value),
ingredient: ing,
};
const shoppingListIngredients: ShoppingListIngredient[] = [];
function flattenRecipeIngredients(ing: RecipeIngredient, parentTitle = ""): ShoppingListIngredient[] {
if (ing.referencedRecipe) {
// Recursively flatten all ingredients in the referenced recipe
return (ing.referencedRecipe.recipeIngredient ?? []).flatMap((subIng) => {
const calculatedQty = (ing.quantity || 1) * (subIng.quantity || 1);
// Pass the referenced recipe name as the section title
return flattenRecipeIngredients(
{ ...subIng, quantity: calculatedQty },
"",
);
});
}
else {
// Regular ingredient
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
return [{
checked: !householdsWithFood.includes(currentHouseholdSlug.value),
ingredient: {
...ing,
title: ing.title || parentTitle,
},
}];
}
}
recipeData.recipeIngredient.forEach((ing) => {
const flattened = flattenRecipeIngredients(ing, "");
shoppingListIngredients.push(...flattened);
});
let currentTitle = "";
@@ -301,6 +327,9 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
if (ing.ingredient.title) {
currentTitle = ing.ingredient.title;
}
else if (ing.ingredient.referencedRecipe?.name) {
currentTitle = ing.ingredient.referencedRecipe.name;
}
// If this is the first item in the section, create a new section
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
@@ -316,8 +345,8 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
}
// Store the on-hand ingredients for later
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
if (householdsWithFood.includes(userHousehold.value)) {
const householdsWithFood = (ing.ingredient?.food?.householdsWithIngredientFood || []);
if (householdsWithFood.includes(currentHouseholdSlug.value)) {
onHandIngs.push(ing);
return sections;
}
@@ -331,9 +360,9 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
recipeSectionMap.set(recipe.slug, {
recipeId: recipe.id,
recipeName: recipe.name,
recipeScale: recipe.scale,
recipeId: recipeData.id,
recipeName: recipeData.name,
recipeScale: recipeData.scale,
ingredientSections: shoppingListIngredientSections,
});
}

View File

@@ -141,6 +141,13 @@ function save() {
dialog.value = false;
}
function open() {
dialog.value = true;
}
function close() {
dialog.value = false;
}
const i18n = useI18n();
const utilities = [
@@ -160,4 +167,10 @@ const utilities = [
action: splitByNumberedLine,
},
];
// Expose functions to parent components
defineExpose({
open,
close,
});
</script>

View File

@@ -69,7 +69,14 @@
:label="$t('recipe.nutrition')"
/>
</v-row>
<v-row no-gutters />
<v-row no-gutters>
<v-switch
v-model="preferences.expandChildRecipes"
hide-details
color="primary"
:label="$t('recipe.include-linked-recipe-ingredients')"
/>
</v-row>
</v-col>
</v-row>
</v-container>

View File

@@ -16,7 +16,7 @@
>
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="expirationDateString"
:model-value="$d(expirationDate)"
:label="$t('recipe-share.expiration-date')"
:hint="$t('recipe-share.default-30-days')"
persistent-hint
@@ -59,11 +59,8 @@
<div class="pl-3 flex-grow-1">
<v-list-item-title>
{{ $t("recipe-share.expires-at") }}
{{ $t("recipe-share.expires-at") + ' ' + $d(new Date(token.expiresAt!), "short") }}
</v-list-item-title>
<v-list-item-subtitle>
{{ $d(new Date(token.expiresAt!), "long") }}
</v-list-item-subtitle>
</div>
<v-btn
@@ -111,10 +108,6 @@ const datePickerMenu = ref(false);
const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
const tokens = ref<RecipeShareToken[]>([]);
const expirationDateString = computed(() => {
return expirationDate.value.toISOString().substring(0, 10);
});
whenever(
() => dialog.value,
() => {

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,254 @@
<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.sortDescending : $globals.icons.sortAscending }}
</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.sortAscending : $globals.icons.sortDescending"
: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"
@click="v.value === 'random' ? setRandomOrderByWrapper() : setOrderBy(v.value)"
>
<template #prepend>
<v-icon>{{ v.icon }}</v-icon>
</template>
<template #title>
<span>{{ v.name }}</span>
<v-icon
v-if="v.value === 'random' && showRandomLoading"
size="small"
class="ml-3"
>
{{ $globals.icons.refreshCircle }}
</v-icon>
</template>
</v-list-item>
</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 showRandomLoading = ref(false);
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const {
state,
passedQueryWithSeed,
search,
reset,
toggleOrderDirection,
setOrderBy,
setRandomOrderBy,
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();
}
// function to show refresh icon
async function setRandomOrderByWrapper() {
if (!showRandomLoading.value) {
showRandomLoading.value = true;
}
await setRandomOrderBy();
}
</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,114 @@
<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);
watch(
households,
() => {
// if exactly one household exists, then we shouldn't be filtering by household
if (households.value.length == 1) {
selectedHouseholds.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

@@ -20,18 +20,36 @@
</v-btn>
</template>
<v-card width="400">
<v-card-title class="headline flex mb-0">
<v-card-title class="headline flex-wrap mb-0">
<div>
{{ $t("recipe.recipe-image") }}
</div>
<AppButtonUpload
class="ml-auto"
url="none"
file-name="image"
:text-btn="false"
:post="false"
@uploaded="uploadImage"
/>
<div class="d-flex gap-2">
<AppButtonUpload
url="none"
file-name="image"
:text-btn="false"
:post="false"
@uploaded="uploadImage"
/>
<BaseButton
class="ml-2"
delete
@click="dialogDeleteImage = true"
/>
<BaseDialog
v-model="dialogDeleteImage"
:title="$t('recipe.delete-image')"
:icon="$globals.icons.alertCircle"
color="error"
can-delete
@delete="deleteImage"
>
<v-card-text>
{{ $t("recipe.delete-image-confirmation") }}
</v-card-text>
</BaseDialog>
</div>
</v-card-title>
<v-card-text class="mt-n5">
<div>
@@ -62,38 +80,58 @@
</template>
<script setup lang="ts">
import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api";
const REFRESH_EVENT = "refresh";
const UPLOAD_EVENT = "upload";
const DELETE_EVENT = "delete";
const props = defineProps<{ slug: string }>();
const emit = defineEmits<{
refresh: [];
upload: [fileObject: File];
delete: [];
}>();
const i18n = useI18n();
const api = useUserApi();
const url = ref("");
const loading = ref(false);
const menu = ref(false);
const dialogDeleteImage = ref(false);
function uploadImage(fileObject: File) {
emit(UPLOAD_EVENT, fileObject);
menu.value = false;
}
const api = useUserApi();
async function deleteImage() {
loading.value = true;
try {
await api.recipes.deleteImage(props.slug);
emit(DELETE_EVENT);
menu.value = false;
}
catch (e) {
alert.error(i18n.t("events.something-went-wrong"));
console.error("Failed to delete image", e);
}
finally {
loading.value = false;
}
}
async function getImageFromURL() {
loading.value = true;
if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
emit(REFRESH_EVENT);
emit(DELETE_EVENT);
}
loading.value = false;
menu.value = false;
}
const i18n = useI18n();
const messages = computed(() =>
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
);

View File

@@ -22,25 +22,29 @@
cols="12"
class="flex-grow-0 flex-shrink-0"
>
<v-text-field
<v-number-input
v-model="model.quantity"
variant="solo"
:precision="null"
:min="0"
hide-details
control-variant="stacked"
inset
density="compact"
type="number"
:placeholder="$t('recipe.quantity')"
@keypress="quantityFilter"
>
<template #prepend>
<template v-if="enableDragHandle" #prepend>
<v-icon
class="mr-n1 handle"
>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</template>
</v-text-field>
</v-number-input>
</v-col>
<v-col
v-if="!state.isRecipe"
sm="12"
md="3"
cols="12"
@@ -55,10 +59,12 @@
variant="solo"
return-object
:items="units || []"
:custom-filter="normalizeFilter"
item-title="name"
class="mx-1"
:placeholder="$t('recipe.choose-unit')"
clearable
:menu-props="{ attach: props.menuAttachTarget, maxHeight: '250px' }"
@keyup.enter="handleUnitEnter"
>
<template #prepend>
@@ -96,6 +102,7 @@
<!-- Foods Input -->
<v-col
v-if="!state.isRecipe"
m="12"
md="3"
cols="12"
@@ -111,10 +118,12 @@
variant="solo"
return-object
:items="foods || []"
:custom-filter="normalizeFilter"
item-title="name"
class="mx-1 py-0"
:placeholder="$t('recipe.choose-food')"
clearable
:menu-props="{ attach: props.menuAttachTarget, maxHeight: '250px' }"
@keyup.enter="handleFoodEnter"
>
<template #prepend>
@@ -149,6 +158,36 @@
</template>
</v-autocomplete>
</v-col>
<!-- Recipe Input -->
<v-col
v-if="state.isRecipe"
m="12"
md="6"
cols="12"
class=""
>
<v-autocomplete
ref="search.query"
v-model="model.referencedRecipe"
v-model:search="search.query.value"
auto-select-first
hide-details
density="compact"
variant="solo"
return-object
:items="search.data.value || []"
:custom-filter="normalizeFilter"
item-title="name"
class="mx-1 py-0"
:placeholder="$t('search.type-to-search')"
clearable
:label="!model.referencedRecipe ? $t('recipe.choose-recipe') : ''"
@click="search.trigger()"
@focus="search.trigger()"
>
<template #prepend />
</v-autocomplete>
</v-col>
<v-col
sm="12"
md=""
@@ -165,12 +204,13 @@
@click="$emit('clickIngredientField', 'note')"
/>
<BaseButtonGroup
v-if="enableContextMenu"
hover
:large="false"
class="my-auto d-flex"
:buttons="btns"
@toggle-section="toggleTitle"
@toggle-original="toggleOriginalText"
@toggle-subrecipe="toggleIsRecipe"
@insert-above="$emit('insert-above')"
@insert-below="$emit('insert-below')"
@delete="$emit('delete')"
@@ -178,13 +218,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"
@@ -197,13 +231,24 @@ import { ref, computed, reactive, toRefs } from "vue";
import { useDisplay } from "vuetify";
import { useI18n } from "vue-i18n";
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
import { normalizeFilter } from "~/composables/use-utils";
import { useNuxtApp } from "#app";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
// defineModel replaces modelValue prop
const model = defineModel<RecipeIngredient>({ required: true });
defineProps({
const props = defineProps({
menuAttachTarget: {
type: String,
default: "body",
},
isRecipe: {
type: Boolean,
default: false,
},
unitError: {
type: Boolean,
default: false,
@@ -220,6 +265,18 @@ defineProps({
type: String,
default: "",
},
enableContextMenu: {
type: Boolean,
default: false,
},
enableDragHandle: {
type: Boolean,
default: false,
},
deleteDisabled: {
type: Boolean,
default: false,
},
});
defineEmits([
@@ -235,7 +292,7 @@ const { $globals } = useNuxtApp();
const state = reactive({
showTitle: false,
showOriginalText: false,
isRecipe: props.isRecipe,
});
const contextMenuOptions = computed(() => {
@@ -244,6 +301,10 @@ const contextMenuOptions = computed(() => {
text: i18n.t("recipe.toggle-section"),
event: "toggle-section",
},
{
text: i18n.t("recipe.toggle-recipe"),
event: "toggle-subrecipe",
},
{
text: i18n.t("recipe.insert-above"),
event: "insert-above",
@@ -254,13 +315,6 @@ const contextMenuOptions = computed(() => {
},
];
if (model.value.originalText) {
options.push({
text: i18n.t("recipe.see-original-text"),
event: "toggle-original",
});
}
return options;
});
@@ -281,8 +335,8 @@ const btns = computed(() => {
text: i18n.t("general.delete"),
event: "delete",
children: undefined,
disabled: props.deleteDisabled,
});
return out;
});
@@ -299,6 +353,25 @@ async function createAssignFood() {
foodAutocomplete.value?.blur();
}
// Recipes
const route = useRoute();
const $auth = useMealieAuth();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { isOwnGroup } = useLoggedInState();
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
const search = useRecipeSearch(api);
const loading = ref(false);
const selectedIndex = ref(-1);
// Reset or Grab Recipes on Change
watch(loading, (val) => {
if (!val) {
search.query.value = "";
selectedIndex.value = -1;
search.data.value = [];
}
});
// Units
const unitStore = useUnitStore();
const unitsData = useUnitData();
@@ -319,8 +392,15 @@ function toggleTitle() {
state.showTitle = !state.showTitle;
}
function toggleOriginalText() {
state.showOriginalText = !state.showOriginalText;
function toggleIsRecipe() {
if (state.isRecipe) {
model.value.referencedRecipe = undefined;
}
else {
model.value.unit = undefined;
model.value.food = undefined;
}
state.isRecipe = !state.isRecipe;
}
function handleUnitEnter() {
@@ -349,7 +429,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

@@ -13,6 +13,10 @@
class="text-bold d-inline"
:source="parsedIng.note"
/>
<template v-else-if="parsedIng.recipeLink">
<SafeMarkdown v-if="parsedIng.recipeLink" class="text-bold d-inline" :source="parsedIng.recipeLink" />
<SafeMarkdown v-if="parsedIng.note" class="note" :source="parsedIng.note" />
</template>
<template v-else>
<SafeMarkdown
v-if="parsedIng.name"
@@ -39,9 +43,12 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
scale: 1,
});
const route = useRoute();
const $auth = useMealieAuth();
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
const parsedIng = computed(() => {
return useParsedIngredientText(props.ingredient, props.scale);
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
});
</script>

View File

@@ -8,6 +8,7 @@
:title="$t('recipe.made-this')"
:submit-text="$t('recipe.add-to-timeline')"
can-submit
disable-submit-on-enter
@submit="createTimelineEvent"
>
<v-card-text>
@@ -20,6 +21,29 @@
persistent-hint
rows="4"
/>
<div v-if="childRecipes?.length">
<v-card-text class="pt-6 pb-0">
{{ $t('recipe.include-linked-recipes') }}
</v-card-text>
<v-list>
<v-list-item
v-for="(childRecipe, i) in childRecipes"
:key="childRecipe.recipeId + i"
density="compact"
class="my-0 py-0"
@click="childRecipe.checked = !childRecipe.checked"
>
<v-checkbox
hide-details
density="compact"
:input-value="childRecipe.checked"
:label="childRecipe.name"
class="my-0 py-0"
color="secondary"
/>
</v-list-item>
</v-list>
</div>
<v-container>
<v-row>
<v-col cols="6">
@@ -32,7 +56,7 @@
>
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="newTimelineEventTimestampString"
:model-value="$d(newTimelineEventTimestamp)"
:prepend-icon="$globals.icons.calendar"
v-bind="activatorProps"
readonly
@@ -102,7 +126,7 @@
<span class="text-body-1 opacity-80">
<b>{{ $t("general.last-made") }}</b>
<br>
{{ lastMade ? new Date(lastMade).toLocaleDateString($i18n.locale) : $t("general.never") }}
{{ lastMade ? $d(new Date(lastMade)) : $t("general.never") }}
</span>
<v-icon end size="large" color="primary">
{{ $globals.icons.createAlt }}
@@ -119,6 +143,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 +173,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);
@@ -165,11 +190,26 @@ onMounted(async () => {
lastMadeReady.value = true;
});
const childRecipes = computed(() => {
return props.recipe.recipeIngredient?.map((ingredient) => {
if (ingredient.referencedRecipe) {
return {
checked: false, // Default value for checked
recipeId: ingredient.referencedRecipe.id || "", // Non-nullable recipeId
...ingredient.referencedRecipe, // Spread the rest of the referencedRecipe properties
};
}
else {
return undefined;
}
}).filter(recipe => recipe !== undefined); // Filter out undefined values
});
whenever(
() => madeThisDialog.value,
() => {
// Set timestamp to now
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
newTimelineEventTimestamp.value = new Date();
},
);
@@ -249,6 +289,37 @@ async function createTimelineEvent() {
}
}
for (const childRecipe of childRecipes.value || []) {
if (!childRecipe.checked) {
continue;
}
const childTimelineEvent = {
...newTimelineEvent.value,
recipeId: childRecipe.recipeId,
eventMessage: i18n.t("recipe.made-for-recipe", { recipe: childRecipe.name }),
image: undefined,
};
try {
await userApi.recipes.createTimelineEvent(childTimelineEvent);
}
catch (error) {
console.error(`Failed to create timeline event for child recipe ${childRecipe.slug}:`, error);
}
if (
newTimelineEvent.value.timestamp
&& (!childRecipe.lastMade || newTimelineEvent.value.timestamp > childRecipe.lastMade)
) {
try {
await userApi.recipes.updateLastMade(childRecipe.slug || "", newTimelineEvent.value.timestamp);
}
catch (error) {
console.error(`Failed to update last made date for child recipe ${childRecipe.slug}:`, error);
}
}
}
// update the image, if provided
let imageError = false;
if (newTimelineEventImage.value) {
@@ -267,7 +338,6 @@ async function createTimelineEvent() {
console.error("Failed to upload image for timeline event:", error);
}
}
if (imageError) {
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
}

View File

@@ -10,14 +10,17 @@
v-for="(item, key, index) in modelValue"
:key="index"
>
<v-text-field
density="compact"
<v-number-input
:model-value="modelValue[key]"
:label="labels[key].label"
:suffix="labels[key].suffix"
type="number"
density="compact"
autocomplete="off"
variant="underlined"
control-variant="stacked"
inset
:precision="null"
:min="0"
@update:model-value="updateValue(key, $event)"
/>
</div>

View File

@@ -105,10 +105,9 @@
<v-icon>
{{ icon }}
</v-icon>
<v-card-title class="py-1">
<v-card-title class="py-1 text-truncate flex-shrink-1 flex-grow-1">
{{ item.name }}
</v-card-title>
<v-spacer />
<ContextMenu
:items="[presets.delete, presets.edit]"
@delete="confirmDelete(item)"

View File

@@ -4,17 +4,19 @@
v-bind="inputAttrs"
v-model:search="searchInput"
:items="items"
:custom-filter="normalizeFilter"
:label="label"
chips
closable-chips
item-title="name"
:item-title="itemTitle"
item-value="name"
multiple
:variant="variant"
:prepend-inner-icon="icon"
:append-icon="showAdd ? $globals.icons.create : undefined"
return-object
auto-select-first
class="pa-0"
class="pa-0 ma-0"
@update:model-value="resetSearchInput"
@click:append="dialog = true"
>
@@ -32,7 +34,6 @@
{{ item.value }}
</v-chip>
</template>
<template
v-if="showAdd"
#append
@@ -47,16 +48,17 @@
</template>
<script setup lang="ts">
import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
import type { RecipeTool } from "~/lib/api/types/admin";
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
import type { HouseholdSummary } from "~/lib/api/types/household";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { useUserStore } from "~/composables/store/use-user-store";
import { normalizeFilter } from "~/composables/use-utils";
import type { UserSummary } from "~/lib/api/types/user";
interface Props {
selectorType: RecipeOrganizer;
inputAttrs?: Record<string, any>;
returnObject?: boolean;
showAdd?: boolean;
showLabel?: boolean;
showIcon?: boolean;
@@ -65,7 +67,6 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
inputAttrs: () => ({}),
returnObject: true,
showAdd: true,
showLabel: true,
showIcon: true,
@@ -78,7 +79,7 @@ const selected = defineModel<(
| RecipeCategory
| RecipeTool
| IngredientFood
| string
| UserSummary
)[] | undefined>({ required: true });
onMounted(() => {
@@ -106,6 +107,8 @@ const label = computed(() => {
return i18n.t("general.foods");
case Organizer.Household:
return i18n.t("household.households");
case Organizer.User:
return i18n.t("user.users");
default:
return i18n.t("general.organizer");
}
@@ -127,11 +130,19 @@ const icon = computed(() => {
return $globals.icons.foods;
case Organizer.Household:
return $globals.icons.household;
case Organizer.User:
return $globals.icons.user;
default:
return $globals.icons.tags;
}
});
const itemTitle = computed(() =>
props.selectorType === Organizer.User
? (i: any) => i?.fullName ?? i?.name ?? ""
: "name",
);
// ===========================================================================
// Store & Items Setup
@@ -141,24 +152,24 @@ const storeMap = {
[Organizer.Tool]: useToolStore(),
[Organizer.Food]: useFoodStore(),
[Organizer.Household]: useHouseholdStore(),
[Organizer.User]: useUserStore(),
};
const store = computed(() => {
const activeStore = computed(() => {
const { store } = storeMap[props.selectorType];
return store.value;
});
const items = computed(() => {
if (!props.returnObject) {
return store.value.map(item => item.name);
}
return store.value;
const items = computed<any[]>(() => {
const list = (activeStore.value as unknown as any[]) ?? [];
return list;
});
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}

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"
@@ -88,9 +95,12 @@
<RecipePrintContainer :recipe="recipe" :scale="scale" />
</v-container>
<!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same time -->
<!-- The calc is to account for the navabar height (48px) -->
<v-sheet
v-show="isCookMode && !hasLinkedIngredients"
key="cookmode"
:height="$vuetify.display.smAndUp ? 'calc(100vh - 48px)' : 'auto'"
class-name="overflow-hidden"
>
<!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
<v-row style="height: 100%" no-gutters class="overflow-hidden">
@@ -106,9 +116,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 +182,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 +193,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 +263,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;
}
});
/** =============================================================
@@ -259,13 +293,22 @@ onMounted(() => {
*/
async function saveRecipe() {
const { data } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
setMode(PageMode.VIEW);
const { data, error } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
if (!error) {
setMode(PageMode.VIEW);
}
if (data?.slug) {
router.push(`/g/${groupSlug.value}/r/` + data.slug);
recipe.value = data as NoUndefinedField<Recipe>;
}
}
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 +321,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 +345,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

@@ -5,6 +5,7 @@
:slug="recipe.slug"
@upload="uploadImage"
@refresh="imageKey++"
@delete="deleteImage"
/>
<RecipeSettingsMenu
v-model="recipe.settings"
@@ -13,25 +14,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">
@@ -78,4 +79,10 @@ async function uploadImage(fileObject: File) {
}
imageKey.value++;
}
async function deleteImage() {
// The image is already deleted on the backend, just need to update the UI
recipe.value.image = "";
imageKey.value++;
}
</script>

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

@@ -47,7 +47,7 @@ const props = withDefaults(defineProps<Props>(), {
landscape: false,
});
defineEmits(["save", "delete"]);
defineEmits(["save", "delete", "print"]);
const { recipeImage } = useStaticRoutes();
const { imageKey, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);

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,8 +27,8 @@ const props = withDefaults(defineProps<Props>(), {
maxWidth: undefined,
});
const { $vuetify } = useNuxtApp();
const { recipeImage } = useStaticRoutes();
const display = useDisplay();
const { recipeImage, recipeSmallImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { user } = usePageUser();
@@ -42,11 +42,13 @@ 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(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
return display.smAndDown.value
? recipeSmallImage(props.recipe.id, props.recipe.image, imageKey.value)
: recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
watch(

View File

@@ -11,27 +11,27 @@
<v-container class="ma-0 pa-0">
<v-row>
<v-col cols="3">
<v-text-field
:model-value="recipeServings"
type="number"
<v-number-input
:model-value="recipe.recipeServings"
:min="0"
hide-spin-buttons
:precision="null"
density="compact"
:label="$t('recipe.servings')"
variant="underlined"
@update:model-value="validateInput($event, 'recipeServings')"
control-variant="hidden"
@update:model-value="recipe.recipeServings = $event"
/>
</v-col>
<v-col cols="3">
<v-text-field
:model-value="recipeYieldQuantity"
type="number"
<v-number-input
:model-value="recipe.recipeYieldQuantity"
:min="0"
hide-spin-buttons
:precision="null"
density="compact"
:label="$t('recipe.yield')"
variant="underlined"
@update:model-value="validateInput($event, 'recipeYieldQuantity')"
control-variant="hidden"
@update:model-value="recipe.recipeYieldQuantity = $event"
/>
</v-col>
<v-col cols="6">
@@ -85,37 +85,4 @@ import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const recipeServings = computed<number>({
get() {
return recipe.value.recipeServings;
},
set(val) {
validateInput(val.toString(), "recipeServings");
},
});
const recipeYieldQuantity = computed<number>({
get() {
return recipe.value.recipeYieldQuantity;
},
set(val) {
validateInput(val.toString(), "recipeYieldQuantity");
},
});
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
if (!value) {
recipe.value[property] = 0;
return;
}
const number = parseFloat(value.replace(/[^0-9.]/g, ""));
if (isNaN(number) || number <= 0) {
recipe.value[property] = 0;
return;
}
recipe.value[property] = number;
}
</script>

View File

@@ -1,4 +1,3 @@
<!-- eslint-disable vue/no-mutating-props -->
<template>
<div>
<div class="mb-4">
@@ -31,6 +30,9 @@
v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
:is-recipe="ingredientIsRecipe(ingredient)"
enable-drag-handle
enable-context-menu
class="list-group-item"
@delete="recipe.recipeIngredient.splice(index, 1)"
@insert-above="insertNewIngredient(index)"
@@ -55,8 +57,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 }}
@@ -68,15 +70,59 @@
<span>{{ parserToolTip }}</span>
</v-tooltip>
<RecipeDialogBulkAdd
ref="domBulkAddDialog"
class="mx-1 mb-1"
style="display: none"
@bulk-data="addIngredient"
/>
<BaseButton
class="mb-1"
@click="addIngredient"
>
{{ $t("general.add") }}
</BaseButton>
<div class="d-inline-flex">
<!-- Main button: Add Food -->
<v-btn
color="success"
class="split-main ml-2"
@click="addIngredient"
>
<v-icon start>
{{ $globals.icons.createAlt }}
</v-icon>
{{ $t('general.add') || 'Add Food' }}
</v-btn>
<!-- Dropdown button -->
<v-menu>
<template #activator="{ props }">
<v-btn
color="success"
class="split-dropdown"
v-bind="props"
>
<v-icon>{{ $globals.icons.chevronDown }}</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.foods"
:title="$t('new-recipe.add-food')"
@click="addIngredient"
/>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.silverwareForkKnife"
:title="$t('new-recipe.add-recipe')"
@click="addRecipe"
/>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.create"
:title="$t('new-recipe.bulk-add')"
@click="showBulkAdd"
/>
</v-list>
</v-menu>
</div>
</div>
</div>
</template>
@@ -84,19 +130,19 @@
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe";
import type { Recipe, RecipeIngredient } 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 ingredientsWithRecipe = new Map<string, boolean>();
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 domBulkAddDialog = ref<InstanceType<typeof RecipeDialogBulkAdd> | null>(null);
const { toggleIsParsing } = usePageState(recipe.value.slug);
const hasFoodOrUnit = computed(() => {
if (!recipe.value) {
@@ -119,6 +165,22 @@ const parserToolTip = computed(() => {
return i18n.t("recipe.parse-ingredients");
});
function showBulkAdd() {
domBulkAddDialog.value?.open();
}
function ingredientIsRecipe(ingredient: RecipeIngredient): boolean {
if (ingredient.referencedRecipe) {
return true;
}
if (ingredient.referenceId) {
return !!ingredientsWithRecipe.get(ingredient.referenceId);
}
return false;
}
function addIngredient(ingredients: Array<string> | null = null) {
if (ingredients?.length) {
const newIngredients = ingredients.map((x) => {
@@ -128,7 +190,7 @@ function addIngredient(ingredients: Array<string> | null = null) {
note: x,
unit: undefined,
food: undefined,
quantity: 1,
quantity: 0,
};
});
@@ -146,6 +208,41 @@ 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: 0,
});
}
}
function addRecipe(recipes: Array<string> | null = null) {
const refId = uuid4();
ingredientsWithRecipe.set(refId, true);
if (recipes?.length) {
const newRecipes = recipes.map((x) => {
return {
referenceId: refId,
title: "",
note: x,
unit: undefined,
referencedRecipe: undefined,
quantity: 1,
};
});
if (newRecipes) {
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
recipe.value.recipeIngredient.push(...newRecipes);
}
}
else {
recipe.value.recipeIngredient.push({
referenceId: refId,
title: "",
note: "",
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
unit: undefined,
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
referencedRecipe: undefined,
quantity: 1,
});
}
@@ -160,7 +257,21 @@ 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>
<style scoped>
.split-main {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.split-dropdown {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
min-width: 30px;
padding-left: 0;
padding-right: 0;
}
</style>

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,552 @@
<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 { 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 { $appInfo } = useNuxtApp();
const i18n = useGlobalI18n();
const api = useUserApi();
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.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.ingredient.referencedRecipe) {
console.debug("No review needed for sub-recipe ingredient");
return false;
}
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
.filter(ing => !ing.referencedRecipe)
.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;
const parsed = data ?? [];
const recipeRefs = props.ingredients.filter(ing => ing.referencedRecipe).map(ing => ({
input: ing.note || "",
confidence: {},
ingredient: ing,
}));
parsedIngs.value = [...parsed, ...recipeRefs];
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>
@@ -243,32 +262,55 @@ const ingredientSections = computed<IngredientSection[]>(() => {
if (!props.recipe.recipeIngredient) {
return [];
}
return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
// if title append new section to the end of the array
if (ingredient.title) {
sections.push({
sectionName: ingredient.title,
ingredients: [ingredient],
});
return sections;
const addIngredientsToSections = (ingredients: RecipeIngredient[], sections: IngredientSection[], title: string | null) => {
// If title is set, ensure the section exists before adding ingredients
let section: IngredientSection | undefined;
if (title) {
section = sections.find(sec => sec.sectionName === title);
if (!section) {
section = { sectionName: title, ingredients: [] };
sections.push(section);
}
}
// append new section if first
if (sections.length === 0) {
sections.push({
sectionName: "",
ingredients: [ingredient],
});
ingredients.forEach((ingredient) => {
if (preferences.value.expandChildRecipes && ingredient.referencedRecipe?.recipeIngredient?.length) {
// Recursively add to the section for this referenced recipe
addIngredientsToSections(
ingredient.referencedRecipe.recipeIngredient,
sections,
"",
);
}
else {
const sectionName = title || ingredient.title || "";
if (sectionName) {
let sec = sections.find(sec => sec.sectionName === sectionName);
if (!sec) {
sec = { sectionName, ingredients: [] };
sections.push(sec);
}
ingredient.title = sectionName;
sec.ingredients.push(ingredient);
}
else {
if (sections.length === 0) {
sections.push({
sectionName: "",
ingredients: [ingredient],
});
}
else {
sections[sections.length - 1].ingredients.push(ingredient);
}
}
}
});
};
return sections;
}
// otherwise add ingredient to last section in the array
sections[sections.length - 1].ingredients.push(ingredient);
return sections;
}, [] as IngredientSection[]);
const sections: IngredientSection[] = [];
addIngredientsToSections(props.recipe.recipeIngredient, sections, null);
return sections;
});
// Group instructions by section so we can style them independently

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

@@ -65,13 +65,13 @@
</v-card-title>
<v-card-text class="mt-n5">
<div class="mt-4 d-flex align-center">
<v-text-field
<v-number-input
:model-value="yieldQuantity"
type="number"
:precision="null"
:min="0"
variant="underlined"
hide-spin-buttons
@update:model-value="recalculateScale(parseFloat($event) || 0)"
control-variant="hidden"
@update:model-value="recalculateScale($event || 0)"
/>
<v-tooltip
location="end"

View File

@@ -47,7 +47,7 @@
left
color="primary"
>
{{ $globals.icons.knfife }}
{{ $globals.icons.knife }}
</v-icon>
<p class="my-0">
<span class="font-weight-bold opacity-80">{{ validatePrepTime.name }}</span><br>{{ validatePrepTime.value }}

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

@@ -5,6 +5,7 @@
:title="$t('recipe.edit-timeline-event')"
:icon="$globals.icons.edit"
can-submit
disable-submit-on-enter
:submit-text="$t('general.save')"
@submit="submitEdit"
>

View File

@@ -5,7 +5,7 @@
<v-icon class="mr-1">
{{ $globals.icons.calendar }}
</v-icon>
{{ new Date(event.timestamp).toLocaleDateString($i18n.locale) }}
{{ $d(new Date(event.timestamp)) }}
</v-chip>
</template>
<v-card
@@ -22,7 +22,7 @@
<v-col v-if="useMobileFormat" align-self="center" class="pr-0">
<v-chip label>
<v-icon> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp || "").toLocaleDateString($i18n.locale) }}
{{ $d(new Date(event.timestamp || "")) }}
</v-chip>
</v-col>
<v-col v-else cols="9" style="margin: auto; text-align: center">
@@ -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,12 +113,13 @@ const props = withDefaults(defineProps<Props>(), {
defineEmits<{
selected: [];
update: [];
update: [event: RecipeTimelineEventUpdate];
delete: [];
}>();
const { $vuetify, $globals } = useNuxtApp();
const { recipeTimelineEventImage } = useStaticRoutes();
const { $globals } = useNuxtApp();
const display = useDisplay();
const { recipeTimelineEventSmallImage } = useStaticRoutes();
const { eventTypeOptions } = useTimelineEventTypes();
const { user: currentUser } = useMealieAuth();
@@ -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(() => {
@@ -172,7 +173,7 @@ const eventImageUrl = computed<string>(() => {
return "";
}
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
return recipeTimelineEventSmallImage(props.event.recipeId, props.event.id);
});
</script>

View File

@@ -9,10 +9,11 @@
>
<template #activator="{ props }">
<v-badge
:model-value="selected.length > 0"
v-memo="[selectedCount]"
:model-value="selectedCount > 0"
size="small"
color="primary"
:content="selected.length"
:content="selectedCount"
>
<v-btn
size="small"
@@ -28,6 +29,7 @@
<v-card-text>
<v-text-field
v-model="state.search"
v-memo="[state.search]"
class="mb-2"
hide-details
density="comfortable"
@@ -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

@@ -7,8 +7,16 @@
no-gutters
class="flex-nowrap align-center"
>
<v-col :cols="itemLabelCols">
<div class="d-flex align-center flex-nowrap">
<v-card
flat
link
class="grow"
@click="() => {
listItem.checked = !listItem.checked
$emit('checked', listItem)
}"
>
<div class="d-flex align-center flex-nowrap grow">
<v-checkbox
v-model="listItem.checked"
hide-details
@@ -25,84 +33,78 @@
<RecipeIngredientListItem :ingredient="listItem" />
</div>
</div>
</v-col>
<v-spacer />
<v-col
cols="auto"
class="text-right"
</v-card>
<div
v-if="!listItem.checked"
style="min-width: 72px"
>
<div
v-if="!listItem.checked"
style="min-width: 72px"
<v-menu
offset-x
start
min-width="125px"
>
<v-menu
offset-x
start
min-width="125px"
>
<template #activator="{ props }">
<v-tooltip
v-if="recipeList && recipeList.length"
open-delay="200"
transition="slide-x-reverse-transition"
density="compact"
location="end"
content-class="text-caption"
>
<template #activator="{ props: tooltipProps }">
<v-btn
size="small"
variant="text"
class="ml-2"
icon
v-bind="tooltipProps"
@click="displayRecipeRefs = !displayRecipeRefs"
>
<v-icon>
{{ $globals.icons.potSteam }}
</v-icon>
</v-btn>
</template>
<span>Toggle Recipes</span>
</v-tooltip>
<v-btn
size="small"
variant="text"
class="ml-2"
icon
@click="toggleEdit(true)"
>
<v-icon>
{{ $globals.icons.edit }}
</v-icon>
</v-btn>
<v-btn
size="small"
variant="text"
class="handle"
icon
v-bind="props"
>
<v-icon>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item
v-for="action in contextMenu"
:key="action.event"
density="compact"
@click="contextHandler(action.event)"
>
<v-list-item-title>
{{ action.text }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</v-col>
<template #activator="{ props }">
<v-tooltip
v-if="recipeList && recipeList.length"
open-delay="200"
transition="slide-x-reverse-transition"
density="compact"
location="end"
content-class="text-caption"
>
<template #activator="{ props: tooltipProps }">
<v-btn
size="small"
variant="text"
class="ml-2"
icon
v-bind="tooltipProps"
@click="displayRecipeRefs = !displayRecipeRefs"
>
<v-icon>
{{ $globals.icons.potSteam }}
</v-icon>
</v-btn>
</template>
<span>Toggle Recipes</span>
</v-tooltip>
<v-btn
size="small"
variant="text"
class="ml-2"
icon
@click="toggleEdit(true)"
>
<v-icon>
{{ $globals.icons.edit }}
</v-icon>
</v-btn>
<v-btn
size="small"
variant="text"
class="handle"
icon
v-bind="props"
>
<v-icon>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item
v-for="action in contextMenu"
:key="action.event"
density="compact"
@click="contextHandler(action.event)"
>
<v-list-item-title>
{{ action.text }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</v-row>
<v-row
v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs"
@@ -130,9 +132,8 @@
<v-col cols="auto">
<div class="text-caption font-weight-light font-italic">
{{ $t("shopping-list.completed-on", {
date: new Date(listItem.updatedAt
|| "").toLocaleDateString($i18n.locale) })
}}
date: listItem.updatedAt ? $d(new Date(listItem.updatedAt)) : '',
}) }}
</div>
</v-col>
</v-row>
@@ -312,4 +313,8 @@ export default defineNuxtComponent({
.strike-through {
text-decoration: line-through !important;
}
.grow {
flex-grow: 1;
}
</style>

View File

@@ -4,13 +4,25 @@
<v-card-text class="pb-3 pt-1">
<div class="d-md-flex align-center mb-2" style="gap: 20px">
<div>
<InputQuantity v-model="listItem.quantity" />
<v-number-input
v-model="listItem.quantity"
hide-details
:label="$t('form.quantity-label-abbreviated')"
:min="0"
:precision="null"
variant="plain"
control-variant="stacked"
inset
density="compact"
class="centered-number-input"
style="width: 100px;"
/>
</div>
<InputLabelType
v-model="listItem.unit"
v-model:item-id="listItem.unitId!"
:items="units"
:label="$t('general.units')"
:label="$t('recipe.unit')"
:icon="$globals.icons.units"
create
@create="createAssignUnit"
@@ -69,7 +81,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 +96,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,
@@ -158,6 +170,15 @@ export default defineNuxtComponent({
},
});
watch(
() => props.modelValue.quantity,
() => {
if (!props.modelValue.quantity) {
listItem.value.quantity = 0;
}
},
);
watch(
() => props.modelValue.food,
(newFood) => {
@@ -221,3 +242,10 @@ export default defineNuxtComponent({
},
});
</script>
<style scoped>
.centered-number-input :deep(.v-field) {
display: flex;
align-items: center;
}
</style>

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>
@@ -118,32 +97,24 @@
<script lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
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 { $appInfo, $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 +123,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 +134,12 @@ 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 showImageImport = computed(() => $appInfo.enableOpenaiImageServices);
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 +163,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 +225,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 +293,9 @@ export default defineNuxtComponent({
groupSlug,
cookbookLinks,
createLinks,
bottomLinks,
topLinks,
isOwnGroup,
languageDialog,
toggleDark,
sidebar,
};
},

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