Compare commits

...

194 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
326 changed files with 23014 additions and 21542 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

@@ -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

@@ -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

@@ -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 }}

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/\("version": "\)[^"]*"/\1${{ 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

@@ -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.14.2
rev: v0.14.10
hooks:
- id: ruff
- id: ruff-format

View File

@@ -55,7 +55,7 @@
"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"
},

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
@@ -96,22 +96,22 @@ tasks:
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
@@ -124,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:
@@ -147,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
@@ -184,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
@@ -202,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:
@@ -228,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

@@ -113,8 +113,8 @@ def main():
{"children": all_children},
)
subprocess.run(["poetry", "run", "ruff", "check", str(out_path), "--fix"])
subprocess.run(["poetry", "run", "ruff", "format", str(out_path)])
subprocess.run(["uv", "run", "ruff", "check", str(out_path), "--fix"])
subprocess.run(["uv", "run", "ruff", "format", str(out_path)])
if __name__ == "__main__":

View File

@@ -100,8 +100,8 @@ def main() -> None:
render_python_template(template, template_path, {"module": module})
path_args = (str(p) for p in template_paths)
subprocess.run(["poetry", "run", "ruff", "check", *path_args, "--fix"])
subprocess.run(["poetry", "run", "ruff", "format", *path_args])
subprocess.run(["uv", "run", "ruff", "check", *path_args, "--fix"])
subprocess.run(["uv", "run", "ruff", "format", *path_args])
if __name__ == "__main__":

View File

@@ -1,7 +1,7 @@
###############################################
# Frontend Build
###############################################
FROM node:24@sha256:34af25027ee1b8bffd482ba995ec1e577fbd398db87beb4c60b80c2c9c025127 \
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,7 +33,7 @@ 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)
- [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)

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. Must be <= 87600 (10 years, in hours). |
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
| API_DOCS | True | Turns on/off access to the API documentation locally |
| TZ | UTC | Must be set to get correct date/time on the server |
| 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.
@@ -145,22 +145,95 @@ Setting the following environmental variables will change the theme of the front
If using YAML sequence syntax, don't include any quotes:<br>`THEME_LIGHT_PRIMARY=#E58325` or `THEME_LIGHT_PRIMARY=E58325`
| Variables | Default | Description |
| --------------------- | :-----: | --------------------------- |
| THEME_LIGHT_PRIMARY | #E58325 | Light Theme Config Variable |
| 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 |
| 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.3.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

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.3.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.3.2 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.8.0 # (3)
container_name: mealie
restart: always
ports:

View File

@@ -9,6 +9,23 @@
- 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

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

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

@@ -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,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) {
@@ -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

@@ -126,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

@@ -15,11 +15,11 @@
@click.self="$emit('click')"
>
<RecipeCardImage
small
:icon-size="imageHeight"
:height="imageHeight"
:slug="slug"
:recipe-id="recipeId"
size="small"
:image-version="image"
>
<v-expand-transition v-if="description">

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"
/>

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
@@ -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

@@ -45,31 +45,15 @@
@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-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"
@@ -207,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
@@ -377,11 +360,14 @@ async function deleteRecipe() {
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

@@ -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

@@ -53,10 +53,23 @@
:active="state.orderBy === v.value"
slim
density="comfortable"
:prepend-icon="v.icon"
:title="v.name"
@click="setOrderBy(v.value)"
/>
@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>
@@ -131,6 +144,7 @@ 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 || "");
@@ -141,6 +155,7 @@ const {
reset,
toggleOrderDirection,
setOrderBy,
setRandomOrderBy,
filterItems,
initialize,
} = useRecipeExplorerSearch(groupSlug);
@@ -205,6 +220,14 @@ 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>

View File

@@ -101,4 +101,14 @@ const { store: tags } = isOwnGroup.value ? useTagStore() : usePublicTagStore(gro
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

@@ -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,12 +22,15 @@
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"
>
@@ -38,9 +41,10 @@
{{ $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,6 +59,7 @@
variant="solo"
return-object
:items="units || []"
:custom-filter="normalizeFilter"
item-title="name"
class="mx-1"
:placeholder="$t('recipe.choose-unit')"
@@ -97,6 +102,7 @@
<!-- Foods Input -->
<v-col
v-if="!state.isRecipe"
m="12"
md="3"
cols="12"
@@ -112,6 +118,7 @@
variant="solo"
return-object
:items="foods || []"
:custom-filter="normalizeFilter"
item-title="name"
class="mx-1 py-0"
:placeholder="$t('recipe.choose-food')"
@@ -151,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=""
@@ -173,6 +210,7 @@
class="my-auto d-flex"
:buttons="btns"
@toggle-section="toggleTitle"
@toggle-subrecipe="toggleIsRecipe"
@insert-above="$emit('insert-above')"
@insert-below="$emit('insert-below')"
@delete="$emit('delete')"
@@ -193,8 +231,11 @@ 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 });
@@ -204,6 +245,10 @@ const props = defineProps({
type: String,
default: "body",
},
isRecipe: {
type: Boolean,
default: false,
},
unitError: {
type: Boolean,
default: false,
@@ -247,6 +292,7 @@ const { $globals } = useNuxtApp();
const state = reactive({
showTitle: false,
isRecipe: props.isRecipe,
});
const contextMenuOptions = computed(() => {
@@ -255,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",
@@ -303,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();
@@ -323,6 +392,17 @@ function toggleTitle() {
state.showTitle = !state.showTitle;
}
function toggleIsRecipe() {
if (state.isRecipe) {
model.value.referencedRecipe = undefined;
}
else {
model.value.unit = undefined;
model.value.food = undefined;
}
state.isRecipe = !state.isRecipe;
}
function handleUnitEnter() {
if (
model.value.unit === undefined

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 }}
@@ -166,6 +190,21 @@ 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,
() => {
@@ -250,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) {
@@ -268,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

@@ -95,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">
@@ -290,10 +293,13 @@ watch(isParsing, () => {
*/
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>;
}
}

View File

@@ -5,6 +5,7 @@
:slug="recipe.slug"
@upload="uploadImage"
@refresh="imageKey++"
@delete="deleteImage"
/>
<RecipeSettingsMenu
v-model="recipe.settings"
@@ -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

@@ -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

@@ -28,7 +28,7 @@ const props = withDefaults(defineProps<Props>(), {
});
const display = useDisplay();
const { recipeImage } = useStaticRoutes();
const { recipeImage, recipeSmallImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { user } = usePageUser();
@@ -46,7 +46,9 @@ const imageHeight = computed(() => {
});
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

@@ -30,6 +30,7 @@
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"
@@ -69,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>
@@ -85,16 +130,18 @@
<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 drag = ref(false);
const domBulkAddDialog = ref<InstanceType<typeof RecipeDialogBulkAdd> | null>(null);
const { toggleIsParsing } = usePageState(recipe.value.slug);
const hasFoodOrUnit = computed(() => {
@@ -118,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) => {
@@ -150,6 +213,41 @@ function addIngredient(ingredients: Array<string> | null = null) {
}
}
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,
});
}
}
function insertNewIngredient(dest: number) {
recipe.value.recipeIngredient.splice(dest, 0, {
referenceId: uuid4(),
@@ -163,3 +261,17 @@ function insertNewIngredient(dest: number) {
});
}
</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

@@ -196,7 +196,7 @@ import { VueDraggable } from "vue-draggable-plus";
import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient } from "~/lib/api/types/recipe";
import type { Parser } from "~/lib/api/user/recipes/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useAppInfo, useUserApi } from "~/composables/api";
import { useUserApi } from "~/composables/api";
import { parseIngredientText } from "~/composables/recipes";
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
import { useGlobalI18n } from "~/composables/use-global-i18n";
@@ -213,9 +213,9 @@ const emit = defineEmits<{
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
}>();
const { $appInfo } = useNuxtApp();
const i18n = useGlobalI18n();
const api = useUserApi();
const appInfo = useAppInfo();
const drag = ref(false);
const unitStore = useUnitStore();
@@ -238,7 +238,7 @@ const availableParsers = computed(() => {
{
text: i18n.t("recipe.parser.openai-parser"),
value: "openai",
hide: !appInfo.value?.enableOpenai,
hide: !$appInfo.enableOpenai,
},
];
});
@@ -268,6 +268,11 @@ const state = reactive({
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;
@@ -364,12 +369,21 @@ async function parseIngredients() {
}
state.loading.parser = true;
try {
const ingsAsString = props.ingredients.map(ing => parseIngredientText(ing, 1, false) ?? "");
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();

View File

@@ -262,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

@@ -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

@@ -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">
@@ -119,7 +119,7 @@ defineEmits<{
const { $globals } = useNuxtApp();
const display = useDisplay();
const { recipeTimelineEventImage } = useStaticRoutes();
const { recipeTimelineEventSmallImage } = useStaticRoutes();
const { eventTypeOptions } = useTimelineEventTypes();
const { user: currentUser } = useMealieAuth();
@@ -173,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

@@ -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,7 +4,19 @@
<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"
@@ -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

@@ -97,7 +97,6 @@
<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 type { ReadCookBook } from "~/lib/api/types/cookbook";
@@ -105,7 +104,7 @@ import type { ReadCookBook } from "~/lib/api/types/cookbook";
export default defineNuxtComponent({
setup() {
const i18n = useI18n();
const { $globals } = useNuxtApp();
const { $appInfo, $globals } = useNuxtApp();
const display = useDisplay();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
@@ -135,9 +134,7 @@ export default defineNuxtComponent({
return [];
});
const appInfo = useAppInfo();
const showImageImport = computed(() => appInfo.value?.enableOpenaiImageServices);
const showImageImport = computed(() => $appInfo.enableOpenaiImageServices);
const languageDialog = ref<boolean>(false);
const sidebar = ref<boolean>(false);

View File

@@ -149,6 +149,6 @@ export default defineNuxtComponent({
<style scoped>
.v-toolbar {
z-index: 1010 !important;
z-index: 2010 !important;
}
</style>

View File

@@ -59,7 +59,6 @@
<BaseButton
v-if="canDelete"
delete
secondary
@click="deleteEvent"
/>
<BaseButton

View File

@@ -7,6 +7,7 @@
item-title="name"
return-object
:items="items"
:custom-filter="normalizeFilter"
:prepend-icon="icon || $globals.icons.tags"
auto-select-first
clearable
@@ -52,6 +53,7 @@
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
import { normalizeFilter } from "~/composables/use-utils";
export default defineNuxtComponent({
props: {
@@ -122,6 +124,7 @@ export default defineNuxtComponent({
itemIdVal,
searchInput,
emitCreate,
normalizeFilter,
};
},
});

View File

@@ -1,61 +0,0 @@
<template>
<div
class="d-flex align-center"
style="max-width: 60px"
>
<v-text-field
v-model.number="quantity"
hide-details
:label="$t('form.quantity-label-abbreviated')"
:min="min"
:max="max"
type="number"
variant="plain"
density="compact"
style="width: 60px;"
/>
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
name: "VInputNumber",
props: {
min: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 9999,
},
rules: {
type: Array,
default: () => [],
},
step: {
type: Number,
default: 1,
},
modelValue: {
type: Number,
default: 0,
},
},
emits: ["update:modelValue"],
setup(props, context) {
const quantity = computed({
get: () => {
return Number(props.modelValue);
},
set: (val) => {
context.emit("update:modelValue", val);
},
});
return {
quantity,
};
},
});
</script>

View File

@@ -9,6 +9,7 @@
<v-autocomplete
v-model="selectedLocale"
:items="locales"
:custom-filter="normalizeFilter"
item-title="name"
item-value="value"
class="my-3"
@@ -44,6 +45,7 @@
<script lang="ts">
import { useLocales } from "~/composables/use-locales";
import { normalizeFilter } from "~/composables/use-utils";
export default defineNuxtComponent({
props: {
@@ -83,6 +85,7 @@ export default defineNuxtComponent({
locale,
selectedLocale,
onLocaleSelect,
normalizeFilter,
};
},
});

View File

@@ -29,7 +29,7 @@ export default defineNuxtComponent({
"ul", "ol", "li", "dl", "dt", "dd", "abbr", "a", "img", "blockquote", "iframe",
"del", "ins", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup",
],
ADD_ATTR: [
ALLOWED_ATTR: [
"href", "src", "alt", "height", "width", "class", "allow", "title", "allowfullscreen", "frameborder",
"scrolling", "cite", "datetime", "name", "abbr", "target", "border",
],

View File

@@ -1,3 +1,2 @@
export { useAppInfo } from "./use-app-info";
export { useStaticRoutes } from "./static-routes";
export { useAdminApi, usePublicApi, usePublicExploreApi, useUserApi } from "./api-client";

View File

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

View File

@@ -1,9 +1,19 @@
import { alert } from "~/composables/use-toast";
import { useGlobalI18n } from "~/composables/use-global-i18n";
export function useDownloader() {
function download(url: string, filename: string) {
useFetch(url, {
method: "GET",
responseType: "blob",
onResponse({ response }) {
if (!response.ok) {
console.error("Download failed", response);
const i18n = useGlobalI18n();
alert.error(i18n.t("events.something-went-wrong"));
return;
}
const url = window.URL.createObjectURL(new Blob([response._data]));
const link = document.createElement("a");
link.href = url;

View File

@@ -0,0 +1,58 @@
import { describe, expect, test, vi } from "vitest";
import { ref } from "vue";
import { useStoreActions } from "./use-actions-factory";
import type { BaseCRUDAPI } from "~/lib/api/base/base-clients";
describe("useStoreActions", () => {
const mockApi = {
getAll: vi.fn(),
createOne: vi.fn(),
updateOne: vi.fn(),
deleteOne: vi.fn(),
} as unknown as BaseCRUDAPI<unknown, unknown, unknown>;
const mockStore = ref([]);
const mockLoading = ref(false);
test("deleteMany calls deleteOne for each ID and refreshes once", async () => {
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
mockApi.deleteOne = vi.fn().mockResolvedValue({ response: { data: {} } });
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
const ids = ["1", "2", "3"];
await actions.deleteMany(ids);
expect(mockApi.deleteOne).toHaveBeenCalledTimes(3);
expect(mockApi.deleteOne).toHaveBeenCalledWith("1");
expect(mockApi.deleteOne).toHaveBeenCalledWith("2");
expect(mockApi.deleteOne).toHaveBeenCalledWith("3");
expect(mockApi.getAll).toHaveBeenCalledTimes(1);
});
test("deleteMany handles empty array", async () => {
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
mockApi.deleteOne = vi.fn();
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
await actions.deleteMany([]);
expect(mockApi.deleteOne).not.toHaveBeenCalled();
expect(mockApi.getAll).toHaveBeenCalledTimes(1);
});
test("deleteMany sets loading state", async () => {
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
mockApi.deleteOne = vi.fn().mockResolvedValue({});
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
const promise = actions.deleteMany(["1"]);
expect(mockLoading.value).toBe(true);
await promise;
expect(mockLoading.value).toBe(false);
});
});

View File

@@ -12,6 +12,7 @@ interface StoreActions<T extends BoundT> extends ReadOnlyStoreActions<T> {
createOne(createData: T): Promise<T | null>;
updateOne(updateData: T): Promise<T | null>;
deleteOne(id: string | number): Promise<T | null>;
deleteMany(ids: (string | number)[]): Promise<void>;
}
/**
@@ -165,11 +166,23 @@ export function useStoreActions<T extends BoundT>(
return response?.data || null;
}
async function deleteMany(ids: (string | number)[]) {
loading.value = true;
for (const id of ids) {
await api.deleteOne(id);
}
if (allRef?.value) {
await refresh();
}
loading.value = false;
}
return {
getAll,
refresh,
createOne,
updateOne,
deleteOne,
deleteMany,
};
}

View File

@@ -52,7 +52,7 @@ export const useStore = function <T extends BoundT>(
return await storeActions.refresh(1, -1, params);
},
flushStore() {
store = ref([]);
store.value = [];
},
};

View File

@@ -1,6 +1,6 @@
import DOMPurify from "isomorphic-dompurify";
import { useFraction } from "./use-fraction";
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, RecipeIngredient } from "~/lib/api/types/recipe";
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
const { frac } = useFraction();
@@ -36,8 +36,28 @@ function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, us
return returnVal;
}
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true) {
const { quantity, food, unit, note, title } = ingredient;
function useRecipeLink(recipe: Recipe | undefined, groupSlug: string | undefined): string | undefined {
if (!(recipe && recipe.slug && recipe.name && groupSlug)) {
return undefined;
}
return `<a href="/g/${groupSlug}/r/${recipe.slug}" target="_blank">${recipe.name}</a>`;
}
type ParsedIngredientText = {
quantity?: string;
unit?: string;
name?: string;
note?: string;
/**
* If the ingredient is a linked recipe, an HTML link to the referenced recipe, otherwise undefined.
*/
recipeLink?: string;
};
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
const { quantity, food, unit, note, referencedRecipe } = ingredient;
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
const usePluralFood = (!quantity) || quantity * scale > 1;
@@ -63,14 +83,14 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1,
}
const unitName = useUnitName(unit || undefined, usePluralUnit);
const foodName = useFoodName(food || undefined, usePluralFood);
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
return {
title: title ? sanitizeIngredientHTML(title) : undefined,
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
name: foodName ? sanitizeIngredientHTML(foodName) : undefined,
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
note: note ? sanitizeIngredientHTML(note) : undefined,
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
};
}

View File

@@ -97,13 +97,8 @@ export function useShoppingListCrud(
.sort(sortCheckedItems);
}
// Update the item if it's checked, otherwise updateUncheckedListItems will handle it
if (item.checked) {
shoppingListItemActions.updateItem(item);
}
shoppingListItemActions.updateItem(item);
updateListItemOrder();
updateUncheckedListItems();
}
function deleteListItem(item: ShoppingListItemOut) {

View File

@@ -1,7 +1,31 @@
export { useCategoryStore, usePublicCategoryStore, useCategoryData } from "./use-category-store";
export { useFoodStore, usePublicFoodStore, useFoodData } from "./use-food-store";
export { useHouseholdStore, usePublicHouseholdStore } from "./use-household-store";
export { useLabelStore, useLabelData } from "./use-label-store";
export { useTagStore, usePublicTagStore, useTagData } from "./use-tag-store";
export { useToolStore, usePublicToolStore, useToolData } from "./use-tool-store";
export { useUnitStore, useUnitData } from "./use-unit-store";
import { resetCategoryStore } from "./use-category-store";
import { resetFoodStore } from "./use-food-store";
import { resetHouseholdStore } from "./use-household-store";
import { resetLabelStore } from "./use-label-store";
import { resetTagStore } from "./use-tag-store";
import { resetToolStore } from "./use-tool-store";
import { resetUnitStore } from "./use-unit-store";
import { resetCookbookStore } from "./use-cookbook-store";
import { resetUserStore } from "./use-user-store";
export { useCategoryStore, usePublicCategoryStore, useCategoryData, resetCategoryStore } from "./use-category-store";
export { useFoodStore, usePublicFoodStore, useFoodData, resetFoodStore } from "./use-food-store";
export { useHouseholdStore, usePublicHouseholdStore, resetHouseholdStore } from "./use-household-store";
export { useLabelStore, useLabelData, resetLabelStore } from "./use-label-store";
export { useTagStore, usePublicTagStore, useTagData, resetTagStore } from "./use-tag-store";
export { useToolStore, usePublicToolStore, useToolData, resetToolStore } from "./use-tool-store";
export { useUnitStore, useUnitData, resetUnitStore } from "./use-unit-store";
export { useCookbookStore, usePublicCookbookStore, resetCookbookStore } from "./use-cookbook-store";
export { useUserStore, resetUserStore } from "./use-user-store";
export function clearAllStores() {
resetCategoryStore();
resetFoodStore();
resetHouseholdStore();
resetLabelStore();
resetTagStore();
resetToolStore();
resetUnitStore();
resetCookbookStore();
resetUserStore();
}

View File

@@ -7,6 +7,12 @@ const store: Ref<RecipeCategory[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function resetCategoryStore() {
store.value = [];
loading.value = false;
publicLoading.value = false;
}
export const useCategoryData = function () {
return useData<RecipeCategory>({
id: "",

View File

@@ -7,6 +7,12 @@ const cookbooks: Ref<ReadCookBook[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function resetCookbookStore() {
cookbooks.value = [];
loading.value = false;
publicLoading.value = false;
}
export const useCookbookStore = function (i18n?: Composer) {
const api = useUserApi(i18n);
const store = useStore<ReadCookBook>("cookbook", cookbooks, loading, api.cookbooks);

View File

@@ -7,6 +7,12 @@ const store: Ref<IngredientFood[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function resetFoodStore() {
store.value = [];
loading.value = false;
publicLoading.value = false;
}
export const useFoodData = function () {
return useData<IngredientFood>({
id: "",

View File

@@ -7,6 +7,12 @@ const store: Ref<HouseholdSummary[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function resetHouseholdStore() {
store.value = [];
loading.value = false;
publicLoading.value = false;
}
export const useHouseholdStore = function (i18n?: Composer) {
const api = useUserApi(i18n);
return useReadOnlyStore<HouseholdSummary>("household", store, loading, api.households);

View File

@@ -6,6 +6,11 @@ import { useUserApi } from "~/composables/api";
const store: Ref<MultiPurposeLabelOut[]> = ref([]);
const loading = ref(false);
export function resetLabelStore() {
store.value = [];
loading.value = false;
}
export const useLabelData = function () {
return useData<MultiPurposeLabelOut>({
groupId: "",

View File

@@ -7,6 +7,12 @@ const store: Ref<RecipeTag[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function resetTagStore() {
store.value = [];
loading.value = false;
publicLoading.value = false;
}
export const useTagData = function () {
return useData<RecipeTag>({
id: "",

View File

@@ -11,6 +11,12 @@ const store: Ref<RecipeTool[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function resetToolStore() {
store.value = [];
loading.value = false;
publicLoading.value = false;
}
export const useToolData = function () {
return useData<RecipeToolWithOnHand>({
id: "",

View File

@@ -6,6 +6,11 @@ import { useUserApi } from "~/composables/api";
const store: Ref<IngredientUnit[]> = ref([]);
const loading = ref(false);
export function resetUnitStore() {
store.value = [];
loading.value = false;
}
export const useUnitData = function () {
return useData<IngredientUnit>({
id: "",

View File

@@ -7,6 +7,11 @@ import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
const store: Ref<UserSummary[]> = ref([]);
const loading = ref(false);
export function resetUserStore() {
store.value = [];
loading.value = false;
}
class GroupUserAPIReadOnly extends BaseCRUDAPIReadOnly<UserSummary> {
baseRoute = "/api/groups/members";
itemRoute = (idOrUsername: string | number) => `/groups/members/${idOrUsername}`;

View File

@@ -1,5 +1,6 @@
import { ref, computed } from "vue";
import type { UserOut } from "~/lib/api/types/user";
import { clearAllStores } from "~/composables/store";
interface AuthData {
value: UserOut | null;
@@ -23,10 +24,15 @@ const authUser = ref<UserOut | null>(null);
const authStatus = ref<"loading" | "authenticated" | "unauthenticated">("loading");
export const useAuthBackend = function (): AuthState {
const { $axios } = useNuxtApp();
const { $appInfo, $axios } = useNuxtApp();
const router = useRouter();
const tokenName = useRuntimeConfig().public.AUTH_TOKEN;
const tokenCookie = useCookie(tokenName);
const runtimeConfig = useRuntimeConfig();
const tokenName = runtimeConfig.public.AUTH_TOKEN;
const tokenCookie = useCookie(tokenName, {
maxAge: $appInfo.tokenTime * 60 * 60,
secure: $appInfo.production && window?.location?.protocol === "https:",
});
function setToken(token: string | null) {
tokenCookie.value = token;
@@ -96,6 +102,13 @@ export const useAuthBackend = function (): AuthState {
setToken(null);
authUser.value = null;
authStatus.value = "unauthenticated";
// Clear all cached store data to prevent data leakage between users
clearAllStores();
// Clear Nuxt's useAsyncData cache
clearNuxtData();
await router.push(callbackUrl || "/login");
}
}
@@ -115,30 +128,6 @@ export const useAuthBackend = function (): AuthState {
}
}
// Auto-refresh user data periodically when authenticated
if (import.meta.client) {
let refreshInterval: NodeJS.Timeout | null = null;
watch(() => authStatus.value, (status) => {
if (status === "authenticated") {
refreshInterval = setInterval(() => {
if (tokenCookie.value) {
getSession().catch(() => {
// Ignore errors in background refresh
});
}
}, 5 * 60 * 1000); // 5 minutes
}
else {
// Clear interval when not authenticated
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
}, { immediate: true });
}
return {
data: computed(() => authUser.value),
status: computed(() => authStatus.value),

View File

@@ -15,6 +15,9 @@ export function usePlanTypeOptions() {
{ text: i18n.t("meal-plan.lunch"), value: "lunch" },
{ text: i18n.t("meal-plan.dinner"), value: "dinner" },
{ text: i18n.t("meal-plan.side"), value: "side" },
{ text: i18n.t("meal-plan.snack"), value: "snack" },
{ text: i18n.t("meal-plan.drink"), value: "drink" },
{ text: i18n.t("meal-plan.dessert"), value: "dessert" },
] as PlanOption[];
}

View File

@@ -21,19 +21,19 @@ export const LOCALES = [
{
name: "Українська (Ukrainian)",
value: "uk-UA",
progress: 57,
progress: 99,
dir: "ltr",
},
{
name: "Türkçe (Turkish)",
value: "tr-TR",
progress: 36,
progress: 39,
dir: "ltr",
},
{
name: "Svenska (Swedish)",
value: "sv-SE",
progress: 65,
progress: 67,
dir: "ltr",
},
{
@@ -45,7 +45,7 @@ export const LOCALES = [
{
name: "Slovenščina (Slovenian)",
value: "sl-SI",
progress: 40,
progress: 41,
dir: "ltr",
},
{
@@ -57,19 +57,19 @@ export const LOCALES = [
{
name: "Pусский (Russian)",
value: "ru-RU",
progress: 44,
progress: 46,
dir: "ltr",
},
{
name: "Română (Romanian)",
value: "ro-RO",
progress: 37,
progress: 41,
dir: "ltr",
},
{
name: "Português (Portuguese)",
value: "pt-PT",
progress: 38,
progress: 39,
dir: "ltr",
},
{
@@ -81,25 +81,25 @@ export const LOCALES = [
{
name: "Polski (Polish)",
value: "pl-PL",
progress: 43,
progress: 52,
dir: "ltr",
},
{
name: "Norsk (Norwegian)",
value: "no-NO",
progress: 40,
progress: 41,
dir: "ltr",
},
{
name: "Nederlands (Dutch)",
value: "nl-NL",
progress: 52,
progress: 55,
dir: "ltr",
},
{
name: "Latviešu (Latvian)",
value: "lv-LV",
progress: 36,
progress: 35,
dir: "ltr",
},
{
@@ -111,37 +111,37 @@ export const LOCALES = [
{
name: "한국어 (Korean)",
value: "ko-KR",
progress: 9,
progress: 22,
dir: "ltr",
},
{
name: "日本語 (Japanese)",
value: "ja-JP",
progress: 37,
progress: 36,
dir: "ltr",
},
{
name: "Italiano (Italian)",
value: "it-IT",
progress: 46,
progress: 48,
dir: "ltr",
},
{
name: "Íslenska (Icelandic)",
value: "is-IS",
progress: 27,
progress: 45,
dir: "ltr",
},
{
name: "Magyar (Hungarian)",
value: "hu-HU",
progress: 46,
progress: 47,
dir: "ltr",
},
{
name: "Hrvatski (Croatian)",
value: "hr-HR",
progress: 28,
progress: 29,
dir: "ltr",
},
{
@@ -159,13 +159,13 @@ export const LOCALES = [
{
name: "Français (French)",
value: "fr-FR",
progress: 67,
progress: 69,
dir: "ltr",
},
{
name: "Français canadien (Canadian French)",
value: "fr-CA",
progress: 38,
progress: 99,
dir: "ltr",
},
{
@@ -177,19 +177,19 @@ export const LOCALES = [
{
name: "Suomi (Finnish)",
value: "fi-FI",
progress: 40,
progress: 41,
dir: "ltr",
},
{
name: "Eesti (Estonian)",
value: "et-EE",
progress: 36,
progress: 47,
dir: "ltr",
},
{
name: "Español (Spanish)",
value: "es-ES",
progress: 45,
progress: 46,
dir: "ltr",
},
{
@@ -207,37 +207,37 @@ export const LOCALES = [
{
name: "Ελληνικά (Greek)",
value: "el-GR",
progress: 41,
progress: 42,
dir: "ltr",
},
{
name: "Deutsch (German)",
value: "de-DE",
progress: 95,
progress: 97,
dir: "ltr",
},
{
name: "Dansk (Danish)",
value: "da-DK",
progress: 45,
progress: 52,
dir: "ltr",
},
{
name: "Čeština (Czech)",
value: "cs-CZ",
progress: 42,
progress: 41,
dir: "ltr",
},
{
name: "Català (Catalan)",
value: "ca-ES",
progress: 38,
progress: 39,
dir: "ltr",
},
{
name: "Български (Bulgarian)",
value: "bg-BG",
progress: 47,
progress: 51,
dir: "ltr",
},
{

View File

@@ -1,5 +1,5 @@
import { ref, watch, computed } from "vue";
import { useAuthBackend } from "~/composables/useAuthBackend";
import { useAuthBackend } from "~/composables/use-auth-backend";
import type { UserOut } from "~/lib/api/types/user";
export const useMealieAuth = function () {

View File

@@ -168,6 +168,7 @@ export function useQueryFilterBuilder() {
|| type === Organizer.Tool
|| type === Organizer.Food
|| type === Organizer.Household
|| type === Organizer.User
);
};

View File

@@ -30,6 +30,7 @@ interface RecipeExplorerSearchState {
requireAllTags: boolean;
requireAllTools: boolean;
requireAllFoods: boolean;
randomSeed: number;
}>;
selectedCategories: Ref<NoUndefinedField<RecipeCategory>[]>;
selectedFoods: Ref<IngredientFood[]>;
@@ -41,6 +42,7 @@ interface RecipeExplorerSearchState {
reset: () => void;
toggleOrderDirection: () => void;
setOrderBy: (value: string) => void;
setRandomOrderBy: () => void;
filterItems: (item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) => void;
initialize: () => Promise<void>;
}
@@ -67,6 +69,7 @@ function createRecipeExplorerSearchState(groupSlug: ComputedRef<string>): Recipe
requireAllTags: false,
requireAllTools: false,
requireAllFoods: false,
randomSeed: 0,
});
// Store references
@@ -131,9 +134,16 @@ function createRecipeExplorerSearchState(groupSlug: ComputedRef<string>): Recipe
return {
...passedQuery.value,
_searchSeed: Date.now().toString(),
_randomSeed: state.value.randomSeed,
};
});
// Update the seed to trigger a new search
function setRandomOrderBy() {
state.value.orderBy = "random";
state.value.randomSeed = Date.now();
}
// Wait utility for async hydration
function waitUntilAndExecute(
condition: () => boolean,
@@ -442,6 +452,7 @@ function createRecipeExplorerSearchState(groupSlug: ComputedRef<string>): Recipe
reset,
toggleOrderDirection,
setOrderBy,
setRandomOrderBy,
filterItems,
initialize,
};

View File

@@ -112,34 +112,46 @@ export function useShoppingListItemActions(shoppingListId: string) {
async function getList() {
const response = await api.shopping.lists.getOne(shoppingListId);
if (!isOnline.value && response.data) {
if (response.data) {
// Merge pending local changes (both online and offline)
const createAndUpdateQueues = mergeListItemsByLatest(queue.update, queue.create);
response.data.listItems = mergeListItemsByLatest(response.data.listItems ?? [], createAndUpdateQueues);
const deleteQueueIds = new Set(queue.delete.map(item => item.id));
const filteredLocalChanges = createAndUpdateQueues.filter(item => !deleteQueueIds.has(item.id));
let mergedItems = mergeListItemsByLatest(response.data.listItems ?? [], filteredLocalChanges);
mergedItems = mergedItems.filter(item => !deleteQueueIds.has(item.id));
response.data.listItems = mergedItems;
}
return response.data;
}
function createItem(item: ShoppingListItemOut) {
removeFromQueue(queue.create, item);
removeFromQueue(queue.update, item);
removeFromQueue(queue.delete, item);
queue.create.push(item);
}
function updateItem(item: ShoppingListItemOut) {
const removedFromCreate = removeFromQueue(queue.create, item);
if (removedFromCreate) {
// this item hasn't been created yet, so we don't need to update it
queue.create.push(item);
return;
}
removeFromQueue(queue.update, item);
queue.update.push(item);
removeFromQueue(queue.delete, item);
if (removedFromCreate) {
// This item hasn't been created yet, so keep it in create queue with updated data
queue.create.push(item);
}
else {
queue.update.push(item);
}
}
function deleteItem(item: ShoppingListItemOut) {
const removedFromCreate = removeFromQueue(queue.create, item);
if (removedFromCreate) {
// this item hasn't been created yet, so we don't need to delete it
// This item hasn't been created yet, so we don't need to delete it
return;
}
@@ -198,10 +210,12 @@ export function useShoppingListItemActions(shoppingListId: string) {
try {
const itemsToProcess = [...queueItems];
const itemIdsToProcess = itemsToProcess.map(item => item.id);
await action(itemsToProcess)
.then(() => {
if (isOnline.value) {
clearQueueItems(itemQueueType, itemsToProcess.map(item => item.id));
clearQueueItems(itemQueueType, itemIdsToProcess);
}
});
}

View File

@@ -8,6 +8,7 @@ export interface UserPrintPreferences {
showDescription: boolean;
showNotes: boolean;
showNutrition: boolean;
expandChildRecipes: boolean;
}
export interface UserSearchQuery {
@@ -91,6 +92,7 @@ export function useUserPrintPreferences(): Ref<UserPrintPreferences> {
imagePosition: "left",
showDescription: true,
showNotes: true,
expandChildRecipes: false,
},
{ mergeDefaults: true },
// we cast to a Ref because by default it will return an optional type ref

View File

@@ -5,9 +5,9 @@ const userRatings = ref<UserRatingSummary[]>([]);
const loading = ref(false);
const ready = ref(false);
const $auth = useMealieAuth();
export const useUserSelfRatings = function () {
const $auth = useMealieAuth();
async function refreshUserRatings() {
if (!$auth.user.value || loading.value) {
return;

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { normalize, normalizeFilter } from "./use-utils";
describe("test normalize", () => {
test("base case", () => {
expect(normalize("banana")).not.toEqual(normalize("Potatoes"));
});
test("diacritics", () => {
expect(normalize("Rátàtôuile")).toEqual("ratatouile");
});
test("ligatures", () => {
expect(normalize("IJ")).toEqual("ij");
expect(normalize("æ")).toEqual("ae");
expect(normalize("œ")).toEqual("oe");
expect(normalize("ff")).toEqual("ff");
expect(normalize("fi")).toEqual("fi");
expect(normalize("st")).toEqual("st");
});
});
describe("test normalize filter", () => {
test("base case", () => {
const patternA = "Escargots persillés";
const patternB = "persillés";
expect(normalizeFilter(patternA, patternB)).toBeTruthy();
expect(normalizeFilter(patternB, patternA)).toBeFalsy();
});
test("normalize", () => {
const value = "Cœur de bœuf";
const query = "coeur";
expect(normalizeFilter(value, query)).toBeTruthy();
});
});

View File

@@ -1,4 +1,5 @@
import { useDark, useToggle } from "@vueuse/core";
import type { FilterFunction } from "vuetify";
export const useToggleDarkMode = () => {
const isDark = useDark();
@@ -18,6 +19,38 @@ export const titleCase = function (str: string) {
.join(" ");
};
const replaceAllBuilder = (map: Map<string, string>): ((str: string) => string) => {
const re = new RegExp(Array.from(map.keys()).join("|"), "gi");
return str => str.replace(re, matched => map.get(matched)!);
};
const normalizeLigatures = replaceAllBuilder(new Map([
["œ", "oe"],
["æ", "ae"],
["ij", "ij"],
["ff", "ff"],
["fi", "fi"],
["fl", "fl"],
["st", "st"],
]));
export const normalize = (str: string) => {
if (!str) {
return "";
}
let normalized = str.normalize("NFKD").toLowerCase();
normalized = normalized.replace(/\p{Diacritic}/gu, "");
normalized = normalizeLigatures(normalized);
return normalized;
};
export const normalizeFilter: FilterFunction = (value: string, query: string) => {
const normalizedValue = normalize(value);
const normalizeQuery = normalize(query);
return normalizedValue.includes(normalizeQuery);
};
export function uuid4() {
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
(parseInt(c) ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (parseInt(c) / 4)))).toString(16),

View File

@@ -1,12 +1,13 @@
import type { RequestResponse } from "~/lib/api/types/non-generated";
import type { ValidationResponse } from "~/lib/api/types/response";
import { required, email, whitespace, url, minLength, maxLength } from "~/lib/validators";
import { required, email, whitespace, url, urlOptional, minLength, maxLength } from "~/lib/validators";
export const validators = {
required,
email,
whitespace,
url,
urlOptional,
minLength,
maxLength,
};

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