Compare commits

...

290 Commits

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

View File

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

View File

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

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

View File

@@ -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:22@sha256:d367fd3fce932a9d3bc3816c23f85313d9b44e58e1fed49ef90a10c4b99409e8 \
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

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

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

View File

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

View File

@@ -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">
@@ -49,7 +49,6 @@
>
<RecipeFavoriteBadge
v-if="isOwnGroup"
class="absolute"
:recipe-id="recipeId"
show-always
/>

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

@@ -32,7 +32,7 @@
v-bind="props"
>
<v-icon :start="!$vuetify.display.xs">
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
{{ state.orderDirection === "asc" ? $globals.icons.sortDescending : $globals.icons.sortAscending }}
</v-icon>
{{ $vuetify.display.xs ? null : sortText }}
</v-btn>
@@ -42,7 +42,7 @@
<v-list-item
slim
density="comfortable"
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortAscending : $globals.icons.sortDescending"
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
@click="toggleOrderDirection"
/>
@@ -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

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

View File

@@ -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,13 +4,25 @@
<v-card-text class="pb-3 pt-1">
<div class="d-md-flex align-center mb-2" style="gap: 20px">
<div>
<InputQuantity v-model="listItem.quantity" />
<v-number-input
v-model="listItem.quantity"
hide-details
:label="$t('form.quantity-label-abbreviated')"
:min="0"
:precision="null"
variant="plain"
control-variant="stacked"
inset
density="compact"
class="centered-number-input"
style="width: 100px;"
/>
</div>
<InputLabelType
v-model="listItem.unit"
v-model:item-id="listItem.unitId!"
:items="units"
:label="$t('general.units')"
:label="$t('recipe.unit')"
:icon="$globals.icons.units"
create
@create="createAssignUnit"
@@ -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

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

@@ -0,0 +1,17 @@
<template>
<v-expansion-panels v-model="open">
<slot />
</v-expansion-panels>
</template>
<script setup lang="ts">
interface Props {
startOpen?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
startOpen: false,
});
const open = ref(props.startOpen ? [0] : []);
</script>

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,5 +1,5 @@
import { useToggle } from "@vueuse/core";
import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household";
import type { ShoppingListOut } from "~/lib/api/types/household";
/**
* Composable for managing shopping list label state and operations
@@ -36,14 +36,24 @@ export function useShoppingListLabels(shoppingList: Ref<ShoppingListOut | null>)
);
});
const labelColorByName = computed(() => {
const map: Record<string, string | undefined> = {};
shoppingList.value?.listItems?.forEach((item) => {
if (!item.label) return;
const labelName = item.label?.name || t("shopping-list.no-label");
map[labelName] = item.label.color;
});
return map;
});
watch(labelNames, initializeLabelOpenStates, { immediate: true });
function toggleShowLabel(key: string) {
labelOpenState.value[key] = !labelOpenState.value[key];
}
function getLabelColor(item: ShoppingListItemOut | null) {
return item?.label?.color;
function getLabelColor(label: string) {
return labelColorByName.value[label];
}
const presentLabels = computed(() => {

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

@@ -0,0 +1,140 @@
import { ref, computed } from "vue";
import type { UserOut } from "~/lib/api/types/user";
import { clearAllStores } from "~/composables/store";
interface AuthData {
value: UserOut | null;
}
interface AuthStatus {
value: "loading" | "authenticated" | "unauthenticated";
}
interface AuthState {
data: AuthData;
status: AuthStatus;
signIn: (credentials: FormData, options?: { redirect?: boolean }) => Promise<void>;
signOut: (callbackUrl?: string) => Promise<void>;
refresh: () => Promise<void>;
getSession: () => Promise<void>;
setToken: (token: string | null) => void;
}
const authUser = ref<UserOut | null>(null);
const authStatus = ref<"loading" | "authenticated" | "unauthenticated">("loading");
export const useAuthBackend = function (): AuthState {
const { $appInfo, $axios } = useNuxtApp();
const router = useRouter();
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;
}
function handleAuthError(error: any, redirect = false) {
// Only clear token on auth errors, not network errors
if (error?.response?.status === 401) {
setToken(null);
authUser.value = null;
authStatus.value = "unauthenticated";
if (redirect) {
router.push("/login");
}
}
}
async function getSession(): Promise<void> {
if (!tokenCookie.value) {
authUser.value = null;
authStatus.value = "unauthenticated";
return;
}
authStatus.value = "loading";
try {
const { data } = await $axios.get<UserOut>("/api/users/self");
authUser.value = data;
authStatus.value = "authenticated";
}
catch (error: any) {
console.error("Failed to fetch user session:", error);
handleAuthError(error);
authStatus.value = "unauthenticated";
}
}
async function signIn(credentials: FormData): Promise<void> {
authStatus.value = "loading";
try {
const response = await $axios.post("/api/auth/token", credentials, {
headers: {
"Content-Type": "multipart/form-data",
},
});
const { access_token } = response.data;
setToken(access_token);
await getSession();
}
catch (error) {
authStatus.value = "unauthenticated";
throw error;
}
}
async function signOut(callbackUrl: string = ""): Promise<void> {
try {
await $axios.post("/api/auth/logout");
}
catch (error) {
// Continue with logout even if API call fails
console.warn("Logout API call failed:", error);
}
finally {
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");
}
}
async function refresh(): Promise<void> {
if (!tokenCookie.value) return;
try {
const response = await $axios.get("/api/auth/refresh");
const { access_token } = response.data;
setToken(access_token);
await getSession();
}
catch (error: any) {
handleAuthError(error, true);
throw error;
}
}
return {
data: computed(() => authUser.value),
status: computed(() => authStatus.value),
signIn,
signOut,
refresh,
getSession,
setToken,
};
};

View File

@@ -0,0 +1,63 @@
import type { Activity, I18n, TranslationResult } from "~/lib/api/types/activity";
import { ActivityKey } from "~/lib/api/types/activity";
export const DEFAULT_ACTIVITY = "/g/home" as const;
type ActivityRegistry = {
recipes: Activity;
mealplanner: Activity;
shopping_list: Activity;
};
const selectableActivities: ActivityRegistry = {
recipes: {
key: ActivityKey.RECIPES,
route: groupSlug => groupSlug ? `/g/${groupSlug}` : DEFAULT_ACTIVITY,
label: i18n => i18n.t("general.recipes"),
},
mealplanner: {
key: ActivityKey.MEALPLANNER,
route: () => "/household/mealplan/planner/view",
label: i18n => i18n.t("meal-plan.meal-planner"),
},
shopping_list: {
key: ActivityKey.SHOPPING_LIST,
route: () => "/shopping-lists",
label: i18n => i18n.t("shopping-list.shopping-lists"),
},
};
function getDefaultActivityRoute(activityKey?: ActivityKey, groupSlug?: string): string {
if (!activityKey) {
return DEFAULT_ACTIVITY;
}
const route = selectableActivities[activityKey]?.route ?? (() => DEFAULT_ACTIVITY);
return route(groupSlug);
}
function getDefaultActivityLabels(i18n: I18n): TranslationResult[] {
return Object.values(selectableActivities).map(
({ label }) => label(i18n),
);
}
function getActivityKey(i18n: I18n, target: TranslationResult = ""): ActivityKey | undefined {
return Object.values(selectableActivities)
.find(({ label }) => label(i18n) === target)?.key;
}
function getActivityLabel(i18n: I18n, target?: ActivityKey): TranslationResult {
return Object.values(selectableActivities)
.find(({ key }) => key === target)
?.label(i18n) ?? "";
}
export default function useDefaultActivity() {
return {
selectableActivities,
getDefaultActivityRoute,
getDefaultActivityLabels,
getActivityKey,
getActivityLabel,
};
}

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: 44,
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: 52,
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,49 +57,49 @@ export const LOCALES = [
{
name: "Pусский (Russian)",
value: "ru-RU",
progress: 40,
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",
},
{
name: "Português do Brasil (Brazilian Portuguese)",
value: "pt-BR",
progress: 45,
progress: 46,
dir: "ltr",
},
{
name: "Polski (Polish)",
value: "pl-PL",
progress: 42,
progress: 52,
dir: "ltr",
},
{
name: "Norsk (Norwegian)",
value: "no-NO",
progress: 39,
progress: 41,
dir: "ltr",
},
{
name: "Nederlands (Dutch)",
value: "nl-NL",
progress: 49,
progress: 55,
dir: "ltr",
},
{
name: "Latviešu (Latvian)",
value: "lv-LV",
progress: 36,
progress: 35,
dir: "ltr",
},
{
@@ -111,43 +111,43 @@ 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: 41,
progress: 48,
dir: "ltr",
},
{
name: "Íslenska (Icelandic)",
value: "is-IS",
progress: 3,
progress: 45,
dir: "ltr",
},
{
name: "Magyar (Hungarian)",
value: "hu-HU",
progress: 45,
progress: 47,
dir: "ltr",
},
{
name: "Hrvatski (Croatian)",
value: "hr-HR",
progress: 28,
progress: 29,
dir: "ltr",
},
{
name: "עברית (Hebrew)",
value: "he-IL",
progress: 73,
progress: 72,
dir: "rtl",
},
{
@@ -159,37 +159,37 @@ export const LOCALES = [
{
name: "Français (French)",
value: "fr-FR",
progress: 66,
progress: 69,
dir: "ltr",
},
{
name: "Français canadien (Canadian French)",
value: "fr-CA",
progress: 38,
progress: 99,
dir: "ltr",
},
{
name: "Belge (Belgian)",
value: "fr-BE",
progress: 41,
progress: 40,
dir: "ltr",
},
{
name: "Suomi (Finnish)",
value: "fi-FI",
progress: 37,
progress: 41,
dir: "ltr",
},
{
name: "Eesti (Estonian)",
value: "et-EE",
progress: 37,
progress: 47,
dir: "ltr",
},
{
name: "Español (Spanish)",
value: "es-ES",
progress: 42,
progress: 46,
dir: "ltr",
},
{
@@ -207,37 +207,37 @@ export const LOCALES = [
{
name: "Ελληνικά (Greek)",
value: "el-GR",
progress: 40,
progress: 42,
dir: "ltr",
},
{
name: "Deutsch (German)",
value: "de-DE",
progress: 78,
progress: 97,
dir: "ltr",
},
{
name: "Dansk (Danish)",
value: "da-DK",
progress: 40,
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: 44,
progress: 51,
dir: "ltr",
},
{

View File

@@ -1,9 +1,9 @@
import { ref, watch, computed } from "vue";
import { useAuthBackend } from "~/composables/use-auth-backend";
import type { UserOut } from "~/lib/api/types/user";
export const useMealieAuth = function () {
const auth = useAuth();
const { setToken } = useAuthState();
const auth = useAuthBackend();
const { $axios } = useNuxtApp();
// User Management
@@ -40,7 +40,7 @@ export const useMealieAuth = function () {
async function oauthSignIn() {
const params = new URLSearchParams(window.location.search);
const { data: token } = await $axios.get<{ access_token: string; token_type: "bearer" }>("/api/auth/oauth/callback", { params });
setToken(token.access_token);
auth.setToken(token.access_token);
await auth.getSession();
}
@@ -49,7 +49,6 @@ export const useMealieAuth = function () {
loggedIn,
signIn: auth.signIn,
signOut: auth.signOut,
signUp: auth.signUp,
refresh: auth.refresh,
oauthSignIn,
};

View File

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

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