Compare commits

..

194 Commits

Author SHA1 Message Date
github-actions[bot]
9ec1599427 chore: automatic locale sync (#5705)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-13 14:36:47 +00:00
Kuchenpirat
9cfc54b1f5 fix: user & household creation (#5699) 2025-07-13 13:57:28 +00:00
Hayden
40d2ac9a6b chore(l10n): New Crowdin updates (#5706) 2025-07-13 12:05:51 +02:00
Hayden
44db525049 chore(l10n): New Crowdin updates (#5701) 2025-07-12 21:41:46 +00:00
Kuchenpirat
d737cb3e14 fix: set correct github tag in init py (#5693) 2025-07-12 08:54:14 -05:00
Hayden
1034d87a99 chore(l10n): New Crowdin updates (#5691) 2025-07-12 12:30:57 +02:00
Kuchenpirat
1243e6804c fix: crud table bulk actions (#5686) 2025-07-12 00:47:54 +00:00
Hayden
8b9e80358b chore(l10n): New Crowdin updates (#5682) 2025-07-11 21:18:48 +00:00
github-actions[bot]
2bae6e9d02 docs(auto): Update image tag, for release v3.0.0 (#5675)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-11 22:38:46 +02:00
renovate[bot]
6b98a7cd74 fix(deps): update dependency openai to v1.95.0 (#5671)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-11 19:22:03 +02:00
github-actions[bot]
e0238eb3a2 chore: automatic locale sync (#5674)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-11 18:04:36 +02:00
renovate[bot]
5adb7662c4 chore(deps): update dependency ruff to v0.12.3 (#5673)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-11 17:26:37 +02:00
Hayden
4e6a7a09ff chore(l10n): New Crowdin updates (#5672) 2025-07-11 11:28:35 +02:00
renovate[bot]
719c7c9f6b fix(deps): update dependency openai to v1.94.0 (#5667)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 23:46:09 +02:00
Michael Genson
7331007f30 fix: Restore Servings To Print View (#5669) 2025-07-10 17:30:33 +00:00
Michael Genson
ea329a6b71 fix: Remove Padding On Print (#5668)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-10 17:12:41 +00:00
Michael Genson
e1a04ba673 fix: Recipe Timeline Not Filtering (#5666) 2025-07-10 16:57:20 +00:00
Michael Genson
63a4d4c801 fix: Preserve "Completed On" Date In Checked Shopping List Items (#5665) 2025-07-10 16:41:34 +00:00
Hayden
5cf3e2565a chore(l10n): New Crowdin updates (#5664) 2025-07-10 08:39:53 +00:00
renovate[bot]
9e1fe618ba fix(deps): update dependency openai to v1.93.3 (#5663)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 10:15:17 +02:00
renovate[bot]
691300e481 fix(deps): update dependency openai to v1.93.2 (#5660)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 12:55:56 +02:00
Michael Genson
939588f54c chore: Fix Dockerfile "AS" Case (#5662)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-08 22:31:06 +00:00
Arsène Reymond
2d8f491666 feat: Replace google-fonts module with nuxt/fonts (#5618)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-07-08 22:07:18 +00:00
Joey
50754ad012 fix: Remove redundant get_one call in patch_one method (#5619) 2025-07-08 21:56:59 +00:00
Kuchenpirat
04eca1b992 fix: nutrition info visuals (#5659) 2025-07-08 17:23:41 +00:00
Michael Genson
aad7dc1abd fix: Refactor Stores and Fix Missing Public Cookbooks (#5611)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-08 13:32:18 +00:00
renovate[bot]
2f19d31d1b fix(deps): update dependency openai to v1.93.1 (#5655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 12:44:42 +02:00
Michael Genson
095b92c29a chore: Upgrade Pillow HEIF (#5657) 2025-07-08 12:08:04 +02:00
Hayden
49c704a4b1 chore(l10n): New Crowdin updates (#5656) 2025-07-07 22:50:44 +02:00
renovate[bot]
c15a4f786b fix(deps): update dependency typing-extensions to v4.14.1 (#5629)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 17:56:10 +02:00
Hayden
6e33878e4f chore(l10n): New Crowdin updates (#5653) 2025-07-07 16:26:42 +02:00
github-actions[bot]
5ca004802d chore(auto): Update pre-commit hooks (#5652)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-07 07:58:08 +00:00
Arsène Reymond
68115cbf2f fix: AppButtonCopy errors in tooltip & console (#5612)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-07 09:32:34 +02:00
github-actions[bot]
2b4bc8a662 chore: automatic locale sync (#5642)
Co-authored-by: GitHub Action <action@github.com>
2025-07-06 22:08:03 +00:00
Hayden
fc801c9da4 chore(l10n): New Crowdin updates (#5643)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-07-06 21:57:36 +00:00
Kuchenpirat
f99b305dc3 fix: lint error from locale sync (#5644) 2025-07-06 16:20:43 -05:00
renovate[bot]
b0b3d7e5e5 fix(deps): update dependency tzdata to v2025 (#5624)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-06 00:51:49 +00:00
github-actions[bot]
eedd2204a6 chore: automatic locale sync (#5639)
Co-authored-by: GitHub Action <action@github.com>
2025-07-05 21:47:51 +00:00
Hayden
1ccc67774a chore(l10n): New Crowdin updates (#5641) 2025-07-05 16:35:15 -05:00
Hayden
6d98041ec8 chore(l10n): New Crowdin updates (#5640) 2025-07-05 09:12:51 +02:00
Hayden
c24cfb8096 chore(l10n): New Crowdin updates (#5632) 2025-07-05 01:59:52 +00:00
Kuchenpirat
ca41bc8d5c fix: 500 error on recipe share link (#5627) 2025-07-05 01:37:42 +00:00
Hayden
da3271f33f chore: remove unused jinja export option (#5631) 2025-07-05 00:45:56 +00:00
Hayden
50a986f331 fix: workflow branch target/base (#5637) 2025-07-04 19:34:44 -05:00
Hayden
f72ebed0dc fix: workflow permissions (#5636) 2025-07-04 19:19:25 -05:00
Hayden
0c534ad9d4 fix: load from env if available vs file (#5635) 2025-07-04 19:08:50 -05:00
Hayden
9cce0f65aa chore: automatic crowdin sync via gh actions (#5630) 2025-07-04 19:00:23 -05:00
Hayden
c9e22892a6 fix: truncate slugs when too long (#5633) 2025-07-04 15:43:53 -05:00
Cameronwyatt
e794c6b525 feat: Update food seeding logic to use new format, now with removed CrowdIn limits? (#5514)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
2025-07-04 12:50:13 -05:00
renovate[bot]
abc37f258d chore(deps): update dependency ruff to v0.12.2 (#5625)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-03 20:15:26 +00:00
renovate[bot]
c2fda0d85a fix(deps): update dependency uvicorn to ^0.35.0 (#5598)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 12:53:22 -05:00
renovate[bot]
437a6ae526 fix(deps): update dependency tzdata to v2025 (#5534)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 04:41:17 +00:00
renovate[bot]
9f5de0bd5d fix(deps): update dependency lxml to v6 (#5585)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 22:17:52 -05:00
renovate[bot]
4bf963b14c fix(deps): update dependency fastapi to v0.115.14 (#5581)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 22:27:58 +00:00
renovate[bot]
7092d85a53 chore(deps): update dependency mkdocs-material to v9.6.15 (#5613)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 17:15:53 -05:00
renovate[bot]
83fd320920 fix(deps): update dependency pillow to v11.3.0 [security] (#5615)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 16:52:54 -05:00
Kuchenpirat
28e2666c17 fix: remove unused deps (#5610) 2025-06-30 16:19:45 +00:00
Kuchenpirat
62c7e2d2fb fix: recipe timeline visuals (nuxt 3) (#5608) 2025-06-30 15:25:32 +00:00
Kuchenpirat
6540bfacfe fix: recipe page warnings (#5609) 2025-06-30 15:10:39 +00:00
Michael Genson
47eb1ebbb1 feat: Consolidate Admin User APIs (#5050)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-06-30 10:13:42 +00:00
github-actions[bot]
31f90c79c0 chore(auto): Update pre-commit hooks (#5605)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-06-30 07:19:52 +00:00
renovate[bot]
3b1edf67fc fix(deps): update dependency openai to v1.93.0 (#5591)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 09:09:35 +02:00
Joey
781bbecc7b fix: check for OPENAI_MODEL in OPENAI_FEATURE (#5603) 2025-06-29 15:38:34 -05:00
Kuchenpirat
15f06b5378 feat: new create from image visuals (#5595) 2025-06-29 13:17:49 -05:00
Ross
95fa0af28a feat: create recipe from multiple images (#5590)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
Co-authored-by: Kuchenpirat <jojow@gmx.net>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-06-28 20:11:12 +00:00
Arsène Reymond
084f99b0de fix: Nuxt3 upgrades UI fixes & improvements (#5589) 2025-06-28 15:59:58 +02:00
Joey
2fb5dac966 fix: typo in app_settings_constructor docstring (#5592)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-06-28 01:49:07 +00:00
renovate[bot]
51ec02bdb2 fix(deps): update dependency pydantic-settings to v2.10.1 (#5559) 2025-06-27 09:00:21 -05:00
Kuchenpirat
1a1fe0a442 fix: get recipe image by url (#5588) 2025-06-27 08:39:47 -05:00
renovate[bot]
b0b88d361f fix(deps): update dependency openai to v1.92.2 (#5584)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-27 09:07:58 +02:00
renovate[bot]
b4a9c472e5 chore(deps): update dependency ruff to v0.12.1 (#5587) 2025-06-26 23:26:30 -05:00
Kuchenpirat
bcc038091a docs: remove duplicate headline (#5558) 2025-06-26 20:21:59 +00:00
Kuchenpirat
9e0db03f8c fix: recipe image creation (#5579)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-06-26 20:12:27 +00:00
Kuchenpirat
af274bf476 fix: markdown list padding and replace nuxtjs/mdc (#5577) 2025-06-26 14:58:31 -05:00
github-actions[bot]
ca9d5677b8 chore(auto): Update pre-commit hooks (#5564)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-06-24 12:30:47 +00:00
renovate[bot]
07483a13ff fix(deps): update dependency python-dotenv to v1.1.1 (#5571)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 14:20:17 +02:00
renovate[bot]
d412271b0b fix(deps): update dependency openai to v1.91.0 (#5567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 07:58:42 +00:00
Michael Genson
cea3ddc883 chore(deps): update dependency ruff to ^0.12.0 (#5568)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-06-24 09:46:49 +02:00
Michael Genson
c965d12bf1 fix: Cookbooks not rendering on sidebar (#5570) 2025-06-24 09:36:40 +02:00
Kuchenpirat
181aebf424 fix: register create group flow (#5565) 2025-06-23 09:20:50 -05:00
Kuchenpirat
b77ff9c341 fix: mealplanner day title card height & alignment (#5561) 2025-06-22 20:44:13 +00:00
Kuchenpirat
93cec24f26 fix: delete recipe instructions after nuxt 3 upgrade (#5560) 2025-06-22 15:34:25 -05:00
Kuchenpirat
a2a0ad1af0 fix: pwa share target (#5557) 2025-06-21 10:17:48 -05:00
renovate[bot]
969a3c9005 chore(deps): update dependency pytest to v8.4.1 (#5542)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-21 07:35:14 +00:00
renovate[bot]
a09601f051 fix(deps): update dependency openai to v1.90.0 (#5555)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-21 09:23:40 +02:00
Kuchenpirat
d6110f1a94 fix: passwort strength indicator (#5553) 2025-06-20 16:39:08 +00:00
Michael Genson
1562437b98 fix: Remove "Ingredients" From OpenAI Prompt For Instructions (#5546) 2025-06-20 16:28:46 +00:00
Kuchenpirat
e2eb754cf2 fix: pwa not being installable after nuxt 3 upgrade (#5552) 2025-06-20 11:04:45 -05:00
Kuchenpirat
3a4222c6c1 fix: shopping list button in one row (#5547) 2025-06-20 09:59:13 +00:00
Michael Genson
2673834a9f fix: Various Nuxt Upgrade Issues (#5545) 2025-06-20 19:42:12 +10:00
Hoa (Kyle) Trinh
c24d532608 feat: Migrate to Nuxt 3 framework (#5184)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-06-19 17:09:12 +00:00
renovate[bot]
89ab7fac25 fix(deps): update dependency alembic to v1.16.2 (#5535)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 20:09:25 +00:00
Felix Schneider
78b55c0b98 feat: add the selected recipe servings and yields in the content of the recipe post action (#5340)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-06-18 14:57:51 -05:00
Craig Matear
ac984a2d04 fix: #5511, list item state doesn't change when offline (#5512)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-06-17 18:41:35 +00:00
renovate[bot]
079cfe7fe0 fix(deps): update dependency openai to v1.88.0 (#5536)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-17 13:29:19 -05:00
renovate[bot]
4a9095fcbb chore(deps): update dependency coverage to v7.9.1 (#5523)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-17 13:11:55 -05:00
renovate[bot]
384bb7480f fix(deps): update dependency fastapi to v0.115.13 (#5538) 2025-06-17 10:46:27 -05:00
Sravan Kumar
69488bd6df fix: Fixing the OpenAPI Spec and the Call to delete a shared recipe. (#5537) 2025-06-17 14:05:17 +00:00
renovate[bot]
038fbd38ef fix(deps): update dependency pydantic to v2.11.7 (#5527)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-16 17:08:11 +00:00
renovate[bot]
1697d6299e chore(deps): update dependency mypy to v1.16.1 (#5533)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-16 11:57:07 -05:00
Ceri Loosley
b87edc823a fix: handle recipe-scraper returning a int causing clean_time to return None (#5522) 2025-06-12 17:34:24 +00:00
renovate[bot]
cacb197aa8 fix(deps): update dependency requests to v2.32.4 [security] (#5519)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-11 12:24:06 +00:00
renovate[bot]
5d58c93331 fix(deps): update dependency openai to v1.86.0 (#5520)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-11 14:12:29 +02:00
renovate[bot]
104c9b36a5 fix(deps): update dependency openai to v1.85.0 (#5518)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-09 20:02:48 +00:00
github-actions[bot]
b68c96c348 chore(auto): Update pre-commit hooks (#5515)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-06-09 08:07:44 +00:00
renovate[bot]
b577cf5520 chore(deps): update dependency ruff to v0.11.13 (#5510)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-09 09:56:53 +02:00
Hayden
431638c1ed chore(l10n): New Crowdin updates (#5507) 2025-06-04 14:55:36 +02:00
renovate[bot]
a4871b65eb fix(deps): update dependency recipe-scrapers to v15.8.0 (#5506)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-03 20:48:20 -05:00
renovate[bot]
582974b265 fix(deps): update dependency openai to v1.84.0 (#5505) 2025-06-03 16:05:25 -05:00
renovate[bot]
22fdb32f61 fix(deps): update dependency openai to v1.83.0 (#5503)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 16:33:22 -05:00
renovate[bot]
649013a028 chore(deps): update dependency pytest to v8.4.0 (#5502)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 13:57:21 -05:00
Hayden
14de1410ae chore(l10n): New Crowdin updates (#5501) 2025-06-02 18:45:07 +00:00
Hayden
03bc87d3a8 chore(l10n): New Crowdin updates (#5500) 2025-06-02 17:39:42 +00:00
renovate[bot]
bb7885543e fix(deps): update dependency typing-extensions to v4.14.0 (#5499)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 15:12:55 +00:00
renovate[bot]
404a4cfa9d fix(deps): update dependency uvicorn to v0.34.3 (#5495)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 15:01:42 +00:00
github-actions[bot]
63a5c0076a chore(auto): Update pre-commit hooks (#5497)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-06-02 14:51:07 +00:00
Michael Genson
a4ea5ba10d chore: Relax Stalebot (#5498) 2025-06-02 09:41:06 -05:00
Hayden
fc6b239343 chore(l10n): New Crowdin updates (#5491) 2025-05-31 14:48:53 +02:00
renovate[bot]
9185cd8df1 fix(deps): update dependency openai to v1.82.1 (#5488)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-29 21:51:05 +00:00
renovate[bot]
f0a9d5333d chore(deps): update dependency mypy to v1.16.0 (#5487)
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-05-29 16:39:38 -05:00
Hayden
7bb84d504a chore(l10n): New Crowdin updates (#5485) 2025-05-29 20:14:27 +00:00
renovate[bot]
dad2712fe9 chore(deps): update dependency ruff to v0.11.12 (#5486)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-29 20:03:13 +00:00
SurfBurger
8e7e3e21ed feat: remove unnecessary UI components if allowPasswordLogin is true (#5484) 2025-05-29 14:52:44 -05:00
Chris Danis
af3057951d feat: setting to hide password login (#4943)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-05-27 19:49:06 +00:00
github-actions[bot]
2f3ef738c4 chore(auto): Update pre-commit hooks (#5474)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-05-26 15:54:21 +00:00
renovate[bot]
44ee1440e2 chore(deps): update dependency pytest-asyncio to v1 (#5473)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-26 10:44:02 -05:00
Hayden
c4aaf1a8c3 chore(l10n): New Crowdin updates (#5471) 2025-05-24 16:42:12 +00:00
renovate[bot]
e093a93189 chore(deps): update dependency freezegun to v1.5.2 (#5472)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-24 11:31:46 -05:00
renovate[bot]
51c92a1e35 chore(deps): update dependency coverage to v7.8.2 (#5470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-23 14:48:52 -05:00
renovate[bot]
84629c540e fix(deps): update dependency authlib to v1.6.0 (#5469)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-23 14:15:39 -05:00
renovate[bot]
28b3ba6506 fix(deps): update dependency pydantic to v2.11.5 (#5468)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-23 14:00:52 -05:00
renovate[bot]
a6ce140e60 fix(deps): update dependency openai to v1.82.0 (#5467) 2025-05-23 11:49:08 -05:00
renovate[bot]
4784672113 chore(deps): update dependency ruff to v0.11.11 (#5466)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-23 09:04:22 -05:00
renovate[bot]
9db31ca125 fix(deps): update dependency alembic to v1.16.1 (#5464)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-22 16:11:12 +00:00
renovate[bot]
972b588250 chore(deps): update dependency coverage to v7.8.1 (#5462)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-22 10:59:41 -05:00
Hayden
57ae31d231 chore(l10n): New Crowdin updates (#5458) 2025-05-22 09:57:33 +00:00
renovate[bot]
7398b2784a fix(deps): update dependency openai to v1.81.0 (#5463)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-22 11:47:19 +02:00
oddlama
c13c0868ae docs: document necessity of forwarded-allow-ips with OIDC behind reverse-proxy https (#5461) 2025-05-21 19:15:14 +00:00
renovate[bot]
a652830a26 fix(deps): update dependency ingredient-parser-nlp to v2.1.1 (#5455)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 16:56:03 +00:00
github-actions[bot]
1f34571820 chore(auto): Update pre-commit hooks (#5457)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-05-19 16:45:48 +00:00
renovate[bot]
4e16273f00 fix(deps): update dependency sqlalchemy to v2.0.41 (#5445)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 11:35:07 -05:00
renovate[bot]
d110f21d37 chore(deps): update dependency ruff to v0.11.10 (#5447)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-18 16:39:01 +00:00
renovate[bot]
6caa74254f fix(deps): update dependency openai to v1.79.0 (#5450)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-18 18:27:45 +02:00
Hayden
66bc4c25ec chore(l10n): New Crowdin updates (#5446) 2025-05-17 14:51:11 -05:00
github-actions[bot]
89bed4d675 chore(auto): Update pre-commit hooks (#5438)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-05-14 15:17:15 +00:00
renovate[bot]
25fbdd6523 fix(deps): update dependency openai to v1.78.1 (#5441)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-14 17:06:55 +02:00
renovate[bot]
7e64ce2767 chore(deps): update dependency mkdocs-material to v9.6.14 (#5442)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-13 16:26:42 -05:00
renovate[bot]
62dabe2c18 chore(deps): update dependency mkdocs-material to v9.6.13 (#5435)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-11 04:52:17 +00:00
renovate[bot]
3742c4e86c chore(deps): update dependency ruff to v0.11.9 (#5434)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-10 23:40:55 -05:00
Hayden
98da2cadc6 chore(l10n): New Crowdin updates (#5428) 2025-05-11 04:09:08 +00:00
renovate[bot]
8360829f61 fix(deps): update dependency openai to v1.78.0 (#5429)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-10 22:57:38 -05:00
renovate[bot]
aec38e367b chore(deps): update dependency pylint to v3.3.7 (#5416)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-10 16:57:12 -05:00
renovate[bot]
6ad7009509 fix(deps): update dependency pydantic to v2.11.4 (#5405)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-10 19:36:45 +00:00
renovate[bot]
46505ba8a5 fix(deps): update dependency orjson to v3.10.18 (#5403)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-10 14:21:05 -05:00
renovate[bot]
4011d6e29b fix(deps): update dependency ingredient-parser-nlp to v2.1.0 (#5373)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-09 21:43:59 +00:00
renovate[bot]
7ee7b753d6 fix(deps): update dependency tzdata to v2025 (#5365)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-09 16:29:46 -05:00
Hayden
c77f41d08e chore(l10n): New Crowdin updates (#5424) 2025-05-06 21:24:31 +02:00
renovate[bot]
ab7fa150fe chore(deps): update dependency ruff to v0.11.8 (#5410)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 09:16:52 +02:00
renovate[bot]
22fa5d27e3 fix(deps): update dependency openai to v1.77.0 (#5404)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 19:15:58 +02:00
github-actions[bot]
5f05002c20 chore(auto): Update pre-commit hooks (#5418)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-05-05 15:38:43 +00:00
Hayden
0cd33de2f6 chore(l10n): New Crowdin updates (#5407)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-05-05 17:29:07 +02:00
renovate[bot]
e46d19edfe fix(deps): update dependency recipe-scrapers to v15.7.1 (#5412)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-02 14:17:53 -05:00
github-actions[bot]
18ff3c3c48 chore(auto): Update pre-commit hooks (#5398)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-04-28 07:51:15 +00:00
Hayden
da1c9a448e chore(l10n): New Crowdin updates (#5396) 2025-04-28 09:40:19 +02:00
Hayden
58e1f71711 chore(l10n): New Crowdin updates (#5394) 2025-04-27 16:42:30 +00:00
Hayden
918899d346 chore(l10n): New Crowdin updates (#5390) 2025-04-27 13:10:22 +02:00
renovate[bot]
7f57e1d9a2 chore(deps): update dependency ruff to v0.11.7 (#5388)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-25 08:52:35 +02:00
renovate[bot]
df6dc6c8ac fix(deps): update dependency lxml to v5.4.0 (#5378)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-25 08:40:30 +02:00
renovate[bot]
840bd32ee3 fix(deps): update dependency pydantic-settings to v2.9.1 (#5366)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-24 19:31:45 +02:00
robertdanahome
da3d056d81 fix: Add missing group_id to RecipeTag and TagBase schemas (#5342)
Co-authored-by: Robert Dana <bob@yall.org>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-04-24 16:09:37 +00:00
renovate[bot]
b3ea48333c fix(deps): update dependency uvicorn to v0.34.2 (#5343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-24 17:58:54 +02:00
renovate[bot]
f37b39aad2 fix(deps): update dependency openai to v1.76.0 (#5381)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-24 15:37:22 +00:00
Hayden
d4c987e48a chore(l10n): New Crowdin updates (#5379) 2025-04-24 17:23:26 +02:00
Hayden
955e38ea0b chore(l10n): New Crowdin updates (#5374) 2025-04-21 21:07:23 +02:00
github-actions[bot]
7d87182b1a chore(auto): Update pre-commit hooks (#5372)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-04-21 07:26:22 +00:00
Hayden
5e80002297 chore(l10n): New Crowdin updates (#5370) 2025-04-21 09:16:51 +02:00
renovate[bot]
1364cd0d6b fix(deps): update dependency html2text to v2025 (#5347)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-19 21:16:29 +02:00
renovate[bot]
5d21af0e02 fix(deps): update dependency aniso8601 to v10.0.1 (#5368)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-19 20:12:04 +02:00
renovate[bot]
64afccb36c fix(deps): update dependency beautifulsoup4 to v4.13.4 (#5352)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-17 21:20:45 +00:00
renovate[bot]
5b0497e14e chore(deps): update dependency ruff to v0.11.6 (#5361)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-17 23:09:34 +02:00
renovate[bot]
5010bb5665 chore(deps): update dependency mkdocs-material to v9.6.12 (#5359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-17 17:09:57 +00:00
Hayden
c7789da1ad chore(l10n): New Crowdin updates (#5360) 2025-04-17 18:59:26 +02:00
renovate[bot]
b853ce221d fix(deps): update dependency openai to v1.75.0 (#5357)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-16 22:13:41 +02:00
renovate[bot]
3522f81025 fix(deps): update dependency openai to v1.74.0 (#5346)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 20:15:28 -05:00
ant385525
a22c0c4787 docs: Add community docs for an iOS shortcut (attempt 2) (#5345) 2025-04-14 16:01:57 +00:00
renovate[bot]
4dfc5ead54 fix(deps): update dependency pillow to v11.2.1 (#5337)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 10:12:17 -05:00
github-actions[bot]
c667bda427 chore(auto): Update pre-commit hooks (#5344)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-04-14 08:52:50 +00:00
renovate[bot]
188b129da4 fix(deps): update dependency typing-extensions to v4.13.2 (#5313)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 10:42:32 +02:00
renovate[bot]
6845b51def chore(deps): update dependency ruff to v0.11.5 (#5333)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-12 21:00:07 +02:00
renovate[bot]
c8ec19e371 fix(deps): update dependency openai to v1.73.0 (#5335)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-12 19:39:20 +02:00
renovate[bot]
c9002d2391 fix(deps): update dependency pydantic to v2.11.3 (#5325)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-10 16:47:31 +02:00
Hayden
0ba4cc4d4c chore(l10n): New Crowdin updates (#5310)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-04-10 13:40:47 +00:00
renovate[bot]
5baade58fb fix(deps): update dependency openai to v1.72.0 (#5328)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-10 15:30:36 +02:00
Kuchenpirat
e667fe8a5e fix: build pull request image only in mealie repo (#5327) 2025-04-09 07:58:49 +02:00
614 changed files with 711151 additions and 52048 deletions

View File

@@ -11,7 +11,7 @@
// Use -bullseye variants on local on arm64/Apple Silicon. // Use -bullseye variants on local on arm64/Apple Silicon.
"VARIANT": "3.12-bullseye", "VARIANT": "3.12-bullseye",
// Options // Options
"NODE_VERSION": "16" "NODE_VERSION": "20"
} }
}, },
"mounts": [ "mounts": [
@@ -55,5 +55,6 @@
"ghcr.io/devcontainers/features/docker-in-docker:2": { "ghcr.io/devcontainers/features/docker-in-docker:2": {
"dockerDashComposeVersion": "v2" "dockerDashComposeVersion": "v2"
} }
} },
"appPort": 3000
} }

View File

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

View File

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

114
.github/workflows/locale-sync.yml vendored Normal file
View File

@@ -0,0 +1,114 @@
name: Automatic Locale Sync
on:
schedule:
# Run every Sunday at 2 AM UTC
- cron: "0 2 * * 0"
workflow_dispatch:
# Allow manual triggering from the GitHub UI
permissions:
contents: write # To checkout, commit, and push changes
pull-requests: write # To create pull requests
jobs:
sync-locales:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
- name: Check venv cache
id: cache-validate
if: steps.cached-poetry-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
rm test.py
continue-on-error: true
- name: Install dependencies
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'
- name: Run locale generation
run: |
cd dev/code-generation
poetry run python main.py locales
env:
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
- name: Check for changes
id: changes
run: |
if git diff --quiet; then
echo "has_changes=false" >> $GITHUB_OUTPUT
else
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
- name: Commit and create PR
if: steps.changes.outputs.has_changes == 'true'
run: |
# Configure git
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
# Use the current branch as the base
BASE_BRANCH="${{ github.ref_name }}"
echo "Using base branch: $BASE_BRANCH"
# Create a new branch from the base branch
BRANCH_NAME="auto-locale-sync-$(date +%Y%m%d-%H%M%S)"
git checkout -b "$BRANCH_NAME"
# Add and commit changes
git add .
git commit -m "chore: automatic locale sync"
# Push the branch
git push origin "$BRANCH_NAME"
sleep 2
# Create PR using GitHub CLI with explicit repository
gh pr create \
--repo "${{ github.repository }}" \
--title "chore: automatic locale sync" \
--base "$BASE_BRANCH" \
--head "$BRANCH_NAME" \
--body "## Summary
Automatically generated locale updates from the weekly sync job.
## Changes
- Updated frontend locale files
- Generated from latest translation sources" \
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: No changes detected
if: steps.changes.outputs.has_changes == 'false'
run: echo "No locale changes detected, skipping PR creation"

View File

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

View File

@@ -16,12 +16,13 @@ jobs:
with: with:
stale-issue-label: 'stale' stale-issue-label: 'stale'
exempt-issue-labels: 'pinned,security,early-stages,bug: confirmed,feedback,task' exempt-issue-labels: 'pinned,security,early-stages,bug: confirmed,feedback,task'
stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.' stale-issue-message: 'This issue has been automatically marked as stale because it has been open 90 days with no activity.'
days-before-issue-stale: 30 days-before-issue-stale: 90
days-before-issue-close: 5 # This stops an issue from ever getting closed automatically.
days-before-issue-close: -1
stale-pr-label: 'stale' stale-pr-label: 'stale'
stale-pr-message: 'This PR is stale because it has been open 45 days with no activity.' stale-pr-message: 'This PR has been automatically marked as stale because it has been open 90 days with no activity.'
days-before-pr-stale: 45 days-before-pr-stale: 90
# This stops a PR from ever getting closed automatically. # This stops a PR from ever getting closed automatically.
days-before-pr-close: -1 days-before-pr-close: -1
# If an issue/PR has a milestone, it's exempt from being marked as stale. # If an issue/PR has a milestone, it's exempt from being marked as stale.

View File

@@ -14,7 +14,7 @@ jobs:
- name: Setup node env 🏗 - name: Setup node env 🏗
uses: actions/setup-node@v4.0.0 uses: actions/setup-node@v4.0.0
with: with:
node-version: 16 node-version: 20
check-latest: true check-latest: true
- name: Get yarn cache directory path 🛠 - name: Get yarn cache directory path 🛠
@@ -34,6 +34,10 @@ jobs:
run: yarn run: yarn
working-directory: "frontend" working-directory: "frontend"
- name: Prepare nuxt 🚀
run: yarn nuxt prepare
working-directory: "frontend"
- name: Run linter 👀 - name: Run linter 👀
run: yarn lint run: yarn lint
working-directory: "frontend" working-directory: "frontend"

5
.gitignore vendored
View File

@@ -10,6 +10,9 @@ docs/site/
*temp/* *temp/*
.secret .secret
frontend/dist/ frontend/dist/
frontend/.output/*
frontend/.yarn/*
frontend/.yarnrc.yml
dev/code-generation/generated/* dev/code-generation/generated/*
dev/data/mealie.db-journal dev/data/mealie.db-journal
@@ -164,3 +167,5 @@ dev/code-generation/openapi.json
.run/ .run/
.task/* .task/*
.dev.env
frontend/eslint.config.deprecated.js

View File

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

View File

@@ -18,6 +18,7 @@
"source.organizeImports": "never" "source.organizeImports": "never"
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true,
"eslint.useFlatConfig": true,
"eslint.workingDirectories": [ "eslint.workingDirectories": [
"./frontend" "./frontend"
], ],

View File

@@ -70,7 +70,7 @@ tasks:
dev:generate: dev:generate:
desc: run code generators desc: run code generators
cmds: cmds:
- poetry run python dev/code-generation/main.py - poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
- task: py:format - task: py:format
dev:services: dev:services:
@@ -243,7 +243,7 @@ tasks:
desc: runs the frontend server desc: runs the frontend server
dir: frontend dir: frontend
cmds: cmds:
- yarn run dev - yarn run dev --no-fork
docker:build-from-package: docker:build-from-package:
desc: Builds the Docker image from the existing Python package in dist/ desc: Builds the Docker image from the existing Python package in dist/

View File

@@ -1,3 +1,4 @@
import os
import pathlib import pathlib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -13,7 +14,7 @@ from mealie.schema._mealie import MealieModel
BASE = pathlib.Path(__file__).parent.parent.parent BASE = pathlib.Path(__file__).parent.parent.parent
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") or os.environ.get("CROWDIN_API_KEY", "")
@dataclass @dataclass
@@ -23,19 +24,22 @@ class LocaleData:
LOCALE_DATA: dict[str, LocaleData] = { LOCALE_DATA: dict[str, LocaleData] = {
"en-US": LocaleData(name="American English"),
"en-GB": LocaleData(name="British English"),
"af-ZA": LocaleData(name="Afrikaans (Afrikaans)"), "af-ZA": LocaleData(name="Afrikaans (Afrikaans)"),
"ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"), "ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"),
"bg-BG": LocaleData(name="Български (Bulgarian)"),
"ca-ES": LocaleData(name="Català (Catalan)"), "ca-ES": LocaleData(name="Català (Catalan)"),
"cs-CZ": LocaleData(name="Čeština (Czech)"), "cs-CZ": LocaleData(name="Čeština (Czech)"),
"da-DK": LocaleData(name="Dansk (Danish)"), "da-DK": LocaleData(name="Dansk (Danish)"),
"de-DE": LocaleData(name="Deutsch (German)"), "de-DE": LocaleData(name="Deutsch (German)"),
"el-GR": LocaleData(name="Ελληνικά (Greek)"), "el-GR": LocaleData(name="Ελληνικά (Greek)"),
"en-GB": LocaleData(name="British English"),
"en-US": LocaleData(name="American English"),
"es-ES": LocaleData(name="Español (Spanish)"), "es-ES": LocaleData(name="Español (Spanish)"),
"et-EE": LocaleData(name="Eesti (Estonian)"),
"fi-FI": LocaleData(name="Suomi (Finnish)"), "fi-FI": LocaleData(name="Suomi (Finnish)"),
"fr-FR": LocaleData(name="Français (French)"),
"fr-BE": LocaleData(name="Belge (Belgian)"), "fr-BE": LocaleData(name="Belge (Belgian)"),
"fr-CA": LocaleData(name="Français canadien (Canadian French)"),
"fr-FR": LocaleData(name="Français (French)"),
"gl-ES": LocaleData(name="Galego (Galician)"), "gl-ES": LocaleData(name="Galego (Galician)"),
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"), "he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
"hr-HR": LocaleData(name="Hrvatski (Croatian)"), "hr-HR": LocaleData(name="Hrvatski (Croatian)"),
@@ -53,6 +57,7 @@ LOCALE_DATA: dict[str, LocaleData] = {
"pt-PT": LocaleData(name="Português (Portuguese)"), "pt-PT": LocaleData(name="Português (Portuguese)"),
"ro-RO": LocaleData(name="Română (Romanian)"), "ro-RO": LocaleData(name="Română (Romanian)"),
"ru-RU": LocaleData(name="Pусский (Russian)"), "ru-RU": LocaleData(name="Pусский (Russian)"),
"sk-SK": LocaleData(name="Slovenčina (Slovak)"),
"sl-SI": LocaleData(name="Slovenščina (Slovenian)"), "sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
"sr-SP": LocaleData(name="српски (Serbian)"), "sr-SP": LocaleData(name="српски (Serbian)"),
"sv-SE": LocaleData(name="Svenska (Swedish)"), "sv-SE": LocaleData(name="Svenska (Swedish)"),
@@ -71,7 +76,7 @@ export const LOCALES = [{% for locale in locales %}
progress: {{ locale.progress }}, progress: {{ locale.progress }},
dir: "{{ locale.dir }}", dir: "{{ locale.dir }}",
},{% endfor %} },{% endfor %}
] ];
""" """
@@ -93,8 +98,8 @@ class CrowdinApi:
project_id = "451976" project_id = "451976"
api_key = API_KEY api_key = API_KEY
def __init__(self, api_key: str): def __init__(self, api_key: str | None):
api_key = api_key self.api_key = api_key or API_KEY
@property @property
def headers(self) -> dict: def headers(self) -> dict:
@@ -156,12 +161,13 @@ PROJECT_DIR = Path(__file__).parent.parent.parent
datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats" datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats"
locales_dir = PROJECT_DIR / "frontend" / "lang" / "messages" locales_dir = PROJECT_DIR / "frontend" / "lang" / "messages"
nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.js" nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.ts"
i18n_config = PROJECT_DIR / "frontend" / "i18n.config.ts"
reg_valid = PROJECT_DIR / "mealie" / "schema" / "_mealie" / "validators.py" reg_valid = PROJECT_DIR / "mealie" / "schema" / "_mealie" / "validators.py"
""" """
This snippet walks the message and dat locales directories and generates the import information This snippet walks the message and dat locales directories and generates the import information
for the nuxt.config.js file and automatically injects it into the nuxt.config.js file. Note that for the nuxt.config.ts file and automatically injects it into the nuxt.config.ts file. Note that
the code generation ID is hardcoded into the script and required in the nuxt config. the code generation ID is hardcoded into the script and required in the nuxt config.
""" """
@@ -173,12 +179,12 @@ def inject_nuxt_values():
all_langs = [] all_langs = []
for match in locales_dir.glob("*.json"): for match in locales_dir.glob("*.json"):
lang_string = f'{{ code: "{match.stem}", file: "{match.name}" }},' lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}" }},'
all_langs.append(lang_string) all_langs.append(lang_string)
log.debug(f"injecting locales into nuxt config -> {nuxt_config}") log.debug(f"injecting locales into nuxt config -> {nuxt_config}")
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs) inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)
inject_inline(nuxt_config, CodeKeys.nuxt_local_dates, all_date_locales) inject_inline(i18n_config, CodeKeys.nuxt_local_dates, all_date_locales)
def inject_registration_validation_values(): def inject_registration_validation_values():
@@ -195,7 +201,7 @@ def inject_registration_validation_values():
def generate_locales_ts_file(): def generate_locales_ts_file():
api = CrowdinApi("") api = CrowdinApi(None)
models = api.get_languages() models = api.get_languages()
tmpl = Template(LOCALE_TEMPLATE) tmpl = Template(LOCALE_TEMPLATE)
rendered = tmpl.render(locales=models) rendered = tmpl.render(locales=models)

View File

@@ -1,3 +1,4 @@
import argparse
from pathlib import Path from pathlib import Path
import gen_py_pytest_data_paths import gen_py_pytest_data_paths
@@ -11,15 +12,39 @@ CWD = Path(__file__).parent
def main(): def main():
items = [ parser = argparse.ArgumentParser(description="Run code generators")
(gen_py_schema_exports.main, "schema exports"), parser.add_argument(
(gen_ts_types.main, "frontend types"), "generators",
(gen_ts_locales.main, "locales"), nargs="*",
(gen_py_pytest_data_paths.main, "test data paths"), help="Specific generators to run (schema, types, locales, data-paths, routes). If none specified, all will run.", # noqa: E501 - long line
(gen_py_pytest_routes.main, "pytest routes"), )
] args = parser.parse_args()
for func, name in items: # Define all available generators
all_generators = {
"schema": (gen_py_schema_exports.main, "schema exports"),
"types": (gen_ts_types.main, "frontend types"),
"locales": (gen_ts_locales.main, "locales"),
"data-paths": (gen_py_pytest_data_paths.main, "test data paths"),
"routes": (gen_py_pytest_routes.main, "pytest routes"),
}
# Determine which generators to run
if args.generators:
# Validate requested generators
invalid_generators = [g for g in args.generators if g not in all_generators]
if invalid_generators:
log.error(f"Invalid generator(s): {', '.join(invalid_generators)}")
log.info(f"Available generators: {', '.join(all_generators.keys())}")
return
generators_to_run = [(all_generators[g][0], all_generators[g][1]) for g in args.generators]
else:
# Run all generators (default behavior)
generators_to_run = list(all_generators.values())
# Run the selected generators
for func, name in generators_to_run:
log.info(f"Generating {name}...") log.info(f"Generating {name}...")
func() func()

View File

@@ -1,5 +1,4 @@
import logging import logging
import re
import subprocess import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -35,7 +34,7 @@ class CodeSlicer:
start: int start: int
end: int end: int
indentation: str indentation: str | None
text: list[str] text: list[str]
_next_line = None _next_line = None
@@ -47,15 +46,24 @@ class CodeSlicer:
def push_line(self, string: str) -> None: def push_line(self, string: str) -> None:
self._next_line = self._next_line or self.start + 1 self._next_line = self._next_line or self.start + 1
self.text.insert(self._next_line, self.indentation + string + "\n") self.text.insert(self._next_line, (self.indentation or "") + string + "\n")
self._next_line += 1 self._next_line += 1
def get_indentation_of_string(line: str, comment_char: str = "//|#") -> str: def get_indentation_of_string(line: str) -> str:
return re.sub(rf"{comment_char}.*", "", line).removesuffix("\n") # Extract everything before the comment
if "//" in line:
indentation = line.split("//")[0]
elif "#" in line:
indentation = line.split("#")[0]
else:
indentation = line
# Keep only the whitespace, remove any non-whitespace characters
return "".join(c for c in indentation if c.isspace())
def find_start_end(file_text: list[str], gen_id: str) -> tuple[int, int, str]: def find_start_end(file_text: list[str], gen_id: str) -> tuple[int, int, str | None]:
start = None start = None
end = None end = None
indentation = None indentation = None

View File

@@ -1,24 +0,0 @@
![Recipe Image](../../images/{{ recipe.slug }}/original.jpg)
# {{ recipe.name }}
{{ recipe.description }}
## Ingredients
{% for ingredient in recipe.recipeIngredient %}
- [ ] {{ ingredient }} {% endfor %}
## Instructions
{% for step in recipe.recipeInstructions %}
- [ ] {{ step.text }} {% endfor %}
{% for note in recipe.notes %}
**{{ note.title }}:** {{ note.text }}
{% endfor %}
---
Tags: {{ recipe.tags }}
Categories: {{ recipe.categories }}
Original URL: {{ recipe.orgURL }}

View File

@@ -0,0 +1,75 @@
import glob
import json
import pathlib
def get_seed_locale_names() -> set[str]:
"""Find all locales in the seed/resources/ folder
Returns:
A set of every file name where there's both a seed label and seed food file
"""
LABELS_PATH = "/workspaces/mealie/mealie/repos/seed/resources/labels/locales/"
FOODS_PATH = "/workspaces/mealie/mealie/repos/seed/resources/foods/locales/"
label_locales = glob.glob("*.json", root_dir=LABELS_PATH)
foods_locales = glob.glob("*.json", root_dir=FOODS_PATH)
# ensure that a locale has both a label and a food seed file
return set(label_locales).intersection(foods_locales)
def get_labels_from_file(locale: str) -> list[str]:
"""Query a locale to get all of the labels so that they can be added to the new foods seed format
Returns:
All of the labels found within the seed file for a given locale
"""
locale_path = pathlib.Path("/workspaces/mealie/mealie/repos/seed/resources/labels/locales/" + locale)
label_names = [label["name"] for label in json.loads(locale_path.read_text(encoding="utf-8"))]
return label_names
def transform_foods(locale: str):
"""
Convert the current food seed file for a locale into a new format which maps each food to a label
Existing format of foods seed file is a dictionary where each key is a food name and the values are a dictionary
of attributes such as name and plural_name
New format maps each food to a label. The top-level dictionary has each key as a label e.g. "Fruits".
Each label key as a value that is a dictionary with an element called "foods"
"Foods" is a dictionary of each food for that label, with a key of the english food name e.g. "baking-soda"
and a value of attributes, including the translated name of the item e.g. "bicarbonate of soda" for en-GB.
"""
locale_path = pathlib.Path("/workspaces/mealie/mealie/repos/seed/resources/foods/locales/" + locale)
with open(locale_path, encoding="utf-8") as infile:
data = json.load(infile)
first_value = next(iter(data.values()))
if isinstance(first_value, dict) and "foods" in first_value:
# Locale is already in the new format, skipping transformation
return
transformed_data = {"": {"foods": dict(data.items())}}
# Seeding for labels now pulls from the foods file and parses the labels from there (as top-level keys),
# thus we need to add all of the existing labels to the new food seed file and give them an empty foods dictionary
label_names = get_labels_from_file(locale)
for label in label_names:
transformed_data[label] = {"foods": {}}
with open(locale_path, "w", encoding="utf-8") as outfile:
json.dump(transformed_data, outfile, indent=4, ensure_ascii=False)
def main():
for locale in get_seed_locale_names():
transform_foods(locale)
if __name__ == "__main__":
main()

View File

@@ -1,7 +1,7 @@
############################################### ###############################################
# Frontend Build # Frontend Build
############################################### ###############################################
FROM node:16 AS frontend-builder FROM node:20 AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
@@ -20,7 +20,7 @@ RUN yarn generate
############################################### ###############################################
# Base Image - Python # Base Image - Python
############################################### ###############################################
FROM python:3.12-slim as python-base FROM python:3.12-slim AS python-base
ENV MEALIE_HOME="/app" ENV MEALIE_HOME="/app"
@@ -119,7 +119,7 @@ RUN . $VENV_PATH/bin/activate \
############################################### ###############################################
# Production Image # Production Image
############################################### ###############################################
FROM python-base as production FROM python-base AS production
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie" LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
ENV PRODUCTION=true ENV PRODUCTION=true
ENV TESTING=false ENV TESTING=false

View File

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

View File

@@ -52,6 +52,8 @@ Before you can start using OIDC Authentication, you must first configure a new c
Take the client id and your discovery URL and update your environment variables to include the required OIDC variables described in [Installation - Backend Configuration](../installation/backend-config.md#openid-connect-oidc). Take the client id and your discovery URL and update your environment variables to include the required OIDC variables described in [Installation - Backend Configuration](../installation/backend-config.md#openid-connect-oidc).
You might also want to set ALLOW_PASSWORD_LOGIN to false, to hide the username+password inputs, if you want to allow logins only via OIDC.
### Groups ### Groups
There are two (optional) [environment variables](../installation/backend-config.md#openid-connect-oidc) that can control which of the users in your IdP can log in to Mealie and what permissions they will have. Keep in mind that these groups **do not necessarily correspond to groups in Mealie**. The groups claim is configurable via the `OIDC_GROUPS_CLAIM` environment variable. The groups should be **defined in your IdP** and be returned in the configured claim value. There are two (optional) [environment variables](../installation/backend-config.md#openid-connect-oidc) that can control which of the users in your IdP can log in to Mealie and what permissions they will have. Keep in mind that these groups **do not necessarily correspond to groups in Mealie**. The groups claim is configurable via the `OIDC_GROUPS_CLAIM` environment variable. The groups should be **defined in your IdP** and be returned in the configured claim value.

View File

@@ -36,6 +36,10 @@ Before you can start using OIDC Authentication, you must first configure a new c
http://localhost:9091/login http://localhost:9091/login
https://mealie.example.com/login https://mealie.example.com/login
If you are hosting Mealie behind a reverse proxy (nginx, Caddy, ...) to terminate TLS, make sure to start Mealie's Gunicorn server
with `--forwarded-allow-ips=<ip-of-proxy>`, otherwise the `X-Forwarded-*` headers will be ignored and the generated OIDC redirect
URI will use the wrong scheme (http instead of https). This will lead to authentication errors with strict OIDC providers.
3. Configure origins 3. Configure origins
If your identity provider enforces CORS on any endpoints, you will need to specify your Mealie URL as an Allowed Origin. If your identity provider enforces CORS on any endpoints, you will need to specify your Mealie URL as an Allowed Origin.

View File

@@ -16,6 +16,7 @@
| API_DOCS | True | Turns on/off access to the API documentation locally | | 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 | | 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_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_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) | | 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 | | 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 |
@@ -155,8 +156,6 @@ Setting the following environmental variables will change the theme of the front
### Docker Secrets ### Docker Secrets
### Docker Secrets
> <super>&dagger;</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger > <super>&dagger;</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger
> symbol next to them support the Docker Compose secrets pattern, below. > symbol next to them support the Docker Compose secrets pattern, below.
[Docker Compose secrets][docker-secrets] can be used to secure sensitive information regarding the Mealie implementation [Docker Compose secrets][docker-secrets] can be used to secure sensitive information regarding the Mealie implementation

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: 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! 1. Take a backup just in case!
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.8.0` 2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.0.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. 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 4. Restart the container

View File

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

View File

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

View File

@@ -90,7 +90,7 @@ nav:
- Bulk Url Import: "documentation/community-guide/bulk-url-import.md" - Bulk Url Import: "documentation/community-guide/bulk-url-import.md"
- Home Assistant: "documentation/community-guide/home-assistant.md" - Home Assistant: "documentation/community-guide/home-assistant.md"
- Import Bookmarklet: "documentation/community-guide/import-recipe-bookmarklet.md" - Import Bookmarklet: "documentation/community-guide/import-recipe-bookmarklet.md"
- iOS Shortcuts: "documentation/community-guide/ios.md" - iOS Shortcut: "documentation/community-guide/ios-shortcut.md"
- Reverse Proxy (SWAG): "documentation/community-guide/swag.md" - Reverse Proxy (SWAG): "documentation/community-guide/swag.md"
- API Reference: "api/redoc.md" - API Reference: "api/redoc.md"

View File

@@ -1,74 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
node: true,
},
parser: "vue-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
requireConfigFile: false,
tsConfigRootDir: __dirname,
project: ["./tsconfig.json"],
extraFileExtensions: [".vue"],
},
extends: [
"@nuxtjs/eslint-config-typescript",
"plugin:nuxt/recommended",
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
// "plugin:prettier/recommended",
"prettier",
],
// Re-add once we use nuxt bridge
// See https://v3.nuxtjs.org/getting-started/bridge#update-nuxtconfig
ignorePatterns: ["nuxt.config.js", "lib/api/types/**/*.ts"],
plugins: ["prettier"],
// add your custom rules here
rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
quotes: ["error", "double"],
"vue/component-name-in-template-casing": ["error", "PascalCase"],
camelcase: 0,
"vue/singleline-html-element-content-newline": "off",
"vue/multiline-html-element-content-newline": "off",
"vue/no-mutating-props": "off",
"vue/no-v-text-v-html-on-component": "warn",
"vue/no-v-for-template-key-on-child": "off",
"vue/valid-v-slot": [
"error",
{
allowModifiers: true,
},
],
"@typescript-eslint/ban-ts-comment": [
"error",
{
"ts-ignore": "allow-with-description",
},
],
"no-restricted-imports": [
"error",
{ paths: ["@vue/reactivity", "@vue/runtime-dom", "@vue/composition-api", "vue-demi"] },
],
// TODO Gradually activate all rules
// Allow Promise in onMounted
"@typescript-eslint/no-misused-promises": [
"error",
{
checksVoidReturn: {
arguments: false,
},
},
],
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-explicit-any": "off",
},
};

View File

@@ -1,378 +0,0 @@
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url('~assets/fonts/Roboto-100-cyrillic-ext1.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url('~assets/fonts/Roboto-100-cyrillic2.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url('~assets/fonts/Roboto-100-greek-ext3.woff2') format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url('~assets/fonts/Roboto-100-greek4.woff2') format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url('~assets/fonts/Roboto-100-vietnamese5.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url('~assets/fonts/Roboto-100-latin-ext6.woff2') format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url('~assets/fonts/Roboto-100-latin7.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('~assets/fonts/Roboto-300-cyrillic-ext8.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('~assets/fonts/Roboto-300-cyrillic9.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('~assets/fonts/Roboto-300-greek-ext10.woff2') format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('~assets/fonts/Roboto-300-greek11.woff2') format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('~assets/fonts/Roboto-300-vietnamese12.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('~assets/fonts/Roboto-300-latin-ext13.woff2') format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('~assets/fonts/Roboto-300-latin14.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('~assets/fonts/Roboto-400-cyrillic-ext15.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('~assets/fonts/Roboto-400-cyrillic16.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('~assets/fonts/Roboto-400-greek-ext17.woff2') format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('~assets/fonts/Roboto-400-greek18.woff2') format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('~assets/fonts/Roboto-400-vietnamese19.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('~assets/fonts/Roboto-400-latin-ext20.woff2') format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('~assets/fonts/Roboto-400-latin21.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('~assets/fonts/Roboto-500-cyrillic-ext22.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('~assets/fonts/Roboto-500-cyrillic23.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('~assets/fonts/Roboto-500-greek-ext24.woff2') format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('~assets/fonts/Roboto-500-greek25.woff2') format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('~assets/fonts/Roboto-500-vietnamese26.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('~assets/fonts/Roboto-500-latin-ext27.woff2') format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('~assets/fonts/Roboto-500-latin28.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('~assets/fonts/Roboto-700-cyrillic-ext29.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('~assets/fonts/Roboto-700-cyrillic30.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('~assets/fonts/Roboto-700-greek-ext31.woff2') format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('~assets/fonts/Roboto-700-greek32.woff2') format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('~assets/fonts/Roboto-700-vietnamese33.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('~assets/fonts/Roboto-700-latin-ext34.woff2') format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('~assets/fonts/Roboto-700-latin35.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('~assets/fonts/Roboto-900-cyrillic-ext36.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('~assets/fonts/Roboto-900-cyrillic37.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('~assets/fonts/Roboto-900-greek-ext38.woff2') format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('~assets/fonts/Roboto-900-greek39.woff2') format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('~assets/fonts/Roboto-900-vietnamese40.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('~assets/fonts/Roboto-900-latin-ext41.woff2') format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('~assets/fonts/Roboto-900-latin42.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@@ -17,11 +17,11 @@
} }
.theme--dark.v-application { .theme--dark.v-application {
background-color: var(--v-background-base, #1e1e1e) !important; background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
} }
.theme--dark.v-navigation-drawer { .theme--dark.v-navigation-drawer {
background-color: var(--v-background-base, #1e1e1e) !important; background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
} }
.theme--dark.v-card { .theme--dark.v-card {
@@ -29,11 +29,11 @@
} }
.left-border { .left-border {
border-left: 5px solid var(--v-primary-base) !important; border-left: 5px solid rgb(var(--v-theme-primary)) !important;
} }
.left-warning-border { .left-warning-border {
border-left: 5px solid var(--v-warning-base) !important; border-left: 5px solid rgb(var(--v-theme-warning)) !important;
} }
.handle { .handle {
@@ -56,3 +56,15 @@
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 100%; max-width: 100%;
} }
a {
color: rgb(var(--v-theme-primary));
}
.fill-height {
min-height: 100vh;
}
.vue-simple-handler {
background-color: rgb(var(--v-theme-primary)) !important;
}

View File

@@ -1,17 +1,41 @@
<template> <template>
<div> <div>
<v-card-text v-if="cookbook" class="px-1"> <v-card-text
<v-text-field v-model="cookbook.name" :label="$t('cookbook.cookbook-name')"></v-text-field> v-if="cookbook"
<v-textarea v-model="cookbook.description" auto-grow :rows="2" :label="$t('recipe.description')"></v-textarea> class="px-1"
>
<v-text-field
v-model="cookbook.name"
:label="$t('cookbook.cookbook-name')"
variant="underlined"
color="primary"
/>
<v-textarea
v-model="cookbook.description"
auto-grow
:rows="2"
:label="$t('recipe.description')"
variant="underlined"
color="primary"
/>
<QueryFilterBuilder <QueryFilterBuilder
:field-defs="fieldDefs" :field-defs="fieldDefs"
:initial-query-filter="cookbook.queryFilter" :initial-query-filter="cookbook.queryFilter"
@input="handleInput" @input="handleInput"
/> />
<v-switch v-model="cookbook.public" hide-details single-line> <v-switch
v-model="cookbook.public"
hide-details
single-line
color="primary"
>
<template #label> <template #label>
{{ $t('cookbook.public-cookbook') }} {{ $t('cookbook.public-cookbook') }}
<HelpIcon small right class="ml-2"> <HelpIcon
size="small"
right
class="ml-2"
>
{{ $t('cookbook.public-cookbook-description') }} {{ $t('cookbook.public-cookbook-description') }}
</HelpIcon> </HelpIcon>
</template> </template>
@@ -21,16 +45,15 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, useContext } from "@nuxtjs/composition-api"; import type { ReadCookBook } from "~/lib/api/types/cookbook";
import { ReadCookBook } from "~/lib/api/types/cookbook";
import { Organizer } from "~/lib/api/types/non-generated"; import { Organizer } from "~/lib/api/types/non-generated";
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue"; import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import { FieldDefinition } from "~/composables/use-query-filter-builder"; import type { FieldDefinition } from "~/composables/use-query-filter-builder";
export default defineComponent({ export default defineNuxtComponent({
components: { QueryFilterBuilder }, components: { QueryFilterBuilder },
props: { props: {
cookbook: { modelValue: {
type: Object as () => ReadCookBook, type: Object as () => ReadCookBook,
required: true, required: true,
}, },
@@ -39,52 +62,57 @@ export default defineComponent({
required: true, required: true,
}, },
}, },
setup(props) { emits: ["update:modelValue"],
const { i18n } = useContext(); setup(props, { emit }) {
const i18n = useI18n();
const cookbook = toRef(() => props.modelValue);
function handleInput(value: string | undefined) { function handleInput(value: string | undefined) {
props.cookbook.queryFilterString = value || ""; cookbook.value.queryFilterString = value || "";
emit("update:modelValue", cookbook.value);
} }
const fieldDefs: FieldDefinition[] = [ const fieldDefs: FieldDefinition[] = [
{ {
name: "recipe_category.id", name: "recipe_category.id",
label: i18n.tc("category.categories"), label: i18n.t("category.categories"),
type: Organizer.Category, type: Organizer.Category,
}, },
{ {
name: "tags.id", name: "tags.id",
label: i18n.tc("tag.tags"), label: i18n.t("tag.tags"),
type: Organizer.Tag, type: Organizer.Tag,
}, },
{ {
name: "recipe_ingredient.food.id", name: "recipe_ingredient.food.id",
label: i18n.tc("recipe.ingredients"), label: i18n.t("recipe.ingredients"),
type: Organizer.Food, type: Organizer.Food,
}, },
{ {
name: "tools.id", name: "tools.id",
label: i18n.tc("tool.tools"), label: i18n.t("tool.tools"),
type: Organizer.Tool, type: Organizer.Tool,
}, },
{ {
name: "household_id", name: "household_id",
label: i18n.tc("household.households"), label: i18n.t("household.households"),
type: Organizer.Household, type: Organizer.Household,
}, },
{ {
name: "created_at", name: "created_at",
label: i18n.tc("general.date-created"), label: i18n.t("general.date-created"),
type: "date", type: "date",
}, },
{ {
name: "updated_at", name: "updated_at",
label: i18n.tc("general.date-updated"), label: i18n.t("general.date-updated"),
type: "date", type: "date",
}, },
]; ];
return { return {
cookbook,
handleInput, handleInput,
fieldDefs, fieldDefs,
}; };

View File

@@ -7,44 +7,57 @@
width="100%" width="100%"
max-width="1100px" max-width="1100px"
:icon="$globals.icons.pages" :icon="$globals.icons.pages"
:title="$tc('general.edit')" :title="$t('general.edit')"
:submit-icon="$globals.icons.save" :submit-icon="$globals.icons.save"
:submit-text="$tc('general.save')" :submit-text="$t('general.save')"
:submit-disabled="!editTarget.queryFilterString" :submit-disabled="!editTarget.queryFilterString"
can-submit
@submit="editCookbook" @submit="editCookbook"
> >
<v-card-text> <v-card-text>
<CookbookEditor :cookbook="editTarget" :actions="actions" /> <CookbookEditor
v-model="editTarget"
:actions="actions"
/>
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<!-- Page --> <v-container
<v-container v-if="book" fluid> v-if="book"
<v-app-bar color="transparent" flat class="mt-n1"> class="my-0"
<v-icon large left> {{ $globals.icons.pages }} </v-icon> >
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title> <v-sheet
<v-spacer></v-spacer> color="transparent"
class="d-flex flex-column w-100 pa-0 ma-0"
elevation="0"
>
<div class="d-flex align-center w-100 mb-2">
<v-toolbar-title class="headline mb-0">
<v-icon size="large" class="mr-3">
{{ $globals.icons.pages }}
</v-icon>
{{ book.name }}
</v-toolbar-title>
<BaseButton <BaseButton
v-if="canEdit" v-if="canEdit"
class="mx-1" class="mx-1"
:edit="true" :edit="true"
@click="handleEditCookbook" @click="handleEditCookbook"
/> />
</v-app-bar> </div>
<v-card flat> <div v-if="book.description" class="subtitle-1 text-grey-lighten-1 mb-2">
<v-card-text class="py-0">
{{ book.description }} {{ book.description }}
</v-card-text> </div>
</v-card> </v-sheet>
<v-container class="pa-0"> <v-container class="pa-0">
<RecipeCardSection <RecipeCardSection
class="mb-5 mx-1" class="mb-5 mx-1"
:recipes="recipes" :recipes="recipes"
:query="{ cookbook: slug }" :query="{ cookbook: slug }"
@sortRecipes="assignSorted" @sort-recipes="assignSorted"
@replaceRecipes="replaceRecipes" @replace-recipes="replaceRecipes"
@appendRecipes="appendRecipes" @append-recipes="appendRecipes"
@delete="removeRecipe" @delete="removeRecipe"
/> />
</v-container> </v-container>
@@ -53,39 +66,39 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useRoute, ref, useContext, useMeta, reactive, useRouter } from "@nuxtjs/composition-api";
import { useLazyRecipes } from "~/composables/recipes"; import { useLazyRecipes } from "~/composables/recipes";
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import { useCookbook, useCookbooks } from "~/composables/use-group-cookbooks"; import { useCookbookStore } from "~/composables/store/use-cookbook-store";
import { useCookbook } from "~/composables/use-group-cookbooks";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { RecipeCookBook } from "~/lib/api/types/cookbook"; import type { RecipeCookBook } from "~/lib/api/types/cookbook";
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue"; import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
export default defineComponent({ export default defineNuxtComponent({
components: { RecipeCardSection, CookbookEditor }, components: { RecipeCardSection, CookbookEditor },
setup() { setup() {
const { $auth } = useContext(); const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value); const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const slug = route.value.params.slug; const slug = route.params.slug as string;
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value); const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
const { actions } = useCookbooks(); const { actions } = useCookbookStore();
const router = useRouter(); const router = useRouter();
const tab = ref(null); const tab = ref(null);
const book = getOne(slug); const book = getOne(slug);
const isOwnHousehold = computed(() => { const isOwnHousehold = computed(() => {
if (!($auth.user && book.value?.householdId)) { if (!($auth.user.value && book.value?.householdId)) {
return false; return false;
} }
return $auth.user.householdId === book.value.householdId; return $auth.user.value.householdId === book.value.householdId;
}) });
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value); const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
const dialogStates = reactive({ const dialogStates = reactive({
@@ -106,8 +119,9 @@
if (response?.slug && book.value?.slug !== response?.slug) { if (response?.slug && book.value?.slug !== response?.slug) {
// if name changed, redirect to new slug // if name changed, redirect to new slug
router.push(`/g/${route.value.params.groupSlug}/cookbooks/${response?.slug}`); router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
} else { }
else {
// otherwise reload the page, since the recipe criteria changed // otherwise reload the page, since the recipe criteria changed
router.go(0); router.go(0);
} }
@@ -115,10 +129,8 @@
editTarget.value = null; editTarget.value = null;
} }
useMeta(() => { useSeoMeta({
return {
title: book?.value?.name || "Cookbook", title: book?.value?.name || "Cookbook",
};
}); });
return { return {
@@ -138,6 +150,5 @@
actions, actions,
}; };
}, },
head: {}, // Must include for useMeta
}); });
</script> </script>

View File

@@ -7,21 +7,24 @@
class="elevation-0" class="elevation-0"
@click:row="downloadData" @click:row="downloadData"
> >
<template #item.expires="{ item }"> <template #[`item.expires`]="{ item }">
{{ getTimeToExpire(item.expires) }} {{ getTimeToExpire(item.expires) }}
</template> </template>
<template #item.actions="{ item }"> <template #[`item.actions`]="{ item }">
<BaseButton download small :download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"> <BaseButton
</BaseButton> download
size="small"
:download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"
/>
</template> </template>
</v-data-table> </v-data-table>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, useContext } from "@nuxtjs/composition-api";
import { parseISO, formatDistanceToNow } from "date-fns"; import { parseISO, formatDistanceToNow } from "date-fns";
import { GroupDataExport } from "~/lib/api/types/group"; import type { GroupDataExport } from "~/lib/api/types/group";
export default defineComponent({
export default defineNuxtComponent({
props: { props: {
exports: { exports: {
type: Array as () => GroupDataExport[], type: Array as () => GroupDataExport[],
@@ -29,14 +32,14 @@ export default defineComponent({
}, },
}, },
setup() { setup() {
const { i18n } = useContext(); const i18n = useI18n();
const headers = [ const headers = [
{ text: i18n.t("export.export"), value: "name" }, { title: i18n.t("export.export"), value: "name" },
{ text: i18n.t("export.file-name"), value: "filename" }, { title: i18n.t("export.file-name"), value: "filename" },
{ text: i18n.t("export.size"), value: "size" }, { title: i18n.t("export.size"), value: "size" },
{ text: i18n.t("export.link-expires"), value: "expires" }, { title: i18n.t("export.link-expires"), value: "expires" },
{ text: "", value: "actions" }, { title: "", value: "actions" },
]; ];
function getTimeToExpire(timeString: string) { function getTimeToExpire(timeString: string) {

View File

@@ -1,27 +1,30 @@
<template> <template>
<div v-if="preferences"> <div v-if="preferences">
<BaseCardSectionTitle :title="$tc('group.general-preferences')"></BaseCardSectionTitle> <BaseCardSectionTitle :title="$t('group.general-preferences')" />
<v-checkbox v-model="preferences.privateGroup" class="mt-n4" :label="$t('group.private-group')"></v-checkbox> <v-checkbox
v-model="preferences.privateGroup"
class="mt-n4"
:label="$t('group.private-group')"
/>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed } from "@nuxtjs/composition-api"; export default defineNuxtComponent({
export default defineComponent({
props: { props: {
value: { modelValue: {
type: Object, type: Object,
required: true, required: true,
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const preferences = computed({ const preferences = computed({
get() { get() {
return props.value; return props.modelValue;
}, },
set(val) { set(val) {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
@@ -32,5 +35,4 @@ export default defineComponent({
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped></style>
</style>

View File

@@ -5,31 +5,30 @@
:label="label" :label="label"
:hint="description" :hint="description"
:persistent-hint="!!description" :persistent-hint="!!description"
item-text="name" item-title="name"
:multiple="multiselect" :multiple="multiselect"
:prepend-inner-icon="$globals.icons.household" :prepend-inner-icon="$globals.icons.household"
return-object return-object
> >
<template #selection="data"> <template #chip="data">
<v-chip <v-chip
:key="data.index" :key="data.index"
class="ma-1" class="ma-1"
:input-value="data.selected" :input-value="data.item"
small size="small"
close closable
label label
color="accent" color="accent"
dark dark
@click:close="removeByIndex(data.index)" @click:close="removeByIndex(data.index)"
> >
{{ data.item.name || data.item }} {{ data.item.raw.name || data.item }}
</v-chip> </v-chip>
</template> </template>
</v-select> </v-select>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, onMounted, useContext } from "@nuxtjs/composition-api";
import { useHouseholdStore } from "~/composables/store/use-household-store"; import { useHouseholdStore } from "~/composables/store/use-household-store";
interface HouseholdLike { interface HouseholdLike {
@@ -37,9 +36,9 @@ interface HouseholdLike {
name: string; name: string;
} }
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: Array as () => HouseholdLike[], type: Array as () => HouseholdLike[],
required: true, required: true,
}, },
@@ -52,11 +51,12 @@ export default defineComponent({
default: "", default: "",
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const selected = computed({ const selected = computed({
get: () => props.value, get: () => props.modelValue,
set: (val) => { set: (val) => {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
@@ -66,9 +66,9 @@ export default defineComponent({
} }
}); });
const { i18n } = useContext(); const i18n = useI18n();
const label = computed( const label = computed(
() => props.multiselect ? i18n.tc("household.households") : i18n.tc("household.household") () => props.multiselect ? i18n.t("household.households") : i18n.t("household.household"),
); );
const { store: households } = useHouseholdStore(); const { store: households } = useHouseholdStore();

View File

@@ -8,26 +8,41 @@
/> />
<v-menu <v-menu
offset-y offset-y
left start
:bottom="!menuTop" :bottom="!menuTop"
:nudge-bottom="!menuTop ? '5' : '0'" :nudge-bottom="!menuTop ? '5' : '0'"
:top="menuTop" :top="menuTop"
:nudge-top="menuTop ? '5' : '0'" :nudge-top="menuTop ? '5' : '0'"
allow-overflow allow-overflow
close-delay="125" close-delay="125"
:open-on-hover="$vuetify.breakpoint.mdAndUp" :open-on-hover="mdAndUp"
content-class="d-print-none" content-class="d-print-none"
> >
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent> <v-btn
:class="{ 'rounded-circle': fab }"
:size="fab ? 'small' : undefined"
:color="color"
:icon="!fab"
variant="text"
dark
v-bind="props"
@click.prevent
>
<v-icon>{{ icon }}</v-icon> <v-icon>{{ icon }}</v-icon>
</v-btn> </v-btn>
</template> </template>
<v-list dense> <v-list density="compact">
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)"> <v-list-item
<v-list-item-icon> v-for="(item, index) in menuItems"
<v-icon :color="item.color"> {{ item.icon }} </v-icon> :key="index"
</v-list-item-icon> @click="contextMenuEventHandler(item.event)"
>
<template #prepend>
<v-icon :color="item.color">
{{ item.icon }}
</v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title> <v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item> </v-list-item>
</v-list> </v-list>
@@ -36,10 +51,9 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api"; import type { Recipe } from "~/lib/api/types/recipe";
import { Recipe } from "~/lib/api/types/recipe";
import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue"; import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
import { ShoppingListSummary } from "~/lib/api/types/household"; import type { ShoppingListSummary } from "~/lib/api/types/household";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
export interface ContextMenuItem { export interface ContextMenuItem {
@@ -50,7 +64,7 @@ export interface ContextMenuItem {
isPublic: boolean; isPublic: boolean;
} }
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeDialogAddToShoppingList, RecipeDialogAddToShoppingList,
}, },
@@ -77,7 +91,10 @@ export default defineComponent({
}, },
}, },
setup(props, context) { setup(props, context) {
const { $globals, i18n } = useContext(); const { mdAndUp } = useDisplay();
const i18n = useI18n();
const { $globals } = useNuxtApp();
const api = useUserApi(); const api = useUserApi();
const state = reactive({ const state = reactive({
@@ -85,7 +102,7 @@ export default defineComponent({
shoppingListDialog: false, shoppingListDialog: false,
menuItems: [ menuItems: [
{ {
title: i18n.tc("recipe.add-to-list"), title: i18n.t("recipe.add-to-list"),
icon: $globals.icons.cartCheck, icon: $globals.icons.cartCheck,
color: undefined, color: undefined,
event: "shoppingList", event: "shoppingList",
@@ -103,16 +120,17 @@ export default defineComponent({
scale: 1, scale: 1,
...recipe, ...recipe,
}; };
}) });
}) });
async function getShoppingLists() { async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" }); const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
if (data) { if (data) {
shoppingLists.value = data.items ?? []; shoppingLists.value = data.items as ShoppingListSummary[] ?? [];
} }
} }
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = { const eventHandlers: { [key: string]: () => void | Promise<any> } = {
shoppingList: () => { shoppingList: () => {
getShoppingLists(); getShoppingLists();
@@ -139,7 +157,8 @@ export default defineComponent({
icon, icon,
recipesWithScales, recipesWithScales,
shoppingLists, shoppingLists,
} mdAndUp,
};
}, },
}) });
</script> </script>

View File

@@ -1,8 +1,19 @@
<template> <template>
<div> <div>
<div class="d-md-flex" style="gap: 10px"> <div
<v-select v-model="inputDay" :items="MEAL_DAY_OPTIONS" :label="$t('meal-plan.rule-day')"></v-select> class="d-md-flex"
<v-select v-model="inputEntryType" :items="MEAL_TYPE_OPTIONS" :label="$t('meal-plan.meal-type')"></v-select> style="gap: 10px"
>
<v-select
v-model="inputDay"
:items="MEAL_DAY_OPTIONS"
:label="$t('meal-plan.rule-day')"
/>
<v-select
v-model="inputEntryType"
:items="MEAL_TYPE_OPTIONS"
:label="$t('meal-plan.meal-type')"
/>
</div> </div>
<div class="mb-5"> <div class="mb-5">
@@ -16,19 +27,18 @@
<!-- TODO: proper pluralization of inputDay --> <!-- TODO: proper pluralization of inputDay -->
{{ $t('meal-plan.this-rule-will-apply', { {{ $t('meal-plan.this-rule-will-apply', {
dayCriteria: inputDay === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [inputDay]), dayCriteria: inputDay === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [inputDay]),
mealTypeCriteria: inputEntryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [inputEntryType]) mealTypeCriteria: inputEntryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [inputEntryType]),
}) }} }) }}
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue"; import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import { FieldDefinition } from "~/composables/use-query-filter-builder"; import type { FieldDefinition } from "~/composables/use-query-filter-builder";
import { Organizer } from "~/lib/api/types/non-generated"; import { Organizer } from "~/lib/api/types/non-generated";
import { QueryFilterJSON } from "~/lib/api/types/response"; import type { QueryFilterJSON } from "~/lib/api/types/response";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
QueryFilterBuilder, QueryFilterBuilder,
}, },
@@ -54,26 +64,27 @@ export default defineComponent({
default: false, default: false,
}, },
}, },
emits: ["update:day", "update:entry-type", "update:query-filter-string"],
setup(props, context) { setup(props, context) {
const { i18n } = useContext(); const i18n = useI18n();
const MEAL_TYPE_OPTIONS = [ const MEAL_TYPE_OPTIONS = [
{ text: i18n.t("meal-plan.breakfast"), value: "breakfast" }, { title: i18n.t("meal-plan.breakfast"), value: "breakfast" },
{ text: i18n.t("meal-plan.lunch"), value: "lunch" }, { title: i18n.t("meal-plan.lunch"), value: "lunch" },
{ text: i18n.t("meal-plan.dinner"), value: "dinner" }, { title: i18n.t("meal-plan.dinner"), value: "dinner" },
{ text: i18n.t("meal-plan.side"), value: "side" }, { title: i18n.t("meal-plan.side"), value: "side" },
{ text: i18n.t("meal-plan.type-any"), value: "unset" }, { title: i18n.t("meal-plan.type-any"), value: "unset" },
]; ];
const MEAL_DAY_OPTIONS = [ const MEAL_DAY_OPTIONS = [
{ text: i18n.t("general.monday"), value: "monday" }, { title: i18n.t("general.monday"), value: "monday" },
{ text: i18n.t("general.tuesday"), value: "tuesday" }, { title: i18n.t("general.tuesday"), value: "tuesday" },
{ text: i18n.t("general.wednesday"), value: "wednesday" }, { title: i18n.t("general.wednesday"), value: "wednesday" },
{ text: i18n.t("general.thursday"), value: "thursday" }, { title: i18n.t("general.thursday"), value: "thursday" },
{ text: i18n.t("general.friday"), value: "friday" }, { title: i18n.t("general.friday"), value: "friday" },
{ text: i18n.t("general.saturday"), value: "saturday" }, { title: i18n.t("general.saturday"), value: "saturday" },
{ text: i18n.t("general.sunday"), value: "sunday" }, { title: i18n.t("general.sunday"), value: "sunday" },
{ text: i18n.t("meal-plan.day-any"), value: "unset" }, { title: i18n.t("meal-plan.day-any"), value: "unset" },
]; ];
const inputDay = computed({ const inputDay = computed({
@@ -110,42 +121,42 @@ export default defineComponent({
const fieldDefs: FieldDefinition[] = [ const fieldDefs: FieldDefinition[] = [
{ {
name: "recipe_category.id", name: "recipe_category.id",
label: i18n.tc("category.categories"), label: i18n.t("category.categories"),
type: Organizer.Category, type: Organizer.Category,
}, },
{ {
name: "tags.id", name: "tags.id",
label: i18n.tc("tag.tags"), label: i18n.t("tag.tags"),
type: Organizer.Tag, type: Organizer.Tag,
}, },
{ {
name: "recipe_ingredient.food.id", name: "recipe_ingredient.food.id",
label: i18n.tc("recipe.ingredients"), label: i18n.t("recipe.ingredients"),
type: Organizer.Food, type: Organizer.Food,
}, },
{ {
name: "tools.id", name: "tools.id",
label: i18n.tc("tool.tools"), label: i18n.t("tool.tools"),
type: Organizer.Tool, type: Organizer.Tool,
}, },
{ {
name: "household_id", name: "household_id",
label: i18n.tc("household.households"), label: i18n.t("household.households"),
type: Organizer.Household, type: Organizer.Household,
}, },
{ {
name: "last_made", name: "last_made",
label: i18n.tc("general.last-made"), label: i18n.t("general.last-made"),
type: "date", type: "date",
}, },
{ {
name: "created_at", name: "created_at",
label: i18n.tc("general.date-created"), label: i18n.t("general.date-created"),
type: "date", type: "date",
}, },
{ {
name: "updated_at", name: "updated_at",
label: i18n.tc("general.date-updated"), label: i18n.t("general.date-updated"),
type: "date", type: "date",
}, },
]; ];

View File

@@ -1,27 +1,44 @@
<template> <template>
<div> <div>
<v-card-text> <v-card-text>
<v-switch v-model="webhookCopy.enabled" :label="$t('general.enabled')"></v-switch> <v-switch
<v-text-field v-model="webhookCopy.name" :label="$t('settings.webhooks.webhook-name')"></v-text-field> v-model="webhookCopy.enabled"
<v-text-field v-model="webhookCopy.url" :label="$t('settings.webhooks.webhook-url')"></v-text-field> color="primary"
<v-time-picker v-model="scheduledTime" class="elevation-2" ampm-in-title format="ampm"></v-time-picker> :label="$t('general.enabled')"
/>
<v-text-field
v-model="webhookCopy.name"
:label="$t('settings.webhooks.webhook-name')"
variant="underlined"
/>
<v-text-field
v-model="webhookCopy.url"
:label="$t('settings.webhooks.webhook-url')"
variant="underlined"
/>
<v-time-picker
v-model="scheduledTime"
class="elevation-2"
ampm-in-title
format="ampm"
/>
</v-card-text> </v-card-text>
<v-card-actions class="py-0 justify-end"> <v-card-actions class="py-0 justify-end">
<BaseButtonGroup <BaseButtonGroup
:buttons="[ :buttons="[
{ {
icon: $globals.icons.delete, icon: $globals.icons.delete,
text: $tc('general.delete'), text: $t('general.delete'),
event: 'delete', event: 'delete',
}, },
{ {
icon: $globals.icons.testTube, icon: $globals.icons.testTube,
text: $tc('general.test'), text: $t('general.test'),
event: 'test', event: 'test',
}, },
{ {
icon: $globals.icons.save, icon: $globals.icons.save,
text: $tc('general.save'), text: $t('general.save'),
event: 'save', event: 'save',
}, },
]" ]"
@@ -34,11 +51,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, ref } from "@nuxtjs/composition-api"; import type { ReadWebhook } from "~/lib/api/types/household";
import { ReadWebhook } from "~/lib/api/types/household";
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks"; import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
webhook: { webhook: {
type: Object as () => ReadWebhook, type: Object as () => ReadWebhook,
@@ -47,6 +63,7 @@ export default defineComponent({
}, },
emits: ["delete", "save", "test"], emits: ["delete", "save", "test"],
setup(props, { emit }) { setup(props, { emit }) {
const i18n = useI18n();
const itemUTC = ref<string>(props.webhook.scheduledTime); const itemUTC = ref<string>(props.webhook.scheduledTime);
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime)); const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
@@ -67,6 +84,11 @@ export default defineComponent({
emit("save", webhookCopy.value); emit("save", webhookCopy.value);
} }
// Set page title using useSeoMeta
useSeoMeta({
title: i18n.t("settings.webhooks.webhooks"),
});
return { return {
webhookCopy, webhookCopy,
scheduledTime, scheduledTime,
@@ -75,10 +97,5 @@ export default defineComponent({
itemLocal, itemLocal,
}; };
}, },
head() {
return {
title: this.$t("settings.webhooks.webhooks") as string,
};
},
}); });
</script> </script>

View File

@@ -1,13 +1,8 @@
<template> <template>
<div v-if="preferences"> <div v-if="preferences">
<BaseCardSectionTitle class="mt-10" :title="$tc('household.household-preferences')"></BaseCardSectionTitle> <BaseCardSectionTitle :title="$t('household.household-preferences')" />
<div class="mb-6"> <div class="mb-6">
<v-checkbox <v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
v-model="preferences.privateHousehold"
hide-details
dense
:label="$t('household.private-household')"
/>
<div class="ml-8"> <div class="ml-8">
<p class="text-subtitle-2 my-0 py-0"> <p class="text-subtitle-2 my-0 py-0">
{{ $t("household.private-household-description") }} {{ $t("household.private-household-description") }}
@@ -16,12 +11,7 @@
</div> </div>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<v-checkbox <v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
v-model="preferences.lockRecipeEditsFromOtherHouseholds"
hide-details
dense
:label="$t('household.lock-recipe-edits-from-other-households')"
/>
<div class="ml-8"> <div class="ml-8">
<p class="text-subtitle-2 my-0 py-0"> <p class="text-subtitle-2 my-0 py-0">
{{ $t("household.lock-recipe-edits-from-other-households-description") }} {{ $t("household.lock-recipe-edits-from-other-households-description") }}
@@ -32,20 +22,17 @@
v-model="preferences.firstDayOfWeek" v-model="preferences.firstDayOfWeek"
:prepend-icon="$globals.icons.calendarWeekBegin" :prepend-icon="$globals.icons.calendarWeekBegin"
:items="allDays" :items="allDays"
item-text="name" item-title="name"
item-value="value" item-value="value"
:label="$t('settings.first-day-of-week')" :label="$t('settings.first-day-of-week')"
variant="underlined"
flat
/> />
<BaseCardSectionTitle class="mt-5" :title="$tc('household.household-recipe-preferences')"></BaseCardSectionTitle> <BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')" />
<div class="preference-container"> <div class="preference-container">
<div v-for="p in recipePreferences" :key="p.key"> <div v-for="p in recipePreferences" :key="p.key">
<v-checkbox <v-checkbox v-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
v-model="preferences[p.key]"
hide-details
dense
:label="p.label"
/>
<p class="ml-8 text-subtitle-2 my-0 py-0"> <p class="ml-8 text-subtitle-2 my-0 py-0">
{{ p.description }} {{ p.description }}
</p> </p>
@@ -55,55 +42,55 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api"; import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
import { ReadHouseholdPreferences } from "~/lib/api/types/household";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: Object, type: Object,
required: true, required: true,
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const { i18n } = useContext(); const i18n = useI18n();
type Preference = { type Preference = {
key: keyof ReadHouseholdPreferences; key: keyof ReadHouseholdPreferences;
label: string; label: string;
description: string; description: string;
} };
const recipePreferences: Preference[] = [ const recipePreferences: Preference[] = [
{ {
key: "recipePublic", key: "recipePublic",
label: i18n.tc("group.allow-users-outside-of-your-group-to-see-your-recipes"), label: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes"),
description: i18n.tc("group.allow-users-outside-of-your-group-to-see-your-recipes-description"), description: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes-description"),
}, },
{ {
key: "recipeShowNutrition", key: "recipeShowNutrition",
label: i18n.tc("group.show-nutrition-information"), label: i18n.t("group.show-nutrition-information"),
description: i18n.tc("group.show-nutrition-information-description"), description: i18n.t("group.show-nutrition-information-description"),
}, },
{ {
key: "recipeShowAssets", key: "recipeShowAssets",
label: i18n.tc("group.show-recipe-assets"), label: i18n.t("group.show-recipe-assets"),
description: i18n.tc("group.show-recipe-assets-description"), description: i18n.t("group.show-recipe-assets-description"),
}, },
{ {
key: "recipeLandscapeView", key: "recipeLandscapeView",
label: i18n.tc("group.default-to-landscape-view"), label: i18n.t("group.default-to-landscape-view"),
description: i18n.tc("group.default-to-landscape-view-description"), description: i18n.t("group.default-to-landscape-view-description"),
}, },
{ {
key: "recipeDisableComments", key: "recipeDisableComments",
label: i18n.tc("group.disable-users-from-commenting-on-recipes"), label: i18n.t("group.disable-users-from-commenting-on-recipes"),
description: i18n.tc("group.disable-users-from-commenting-on-recipes-description"), description: i18n.t("group.disable-users-from-commenting-on-recipes-description"),
}, },
{ {
key: "recipeDisableAmount", key: "recipeDisableAmount",
label: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food"), label: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food"),
description: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food-description"), description: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food-description"),
}, },
]; ];
@@ -140,10 +127,10 @@ export default defineComponent({
const preferences = computed({ const preferences = computed({
get() { get() {
return props.value; return props.modelValue;
}, },
set(val) { set(val) {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });

View File

@@ -2,10 +2,10 @@
<v-card class="ma-0" style="overflow-x: auto;"> <v-card class="ma-0" style="overflow-x: auto;">
<v-card-text class="ma-0 pa-0"> <v-card-text class="ma-0 pa-0">
<v-container fluid class="ma-0 pa-0"> <v-container fluid class="ma-0 pa-0">
<draggable <VueDraggable
:value="fields" v-model="fields"
handle=".handle" handle=".handle"
delay="250" :delay="250"
:delay-on-touch-only="true" :delay-on-touch-only="true"
v-bind="{ v-bind="{
animation: 200, animation: 200,
@@ -17,127 +17,142 @@
> >
<v-row <v-row
v-for="(field, index) in fields" v-for="(field, index) in fields"
:key="index" :key="field.id"
class="d-flex flex-nowrap" class="d-flex flex-nowrap"
style="max-width: 100%;" style="max-width: 100%;"
> >
<!-- drag handle -->
<v-col <v-col
:cols="attrs.fields.icon.cols" :cols="config.items.icon.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.icon.style" :style="config.items.icon.style"
> >
<v-icon <v-icon
class="handle" class="handle"
style="width: 100%; height: 100%;" :size="24"
style="cursor: move;margin: auto;"
> >
{{ $globals.icons.arrowUpDown }} {{ $globals.icons.arrowUpDown }}
</v-icon> </v-icon>
</v-col> </v-col>
<!-- and / or -->
<v-col <v-col
:cols="attrs.fields.logicalOperator.cols" :cols="config.items.logicalOperator.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.logicalOperator.style" :style="config.items.logicalOperator.style"
> >
<v-select <v-select
v-if="index" v-if="index"
v-model="field.logicalOperator" :model-value="field.logicalOperator"
:items="[logOps.AND, logOps.OR]" :items="[logOps.AND, logOps.OR]"
item-text="label" item-title="label"
item-value="value" item-value="value"
@input="setLogicalOperatorValue(field, index, $event)" variant="underlined"
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
> >
<template #selection="{ item }"> <template #chip="{ item }">
<span :class="attrs.select.textClass" style="width: 100%;"> <span :class="config.select.textClass" style="width: 100%;">
{{ item.label }} {{ item.raw.label }}
</span> </span>
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- left parenthesis -->
<v-col <v-col
v-if="showAdvanced" v-if="showAdvanced"
:cols="attrs.fields.leftParens.cols" :cols="config.items.leftParens.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.leftParens.style" :style="config.items.leftParens.style"
> >
<v-select <v-select
v-model="field.leftParenthesis" :model-value="field.leftParenthesis"
:items="['', '(', '((', '(((']" :items="['', '(', '((', '(((']"
@input="setLeftParenthesisValue(field, index, $event)" variant="underlined"
@update:model-value="setLeftParenthesisValue(field, index, $event)"
> >
<template #selection="{ item }"> <template #chip="{ item }">
<span :class="attrs.select.textClass" style="width: 100%;"> <span :class="config.select.textClass" style="width: 100%;">
{{ item }} {{ item.raw }}
</span> </span>
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- field name -->
<v-col <v-col
:cols="attrs.fields.fieldName.cols" :cols="config.items.fieldName.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.fieldName.style" :style="config.items.fieldName.style"
> >
<v-select <v-select
v-model="field.label" chips
:model-value="field.label"
:items="fieldDefs" :items="fieldDefs"
item-text="label" variant="underlined"
@change="setField(index, $event)" item-title="label"
@update:model-value="setField(index, $event)"
> >
<template #selection="{ item }"> <template #chip="{ item }">
<span :class="attrs.select.textClass" style="width: 100%;"> <span :class="config.select.textClass" style="width: 100%;">
{{ item.label }} {{ item.raw.label }}
</span> </span>
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- relational operator -->
<v-col <v-col
:cols="attrs.fields.relationalOperator.cols" :cols="config.items.relationalOperator.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.relationalOperator.style" :style="config.items.relationalOperator.style"
> >
<v-select <v-select
v-if="field.type !== 'boolean'" v-if="field.type !== 'boolean'"
v-model="field.relationalOperatorValue" :model-value="field.relationalOperatorValue"
:items="field.relationalOperatorOptions" :items="field.relationalOperatorOptions"
item-text="label" item-title="label"
item-value="value" item-value="value"
@input="setRelationalOperatorValue(field, index, $event)" variant="underlined"
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
> >
<template #selection="{ item }"> <template #chip="{ item }">
<span :class="attrs.select.textClass" style="width: 100%;"> <span :class="config.select.textClass" style="width: 100%;">
{{ item.label }} {{ item.raw.label }}
</span> </span>
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- field value -->
<v-col <v-col
:cols="attrs.fields.fieldValue.cols" :cols="config.items.fieldValue.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.fieldValue.style" :style="config.items.fieldValue.style"
> >
<v-select <v-select
v-if="field.fieldOptions" v-if="field.fieldOptions"
v-model="field.values" :model-value="field.values"
:items="field.fieldOptions" :items="field.fieldOptions"
item-text="label" item-title="label"
item-value="value" item-value="value"
multiple multiple
@input="setFieldValues(field, index, $event)" variant="underlined"
@update:model-value="setFieldValues(field, index, $event)"
/> />
<v-text-field <v-text-field
v-else-if="field.type === 'string'" v-else-if="field.type === 'string'"
v-model="field.value" :model-value="field.value"
@input="setFieldValue(field, index, $event)" variant="underlined"
@update:model-value="setFieldValue(field, index, $event)"
/> />
<v-text-field <v-text-field
v-else-if="field.type === 'number'" v-else-if="field.type === 'number'"
v-model="field.value" :model-value="field.value"
type="number" type="number"
@input="setFieldValue(field, index, $event)" variant="underlined"
@:model-value="setFieldValue(field, index, $event)"
/> />
<v-checkbox <v-checkbox
v-else-if="field.type === 'boolean'" v-else-if="field.type === 'boolean'"
v-model="field.value" :model-value="field.value"
@change="setFieldValue(field, index, $event)" @update:model-value="setFieldValue(field, index, $event!)"
/> />
<v-menu <v-menu
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
@@ -148,137 +163,153 @@
max-width="290px" max-width="290px"
min-width="auto" min-width="auto"
> >
<template #activator="{ on, attrs: menuAttrs }"> <template #activator="{ props }">
<v-text-field <v-text-field
v-model="field.value" v-model="field.value"
persistent-hint persistent-hint
:prepend-icon="$globals.icons.calendar" :prepend-icon="$globals.icons.calendar"
v-bind="menuAttrs" variant="underlined"
color="primary"
v-bind="props"
readonly readonly
v-on="on"
/> />
</template> </template>
<v-date-picker <v-date-picker
v-model="field.value" :model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
no-title hide-header
:first-day-of-week="firstDayOfWeek" :first-day-of-week="firstDayOfWeek"
:local="$i18n.locale" :local="$i18n.locale"
@input="setFieldValue(field, index, $event)" @update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
/> />
</v-menu> </v-menu>
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Category" v-else-if="field.type === Organizer.Category"
v-model="field.organizers" :model-value="field.organizers"
:selector-type="Organizer.Category" :selector-type="Organizer.Category"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
@input="setOrganizerValues(field, index, $event)" variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tag" v-else-if="field.type === Organizer.Tag"
v-model="field.organizers" :model-value="field.organizers"
:selector-type="Organizer.Tag" :selector-type="Organizer.Tag"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
@input="setOrganizerValues(field, index, $event)" variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tool" v-else-if="field.type === Organizer.Tool"
v-model="field.organizers" :model-value="field.organizers"
:selector-type="Organizer.Tool" :selector-type="Organizer.Tool"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
@input="setOrganizerValues(field, index, $event)" variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Food" v-else-if="field.type === Organizer.Food"
v-model="field.organizers" :model-value="field.organizers"
:selector-type="Organizer.Food" :selector-type="Organizer.Food"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
@input="setOrganizerValues(field, index, $event)" variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Household" v-else-if="field.type === Organizer.Household"
v-model="field.organizers" :model-value="field.organizers"
:selector-type="Organizer.Household" :selector-type="Organizer.Household"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
@input="setOrganizerValues(field, index, $event)" variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
/> />
</v-col> </v-col>
<!-- right parenthesis -->
<v-col <v-col
v-if="showAdvanced" v-if="showAdvanced"
:cols="attrs.fields.rightParens.cols" :cols="config.items.rightParens.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.rightParens.style" :style="config.items.rightParens.style"
> >
<v-select <v-select
v-model="field.rightParenthesis" :model-value="field.rightParenthesis"
:items="['', ')', '))', ')))']" :items="['', ')', '))', ')))']"
@input="setRightParenthesisValue(field, index, $event)" variant="underlined"
@update:model-value="setRightParenthesisValue(field, index, $event)"
> >
<template #selection="{ item }"> <template #chip="{ item }">
<span :class="attrs.select.textClass" style="width: 100%;"> <span :class="config.select.textClass" style="width: 100%;">
{{ item }} {{ item.raw }}
</span> </span>
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- field actions -->
<v-col <v-col
:cols="attrs.fields.fieldActions.cols" :cols="config.items.fieldActions.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.fieldActions.style" :style="config.items.fieldActions.style"
> >
<BaseButtonGroup <BaseButtonGroup
:buttons="[ :buttons="[
{ {
icon: $globals.icons.delete, icon: $globals.icons.delete,
text: $tc('general.delete'), text: $t('general.delete'),
event: 'delete', event: 'delete',
disabled: fields.length === 1, disabled: fields.length === 1,
} },
]" ]"
class="my-auto" class="my-auto"
@delete="removeField(index)" @delete="removeField(index)"
/> />
</v-col> </v-col>
</v-row> </v-row>
</draggable> </VueDraggable>
</v-container> </v-container>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-container fluid class="d-flex justify-end pa-0 mx-2"> <v-row fluid class="d-flex justify-end pa-0 mx-2">
<v-spacer />
<v-checkbox <v-checkbox
v-model="showAdvanced" v-model="showAdvanced"
hide-details hide-details
:label="$tc('general.show-advanced')" :label="$t('general.show-advanced')"
class="my-auto mr-4" class="my-auto mr-4"
color="primary"
/> />
<BaseButton create :text="$tc('general.add-field')" @click="addField(fieldDefs[0])" /> <BaseButton
</v-container> create
:text="$t('general.add-field')"
class="my-auto"
@click="addField(fieldDefs[0])"
/>
</v-row>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</template> </template>
<script lang="ts"> <script lang="ts">
import draggable from "vuedraggable"; import { VueDraggable } from "vue-draggable-plus";
import { computed, defineComponent, reactive, ref, toRefs, watch } from "@nuxtjs/composition-api"; import { useDebounceFn } from "@vueuse/core";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue"; import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { Organizer } from "~/lib/api/types/non-generated"; import { Organizer } from "~/lib/api/types/non-generated";
import { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response"; import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store"; import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { Field, FieldDefinition, FieldValue, OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder"; import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
draggable, VueDraggable,
RecipeOrganizerSelector, RecipeOrganizerSelector,
}, },
props: { props: {
@@ -289,8 +320,9 @@ export default defineComponent({
initialQueryFilter: { initialQueryFilter: {
type: Object as () => QueryFilterJSON | null, type: Object as () => QueryFilterJSON | null,
default: null, default: null,
}
}, },
},
emits: ["input", "inputJSON"],
setup(props, context) { setup(props, context) {
const { household } = useHouseholdSelf(); const { household } = useHouseholdSelf();
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder(); const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
@@ -321,21 +353,27 @@ export default defineComponent({
const newIndex: number = event.newIndex; const newIndex: number = event.newIndex;
state.datePickers[oldIndex] = false; state.datePickers[oldIndex] = false;
state.datePickers[newIndex] = false; state.datePickers[newIndex] = false;
const field = fields.value.splice(oldIndex, 1)[0];
fields.value.splice(newIndex, 0, field);
} }
const fields = ref<Field[]>([]); // add id to fields to prevent reactivity issues
type FieldWithId = Field & { id: number };
const fields = ref<FieldWithId[]>([]);
const uid = ref(1); // init uid to pass to fields
function useUid() {
return uid.value++;
}
function addField(field: FieldDefinition) { function addField(field: FieldDefinition) {
fields.value.push(getFieldFromFieldDef(field)); fields.value.push({
...getFieldFromFieldDef(field),
id: useUid(),
});
state.datePickers.push(false); state.datePickers.push(false);
}; };
function setField(index: number, fieldLabel: string) { function setField(index: number, fieldLabel: string) {
state.datePickers[index] = false; state.datePickers[index] = false;
const fieldDef = props.fieldDefs.find((fieldDef) => fieldDef.label === fieldLabel); const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.label === fieldLabel);
if (!fieldDef) { if (!fieldDef) {
return; return;
} }
@@ -346,58 +384,44 @@ export default defineComponent({
// we have to set this explicitly since it might be undefined // we have to set this explicitly since it might be undefined
updatedField.fieldOptions = fieldDef.fieldOptions; updatedField.fieldOptions = fieldDef.fieldOptions;
fields.value.splice(index, 1, getFieldFromFieldDef(updatedField, resetValue)); fields.value[index] = {
...getFieldFromFieldDef(updatedField, resetValue),
id: fields.value[index].id, // keep the id
};
} }
function setLeftParenthesisValue(field: Field, index: number, value: string) { function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
fields.value.splice(index, 1, { fields.value[index].leftParenthesis = value;
...field,
leftParenthesis: value,
});
} }
function setRightParenthesisValue(field: Field, index: number, value: string) { function setRightParenthesisValue(field: FieldWithId, index: number, value: string) {
fields.value.splice(index, 1, { fields.value[index].rightParenthesis = value;
...field,
rightParenthesis: value,
});
} }
function setLogicalOperatorValue(field: Field, index: number, value: LogicalOperator | undefined) { function setLogicalOperatorValue(field: FieldWithId, index: number, value: LogicalOperator | undefined) {
if (!value) { if (!value) {
value = logOps.value.AND.value; value = logOps.value.AND.value;
} }
fields.value.splice(index, 1, { fields.value[index].logicalOperator = value ? logOps.value[value] : undefined;
...field,
logicalOperator: value ? logOps.value[value] : undefined,
});
} }
function setRelationalOperatorValue(field: Field, index: number, value: RelationalKeyword | RelationalOperator) { function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
fields.value.splice(index, 1, { fields.value[index].relationalOperatorValue = relOps.value[value];
...field,
relationalOperatorValue: relOps.value[value],
});
} }
function setFieldValue(field: Field, index: number, value: FieldValue) { function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
state.datePickers[index] = false; state.datePickers[index] = false;
fields.value.splice(index, 1, { fields.value[index].value = value;
...field,
value,
});
} }
function setFieldValues(field: Field, index: number, values: FieldValue[]) { function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
fields.value.splice(index, 1, { fields.value[index].values = values;
...field,
values,
});
} }
function setOrganizerValues(field: Field, index: number, values: OrganizerBase[]) { function setOrganizerValues(field: FieldWithId, index: number, values: OrganizerBase[]) {
setFieldValues(field, index, values.map((value) => value.id.toString())); setFieldValues(field, index, values.map(value => value.id.toString()));
fields.value[index].organizers = values;
} }
function removeField(index: number) { function removeField(index: number) {
@@ -405,24 +429,11 @@ export default defineComponent({
state.datePickers.splice(index, 1); state.datePickers.splice(index, 1);
}; };
watch( const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
// Toggling showAdvanced changes the builder logic without changing the field values, /* newFields.forEach((field, index) => {
// so we need to manually trigger reactivity to re-run the builder.
() => state.showAdvanced,
() => {
if (fields.value?.length) {
fields.value = [...fields.value];
}
},
)
watch(
() => fields.value,
(newFields) => {
newFields.forEach((field, index) => {
const updatedField = getFieldFromFieldDef(field); const updatedField = getFieldFromFieldDef(field);
fields.value[index] = updatedField; fields.value[index] = updatedField; // recursive!!!
}); }); */
const qf = buildQueryFilterString(fields.value, state.showAdvanced); const qf = buildQueryFilterString(fields.value, state.showAdvanced);
if (qf) { if (qf) {
@@ -432,13 +443,11 @@ export default defineComponent({
context.emit("input", qf || undefined); context.emit("input", qf || undefined);
context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined); context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
}, }, 500);
{
deep: true
},
);
async function hydrateOrganizers(field: Field, index: number) { watch(fields, fieldsUpdater, { deep: true });
async function hydrateOrganizers(field: FieldWithId, index: number) {
if (!field.values?.length || !isOrganizerType(field.type)) { if (!field.values?.length || !isOrganizerType(field.type)) {
return; return;
} }
@@ -450,9 +459,15 @@ export default defineComponent({
await actions.refresh(); await actions.refresh();
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-return const organizers = field.values.map((value) => {
const organizers = field.values.map((value) => store.value.find((organizer) => organizer.id === value)); const organizer = store.value.find(item => item?.id?.toString() === value);
field.organizers = organizers.filter((organizer) => organizer !== undefined) as OrganizerBase[]; if (!organizer) {
console.error(`Could not find organizer with id ${value}`);
return undefined;
}
return organizer;
});
field.organizers = organizers.filter(organizer => organizer !== undefined) as OrganizerBase[];
setOrganizerValues(field, index, field.organizers); setOrganizerValues(field, index, field.organizers);
} }
@@ -472,22 +487,27 @@ export default defineComponent({
return initFieldsError(); return initFieldsError();
}; };
const initFields: Field[] = []; const initFields: FieldWithId[] = [];
let error = false; let error = false;
props.initialQueryFilter.parts.forEach((part: QueryFilterJSONPart, index: number) => { props.initialQueryFilter.parts.forEach((part: QueryFilterJSONPart, index: number) => {
const fieldDef = props.fieldDefs.find((fieldDef) => fieldDef.name === part.attributeName); const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.name === part.attributeName);
if (!fieldDef) { if (!fieldDef) {
error = true; error = true;
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`); return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
} }
const field = getFieldFromFieldDef(fieldDef); const field: FieldWithId = {
...getFieldFromFieldDef(fieldDef),
id: useUid(),
};
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis; field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis; field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
field.logicalOperator = part.logicalOperator ? field.logicalOperator = part.logicalOperator
logOps.value[part.logicalOperator] : field.logicalOperator; ? logOps.value[part.logicalOperator]
field.relationalOperatorValue = part.relationalOperator ? : field.logicalOperator;
relOps.value[part.relationalOperator] : field.relationalOperatorValue; field.relationalOperatorValue = part.relationalOperator
? relOps.value[part.relationalOperator]
: field.relationalOperatorValue;
if (field.leftParenthesis || field.rightParenthesis) { if (field.leftParenthesis || field.rightParenthesis) {
state.showAdvanced = true; state.showAdvanced = true;
@@ -496,35 +516,39 @@ export default defineComponent({
if (field.fieldOptions?.length || isOrganizerType(field.type)) { if (field.fieldOptions?.length || isOrganizerType(field.type)) {
if (typeof part.value === "string") { if (typeof part.value === "string") {
field.values = part.value ? [part.value] : []; field.values = part.value ? [part.value] : [];
} else { }
else {
field.values = part.value || []; field.values = part.value || [];
} }
if (isOrganizerType(field.type)) { if (isOrganizerType(field.type)) {
hydrateOrganizers(field, index); hydrateOrganizers(field, index);
} }
}
} else if (field.type === "boolean") { else if (field.type === "boolean") {
const boolString = part.value || "false"; const boolString = part.value || "false";
field.value = ( field.value = (
boolString[0].toLowerCase() === "t" || boolString[0].toLowerCase() === "t"
boolString[0].toLowerCase() === "y" || || boolString[0].toLowerCase() === "y"
boolString[0] === "1" || boolString[0] === "1"
); );
} else if (field.type === "number") { }
else if (field.type === "number") {
field.value = Number(part.value as string || "0"); field.value = Number(part.value as string || "0");
if (isNaN(field.value)) { if (isNaN(field.value)) {
error = true; error = true;
return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`); return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`);
} }
} else if (field.type === "date") { }
else if (field.type === "date") {
field.value = part.value as string || ""; field.value = part.value as string || "";
const date = new Date(field.value); const date = new Date(field.value);
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {
error = true; error = true;
return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`); return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`);
} }
} else { }
else {
field.value = part.value as string || ""; field.value = part.value as string || "";
} }
@@ -533,14 +557,16 @@ export default defineComponent({
if (initFields.length && !error) { if (initFields.length && !error) {
fields.value = initFields; fields.value = initFields;
} else { }
else {
initFieldsError(); initFieldsError();
} }
}; };
try { try {
initializeFields(); initializeFields();
} catch (error) { }
catch (error) {
initFieldsError(`Error initializing fields: ${(error || "").toString()}`); initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
} }
@@ -555,10 +581,12 @@ export default defineComponent({
}; };
if (field.fieldOptions?.length || isOrganizerType(field.type)) { if (field.fieldOptions?.length || isOrganizerType(field.type)) {
part.value = field.values.map((value) => value.toString()); part.value = field.values.map(value => value.toString());
} else if (field.type === "boolean") { }
else if (field.type === "boolean") {
part.value = field.value ? "true" : "false"; part.value = field.value ? "true" : "false";
} else { }
else {
part.value = (field.value || "").toString(); part.value = (field.value || "").toString();
} }
@@ -570,17 +598,16 @@ export default defineComponent({
return qfJSON; return qfJSON;
} }
const config = computed(() => {
const attrs = computed(() => {
const baseColMaxWidth = 55; const baseColMaxWidth = 55;
const attrs = { return {
col: { col: {
class: "d-flex justify-center align-end field-col pa-1", class: "d-flex justify-center align-end field-col pa-1",
}, },
select: { select: {
textClass: "d-flex justify-center text-center", textClass: "d-flex justify-center text-center",
}, },
fields: { items: {
icon: { icon: {
cols: 1, cols: 1,
style: "width: fit-content;", style: "width: fit-content;",
@@ -614,17 +641,15 @@ export default defineComponent({
style: `min-width: ${baseColMaxWidth}px;`, style: `min-width: ${baseColMaxWidth}px;`,
}, },
}, },
} };
});
return attrs;
})
return { return {
Organizer, Organizer,
...toRefs(state), ...toRefs(state),
logOps, logOps,
relOps, relOps,
attrs, config,
firstDayOfWeek, firstDayOfWeek,
onDragEnd, onDragEnd,
// Fields // Fields

View File

@@ -1,33 +1,37 @@
<template> <template>
<v-toolbar <v-toolbar
rounded
height="0"
class="fixed-bar mt-0" class="fixed-bar mt-0"
color="rgb(255, 0, 0, 0.0)" style="z-index: 2; position: sticky; background: transparent; box-shadow: none;"
flat density="compact"
style="z-index: 2; position: sticky" elevation="0"
>
<BaseDialog
v-model="deleteDialog"
:title="$tc('recipe.delete-recipe')"
color="error"
:icon="$globals.icons.alertCircle"
@confirm="emitDelete()"
> >
<BaseDialog v-model="deleteDialog" :title="$t('recipe.delete-recipe')" color="error"
:icon="$globals.icons.alertCircle" can-confirm @confirm="emitDelete()">
<v-card-text> <v-card-text>
{{ $t("recipe.delete-confirmation") }} {{ $t("recipe.delete-confirmation") }}
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<v-spacer></v-spacer> <v-spacer />
<div v-if="!open" class="custom-btn-group ma-1"> <div v-if="!open" class="custom-btn-group ma-1">
<RecipeFavoriteBadge v-if="loggedIn" class="ml-1" color="info" button-style :recipe-id="recipe.id" show-always /> <RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always />
<RecipeTimelineBadge v-if="loggedIn" button-style class="ml-1" :slug="recipe.slug" :recipe-name="recipe.name" /> <RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" />
<div v-if="loggedIn"> <div v-if="loggedIn">
<v-tooltip v-if="canEdit" bottom color="info"> <v-tooltip v-if="canEdit" bottom color="info">
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-btn fab small class="ml-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)"> <v-btn
<v-icon> {{ $globals.icons.edit }} </v-icon> icon
variant="flat"
rounded="circle"
size="small"
color="info"
class="ml-1"
v-bind="props"
@click="$emit('edit', true)"
>
<v-icon size="x-large">
{{ $globals.icons.edit }}
</v-icon>
</v-btn> </v-btn>
</template> </template>
<span>{{ $t("general.edit") }}</span> <span>{{ $t("general.edit") }}</span>
@@ -37,14 +41,14 @@
<RecipeContextMenu <RecipeContextMenu
show-print show-print
:menu-top="false" :menu-top="false"
:name="recipe.name" :name="recipe.name!"
:slug="recipe.slug" :slug="recipe.slug!"
:menu-icon="$globals.icons.dotsVertical" :menu-icon="$globals.icons.dotsVertical"
fab fab
color="info" color="info"
:card-menu="false" :card-menu="false"
:recipe="recipe" :recipe="recipe"
:recipe-id="recipe.id" :recipe-id="recipe.id!"
:recipe-scale="recipeScale" :recipe-scale="recipeScale"
:use-items="{ :use-items="{
edit: false, edit: false,
@@ -66,31 +70,34 @@
<v-btn <v-btn
v-for="(btn, index) in editorButtons" v-for="(btn, index) in editorButtons"
:key="index" :key="index"
:fab="$vuetify.breakpoint.xs" :class="{ 'rounded-circle': $vuetify.display.xs }"
:small="$vuetify.breakpoint.xs" :size="$vuetify.display.xs ? 'small' : undefined"
:color="btn.color" :color="btn.color"
variant="elevated"
:icon="$vuetify.display.xs"
@click="emitHandler(btn.event)" @click="emitHandler(btn.event)"
> >
<v-icon :left="!$vuetify.breakpoint.xs">{{ btn.icon }}</v-icon> <v-icon :left="!$vuetify.display.xs">
{{ $vuetify.breakpoint.xs ? "" : btn.text }} {{ btn.icon }}
</v-icon>
{{ $vuetify.display.xs ? "" : btn.text }}
</v-btn> </v-btn>
</div> </div>
</v-toolbar> </v-toolbar>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import RecipeContextMenu from "./RecipeContextMenu.vue"; import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue"; import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue"; import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
const SAVE_EVENT = "save"; const SAVE_EVENT = "save";
const DELETE_EVENT = "delete"; const DELETE_EVENT = "delete";
const CLOSE_EVENT = "close"; const CLOSE_EVENT = "close";
const JSON_EVENT = "json"; const JSON_EVENT = "json";
export default defineComponent({ export default defineNuxtComponent({
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge }, components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
props: { props: {
recipe: { recipe: {
@@ -126,10 +133,12 @@ export default defineComponent({
default: false, default: false,
}, },
}, },
emits: ["print", "input", "delete", "close", "edit"],
setup(_, context) { setup(_, context) {
const deleteDialog = ref(false); const deleteDialog = ref(false);
const { i18n, $globals } = useContext(); const i18n = useI18n();
const { $globals } = useNuxtApp();
const editorButtons = [ const editorButtons = [
{ {
text: i18n.t("general.delete"), text: i18n.t("general.delete"),
@@ -209,9 +218,13 @@ export default defineComponent({
.fixed-bar { .fixed-bar {
position: sticky; position: sticky;
position: -webkit-sticky; /* for Safari */
top: 4.5em; top: 4.5em;
z-index: 2; z-index: 2;
background: transparent !important;
box-shadow: none !important;
min-height: 0 !important;
height: 48px;
padding: 0 8px;
} }
.fixed-bar-mobile { .fixed-bar-mobile {

View File

@@ -1,74 +1,110 @@
<template> <template>
<div v-if="value.length > 0 || edit"> <div v-if="model.length > 0 || edit">
<v-card class="mt-4"> <v-card class="mt-4">
<v-card-title class="py-2"> <v-card-title class="py-2">
{{ $t("asset.assets") }} {{ $t("asset.assets") }}
</v-card-title> </v-card-title>
<v-divider class="mx-2"></v-divider> <v-divider class="mx-2" />
<v-list v-if="value.length > 0" :flat="!edit"> <v-list
<v-list-item v-for="(item, i) in value" :key="i"> v-if="model.length > 0"
<v-list-item-icon class="ma-auto"> :flat="!edit"
>
<v-list-item
v-for="(item, i) in model"
:key="i"
>
<template #prepend>
<div class="ma-auto">
<v-tooltip bottom> <v-tooltip bottom>
<template #activator="{ on, attrs }"> <template #activator="{ props: tooltipProps }">
<v-icon v-bind="attrs" v-on="on"> <v-icon v-bind="tooltipProps">
{{ getIconDefinition(item.icon).icon }} {{ getIconDefinition(item.icon).icon }}
</v-icon> </v-icon>
</template> </template>
<span>{{ getIconDefinition(item.icon).title }}</span> <span>{{ getIconDefinition(item.icon).title }}</span>
</v-tooltip> </v-tooltip>
</v-list-item-icon> </div>
<v-list-item-content> </template>
<v-list-item-title class="pl-2"> <v-list-item-title class="pl-2">
{{ item.name }} {{ item.name }}
</v-list-item-title> </v-list-item-title>
</v-list-item-content>
<v-list-item-action> <v-list-item-action>
<v-btn v-if="!edit" color="primary" icon :href="assetURL(item.fileName)" target="_blank" top> <v-btn
v-if="!edit"
color="primary"
icon
:href="assetURL(item.fileName ?? '')"
target="_blank"
top
>
<v-icon> {{ $globals.icons.download }} </v-icon> <v-icon> {{ $globals.icons.download }} </v-icon>
</v-btn> </v-btn>
<div v-else> <div v-else>
<v-btn color="error" icon top @click="value.splice(i, 1)"> <v-btn
color="error"
icon
top
@click="model.splice(i, 1)"
>
<v-icon>{{ $globals.icons.delete }}</v-icon> <v-icon>{{ $globals.icons.delete }}</v-icon>
</v-btn> </v-btn>
<AppButtonCopy color="" :copy-text="assetEmbed(item.fileName)" /> <AppButtonCopy
color=""
:copy-text="assetEmbed(item.fileName ?? '')"
/>
</div> </div>
</v-list-item-action> </v-list-item-action>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-card> </v-card>
<div class="d-flex ml-auto mt-2"> <div class="d-flex ml-auto mt-2">
<v-spacer></v-spacer> <v-spacer />
<BaseDialog <BaseDialog
v-model="state.newAssetDialog" v-model="state.newAssetDialog"
:title="$tc('asset.new-asset')" :title="$t('asset.new-asset')"
:icon="getIconDefinition(state.newAsset.icon).icon" :icon="getIconDefinition(state.newAsset.icon).icon"
can-submit
@submit="addAsset" @submit="addAsset"
> >
<template #activator> <template #activator>
<BaseButton v-if="edit" small create @click="state.newAssetDialog = true" /> <BaseButton
v-if="edit"
size="small"
create
@click="state.newAssetDialog = true"
/>
</template> </template>
<v-card-text class="pt-4"> <v-card-text class="pt-4">
<v-text-field v-model="state.newAsset.name" dense :label="$t('general.name')"></v-text-field> <v-text-field
v-model="state.newAsset.name"
density="compact"
:label="$t('general.name')"
/>
<div class="d-flex justify-space-between"> <div class="d-flex justify-space-between">
<v-select <v-select
v-model="state.newAsset.icon" v-model="state.newAsset.icon"
dense density="compact"
:prepend-icon="getIconDefinition(state.newAsset.icon).icon" :prepend-icon="getIconDefinition(state.newAsset.icon).icon"
:items="iconOptions" :items="iconOptions"
item-text="title" item-title="title"
item-value="name" item-value="name"
class="mr-2" class="mr-2"
> >
<template #item="{ item }"> <template #item="{ item }">
<v-list-item-avatar> <v-avatar>
<v-icon class="mr-auto"> <v-icon class="mr-auto">
{{ item.icon }} {{ item.raw.icon }}
</v-icon> </v-icon>
</v-list-item-avatar> </v-avatar>
{{ item.title }} {{ item.title }}
</template> </template>
</v-select> </v-select>
<AppButtonUpload :post="false" file-name="file" :text-btn="false" @uploaded="setFileObject" /> <AppButtonUpload
:post="false"
file-name="file"
:text-btn="false"
@uploaded="setFileObject"
/>
</div> </div>
{{ state.fileObject.name }} {{ state.fileObject.name }}
</v-card-text> </v-card-text>
@@ -77,15 +113,12 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent, reactive, useContext } from "@nuxtjs/composition-api";
import { useStaticRoutes, useUserApi } from "~/composables/api"; import { useStaticRoutes, useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { detectServerBaseUrl } from "~/composables/use-utils"; import type { RecipeAsset } from "~/lib/api/types/recipe";
import { RecipeAsset } from "~/lib/api/types/recipe";
export default defineComponent({ const props = defineProps({
props: {
slug: { slug: {
type: String, type: String,
required: true, required: true,
@@ -94,16 +127,14 @@ export default defineComponent({
type: String, type: String,
required: true, required: true,
}, },
value: {
type: Array as () => RecipeAsset[],
required: true,
},
edit: { edit: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}, });
setup(props, context) {
const model = defineModel<RecipeAsset[]>({ required: true });
const api = useUserApi(); const api = useUserApi();
const state = reactive({ const state = reactive({
@@ -115,7 +146,8 @@ export default defineComponent({
}, },
}); });
const { $globals, i18n, req } = useContext(); const i18n = useI18n();
const { $globals } = useNuxtApp();
const iconOptions = [ const iconOptions = [
{ {
@@ -145,10 +177,10 @@ export default defineComponent({
}, },
]; ];
const serverBase = detectServerBaseUrl(req); const serverBase = useRequestURL().origin;
function getIconDefinition(icon: string) { function getIconDefinition(icon: string) {
return iconOptions.find((item) => item.name === icon) || iconOptions[0]; return iconOptions.find(item => item.name === icon) || iconOptions[0];
} }
const { recipeAssetPath } = useStaticRoutes(); const { recipeAssetPath } = useStaticRoutes();
@@ -180,21 +212,10 @@ export default defineComponent({
file: state.fileObject, file: state.fileObject,
extension: state.fileObject.name.split(".").pop() || "", extension: state.fileObject.name.split(".").pop() || "",
}); });
if (data) {
context.emit("input", [...props.value, data]); model.value = [...model.value, data];
}
state.newAsset = { name: "", icon: "mdi-file" }; state.newAsset = { name: "", icon: "mdi-file" };
state.fileObject = {} as File; state.fileObject = {} as File;
} }
return {
state,
addAsset,
assetURL,
assetEmbed,
getIconDefinition,
iconOptions,
setFileObject,
};
},
});
</script> </script>

View File

@@ -1,10 +1,16 @@
<template> <template>
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
<v-lazy> <v-lazy>
<v-hover v-slot="{ hover }" :open-delay="50"> <div>
<v-hover
v-slot="{ isHovering, props }"
:open-delay="50"
>
<v-card <v-card
:class="{ 'on-hover': hover }" v-bind="props"
:class="{ 'on-hover': isHovering }"
:style="{ cursor }" :style="{ cursor }"
:elevation="hover ? 12 : 2" :elevation="isHovering ? 12 : 2"
:to="recipeRoute" :to="recipeRoute"
:min-height="imageHeight + 75" :min-height="imageHeight + 75"
@click.self="$emit('click')" @click.self="$emit('click')"
@@ -14,11 +20,15 @@
:height="imageHeight" :height="imageHeight"
:slug="slug" :slug="slug"
:recipe-id="recipeId" :recipe-id="recipeId"
small size="small"
:image-version="image" :image-version="image"
> >
<v-expand-transition v-if="description"> <v-expand-transition v-if="description">
<div v-if="hover" class="d-flex transition-fast-in-fast-out secondary v-card--reveal" style="height: 100%"> <div
v-if="isHovering"
class="d-flex transition-fast-in-fast-out bg-secondary v-card--reveal"
style="height: 100%"
>
<v-card-text class="v-card--text-show white--text"> <v-card-text class="v-card--text-show white--text">
<div class="descriptionWrapper"> <div class="descriptionWrapper">
<SafeMarkdown :source="description" /> <SafeMarkdown :source="description" />
@@ -27,24 +37,47 @@
</div> </div>
</v-expand-transition> </v-expand-transition>
</RecipeCardImage> </RecipeCardImage>
<v-card-title class="my-n3 px-2 mb-n6"> <v-card-title class="mb-n3 px-4">
<div class="headerClass"> <div class="headerClass">
{{ name }} {{ name }}
</div> </div>
</v-card-title> </v-card-title>
<slot name="actions"> <slot name="actions">
<v-card-actions v-if="showRecipeContent" class="px-1"> <v-card-actions
<RecipeFavoriteBadge v-if="isOwnGroup" class="absolute" :recipe-id="recipeId" show-always /> v-if="showRecipeContent"
class="px-1"
>
<RecipeFavoriteBadge
v-if="isOwnGroup"
class="absolute"
:recipe-id="recipeId"
show-always
/>
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
<RecipeRating class="pb-1" :value="rating" :recipe-id="recipeId" :slug="slug" :small="true" /> <RecipeRating
<v-spacer></v-spacer> class="ml-n2"
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" v-on="$listeners" /> :value="rating"
:recipe-id="recipeId"
:slug="slug"
small
/>
<v-spacer />
<RecipeChips
:truncate="true"
:items="tags"
:title="false"
:limit="2"
small
url-prefix="tags"
v-bind="$attrs"
/>
<!-- If we're not logged-in, no items display, so we hide this menu --> <!-- If we're not logged-in, no items display, so we hide this menu -->
<RecipeContextMenu <RecipeContextMenu
v-if="isOwnGroup" v-if="isOwnGroup"
color="grey darken-2" color="grey-darken-2"
:slug="slug" :slug="slug"
:name="name" :name="name"
:recipe-id="recipeId" :recipe-id="recipeId"
@@ -62,14 +95,14 @@
/> />
</v-card-actions> </v-card-actions>
</slot> </slot>
<slot></slot> <slot />
</v-card> </v-card>
</v-hover> </v-hover>
</div>
</v-lazy> </v-lazy>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue"; import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeChips from "./RecipeChips.vue"; import RecipeChips from "./RecipeChips.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue"; import RecipeContextMenu from "./RecipeContextMenu.vue";
@@ -77,7 +110,7 @@ import RecipeCardImage from "./RecipeCardImage.vue";
import RecipeRating from "./RecipeRating.vue"; import RecipeRating from "./RecipeRating.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
export default defineComponent({ export default defineNuxtComponent({
components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage }, components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage },
props: { props: {
name: { name: {
@@ -119,12 +152,13 @@ export default defineComponent({
default: 200, default: 200,
}, },
}, },
emits: ["click", "delete"],
setup(props) { setup(props) {
const { $auth } = useContext(); const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug); const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => { const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : ""; return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";

View File

@@ -2,6 +2,7 @@
<v-img <v-img
v-if="!fallBackImage" v-if="!fallBackImage"
:height="height" :height="height"
cover
min-height="125" min-height="125"
max-height="fill-height" max-height="fill-height"
:src="getImage(recipeId)" :src="getImage(recipeId)"
@@ -9,21 +10,28 @@
@load="fallBackImage = false" @load="fallBackImage = false"
@error="fallBackImage = true" @error="fallBackImage = true"
> >
<slot> </slot> <slot />
</v-img> </v-img>
<div v-else class="icon-slot" @click="$emit('click')"> <div
<v-icon color="primary" class="icon-position" :size="iconSize"> v-else
class="icon-slot"
@click="$emit('click')"
>
<v-icon
color="primary"
class="icon-position"
:size="iconSize"
>
{{ $globals.icons.primary }} {{ $globals.icons.primary }}
</v-icon> </v-icon>
<slot> </slot> <slot />
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref, watch } from "@nuxtjs/composition-api";
import { useStaticRoutes, useUserApi } from "~/composables/api"; import { useStaticRoutes, useUserApi } from "~/composables/api";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
tiny: { tiny: {
type: Boolean, type: Boolean,
@@ -55,9 +63,10 @@ export default defineComponent({
}, },
height: { height: {
type: [Number, String], type: [Number, String],
default: "fill-height", default: "100%",
}, },
}, },
emits: ["click"],
setup(props) { setup(props) {
const api = useUserApi(); const api = useUserApi();
@@ -75,7 +84,7 @@ export default defineComponent({
() => props.recipeId, () => props.recipeId,
() => { () => {
fallBackImage.value = false; fallBackImage.value = false;
} },
); );
function getImage(recipeId: string) { function getImage(recipeId: string) {

View File

@@ -1,57 +1,97 @@
<template> <template>
<div :style="`height: ${height}`"> <div :style="`height: ${height}px;`">
<v-expand-transition> <v-expand-transition>
<v-card <v-card
:ripple="false" :ripple="false"
:class="isFlat ? 'mx-auto flat' : 'mx-auto'" :class="isFlat ? 'mx-auto flat' : 'mx-auto'"
:style="{ cursor }" :style="{ cursor }"
hover hover
:to="$listeners.selected ? undefined : recipeRoute" height="100%"
:to="$attrs.selected ? undefined : recipeRoute"
@click="$emit('selected')" @click="$emit('selected')"
> >
<v-img v-if="vertical" class="rounded-sm"> <v-img
v-if="vertical"
class="rounded-sm"
cover
>
<RecipeCardImage <RecipeCardImage
:icon-size="100" :icon-size="100"
:height="height"
:slug="slug" :slug="slug"
:recipe-id="recipeId" :recipe-id="recipeId"
small size="small"
:image-version="image" :image-version="image"
:height="height"
/> />
</v-img> </v-img>
<v-list-item three-line :class="vertical ? 'px-2' : 'px-0'"> <v-list-item
<slot v-if="!vertical" name="avatar"> lines="two"
<v-list-item-avatar tile :height="height" width="125" class="v-mobile-img rounded-sm my-0"> class="py-0"
:class="vertical ? 'px-2' : 'px-0'"
item-props
height="100%"
density="compact"
>
<template #prepend>
<slot
v-if="!vertical"
name="avatar"
>
<RecipeCardImage <RecipeCardImage
:icon-size="100" :icon-size="100"
:height="height"
:slug="slug" :slug="slug"
:recipe-id="recipeId" :recipe-id="recipeId"
:image-version="image" :image-version="image"
small size="small"
width="125"
:height="height"
/> />
</v-list-item-avatar>
</slot> </slot>
<v-list-item-content class="py-0"> </template>
<v-list-item-title class="mt-1 mb-1 text-top">{{ name }}</v-list-item-title> <div class="pl-4 d-flex flex-column justify-space-between align-stretch pr-2">
<v-list-item-title class="mt-3 mb-1 text-top text-truncate w-100">
{{ name }}
</v-list-item-title>
<v-list-item-subtitle class="ma-0 text-top"> <v-list-item-subtitle class="ma-0 text-top">
<SafeMarkdown :source="description" /> <SafeMarkdown v-if="description" :source="description" />
<p v-else>
<br>
<br>
<br>
</p>
</v-list-item-subtitle> </v-list-item-subtitle>
<div class="d-flex flex-wrap justify-start ma-0"> <div
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" v-on="$listeners" /> class="d-flex flex-nowrap justify-start ma-0 pt-2 pb-0"
style="overflow-x: hidden; overflow-y: hidden; white-space: nowrap;"
>
<RecipeChips
:truncate="true"
:items="tags"
:title="false"
:limit="2"
small
url-prefix="tags"
v-bind="$attrs"
/>
</div>
</div> </div>
<div class="d-flex flex-wrap justify-end align-center">
<slot name="actions"> <slot name="actions">
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :recipe-id="recipeId" show-always /> <v-card-actions class="w-100 my-0 px-1 py-0">
<RecipeFavoriteBadge
v-if="isOwnGroup && showRecipeContent"
:recipe-id="recipeId"
show-always
class="ma-0 pa-0"
/>
<div v-else class="my-0 px-1 py-0" /> <!-- Empty div to keep the layout consistent -->
<RecipeRating <RecipeRating
v-if="showRecipeContent" v-if="showRecipeContent"
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'" :class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']"
:value="rating" :value="rating"
:recipe-id="recipeId" :recipe-id="recipeId"
:slug="slug" :slug="slug"
:small="true" small
/> />
<v-spacer></v-spacer>
<!-- If we're not logged-in, no items display, so we hide this menu --> <!-- If we're not logged-in, no items display, so we hide this menu -->
<!-- We also add padding to the v-rating above to compensate --> <!-- We also add padding to the v-rating above to compensate -->
@@ -61,6 +101,7 @@
:menu-icon="$globals.icons.dotsHorizontal" :menu-icon="$globals.icons.dotsHorizontal"
:name="name" :name="name"
:recipe-id="recipeId" :recipe-id="recipeId"
class="ml-auto"
:use-items="{ :use-items="{
delete: false, delete: false,
edit: false, edit: false,
@@ -73,9 +114,8 @@
}" }"
@deleted="$emit('delete', slug)" @deleted="$emit('delete', slug)"
/> />
</v-card-actions>
</slot> </slot>
</div>
</v-list-item-content>
</v-list-item> </v-list-item>
<slot /> <slot />
</v-card> </v-card>
@@ -84,7 +124,6 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue"; import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue"; import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeCardImage from "./RecipeCardImage.vue"; import RecipeCardImage from "./RecipeCardImage.vue";
@@ -92,7 +131,7 @@ import RecipeRating from "./RecipeRating.vue";
import RecipeChips from "./RecipeChips.vue"; import RecipeChips from "./RecipeChips.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeFavoriteBadge, RecipeFavoriteBadge,
RecipeContextMenu, RecipeContextMenu,
@@ -139,27 +178,23 @@ export default defineComponent({
default: false, default: false,
}, },
height: { height: {
type: [Number, String], type: [Number],
default: 150, default: 150,
}, },
imageHeight: {
type: [Number, String],
default: "fill-height",
},
}, },
emits: ["selected", "delete"],
setup(props) { setup(props) {
const { $auth } = useContext(); const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug); const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => { const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : ""; return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
}); });
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto"); const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
return { return {
isOwnGroup, isOwnGroup,
recipeRoute, recipeRoute,
@@ -170,7 +205,10 @@ export default defineComponent({
}); });
</script> </script>
<style> <style scoped>
:deep(.v-list-item__prepend) {
height: 100%;
}
.v-mobile-img { .v-mobile-img {
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;
@@ -198,7 +236,8 @@ export default defineComponent({
align-self: start !important; align-self: start !important;
} }
.flat, .theme--dark .flat { .flat,
.theme--dark .flat {
box-shadow: none !important; box-shadow: none !important;
background-color: transparent !important; background-color: transparent !important;
} }

View File

@@ -1,67 +1,102 @@
<template> <template>
<div> <div>
<v-app-bar v-if="!disableToolbar" color="transparent" flat class="mt-n1 flex-sm-wrap rounded"> <v-app-bar
v-if="!disableToolbar"
color="transparent"
:absolute="false"
flat
class="mt-n1 flex-sm-wrap rounded position-relative w-100 left-0 top-0"
>
<slot name="title"> <slot name="title">
<v-icon v-if="title" large left> <v-icon
v-if="title"
size="large"
start
>
{{ displayTitleIcon }} {{ displayTitleIcon }}
</v-icon> </v-icon>
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title> <v-toolbar-title class="headline">
{{ title }}
</v-toolbar-title>
</slot> </slot>
<v-spacer></v-spacer> <v-spacer />
<v-btn :icon="$vuetify.breakpoint.xsOnly" text :disabled="recipes.length === 0" @click="navigateRandom"> <v-btn
<v-icon :left="!$vuetify.breakpoint.xsOnly"> :icon="$vuetify.display.xs"
variant="text"
:disabled="recipes.length === 0"
@click="navigateRandom"
>
<v-icon :start="!$vuetify.display.xs">
{{ $globals.icons.diceMultiple }} {{ $globals.icons.diceMultiple }}
</v-icon> </v-icon>
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.random") }} {{ $vuetify.display.xs ? null : $t("general.random") }}
</v-btn> </v-btn>
<v-menu
<v-menu v-if="$listeners.sortRecipes" offset-y left> v-if="!disableSort"
<template #activator="{ on, attrs }"> offset-y
<v-btn text :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" :loading="sortLoading" v-on="on"> start
<v-icon :left="!$vuetify.breakpoint.xsOnly"> >
<template #activator="{ props }">
<v-btn
variant="text"
:icon="$vuetify.display.xs"
v-bind="props"
:loading="sortLoading"
>
<v-icon :start="!$vuetify.display.xs">
{{ preferences.sortIcon }} {{ preferences.sortIcon }}
</v-icon> </v-icon>
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.sort") }} {{ $vuetify.display.xs ? null : $t("general.sort") }}
</v-btn> </v-btn>
</template> </template>
<v-list> <v-list>
<v-list-item @click="sortRecipes(EVENTS.az)"> <v-list-item @click="sortRecipes(EVENTS.az)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
{{ $globals.icons.orderAlphabeticalAscending }} {{ $globals.icons.orderAlphabeticalAscending }}
</v-icon> </v-icon>
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title> <v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
<v-list-item @click="sortRecipes(EVENTS.rating)"> <v-list-item @click="sortRecipes(EVENTS.rating)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
{{ $globals.icons.star }} {{ $globals.icons.star }}
</v-icon> </v-icon>
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title> <v-list-item-title>{{ $t("general.rating") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
<v-list-item @click="sortRecipes(EVENTS.created)"> <v-list-item @click="sortRecipes(EVENTS.created)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
{{ $globals.icons.newBox }} {{ $globals.icons.newBox }}
</v-icon> </v-icon>
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title> <v-list-item-title>{{ $t("general.created") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
<v-list-item @click="sortRecipes(EVENTS.updated)"> <v-list-item @click="sortRecipes(EVENTS.updated)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
{{ $globals.icons.update }} {{ $globals.icons.update }}
</v-icon> </v-icon>
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title> <v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
<v-list-item @click="sortRecipes(EVENTS.lastMade)"> <v-list-item @click="sortRecipes(EVENTS.lastMade)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
{{ $globals.icons.chefHat }} {{ $globals.icons.chefHat }}
</v-icon> </v-icon>
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title> <v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
<ContextMenu <ContextMenu
v-if="!$vuetify.breakpoint.smAndDown" v-if="!$vuetify.display.smAndDown"
:items="[ :items="[
{ {
title: $tc('general.toggle-view'), title: $t('general.toggle-view'),
icon: $globals.icons.eye, icon: $globals.icons.eye,
event: 'toggle-dense-view', event: 'toggle-dense-view',
}, },
@@ -72,84 +107,75 @@
<div v-if="recipes && ready"> <div v-if="recipes && ready">
<div class="mt-2"> <div class="mt-2">
<v-row v-if="!useMobileCards"> <v-row v-if="!useMobileCards">
<v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3">
<v-lazy>
<RecipeCard
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:tags="recipe.tags"
:recipe-id="recipe.id"
v-on="$listeners"
/>
</v-lazy>
</v-col>
</v-row>
<v-row v-else dense>
<v-col <v-col
v-for="recipe in recipes" v-for="recipe in recipes"
:key="recipe.name" :key="recipe.id!"
:sm="6"
:md="6"
:lg="4"
:xl="3"
>
<RecipeCard
:name="recipe.name!"
:description="recipe.description!"
:slug="recipe.slug!"
:rating="recipe.rating!"
:image="recipe.image!"
:tags="recipe.tags!"
:recipe-id="recipe.id!"
/>
</v-col>
</v-row>
<v-row
v-else
dense
>
<v-col
v-for="recipe in recipes"
:key="recipe.id!"
cols="12" cols="12"
:sm="singleColumn ? '12' : '12'" :sm="singleColumn ? '12' : '12'"
:md="singleColumn ? '12' : '6'" :md="singleColumn ? '12' : '6'"
:lg="singleColumn ? '12' : '4'" :lg="singleColumn ? '12' : '4'"
:xl="singleColumn ? '12' : '3'" :xl="singleColumn ? '12' : '3'"
> >
<v-lazy>
<RecipeCardMobile <RecipeCardMobile
:name="recipe.name" :name="recipe.name!"
:description="recipe.description" :description="recipe.description!"
:slug="recipe.slug" :slug="recipe.slug!"
:rating="recipe.rating" :rating="recipe.rating!"
:image="recipe.image" :image="recipe.image!"
:tags="recipe.tags" :tags="recipe.tags!"
:recipe-id="recipe.id" :recipe-id="recipe.id!"
v-on="$listeners"
/> />
</v-lazy>
</v-col> </v-col>
</v-row> </v-row>
</div> </div>
<v-card v-intersect="infiniteScroll"></v-card> <v-card v-intersect="infiniteScroll" />
<v-fade-transition> <v-fade-transition>
<AppLoader v-if="loading" :loading="loading" /> <AppLoader
v-if="loading"
:loading="loading"
/>
</v-fade-transition> </v-fade-transition>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import {
computed,
defineComponent,
onMounted,
reactive,
ref,
toRefs,
useAsync,
useContext,
useRoute,
useRouter,
watch,
} from "@nuxtjs/composition-api";
import { useThrottleFn } from "@vueuse/core"; import { useThrottleFn } from "@vueuse/core";
import RecipeCard from "./RecipeCard.vue"; import RecipeCard from "./RecipeCard.vue";
import RecipeCardMobile from "./RecipeCardMobile.vue"; import RecipeCardMobile from "./RecipeCardMobile.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useAsyncKey } from "~/composables/use-utils";
import { useLazyRecipes } from "~/composables/recipes"; import { useLazyRecipes } from "~/composables/recipes";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import { useUserSortPreferences } from "~/composables/use-users/preferences"; import { useUserSortPreferences } from "~/composables/use-users/preferences";
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe"; import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
const REPLACE_RECIPES_EVENT = "replaceRecipes"; const REPLACE_RECIPES_EVENT = "replaceRecipes";
const APPEND_RECIPES_EVENT = "appendRecipes"; const APPEND_RECIPES_EVENT = "appendRecipes";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeCard, RecipeCard,
RecipeCardMobile, RecipeCardMobile,
@@ -159,6 +185,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
disableSort: {
type: Boolean,
default: false,
},
icon: { icon: {
type: String, type: String,
default: null, default: null,
@@ -181,6 +211,7 @@ export default defineComponent({
}, },
}, },
setup(props, context) { setup(props, context) {
const { $vuetify } = useNuxtApp();
const preferences = useUserSortPreferences(); const preferences = useUserSortPreferences();
const EVENTS = { const EVENTS = {
@@ -192,10 +223,11 @@ export default defineComponent({
shuffle: "shuffle", shuffle: "shuffle",
}; };
const { $auth, $globals, $vuetify } = useContext(); const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => { const useMobileCards = computed(() => {
return $vuetify.breakpoint.smAndDown || preferences.value.useMobileCards; return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
}); });
const displayTitleIcon = computed(() => { const displayTitleIcon = computed(() => {
@@ -207,7 +239,7 @@ export default defineComponent({
}); });
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const page = ref(1); const page = ref(1);
const perPage = 32; const perPage = 32;
@@ -259,14 +291,14 @@ export default defineComponent({
watch( watch(
() => props.query, () => props.query,
async (newValue: RecipeSearchQuery | undefined) => { async (newValue: RecipeSearchQuery | undefined) => {
const newValueString = JSON.stringify(newValue) const newValueString = JSON.stringify(newValue);
if (lastQuery !== newValueString) { if (lastQuery !== newValueString) {
lastQuery = newValueString; lastQuery = newValueString;
ready.value = false; ready.value = false;
await initRecipes(); await initRecipes();
ready.value = true; ready.value = true;
} }
} },
); );
async function initRecipes() { async function initRecipes() {
@@ -286,8 +318,7 @@ export default defineComponent({
context.emit(REPLACE_RECIPES_EVENT, newRecipes); context.emit(REPLACE_RECIPES_EVENT, newRecipes);
} }
const infiniteScroll = useThrottleFn(() => { const infiniteScroll = useThrottleFn(async () => {
useAsync(async () => {
if (!hasMore.value || loading.value) { if (!hasMore.value || loading.value) {
return; return;
} }
@@ -304,11 +335,9 @@ export default defineComponent({
} }
loading.value = false; loading.value = false;
}, useAsyncKey());
}, 500); }, 500);
async function sortRecipes(sortType: string) {
function sortRecipes(sortType: string) {
if (state.sortLoading || loading.value) { if (state.sortLoading || loading.value) {
return; return;
} }
@@ -318,13 +347,14 @@ export default defineComponent({
ascIcon: string, ascIcon: string,
descIcon: string, descIcon: string,
defaultOrderDirection = "asc", defaultOrderDirection = "asc",
filterNull = false filterNull = false,
) { ) {
if (preferences.value.orderBy !== orderBy) { if (preferences.value.orderBy !== orderBy) {
preferences.value.orderBy = orderBy; preferences.value.orderBy = orderBy;
preferences.value.orderDirection = defaultOrderDirection; preferences.value.orderDirection = defaultOrderDirection;
preferences.value.filterNull = filterNull; preferences.value.filterNull = filterNull;
} else { }
else {
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc"; preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
} }
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon; preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
@@ -337,7 +367,7 @@ export default defineComponent({
$globals.icons.sortAlphabeticalAscending, $globals.icons.sortAlphabeticalAscending,
$globals.icons.sortAlphabeticalDescending, $globals.icons.sortAlphabeticalDescending,
"asc", "asc",
false false,
); );
break; break;
case EVENTS.rating: case EVENTS.rating:
@@ -349,7 +379,7 @@ export default defineComponent({
$globals.icons.sortCalendarAscending, $globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending, $globals.icons.sortCalendarDescending,
"desc", "desc",
false false,
); );
break; break;
case EVENTS.updated: case EVENTS.updated:
@@ -361,7 +391,7 @@ export default defineComponent({
$globals.icons.sortCalendarAscending, $globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending, $globals.icons.sortCalendarDescending,
"desc", "desc",
true true,
); );
break; break;
default: default:
@@ -369,7 +399,6 @@ export default defineComponent({
return; return;
} }
useAsync(async () => {
// reset pagination // reset pagination
page.value = 1; page.value = 1;
hasMore.value = true; hasMore.value = true;
@@ -383,7 +412,6 @@ export default defineComponent({
state.sortLoading = false; state.sortLoading = false;
loading.value = false; loading.value = false;
}, useAsyncKey());
} }
async function navigateRandom() { async function navigateRandom() {

View File

@@ -1,13 +1,19 @@
<template> <template>
<div v-if="items.length > 0"> <div v-if="items.length > 0">
<h2 v-if="title" class="mt-4">{{ title }}</h2> <h2
v-if="title"
class="mt-4"
>
{{ title }}
</h2>
<v-chip <v-chip
v-for="category in items.slice(0, limit)" v-for="category in items.slice(0, limit)"
:key="category.name" :key="category.name"
label label
class="ma-1" class="mr-1 mt-1"
color="accent" color="accent"
:small="small" variant="flat"
:size="small ? 'small' : 'default'"
dark dark
@click.prevent="() => $emit('item-selected', category, urlPrefix)" @click.prevent="() => $emit('item-selected', category, urlPrefix)"
@@ -18,12 +24,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api"; import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
export type UrlPrefixParam = "tags" | "categories" | "tools"; export type UrlPrefixParam = "tags" | "categories" | "tools";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
truncate: { truncate: {
type: Boolean, type: Boolean,
@@ -54,13 +59,14 @@ export default defineComponent({
default: null, default: null,
}, },
}, },
emits: ["item-selected"],
setup(props) { setup(props) {
const { $auth } = useContext(); const $auth = useMealieAuth();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "") const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const baseRecipeRoute = computed<string>(() => { const baseRecipeRoute = computed<string>(() => {
return `/g/${groupSlug.value}` return `/g/${groupSlug.value}`;
}); });
function truncateText(text: string, length = 20, clamp = "...") { function truncateText(text: string, length = 20, clamp = "...") {

View File

@@ -8,6 +8,7 @@
:title="$t('recipe.delete-recipe')" :title="$t('recipe.delete-recipe')"
color="error" color="error"
:icon="$globals.icons.alertCircle" :icon="$globals.icons.alertCircle"
can-confirm
@confirm="deleteRecipe()" @confirm="deleteRecipe()"
> >
<v-card-text> <v-card-text>
@@ -19,16 +20,17 @@
:title="$t('recipe.duplicate')" :title="$t('recipe.duplicate')"
color="primary" color="primary"
:icon="$globals.icons.duplicate" :icon="$globals.icons.duplicate"
can-confirm
@confirm="duplicateRecipe()" @confirm="duplicateRecipe()"
> >
<v-card-text> <v-card-text>
<v-text-field <v-text-field
v-model="recipeName" v-model="recipeName"
dense density="compact"
:label="$t('recipe.recipe-name')" :label="$t('recipe.recipe-name')"
autofocus autofocus
@keyup.enter="duplicateRecipe()" @keyup.enter="duplicateRecipe()"
></v-text-field> />
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<BaseDialog <BaseDialog
@@ -36,6 +38,7 @@
:title="$t('recipe.add-recipe-to-mealplan')" :title="$t('recipe.add-recipe-to-mealplan')"
color="primary" color="primary"
:icon="$globals.icons.calendar" :icon="$globals.icons.calendar"
can-confirm
@confirm="addRecipeToPlan()" @confirm="addRecipeToPlan()"
> >
<v-card-text> <v-card-text>
@@ -47,22 +50,21 @@
max-width="290px" max-width="290px"
min-width="auto" min-width="auto"
> >
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-text-field <v-text-field
v-model="newMealdate" v-model="newMealdateString"
:label="$t('general.date')" :label="$t('general.date')"
:prepend-icon="$globals.icons.calendar" :prepend-icon="$globals.icons.calendar"
v-bind="attrs" v-bind="props"
readonly readonly
v-on="on" />
></v-text-field>
</template> </template>
<v-date-picker <v-date-picker
v-model="newMealdate" v-model="newMealdate"
no-title hide-header
:first-day-of-week="firstDayOfWeek" :first-day-of-week="firstDayOfWeek"
:local="$i18n.locale" :local="$i18n.locale"
@input="pickerMenu = false" @update:model-value="pickerMenu = false"
/> />
</v-menu> </v-menu>
<v-select <v-select
@@ -70,7 +72,9 @@
:return-object="false" :return-object="false"
:items="planTypeOptions" :items="planTypeOptions"
:label="$t('recipe.entry-type')" :label="$t('recipe.entry-type')"
></v-select> item-title="text"
item-value="value"
/>
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<RecipeDialogAddToShoppingList <RecipeDialogAddToShoppingList
@@ -81,35 +85,53 @@
/> />
<v-menu <v-menu
offset-y offset-y
left start
:bottom="!menuTop" :bottom="!menuTop"
:nudge-bottom="!menuTop ? '5' : '0'" :nudge-bottom="!menuTop ? '5' : '0'"
:top="menuTop" :top="menuTop"
:nudge-top="menuTop ? '5' : '0'" :nudge-top="menuTop ? '5' : '0'"
allow-overflow allow-overflow
close-delay="125" close-delay="125"
:open-on-hover="$vuetify.breakpoint.mdAndUp" :open-on-hover="$vuetify.display.mdAndUp"
content-class="d-print-none" content-class="d-print-none"
> >
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent> <v-btn
<v-icon>{{ icon }}</v-icon> icon
:variant="fab ? 'flat' : undefined"
:rounded="fab ? 'circle' : undefined"
:size="fab ? 'small' : undefined"
:color="fab ? 'info' : 'secondary'"
:fab="fab"
v-bind="props"
@click.prevent
>
<v-icon
:size="!fab ? undefined : 'x-large'"
:color="fab ? 'white' : 'secondary'"
>
{{ icon }}
</v-icon>
</v-btn> </v-btn>
</template> </template>
<v-list dense> <v-list density="compact">
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)"> <v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
<v-list-item-icon> <template #prepend>
<v-icon :color="item.color"> {{ item.icon }} </v-icon> <v-icon :color="item.color">
</v-list-item-icon> {{ item.icon }}
</v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title> <v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item> </v-list-item>
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length"> <div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
<v-divider /> <v-divider />
<v-list-group @click.stop> <v-list-group @click.stop>
<template #activator> <template #activator="{ props }">
<v-list-item-title>{{ $tc("recipe.recipe-actions") }}</v-list-item-title> <v-list-item-title v-bind="props">
{{ $t("recipe.recipe-actions") }}
</v-list-item-title>
</template> </template>
<v-list dense class="ma-0 pa-0"> <v-list density="compact" class="ma-0 pa-0">
<v-list-item <v-list-item
v-for="(action, index) in recipeActions" v-for="(action, index) in recipeActions"
:key="index" :key="index"
@@ -129,7 +151,6 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, toRefs, useContext, useRoute, useRouter, ref } from "@nuxtjs/composition-api";
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue"; import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue"; import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
import RecipeDialogShare from "./RecipeDialogShare.vue"; import RecipeDialogShare from "./RecipeDialogShare.vue";
@@ -139,15 +160,16 @@ import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { usePlanTypeOptions } from "~/composables/use-group-mealplan"; import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household"; import type { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
import { PlanEntryType } from "~/lib/api/types/meal-plan"; import type { PlanEntryType } from "~/lib/api/types/meal-plan";
import { useAxiosDownloader } from "~/composables/api/use-axios-download"; import { useDownloader } from "~/composables/api/use-downloader";
export interface ContextMenuIncludes { export interface ContextMenuIncludes {
delete: boolean; delete: boolean;
edit: boolean; edit: boolean;
download: boolean; download: boolean;
duplicate: boolean;
mealplanner: boolean; mealplanner: boolean;
shoppingList: boolean; shoppingList: boolean;
print: boolean; print: boolean;
@@ -164,7 +186,7 @@ export interface ContextMenuItem {
isPublic: boolean; isPublic: boolean;
} }
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeDialogAddToShoppingList, RecipeDialogAddToShoppingList,
RecipeDialogPrintPreferences, RecipeDialogPrintPreferences,
@@ -233,6 +255,7 @@ export default defineComponent({
default: 1, default: 1,
}, },
}, },
emits: ["delete"],
setup(props, context) { setup(props, context) {
const api = useUserApi(); const api = useUserApi();
@@ -246,17 +269,23 @@ export default defineComponent({
recipeName: props.name, recipeName: props.name,
loading: false, loading: false,
menuItems: [] as ContextMenuItem[], menuItems: [] as ContextMenuItem[],
newMealdate: "", newMealdate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
newMealType: "dinner" as PlanEntryType, newMealType: "dinner" as PlanEntryType,
pickerMenu: false, pickerMenu: false,
}); });
const { i18n, $auth, $globals } = useContext(); const newMealdateString = computed(() => {
return state.newMealdate.toISOString().substring(0, 10);
});
const i18n = useI18n();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { household } = useHouseholdSelf(); const { household } = useHouseholdSelf();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => { const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0; return household.value?.preferences?.firstDayOfWeek || 0;
@@ -267,63 +296,63 @@ export default defineComponent({
const defaultItems: { [key: string]: ContextMenuItem } = { const defaultItems: { [key: string]: ContextMenuItem } = {
edit: { edit: {
title: i18n.tc("general.edit"), title: i18n.t("general.edit"),
icon: $globals.icons.edit, icon: $globals.icons.edit,
color: undefined, color: undefined,
event: "edit", event: "edit",
isPublic: false, isPublic: false,
}, },
delete: { delete: {
title: i18n.tc("general.delete"), title: i18n.t("general.delete"),
icon: $globals.icons.delete, icon: $globals.icons.delete,
color: undefined, color: undefined,
event: "delete", event: "delete",
isPublic: false, isPublic: false,
}, },
download: { download: {
title: i18n.tc("general.download"), title: i18n.t("general.download"),
icon: $globals.icons.download, icon: $globals.icons.download,
color: undefined, color: undefined,
event: "download", event: "download",
isPublic: false, isPublic: false,
}, },
duplicate: { duplicate: {
title: i18n.tc("general.duplicate"), title: i18n.t("general.duplicate"),
icon: $globals.icons.duplicate, icon: $globals.icons.duplicate,
color: undefined, color: undefined,
event: "duplicate", event: "duplicate",
isPublic: false, isPublic: false,
}, },
mealplanner: { mealplanner: {
title: i18n.tc("recipe.add-to-plan"), title: i18n.t("recipe.add-to-plan"),
icon: $globals.icons.calendar, icon: $globals.icons.calendar,
color: undefined, color: undefined,
event: "mealplanner", event: "mealplanner",
isPublic: false, isPublic: false,
}, },
shoppingList: { shoppingList: {
title: i18n.tc("recipe.add-to-list"), title: i18n.t("recipe.add-to-list"),
icon: $globals.icons.cartCheck, icon: $globals.icons.cartCheck,
color: undefined, color: undefined,
event: "shoppingList", event: "shoppingList",
isPublic: false, isPublic: false,
}, },
print: { print: {
title: i18n.tc("general.print"), title: i18n.t("general.print"),
icon: $globals.icons.printer, icon: $globals.icons.printer,
color: undefined, color: undefined,
event: "print", event: "print",
isPublic: true, isPublic: true,
}, },
printPreferences: { printPreferences: {
title: i18n.tc("general.print-preferences"), title: i18n.t("general.print-preferences"),
icon: $globals.icons.printerSettings, icon: $globals.icons.printerSettings,
color: undefined, color: undefined,
event: "printPreferences", event: "printPreferences",
isPublic: true, isPublic: true,
}, },
share: { share: {
title: i18n.tc("general.share"), title: i18n.t("general.share"),
icon: $globals.icons.shareVariant, icon: $globals.icons.shareVariant,
color: undefined, color: undefined,
event: "share", event: "share",
@@ -350,8 +379,10 @@ export default defineComponent({
// Context Menu Event Handler // Context Menu Event Handler
const shoppingLists = ref<ShoppingListSummary[]>(); const shoppingLists = ref<ShoppingListSummary[]>();
const recipeRef = ref<Recipe>(props.recipe); const recipeRef = ref<Recipe | undefined>(props.recipe);
const recipeRefWithScale = computed(() => recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined); const recipeRefWithScale = computed(() =>
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
);
async function getShoppingLists() { async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" }); const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
@@ -371,13 +402,15 @@ export default defineComponent({
const groupRecipeActionsStore = useGroupRecipeActions(); const groupRecipeActionsStore = useGroupRecipeActions();
async function executeRecipeAction(action: GroupRecipeActionOut) { async function executeRecipeAction(action: GroupRecipeActionOut) {
if (!props.recipe) return;
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale); const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
if (action.actionType === "post") { if (action.actionType === "post") {
if (!response?.error) { if (!response?.error) {
alert.success(i18n.tc("events.message-sent")); alert.success(i18n.t("events.message-sent"));
} else { }
alert.error(i18n.tc("events.something-went-wrong")); else {
alert.error(i18n.t("events.something-went-wrong"));
} }
} }
} }
@@ -390,7 +423,7 @@ export default defineComponent({
context.emit("delete", props.slug); context.emit("delete", props.slug);
} }
const download = useAxiosDownloader(); const download = useDownloader();
async function handleDownloadEvent() { async function handleDownloadEvent() {
const { data } = await api.recipes.getZipToken(props.slug); const { data } = await api.recipes.getZipToken(props.slug);
@@ -402,7 +435,7 @@ export default defineComponent({
async function addRecipeToPlan() { async function addRecipeToPlan() {
const { response } = await api.mealplans.createOne({ const { response } = await api.mealplans.createOne({
date: state.newMealdate, date: newMealdateString.value,
entryType: state.newMealType, entryType: state.newMealType,
title: "", title: "",
text: "", text: "",
@@ -411,7 +444,8 @@ export default defineComponent({
if (response?.status === 201) { if (response?.status === 201) {
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string); alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
} else { }
else {
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string); alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
} }
} }
@@ -424,6 +458,7 @@ export default defineComponent({
} }
// Note: Print is handled as an event in the parent component // Note: Print is handled as an event in the parent component
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = { const eventHandlers: { [key: string]: () => void | Promise<any> } = {
delete: () => { delete: () => {
state.recipeDeleteDialog = true; state.recipeDeleteDialog = true;
@@ -448,7 +483,9 @@ export default defineComponent({
promises.push(refreshRecipe()); promises.push(refreshRecipe());
} }
Promise.allSettled(promises).then(() => { state.shoppingListDialog = true }); Promise.allSettled(promises).then(() => {
state.shoppingListDialog = true;
});
}, },
share: () => { share: () => {
state.shareDialog = true; state.shareDialog = true;
@@ -472,6 +509,7 @@ export default defineComponent({
return { return {
...toRefs(state), ...toRefs(state),
newMealdateString,
recipeRef, recipeRef,
recipeRefWithScale, recipeRefWithScale,
executeRecipeAction, executeRecipeAction,

View File

@@ -1,41 +1,29 @@
<template> <template>
<div> <div>
<BaseDialog <BaseDialog v-model="dialog" :title="$t('data-pages.manage-aliases')" :icon="$globals.icons.edit"
v-model="dialog" :submit-icon="$globals.icons.check" :submit-text="$t('general.confirm')" can-submit @submit="saveAliases"
:title="$t('data-pages.manage-aliases')" @cancel="$emit('cancel')">
:icon="$globals.icons.edit"
:submit-icon="$globals.icons.check"
:submit-text="$tc('general.confirm')"
@submit="saveAliases"
@cancel="$emit('cancel')"
>
<v-card-text> <v-card-text>
<v-container> <v-container>
<v-row v-for="alias, i in aliases" :key="i"> <v-row v-for="alias, i in aliases" :key="i">
<v-col cols="10"> <v-col cols="10">
<v-text-field <v-text-field v-model="alias.name" :label="$t('general.name')" :rules="[validators.required]" />
v-model="alias.name"
:label="$t('general.name')"
:rules="[validators.required]"
/>
</v-col> </v-col>
<v-col cols="2"> <v-col cols="2">
<BaseButtonGroup <BaseButtonGroup :buttons="[
:buttons="[
{ {
icon: $globals.icons.delete, icon: $globals.icons.delete,
text: $tc('general.delete'), text: $t('general.delete'),
event: 'delete' event: 'delete',
} },
]" ]" @delete="deleteAlias(i)" />
@delete="deleteAlias(i)"
/>
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
</v-card-text> </v-card-text>
<template #custom-card-action> <template #custom-card-action>
<BaseButton edit @click="createAlias">{{ $t('data-pages.create-alias') }} <BaseButton edit @click="createAlias">
{{ $t('data-pages.create-alias') }}
<template #icon> <template #icon>
{{ $globals.icons.create }} {{ $globals.icons.create }}
</template> </template>
@@ -46,18 +34,17 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/core"; import { whenever } from "@vueuse/core";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe"; import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
export interface GenericAlias { export interface GenericAlias {
name: string; name: string;
} }
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@@ -66,21 +53,22 @@ export default defineComponent({
required: true, required: true,
}, },
}, },
emits: ["submit", "update:modelValue", "cancel"],
setup(props, context) { setup(props, context) {
// V-Model Support // V-Model Support
const dialog = computed({ const dialog = computed({
get: () => { get: () => {
return props.value; return props.modelValue;
}, },
set: (val) => { set: (val) => {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
function createAlias() { function createAlias() {
aliases.value.push({ aliases.value.push({
"name": "", name: "",
}) });
} }
function deleteAlias(index: number) { function deleteAlias(index: number) {
@@ -97,11 +85,11 @@ export default defineComponent({
initAliases(); initAliases();
whenever( whenever(
() => props.value, () => props.modelValue,
() => { () => {
initAliases(); initAliases();
}, },
) );
function saveAliases() { function saveAliases() {
const seenAliasNames: string[] = []; const seenAliasNames: string[] = [];
@@ -111,9 +99,7 @@ export default defineComponent({
!alias.name !alias.name
|| alias.name === props.data.name || alias.name === props.data.name
|| alias.name === props.data.pluralName || alias.name === props.data.pluralName
// @ts-ignore only applies to units
|| alias.name === props.data.abbreviation || alias.name === props.data.abbreviation
// @ts-ignore only applies to units
|| alias.name === props.data.pluralAbbreviation || alias.name === props.data.pluralAbbreviation
|| seenAliasNames.includes(alias.name) || seenAliasNames.includes(alias.name)
) { ) {
@@ -122,7 +108,7 @@ export default defineComponent({
keepAliases.push(alias); keepAliases.push(alias);
seenAliasNames.push(alias.name); seenAliasNames.push(alias.name);
}) });
aliases.value = keepAliases; aliases.value = keepAliases;
context.emit("submit", keepAliases); context.emit("submit", keepAliases);
@@ -135,7 +121,7 @@ export default defineComponent({
deleteAlias, deleteAlias,
saveAliases, saveAliases,
validators, validators,
} };
}, },
}); });
</script> </script>

View File

@@ -3,60 +3,73 @@
v-model="selected" v-model="selected"
item-key="id" item-key="id"
show-select show-select
sort-by="dateAdded" :sort-by="[{ key: 'dateAdded', order: 'desc' }]"
sort-desc
:headers="headers" :headers="headers"
:items="recipes" :items="recipes"
:items-per-page="15" :items-per-page="15"
class="elevation-0" class="elevation-0"
:loading="loading" :loading="loading"
@input="setValue(selected)"
> >
<template #body.preappend> <template #[`item.name`]="{ item }">
<tr> <a
<td></td> :href="`/g/${groupSlug}/r/${item.slug}`"
<td>Hello</td> style="color: inherit; text-decoration: inherit; "
<td colspan="4"></td> @click="$emit('click')"
</tr> >{{ item.name }}</a>
</template> </template>
<template #item.name="{ item }"> <template #[`item.tags`]="{ item }">
<a :href="`/g/${groupSlug}/r/${item.slug}`" style="color: inherit; text-decoration: inherit; " @click="$emit('click')">{{ item.name }}</a> <RecipeChip
small
:items="item.tags!"
:is-category="false"
url-prefix="tags"
@item-selected="filterItems"
/>
</template> </template>
<template #item.tags="{ item }"> <template #[`item.recipeCategory`]="{ item }">
<RecipeChip small :items="item.tags" :is-category="false" url-prefix="tags" @item-selected="filterItems" /> <RecipeChip
small
:items="item.recipeCategory!"
@item-selected="filterItems"
/>
</template> </template>
<template #item.recipeCategory="{ item }"> <template #[`item.tools`]="{ item }">
<RecipeChip small :items="item.recipeCategory" @item-selected="filterItems" /> <RecipeChip
small
:items="item.tools"
url-prefix="tools"
@item-selected="filterItems"
/>
</template> </template>
<template #item.tools="{ item }"> <template #[`item.userId`]="{ item }">
<RecipeChip small :items="item.tools" url-prefix="tools" @item-selected="filterItems" /> <div class="d-flex align-center">
<UserAvatar
:user-id="item.userId!"
:tooltip="false"
size="40"
/>
<div class="pl-2">
<span class="text-left">
{{ getMember(item.userId!) }}
</span>
</div>
</div>
</template> </template>
<template #item.userId="{ item }"> <template #[`item.dateAdded`]="{ item }">
<v-list-item class="justify-start"> {{ formatDate(item.dateAdded!) }}
<UserAvatar :user-id="item.userId" :tooltip="false" size="40" />
<v-list-item-content class="pl-2">
<v-list-item-title class="text-left">
{{ getMember(item.userId) }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</template>
<template #item.dateAdded="{ item }">
{{ formatDate(item.dateAdded) }}
</template> </template>
</v-data-table> </v-data-table>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, onMounted, ref, useContext, useRouter } from "@nuxtjs/composition-api";
import UserAvatar from "../User/UserAvatar.vue"; import UserAvatar from "../User/UserAvatar.vue";
import RecipeChip from "./RecipeChips.vue"; import RecipeChip from "./RecipeChips.vue";
import { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe"; import type { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { UserSummary } from "~/lib/api/types/user"; import type { UserSummary } from "~/lib/api/types/user";
import { RecipeTag } from "~/lib/api/types/household"; import type { RecipeTag } from "~/lib/api/types/household";
const INPUT_EVENT = "input"; const INPUT_EVENT = "update:modelValue";
interface ShowHeaders { interface ShowHeaders {
id: boolean; id: boolean;
@@ -70,11 +83,11 @@ interface ShowHeaders {
dateAdded: boolean; dateAdded: boolean;
} }
export default defineComponent({ export default defineNuxtComponent({
components: { RecipeChip, UserAvatar }, components: { RecipeChip, UserAvatar },
props: { props: {
value: { modelValue: {
type: Array, type: Array as PropType<Recipe[]>,
required: false, required: false,
default: () => [], default: () => [],
}, },
@@ -104,45 +117,48 @@ export default defineComponent({
}, },
}, },
}, },
emits: ["click"],
setup(props, context) { setup(props, context) {
const { $auth, i18n } = useContext(); const i18n = useI18n();
const groupSlug = $auth.user?.groupSlug; const $auth = useMealieAuth();
const groupSlug = $auth.user.value?.groupSlug;
const router = useRouter(); const router = useRouter();
function setValue(value: Recipe[]) { const selected = computed({
context.emit(INPUT_EVENT, value); get: () => props.modelValue,
} set: value => context.emit(INPUT_EVENT, value),
});
const headers = computed(() => { const headers = computed(() => {
const hdrs = []; const hdrs: Array<{ title: string; value: string; align?: string; sortable?: boolean }> = [];
if (props.showHeaders.id) { if (props.showHeaders.id) {
hdrs.push({ text: i18n.t("general.id"), value: "id" }); hdrs.push({ title: i18n.t("general.id"), value: "id" });
} }
if (props.showHeaders.owner) { if (props.showHeaders.owner) {
hdrs.push({ text: i18n.t("general.owner"), value: "userId", align: "center" }); hdrs.push({ title: i18n.t("general.owner"), value: "userId", align: "center", sortable: true });
} }
hdrs.push({ text: i18n.t("general.name"), value: "name" }); hdrs.push({ title: i18n.t("general.name"), value: "name", sortable: true });
if (props.showHeaders.categories) { if (props.showHeaders.categories) {
hdrs.push({ text: i18n.t("recipe.categories"), value: "recipeCategory" }); hdrs.push({ title: i18n.t("recipe.categories"), value: "recipeCategory", sortable: true });
} }
if (props.showHeaders.tags) { if (props.showHeaders.tags) {
hdrs.push({ text: i18n.t("tag.tags"), value: "tags" }); hdrs.push({ title: i18n.t("tag.tags"), value: "tags", sortable: true });
} }
if (props.showHeaders.tools) { if (props.showHeaders.tools) {
hdrs.push({ text: i18n.t("tool.tools"), value: "tools" }); hdrs.push({ title: i18n.t("tool.tools"), value: "tools", sortable: true });
} }
if (props.showHeaders.recipeServings) { if (props.showHeaders.recipeServings) {
hdrs.push({ text: i18n.t("recipe.servings"), value: "recipeServings" }); hdrs.push({ title: i18n.t("recipe.servings"), value: "recipeServings", sortable: true });
} }
if (props.showHeaders.recipeYieldQuantity) { if (props.showHeaders.recipeYieldQuantity) {
hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYieldQuantity" }); hdrs.push({ title: i18n.t("recipe.yield"), value: "recipeYieldQuantity", sortable: true });
} }
if (props.showHeaders.recipeYield) { if (props.showHeaders.recipeYield) {
hdrs.push({ text: i18n.t("recipe.yield-text"), value: "recipeYield" }); hdrs.push({ title: i18n.t("recipe.yield-text"), value: "recipeYield", sortable: true });
} }
if (props.showHeaders.dateAdded) { if (props.showHeaders.dateAdded) {
hdrs.push({ text: i18n.t("general.date-added"), value: "dateAdded" }); hdrs.push({ title: i18n.t("general.date-added"), value: "dateAdded", sortable: true });
} }
return hdrs; return hdrs;
@@ -151,7 +167,8 @@ export default defineComponent({
function formatDate(date: string) { function formatDate(date: string) {
try { try {
return i18n.d(Date.parse(date), "medium"); return i18n.d(Date.parse(date), "medium");
} catch { }
catch {
return ""; return "";
} }
} }
@@ -181,15 +198,15 @@ export default defineComponent({
function getMember(id: string) { function getMember(id: string) {
if (members.value[0]) { if (members.value[0]) {
return members.value.find((m) => m.id === id)?.fullName; return members.value.find(m => m.id === id)?.fullName;
} }
return i18n.t("general.none"); return i18n.t("general.none");
} }
return { return {
selected,
groupSlug, groupSlug,
setValue,
headers, headers,
formatDate, formatDate,
members, members,
@@ -197,16 +214,5 @@ export default defineComponent({
filterItems, filterItems,
}; };
}, },
data() {
return {
selected: [],
};
},
watch: {
value(val) {
this.selected = val;
},
},
}); });
</script> </script>

View File

@@ -1,9 +1,16 @@
<template> <template>
<div v-if="dialog"> <div v-if="dialog">
<BaseDialog v-if="shoppingListDialog && ready" v-model="dialog" :title="$t('recipe.add-to-list')" :icon="$globals.icons.cartCheck"> <BaseDialog
v-if="shoppingListDialog && ready"
v-model="dialog"
:title="$t('recipe.add-to-list')"
:icon="$globals.icons.cartCheck"
>
<v-container v-if="!shoppingListChoices.length"> <v-container v-if="!shoppingListChoices.length">
<BasePageTitle> <BasePageTitle>
<template #title>{{ $t('shopping-list.no-shopping-lists-found') }}</template> <template #title>
{{ $t('shopping-list.no-shopping-lists-found') }}
</template>
</BasePageTitle> </BasePageTitle>
</v-container> </v-container>
<v-card-text> <v-card-text>
@@ -21,14 +28,23 @@
</v-card-text> </v-card-text>
<template #card-actions> <template #card-actions>
<v-btn <v-btn
text variant="text"
color="grey" color="grey"
@click="dialog = false" @click="dialog = false"
> >
{{ $t("general.cancel") }} {{ $t("general.cancel") }}
</v-btn> </v-btn>
<div class="d-flex justify-end" style="width: 100%;"> <div
<v-checkbox v-model="preferences.viewAllLists" hide-details :label="$tc('general.show-all')" class="my-auto mr-4" @click="setShowAllToggled()" /> class="d-flex justify-end"
style="width: 100%;"
>
<v-checkbox
v-model="preferences.viewAllLists"
hide-details
:label="$t('general.show-all')"
class="my-auto mr-4"
@click="setShowAllToggled()"
/>
</div> </div>
</template> </template>
</BaseDialog> </BaseDialog>
@@ -38,32 +54,52 @@
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')" :title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
:icon="$globals.icons.cartCheck" :icon="$globals.icons.cartCheck"
width="70%" width="70%"
:submit-text="$tc('recipe.add-to-list')" :submit-text="$t('recipe.add-to-list')"
can-submit
@submit="addRecipesToList()" @submit="addRecipesToList()"
> >
<div style="max-height: 70vh; overflow-y: auto"> <div style="max-height: 70vh; overflow-y: auto">
<v-card <v-card
v-for="(recipeSection, recipeSectionIndex) in recipeIngredientSections" :key="recipeSection.recipeId + recipeSectionIndex" v-for="(recipeSection, recipeSectionIndex) in recipeIngredientSections"
:key="recipeSection.recipeId + recipeSectionIndex"
elevation="0" elevation="0"
height="fit-content" height="fit-content"
width="100%" width="100%"
> >
<v-divider v-if="recipeSectionIndex > 0" class="mt-3" /> <v-divider
v-if="recipeSectionIndex > 0"
class="mt-3"
/>
<v-card-title <v-card-title
v-if="recipeIngredientSections.length > 1" v-if="recipeIngredientSections.length > 1"
class="justify-center text-h5" class="justify-center text-h5"
width="100%" width="100%"
> >
<v-container style="width: 100%;"> <v-container style="width: 100%;">
<v-row no-gutters class="ma-0 pa-0"> <v-row
<v-col cols="12" align-self="center" class="text-center"> no-gutters
class="ma-0 pa-0"
>
<v-col
cols="12"
align-self="center"
class="text-center"
>
{{ recipeSection.recipeName }} {{ recipeSection.recipeName }}
</v-col> </v-col>
</v-row> </v-row>
<v-row v-if="recipeSection.recipeScale > 1" no-gutters class="ma-0 pa-0"> <v-row
v-if="recipeSection.recipeScale > 1"
no-gutters
class="ma-0 pa-0"
>
<!-- TODO: make this editable in the dialog and visible on single-recipe lists --> <!-- TODO: make this editable in the dialog and visible on single-recipe lists -->
<v-col cols="12" align-self="center" class="text-center"> <v-col
({{ $tc("recipe.quantity") }}: {{ recipeSection.recipeScale }}) cols="12"
align-self="center"
class="text-center"
>
({{ $t("recipe.quantity") }}: {{ recipeSection.recipeScale }})
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
@@ -73,17 +109,20 @@
v-for="(ingredientSection, ingredientSectionIndex) in recipeSection.ingredientSections" v-for="(ingredientSection, ingredientSectionIndex) in recipeSection.ingredientSections"
:key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex" :key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex"
> >
<v-card-title v-if="ingredientSection.sectionName" class="ingredient-title mt-2 pb-0 text-h6"> <v-card-title
v-if="ingredientSection.sectionName"
class="ingredient-title mt-2 pb-0 text-h6"
>
{{ ingredientSection.sectionName }} {{ ingredientSection.sectionName }}
</v-card-title> </v-card-title>
<div <div
:class="$vuetify.breakpoint.smAndDown ? '' : 'ingredient-grid'" :class="$vuetify.display.smAndDown ? '' : 'ingredient-grid'"
:style="$vuetify.breakpoint.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }" :style="$vuetify.display.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
> >
<v-list-item <v-list-item
v-for="(ingredientData, i) in ingredientSection.ingredients" v-for="(ingredientData, i) in ingredientSection.ingredients"
:key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex + i" :key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex + i"
dense density="compact"
@click="recipeIngredientSections[recipeSectionIndex] @click="recipeIngredientSections[recipeSectionIndex]
.ingredientSections[ingredientSectionIndex] .ingredientSections[ingredientSectionIndex]
.ingredients[i].checked = !recipeIngredientSections[recipeSectionIndex] .ingredients[i].checked = !recipeIngredientSections[recipeSectionIndex]
@@ -93,16 +132,18 @@
> >
<v-checkbox <v-checkbox
hide-details hide-details
:input-value="ingredientData.checked" :model-value="ingredientData.checked"
class="pt-0 my-auto py-auto" class="pt-0 my-auto py-auto"
color="secondary" color="secondary"
density="compact"
/> />
<v-list-item-content :key="ingredientData.ingredient.quantity"> <div :key="ingredientData.ingredient.quantity">
<RecipeIngredientListItem <RecipeIngredientListItem
:ingredient="ingredientData.ingredient" :ingredient="ingredientData.ingredient"
:disable-amount="ingredientData.disableAmount" :disable-amount="ingredientData.disableAmount"
:scale="recipeSection.recipeScale" /> :scale="recipeSection.recipeScale"
</v-list-item-content> />
</div>
</v-list-item> </v-list-item>
</div> </div>
</div> </div>
@@ -114,12 +155,12 @@
:buttons="[ :buttons="[
{ {
icon: $globals.icons.checkboxBlankOutline, icon: $globals.icons.checkboxBlankOutline,
text: $tc('shopping-list.uncheck-all-items'), text: $t('shopping-list.uncheck-all-items'),
event: 'uncheck', event: 'uncheck',
}, },
{ {
icon: $globals.icons.checkboxOutline, icon: $globals.icons.checkboxOutline,
text: $tc('shopping-list.check-all-items'), text: $t('shopping-list.check-all-items'),
event: 'check', event: 'check',
}, },
]" ]"
@@ -132,14 +173,13 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, ref, useContext, watchEffect } from "@nuxtjs/composition-api";
import { toRefs } from "@vueuse/core"; import { toRefs } from "@vueuse/core";
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue"; import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { useShoppingListPreferences } from "~/composables/use-users/preferences"; import { useShoppingListPreferences } from "~/composables/use-users/preferences";
import { RecipeIngredient, ShoppingListAddRecipeParamsBulk, ShoppingListSummary } from "~/lib/api/types/household"; import type { RecipeIngredient, ShoppingListAddRecipeParamsBulk, ShoppingListSummary } from "~/lib/api/types/household";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
export interface RecipeWithScale extends Recipe { export interface RecipeWithScale extends Recipe {
scale: number; scale: number;
@@ -163,12 +203,12 @@ export interface ShoppingListRecipeIngredientSection {
ingredientSections: ShoppingListIngredientSection[]; ingredientSections: ShoppingListIngredientSection[];
} }
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeIngredientListItem, RecipeIngredientListItem,
}, },
props: { props: {
value: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@@ -181,8 +221,10 @@ export default defineComponent({
default: () => [], default: () => [],
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const { $auth, i18n } = useContext(); const i18n = useI18n();
const $auth = useMealieAuth();
const api = useUserApi(); const api = useUserApi();
const preferences = useShoppingListPreferences(); const preferences = useShoppingListPreferences();
const ready = ref(false); const ready = ref(false);
@@ -190,10 +232,10 @@ export default defineComponent({
// v-model support // v-model support
const dialog = computed({ const dialog = computed({
get: () => { get: () => {
return props.value; return props.modelValue;
}, },
set: (val) => { set: (val) => {
context.emit("input", val); context.emit("update:modelValue", val);
initState(); initState();
}, },
}); });
@@ -205,11 +247,11 @@ export default defineComponent({
}); });
const userHousehold = computed(() => { const userHousehold = computed(() => {
return $auth.user?.householdSlug || ""; return $auth.user.value?.householdSlug || "";
}); });
const shoppingListChoices = computed(() => { const shoppingListChoices = computed(() => {
return props.shoppingLists.filter((list) => preferences.value.viewAllLists || list.userId === $auth.user?.id); return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
}); });
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]); const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
@@ -220,7 +262,8 @@ export default defineComponent({
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) { if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
selectedShoppingList.value = shoppingListChoices.value[0]; selectedShoppingList.value = shoppingListChoices.value[0];
openShoppingListIngredientDialog(selectedShoppingList.value); openShoppingListIngredientDialog(selectedShoppingList.value);
} else { }
else {
ready.value = true; ready.value = true;
} }
}, },
@@ -234,7 +277,6 @@ export default defineComponent({
} }
if (recipeSectionMap.has(recipe.slug)) { if (recipeSectionMap.has(recipe.slug)) {
// @ts-ignore not undefined, see above
recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale; recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
continue; continue;
} }
@@ -247,7 +289,8 @@ export default defineComponent({
recipe.id = data.id || ""; recipe.id = data.id || "";
recipe.name = data.name || ""; recipe.name = data.name || "";
recipe.recipeIngredient = data.recipeIngredient; recipe.recipeIngredient = data.recipeIngredient;
} else if (!recipe.recipeIngredient.length) { }
else if (!recipe.recipeIngredient.length) {
continue; continue;
} }
@@ -257,7 +300,7 @@ export default defineComponent({
checked: !householdsWithFood.includes(userHousehold.value), checked: !householdsWithFood.includes(userHousehold.value),
ingredient: ing, ingredient: ing,
disableAmount: recipe.settings?.disableAmount || false, disableAmount: recipe.settings?.disableAmount || false,
} };
}); });
let currentTitle = ""; let currentTitle = "";
@@ -300,7 +343,7 @@ export default defineComponent({
recipeName: recipe.name, recipeName: recipe.name,
recipeScale: recipe.scale, recipeScale: recipe.scale,
ingredientSections: shoppingListIngredientSections, ingredientSections: shoppingListIngredientSections,
}) });
} }
recipeIngredientSections.value = Array.from(recipeSectionMap.values()); recipeIngredientSections.value = Array.from(recipeSectionMap.values());
@@ -366,13 +409,13 @@ export default defineComponent({
recipeId: section.recipeId, recipeId: section.recipeId,
recipeIncrementQuantity: section.recipeScale, recipeIncrementQuantity: section.recipeScale,
recipeIngredients: ingredients, recipeIngredients: ingredients,
} },
); );
}); });
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData); const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
error ? alert.error(i18n.tc("recipe.failed-to-add-recipes-to-list")) // eslint-disable-next-line @typescript-eslint/no-unused-expressions
: alert.success(i18n.tc("recipe.successfully-added-to-list")); error ? alert.error(i18n.t("recipe.failed-to-add-recipes-to-list")) : alert.success(i18n.t("recipe.successfully-added-to-list"));
state.shoppingListDialog = false; state.shoppingListDialog = false;
state.shoppingListIngredientDialog = false; state.shoppingListIngredientDialog = false;
@@ -391,9 +434,9 @@ export default defineComponent({
setShowAllToggled, setShowAllToggled,
recipeIngredientSections, recipeIngredientSections,
selectedShoppingList, selectedShoppingList,
} };
}, },
}) });
</script> </script>
<style scoped lang="css"> <style scoped lang="css">

View File

@@ -1,54 +1,88 @@
<template> <template>
<div class="text-center"> <div class="text-center">
<v-dialog v-model="dialog" width="800"> <v-dialog
<template #activator="{ on, attrs }"> v-model="dialog"
<BaseButton v-bind="attrs" v-on="on" @click="inputText = inputTextProp"> width="800"
>
<template #activator="{ props }">
<BaseButton
v-bind="props"
@click="inputText = inputTextProp"
>
{{ $t("new-recipe.bulk-add") }} {{ $t("new-recipe.bulk-add") }}
</BaseButton> </BaseButton>
</template> </template>
<v-card> <v-card>
<v-app-bar dense dark color="primary" class="mb-2"> <v-app-bar
<v-icon large left> density="compact"
dark
color="primary"
class="mb-2 position-relative left-0 top-0 w-100"
>
<v-icon
size="large"
start
>
{{ $globals.icons.createAlt }} {{ $globals.icons.createAlt }}
</v-icon> </v-icon>
<v-toolbar-title class="headline"> {{ $t("new-recipe.bulk-add") }}</v-toolbar-title> <v-toolbar-title class="headline">
<v-spacer></v-spacer> {{ $t("new-recipe.bulk-add") }}
</v-toolbar-title>
<v-spacer />
</v-app-bar> </v-app-bar>
<v-card-text> <v-card-text>
<v-textarea <v-textarea
v-model="inputText" v-model="inputText"
outlined variant="outlined"
rows="12" rows="12"
hide-details hide-details
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')" :placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
> />
</v-textarea>
<v-divider></v-divider> <v-divider />
<template v-for="(util, idx) in utilities"> <template
<v-list-item :key="util.id" dense class="py-1"> v-for="(util) in utilities"
:key="util.id"
>
<v-list-item
density="compact"
class="py-1"
>
<v-list-item-title> <v-list-item-title>
<v-list-item-subtitle class="wrap-word"> <v-list-item-subtitle class="wrap-word">
{{ util.description }} {{ util.description }}
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item-title> </v-list-item-title>
<BaseButton small color="info" @click="util.action"> <BaseButton
<template #icon> {{ $globals.icons.robot }}</template> size="small"
color="info"
@click="util.action"
>
<template #icon>
{{ $globals.icons.robot }}
</template>
{{ $t("general.run") }} {{ $t("general.run") }}
</BaseButton> </BaseButton>
</v-list-item> </v-list-item>
<v-divider :key="`divider-${idx}`" class="mx-2"></v-divider> <v-divider class="mx-2" />
</template> </template>
</v-card-text> </v-card-text>
<v-divider></v-divider> <v-divider />
<v-card-actions> <v-card-actions>
<BaseButton cancel @click="dialog = false"> </BaseButton> <BaseButton
<v-spacer></v-spacer> cancel
<BaseButton save color="success" @click="save"> </BaseButton> @click="dialog = false"
/>
<v-spacer />
<BaseButton
save
color="success"
@click="save"
/>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
@@ -56,8 +90,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { reactive, toRefs, defineComponent, useContext } from "@nuxtjs/composition-api"; export default defineNuxtComponent({
export default defineComponent({
props: { props: {
inputTextProp: { inputTextProp: {
type: String, type: String,
@@ -65,6 +98,7 @@ export default defineComponent({
default: "", default: "",
}, },
}, },
emits: ["bulk-data"],
setup(props, context) { setup(props, context) {
const state = reactive({ const state = reactive({
dialog: false, dialog: false,
@@ -72,12 +106,12 @@ export default defineComponent({
}); });
function splitText() { function splitText() {
return state.inputText.split("\n").filter((line) => !(line === "\n" || !line)); return state.inputText.split("\n").filter(line => !(line === "\n" || !line));
} }
function removeFirstCharacter() { function removeFirstCharacter() {
state.inputText = splitText() state.inputText = splitText()
.map((line) => line.substring(1)) .map(line => line.substring(1))
.join("\n"); .join("\n");
} }
@@ -108,22 +142,22 @@ export default defineComponent({
state.dialog = false; state.dialog = false;
} }
const { i18n } = useContext(); const i18n = useI18n();
const utilities = [ const utilities = [
{ {
id: "trim-whitespace", id: "trim-whitespace",
description: i18n.tc("new-recipe.trim-whitespace-description"), description: i18n.t("new-recipe.trim-whitespace-description"),
action: trimAllLines, action: trimAllLines,
}, },
{ {
id: "trim-prefix", id: "trim-prefix",
description: i18n.tc("new-recipe.trim-prefix-description"), description: i18n.t("new-recipe.trim-prefix-description"),
action: removeFirstCharacter, action: removeFirstCharacter,
}, },
{ {
id: "split-by-numbered-line", id: "split-by-numbered-line",
description: i18n.tc("new-recipe.split-by-numbered-line-description"), description: i18n.t("new-recipe.split-by-numbered-line-description"),
action: splitByNumberedLine, action: splitByNumberedLine,
}, },
]; ];

View File

@@ -2,16 +2,29 @@
<BaseDialog <BaseDialog
v-model="dialog" v-model="dialog"
:icon="$globals.icons.printerSettings" :icon="$globals.icons.printerSettings"
:title="$tc('general.print-preferences')" :title="$t('general.print-preferences')"
width="70%" width="70%"
max-width="816px" max-width="816px"
> >
<div class="pa-6"> <div class="pa-6">
<v-container class="print-config mb-3 pa-0"> <v-container class="print-config mb-3 pa-0">
<v-row> <v-row>
<v-col cols="auto" align-self="center" class="text-center"> <v-col
<div class="text-subtitle-2" style="text-align: center;">{{ $tc('recipe.recipe-image') }}</div> cols="auto"
<v-btn-toggle v-model="preferences.imagePosition" mandatory style="width: fit-content;"> align-self="center"
class="text-center"
>
<div
class="text-subtitle-2"
style="text-align: center;"
>
{{ $t('recipe.recipe-image') }}
</div>
<v-btn-toggle
v-model="preferences.imagePosition"
mandatory="force"
style="width: fit-content;"
>
<v-btn :value="ImagePosition.left"> <v-btn :value="ImagePosition.left">
<v-icon>{{ $globals.icons.dockLeft }}</v-icon> <v-icon>{{ $globals.icons.dockLeft }}</v-icon>
</v-btn> </v-btn>
@@ -23,20 +36,37 @@
</v-btn> </v-btn>
</v-btn-toggle> </v-btn-toggle>
</v-col> </v-col>
<v-col cols="auto" align-self="start"> <v-col
cols="auto"
align-self="start"
>
<v-row no-gutters> <v-row no-gutters>
<v-switch v-model="preferences.showDescription" hide-details :label="$tc('recipe.description')" /> <v-switch
v-model="preferences.showDescription"
hide-details
:label="$t('recipe.description')"
/>
</v-row> </v-row>
<v-row no-gutters> <v-row no-gutters>
<v-switch v-model="preferences.showNotes" hide-details :label="$tc('recipe.notes')" /> <v-switch
v-model="preferences.showNotes"
hide-details
:label="$t('recipe.notes')"
/>
</v-row> </v-row>
</v-col> </v-col>
<v-col cols="auto" align-self="start"> <v-col
<v-row no-gutters> cols="auto"
<v-switch v-model="preferences.showNutrition" hide-details :label="$tc('recipe.nutrition')" /> align-self="start"
</v-row> >
<v-row no-gutters> <v-row no-gutters>
<v-switch
v-model="preferences.showNutrition"
hide-details
:label="$t('recipe.nutrition')"
/>
</v-row> </v-row>
<v-row no-gutters />
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
@@ -54,35 +84,36 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api"; import type { Recipe } from "~/lib/api/types/recipe";
import { Recipe } from "~/lib/api/types/recipe";
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences"; import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue"; import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipePrintView, RecipePrintView,
}, },
props: { props: {
value: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
recipe: { recipe: {
type: Object as () => Recipe, type: Object as () => NoUndefinedField<Recipe>,
default: undefined, default: undefined,
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const preferences = useUserPrintPreferences(); const preferences = useUserPrintPreferences();
// V-Model Support // V-Model Support
const dialog = computed({ const dialog = computed({
get: () => { get: () => {
return props.value; return props.modelValue;
}, },
set: (val) => { set: (val) => {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
@@ -90,7 +121,7 @@ export default defineComponent({
dialog, dialog,
ImagePosition, ImagePosition,
preferences, preferences,
} };
} },
}); });
</script> </script>

View File

@@ -1,37 +1,61 @@
<template> <template>
<div> <div>
<slot v-bind="{ open, close }"> </slot> <slot v-bind="{ open, close }" />
<v-dialog v-model="dialog" max-width="988px" content-class="top-dialog" :scrollable="false"> <v-dialog
<v-app-bar sticky dark color="primary lighten-1" :rounded="!$vuetify.breakpoint.xs"> v-model="dialog"
max-width="988px"
content-class="top-dialog"
:scrollable="false"
>
<v-app-bar
sticky
dark
color="primary-lighten-1 top-0 position-relative left-0"
:rounded="!$vuetify.display.xs"
>
<v-text-field <v-text-field
id="arrow-search" id="arrow-search"
v-model="search.query.value" v-model="search.query.value"
autofocus autofocus
solo variant="solo"
flat flat
autocomplete="off" autocomplete="off"
background-color="primary lighten-1" bg-color="primary-lighten-1"
color="white" color="white"
dense density="compact"
class="mx-2 arrow-search" class="mx-2 arrow-search"
hide-details hide-details
single-line single-line
:placeholder="$t('search.search')" :placeholder="$t('search.search')"
:prepend-inner-icon="$globals.icons.search" :prepend-inner-icon="$globals.icons.search"
></v-text-field> />
<v-btn v-if="$vuetify.breakpoint.xs" x-small fab light @click="dialog = false"> <v-btn
v-if="$vuetify.display.xs"
size="x-small"
class="rounded-circle"
light
@click="dialog = false"
>
<v-icon> <v-icon>
{{ $globals.icons.close }} {{ $globals.icons.close }}
</v-icon> </v-icon>
</v-btn> </v-btn>
</v-app-bar> </v-app-bar>
<v-card class="mt-1 pa-1 scroll" max-height="700px" relative :loading="loading"> <v-card
class="position-relative mt-1 pa-1 scroll"
max-height="700px"
relative
:loading="loading"
>
<v-card-actions> <v-card-actions>
<div class="mr-auto"> <div class="mr-auto">
{{ $t("search.results") }} {{ $t("search.results") }}
</div> </div>
<router-link :to="advancedSearchUrl"> {{ $t("search.advanced-search") }} </router-link> <!-- <router-link
:to="advancedSearchUrl"
class="text-primary"
> {{ $t("search.advanced-search") }} </router-link> -->
</v-card-actions> </v-card-actions>
<RecipeCardMobile <RecipeCardMobile
@@ -39,13 +63,13 @@
:key="index" :key="index"
:tabindex="index" :tabindex="index"
class="ma-1 arrow-nav" class="ma-1 arrow-nav"
:name="recipe.name" :name="recipe.name ?? ''"
:description="recipe.description || ''" :description="recipe.description ?? ''"
:slug="recipe.slug" :slug="recipe.slug ?? ''"
:rating="recipe.rating" :rating="recipe.rating ?? 0"
:image="recipe.image" :image="recipe.image"
:recipe-id="recipe.id" :recipe-id="recipe.id ?? ''"
v-on="$listeners.selected ? { selected: () => handleSelect(recipe) } : {}" v-bind="$attrs.selected ? { selected: () => handleSelect(recipe) } : {}"
/> />
</v-card> </v-card>
</v-dialog> </v-dialog>
@@ -53,21 +77,21 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, toRefs, reactive, ref, watch, useContext, useRoute } from "@nuxtjs/composition-api";
import RecipeCardMobile from "./RecipeCardMobile.vue"; import RecipeCardMobile from "./RecipeCardMobile.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { RecipeSummary } from "~/lib/api/types/recipe"; import type { RecipeSummary } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search"; import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
import { usePublicExploreApi } from "~/composables/api/api-client"; import { usePublicExploreApi } from "~/composables/api/api-client";
const SELECTED_EVENT = "selected"; const SELECTED_EVENT = "selected";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeCardMobile, RecipeCardMobile,
}, },
setup(_, context) { setup(_, context) {
const { $auth } = useContext(); const $auth = useMealieAuth();
const state = reactive({ const state = reactive({
loading: false, loading: false,
selectedIndex: -1, selectedIndex: -1,
@@ -110,13 +134,16 @@ export default defineComponent({
if (e.key === "Enter") { if (e.key === "Enter") {
console.log(document.activeElement); console.log(document.activeElement);
// (document.activeElement as HTMLElement).click(); // (document.activeElement as HTMLElement).click();
} else if (e.key === "ArrowUp") { }
else if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
state.selectedIndex--; state.selectedIndex--;
} else if (e.key === "ArrowDown") { }
else if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
state.selectedIndex++; state.selectedIndex++;
} else { }
else {
return; return;
} }
selectRecipe(); selectRecipe();
@@ -125,14 +152,15 @@ export default defineComponent({
watch(dialog, (val) => { watch(dialog, (val) => {
if (!val) { if (!val) {
document.removeEventListener("keyup", onUpDown); document.removeEventListener("keyup", onUpDown);
} else { }
else {
document.addEventListener("keyup", onUpDown); document.addEventListener("keyup", onUpDown);
} }
}); });
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const route = useRoute(); const route = useRoute();
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`) const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`);
watch(route, close); watch(route, close);
function open() { function open() {

View File

@@ -1,6 +1,10 @@
<template> <template>
<div> <div>
<BaseDialog v-model="dialog" :title="$t('recipe-share.share-recipe')" :icon="$globals.icons.link"> <BaseDialog
v-model="dialog"
:title="$t('recipe-share.share-recipe')"
:icon="$globals.icons.link"
>
<v-card-text> <v-card-text>
<v-menu <v-menu
v-model="datePickerMenu" v-model="datePickerMenu"
@@ -10,68 +14,94 @@
max-width="290px" max-width="290px"
min-width="auto" min-width="auto"
> >
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-text-field <v-text-field
v-model="expirationDate" v-model="expirationDateString"
:label="$t('recipe-share.expiration-date')" :label="$t('recipe-share.expiration-date')"
:hint="$t('recipe-share.default-30-days')" :hint="$t('recipe-share.default-30-days')"
persistent-hint persistent-hint
:prepend-icon="$globals.icons.calendar" :prepend-icon="$globals.icons.calendar"
v-bind="attrs" v-bind="props"
readonly readonly
v-on="on" />
></v-text-field>
</template> </template>
<v-date-picker <v-date-picker
v-model="expirationDate" v-model="expirationDate"
no-title hide-header
:first-day-of-week="firstDayOfWeek" :first-day-of-week="firstDayOfWeek"
:local="$i18n.locale" :local="$i18n.locale"
@input="datePickerMenu = false" @update:model-value="datePickerMenu = false"
/> />
</v-menu> </v-menu>
</v-card-text> </v-card-text>
<v-card-actions class="justify-end"> <v-card-actions class="justify-end">
<BaseButton small @click="createNewToken"> {{ $t("general.new") }}</BaseButton> <BaseButton
size="small"
@click="createNewToken"
>
{{ $t("general.new") }}
</BaseButton>
</v-card-actions> </v-card-actions>
<v-list-item v-for="token in tokens" :key="token.id" @click="shareRecipe(token.id)"> <v-list-item
<v-list-item-avatar color="grey"> v-for="token in tokens"
<v-icon dark class="pa-2"> {{ $globals.icons.link }} </v-icon> :key="token.id"
</v-list-item-avatar> class="px-2"
style="padding-top: 8px; padding-bottom: 8px;"
@click="shareRecipe(token.id)"
>
<div class="d-flex align-center" style="width: 100%;">
<v-avatar color="grey">
<v-icon>
{{ $globals.icons.link }}
</v-icon>
</v-avatar>
<v-list-item-content> <div class="pl-3 flex-grow-1">
<v-list-item-title> {{ $t("recipe-share.expires-at") }} </v-list-item-title> <v-list-item-title>
{{ $t("recipe-share.expires-at") }}
</v-list-item-title>
<v-list-item-subtitle>
{{ $d(new Date(token.expiresAt!), "long") }}
</v-list-item-subtitle>
</div>
<v-list-item-subtitle>{{ $d(new Date(token.expiresAt), "long") }}</v-list-item-subtitle> <v-btn
</v-list-item-content> icon
variant="text"
<v-list-item-action> class="ml-2"
<v-btn icon @click.stop="deleteToken(token.id)"> @click.stop="deleteToken(token.id)"
<v-icon color="error lighten-1"> {{ $globals.icons.delete }} </v-icon> >
<v-icon color="error-lighten-1">
{{ $globals.icons.delete }}
</v-icon>
</v-btn> </v-btn>
</v-list-item-action> <v-btn
<v-list-item-action> icon
<v-btn icon @click.stop="copyTokenLink(token.id)"> variant="text"
<v-icon color="info lighten-1"> {{ $globals.icons.contentCopy }} </v-icon> class="ml-2"
@click.stop="copyTokenLink(token.id)"
>
<v-icon color="info-lighten-1">
{{ $globals.icons.contentCopy }}
</v-icon>
</v-btn> </v-btn>
</v-list-item-action> </div>
</v-list-item> </v-list-item>
</BaseDialog> </BaseDialog>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, toRefs, reactive, useContext, useRoute } from "@nuxtjs/composition-api";
import { useClipboard, useShare, whenever } from "@vueuse/core"; import { useClipboard, useShare, whenever } from "@vueuse/core";
import { RecipeShareToken } from "~/lib/api/types/recipe"; import type { RecipeShareToken } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@@ -84,38 +114,43 @@ export default defineComponent({
required: true, required: true,
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
// V-Model Support // V-Model Support
const dialog = computed({ const dialog = computed({
get: () => { get: () => {
return props.value; return props.modelValue;
}, },
set: (val) => { set: (val) => {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
const state = reactive({ const state = reactive({
datePickerMenu: false, datePickerMenu: false,
expirationDate: "", expirationDate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
tokens: [] as RecipeShareToken[], tokens: [] as RecipeShareToken[],
}); });
const expirationDateString = computed(() => {
return state.expirationDate.toISOString().substring(0, 10);
});
whenever( whenever(
() => props.value, () => props.modelValue,
() => { () => {
// Set expiration date to today + 30 Days // Set expiration date to today + 30 Days
const today = new Date(); const today = new Date();
const expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000); state.expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
state.expirationDate = expirationDate.toISOString().substring(0, 10);
refreshTokens(); refreshTokens();
} },
); );
const { $auth, i18n } = useContext(); const i18n = useI18n();
const $auth = useMealieAuth();
const { household } = useHouseholdSelf(); const { household } = useHouseholdSelf();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => { const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0; return household.value?.preferences?.firstDayOfWeek || 0;
@@ -128,11 +163,9 @@ export default defineComponent({
async function createNewToken() { async function createNewToken() {
// Convert expiration date to timestamp // Convert expiration date to timestamp
const expirationDate = new Date(state.expirationDate);
const { data } = await userApi.recipes.share.createOne({ const { data } = await userApi.recipes.share.createOne({
recipeId: props.recipeId, recipeId: props.recipeId,
expiresAt: expirationDate.toISOString(), expiresAt: state.expirationDate.toISOString(),
}); });
if (data) { if (data) {
@@ -142,7 +175,7 @@ export default defineComponent({
async function deleteToken(id: string) { async function deleteToken(id: string) {
await userApi.recipes.share.deleteOne(id); await userApi.recipes.share.deleteOne(id);
state.tokens = state.tokens.filter((token) => token.id !== id); state.tokens = state.tokens.filter(token => token.id !== id);
} }
async function refreshTokens() { async function refreshTokens() {
@@ -187,13 +220,15 @@ export default defineComponent({
url: getTokenLink(token), url: getTokenLink(token),
text: getRecipeText() as string, text: getRecipeText() as string,
}); });
} else { }
else {
await copyTokenLink(token); await copyTokenLink(token);
} }
} }
return { return {
...toRefs(state), ...toRefs(state),
expirationDateString,
dialog, dialog,
createNewToken, createNewToken,
deleteToken, deleteToken,

View File

@@ -1,16 +1,22 @@
<template> <template>
<v-container fluid class="pa-0"> <v-container
<div class="search-container py-8"> fluid
<form class="search-box pa-2" @submit.prevent="search"> class="px-0"
<div class="d-flex justify-center my-2"> >
<div class="search-container pb-8">
<form
class="search-box pa-2"
@submit.prevent="search"
>
<div class="d-flex justify-center mb-2">
<v-text-field <v-text-field
ref="input" ref="input"
v-model="state.search" v-model="state.search"
outlined variant="outlined"
hide-details hide-details
clearable clearable
color="primary" color="primary"
:placeholder="$tc('search.search-placeholder')" :placeholder="$t('search.search-placeholder')"
:prepend-inner-icon="$globals.icons.search" :prepend-inner-icon="$globals.icons.search"
@keyup.enter="hideKeyboard" @keyup.enter="hideKeyboard"
/> />
@@ -20,134 +26,184 @@
<SearchFilter <SearchFilter
v-if="categories" v-if="categories"
v-model="selectedCategories" v-model="selectedCategories"
:require-all.sync="state.requireAllCategories" v-model:require-all="state.requireAllCategories"
:items="categories" :items="categories"
> >
<v-icon left> <v-icon start>
{{ $globals.icons.categories }} {{ $globals.icons.categories }}
</v-icon> </v-icon>
{{ $t("category.categories") }} {{ $t("category.categories") }}
</SearchFilter> </SearchFilter>
<!-- Tag Filter --> <!-- Tag Filter -->
<SearchFilter v-if="tags" v-model="selectedTags" :require-all.sync="state.requireAllTags" :items="tags"> <SearchFilter
<v-icon left> v-if="tags"
v-model="selectedTags"
v-model:require-all="state.requireAllTags"
:items="tags"
>
<v-icon start>
{{ $globals.icons.tags }} {{ $globals.icons.tags }}
</v-icon> </v-icon>
{{ $t("tag.tags") }} {{ $t("tag.tags") }}
</SearchFilter> </SearchFilter>
<!-- Tool Filter --> <!-- Tool Filter -->
<SearchFilter v-if="tools" v-model="selectedTools" :require-all.sync="state.requireAllTools" :items="tools"> <SearchFilter
<v-icon left> v-if="tools"
v-model="selectedTools"
v-model:require-all="state.requireAllTools"
:items="tools"
>
<v-icon start>
{{ $globals.icons.potSteam }} {{ $globals.icons.potSteam }}
</v-icon> </v-icon>
{{ $t("tool.tools") }} {{ $t("tool.tools") }}
</SearchFilter> </SearchFilter>
<!-- Food Filter --> <!-- Food Filter -->
<SearchFilter v-if="foods" v-model="selectedFoods" :require-all.sync="state.requireAllFoods" :items="foods"> <SearchFilter
<v-icon left> v-if="foods"
v-model="selectedFoods"
v-model:require-all="state.requireAllFoods"
:items="foods"
>
<v-icon start>
{{ $globals.icons.foods }} {{ $globals.icons.foods }}
</v-icon> </v-icon>
{{ $t("general.foods") }} {{ $t("general.foods") }}
</SearchFilter> </SearchFilter>
<!-- Household Filter --> <!-- Household Filter -->
<SearchFilter v-if="households.length > 1" v-model="selectedHouseholds" :items="households" radio> <SearchFilter
<v-icon left> v-if="households.length > 1"
v-model="selectedHouseholds"
:items="households"
radio
>
<v-icon start>
{{ $globals.icons.household }} {{ $globals.icons.household }}
</v-icon> </v-icon>
{{ $t("household.households") }} {{ $t("household.households") }}
</SearchFilter> </SearchFilter>
<!-- Sort Options --> <!-- Sort Options -->
<v-menu offset-y nudge-bottom="3"> <v-menu
<template #activator="{ on, attrs }"> offset-y
<v-btn class="ml-auto" small color="accent" v-bind="attrs" v-on="on"> nudge-bottom="3"
<v-icon :left="!$vuetify.breakpoint.xsOnly"> >
<template #activator="{ props }">
<v-btn
class="ml-auto"
size="small"
color="accent"
v-bind="props"
>
<v-icon :start="!$vuetify.display.xs">
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }} {{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
</v-icon> </v-icon>
{{ $vuetify.breakpoint.xsOnly ? null : sortText }} {{ $vuetify.display.xs ? null : sortText }}
</v-btn> </v-btn>
</template> </template>
<v-card> <v-card>
<v-list> <v-list>
<v-list-item @click="toggleOrderDirection()"> <v-list-item
<v-icon left> slim
{{ density="comfortable"
state.orderDirection === "asc" ? :prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
$globals.icons.sortDescending : $globals.icons.sortAscending :title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
}} @click="toggleOrderDirection()"
</v-icon> />
<v-list-item-title>
{{ state.orderDirection === "asc" ? $tc("general.sort-descending") : $tc("general.sort-ascending") }}
</v-list-item-title>
</v-list-item>
<v-divider /> <v-divider />
<v-list-item <v-list-item
v-for="v in sortable" v-for="v in sortable"
:key="v.name" :key="v.name"
:input-value="state.orderBy === v.value" :active="state.orderBy === v.value"
slim
density="comfortable"
:prepend-icon="v.icon"
:title="v.name"
@click="state.orderBy = v.value" @click="state.orderBy = v.value"
> />
<v-icon left>
{{ v.icon }}
</v-icon>
<v-list-item-title>{{ v.name }}</v-list-item-title>
</v-list-item>
</v-list> </v-list>
</v-card> </v-card>
</v-menu> </v-menu>
<!-- Settings --> <!-- Settings -->
<v-menu offset-y bottom left nudge-bottom="3" :close-on-content-click="false"> <v-menu
<template #activator="{ on, attrs }"> offset-y
<v-btn small color="accent" dark v-bind="attrs" v-on="on"> bottom
<v-icon small> start
nudge-bottom="3"
:close-on-content-click="false"
>
<template #activator="{ props }">
<v-btn
size="small"
color="accent"
dark
v-bind="props"
>
<v-icon size="small">
{{ $globals.icons.cog }} {{ $globals.icons.cog }}
</v-icon> </v-icon>
</v-btn> </v-btn>
</template> </template>
<v-card> <v-card>
<v-card-text> <v-card-text>
<v-switch v-model="state.auto" :label="$t('search.auto-search')" single-line></v-switch> <v-switch
<v-btn block color="primary" @click="reset"> v-model="state.auto"
{{ $tc("general.reset") }} :label="$t('search.auto-search')"
single-line
/>
<v-btn
block
color="primary"
@click="reset"
>
{{ $t("general.reset") }}
</v-btn> </v-btn>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-menu> </v-menu>
</div> </div>
<div v-if="!state.auto" class="search-button-container"> <div
<v-btn x-large color="primary" type="submit" block> v-if="!state.auto"
<v-icon left> class="search-button-container"
>
<v-btn
size="x-large"
color="primary"
type="submit"
block
>
<v-icon start>
{{ $globals.icons.search }} {{ $globals.icons.search }}
</v-icon> </v-icon>
{{ $tc("search.search") }} {{ $t("search.search") }}
</v-btn> </v-btn>
</div> </div>
</form> </form>
</div> </div>
<v-divider></v-divider> <v-divider />
<v-container class="mt-6 px-md-6"> <v-container class="mt-6 px-md-6">
<RecipeCardSection <RecipeCardSection
v-if="state.ready" v-if="state.ready"
class="mt-n5" class="mt-n5"
:icon="$globals.icons.silverwareForkKnife" :icon="$globals.icons.silverwareForkKnife"
:title="$tc('general.recipes')" :title="$t('general.recipes')"
:recipes="recipes" :recipes="recipes"
:query="passedQueryWithSeed" :query="passedQueryWithSeed"
disable-sort
@item-selected="filterItems" @item-selected="filterItems"
@replaceRecipes="replaceRecipes" @replace-recipes="replaceRecipes"
@appendRecipes="appendRecipes" @append-recipes="appendRecipes"
/> />
</v-container> </v-container>
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import { ref, defineComponent, useRouter, onMounted, useContext, computed, Ref, useRoute, watch } from "@nuxtjs/composition-api";
import { watchDebounced } from "@vueuse/shared"; import { watchDebounced } from "@vueuse/shared";
import SearchFilter from "~/components/Domain/SearchFilter.vue"; import SearchFilter from "~/components/Domain/SearchFilter.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
@@ -165,17 +221,19 @@ import {
} from "~/composables/store"; } from "~/composables/store";
import { useUserSearchQuerySession } from "~/composables/use-users/preferences"; import { useUserSearchQuerySession } from "~/composables/use-users/preferences";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe"; import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useLazyRecipes } from "~/composables/recipes"; import { useLazyRecipes } from "~/composables/recipes";
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe"; import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
import { HouseholdSummary } from "~/lib/api/types/household"; import type { HouseholdSummary } from "~/lib/api/types/household";
export default defineComponent({ export default defineNuxtComponent({
components: { SearchFilter, RecipeCardSection }, components: { SearchFilter, RecipeCardSection },
setup() { setup() {
const router = useRouter(); const router = useRouter();
const { $auth, $globals, i18n } = useContext(); const i18n = useI18n();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const state = ref({ const state = ref({
@@ -193,7 +251,7 @@ export default defineComponent({
}); });
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const searchQuerySession = useUserSearchQuerySession(); const searchQuerySession = useUserSearchQuerySession();
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value); const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
@@ -236,9 +294,9 @@ export default defineComponent({
const passedQueryWithSeed = computed(() => { const passedQueryWithSeed = computed(() => {
return { return {
...passedQuery.value, ...passedQuery.value,
_searchSeed: Date.now().toString() _searchSeed: Date.now().toString(),
}; };
}) });
const queryDefaults = { const queryDefaults = {
search: "", search: "",
@@ -248,7 +306,7 @@ export default defineComponent({
requireAllTags: false, requireAllTags: false,
requireAllTools: false, requireAllTools: false,
requireAllFoods: false, requireAllFoods: false,
} };
function reset() { function reset() {
state.value.search = queryDefaults.search; state.value.search = queryDefaults.search;
@@ -271,11 +329,11 @@ export default defineComponent({
function toIDArray(array: { id: string }[]) { function toIDArray(array: { id: string }[]) {
// we sort the array to make sure the query is always the same // we sort the array to make sure the query is always the same
return array.map((item) => item.id).sort(); return array.map(item => item.id).sort();
} }
function hideKeyboard() { function hideKeyboard() {
input.value.blur() input.value.blur();
} }
const input: Ref<any> = ref(null); const input: Ref<any> = ref(null);
@@ -306,7 +364,7 @@ export default defineComponent({
requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined, requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined, requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined,
}, },
} };
await router.push({ query }); await router.push({ query });
searchQuerySession.value.recipe = JSON.stringify(query); searchQuerySession.value.recipe = JSON.stringify(query);
} }
@@ -314,7 +372,7 @@ export default defineComponent({
function waitUntilAndExecute( function waitUntilAndExecute(
condition: () => boolean, condition: () => boolean,
callback: () => void, callback: () => void,
opts = { timeout: 2000, interval: 500 } opts = { timeout: 2000, interval: 500 },
): Promise<void> { ): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const state = { const state = {
@@ -341,7 +399,7 @@ export default defineComponent({
} }
const sortText = computed(() => { const sortText = computed(() => {
const sort = sortable.find((s) => s.value === state.value.orderBy); const sort = sortable.find(s => s.value === state.value.orderBy);
if (!sort) return ""; if (!sort) return "";
return `${sort.name}`; return `${sort.name}`;
}); });
@@ -349,103 +407,112 @@ export default defineComponent({
const sortable = [ const sortable = [
{ {
icon: $globals.icons.orderAlphabeticalAscending, icon: $globals.icons.orderAlphabeticalAscending,
name: i18n.tc("general.sort-alphabetically"), name: i18n.t("general.sort-alphabetically"),
value: "name", value: "name",
}, },
{ {
icon: $globals.icons.newBox, icon: $globals.icons.newBox,
name: i18n.tc("general.created"), name: i18n.t("general.created"),
value: "created_at", value: "created_at",
}, },
{ {
icon: $globals.icons.chefHat, icon: $globals.icons.chefHat,
name: i18n.tc("general.last-made"), name: i18n.t("general.last-made"),
value: "last_made", value: "last_made",
}, },
{ {
icon: $globals.icons.star, icon: $globals.icons.star,
name: i18n.tc("general.rating"), name: i18n.t("general.rating"),
value: "rating", value: "rating",
}, },
{ {
icon: $globals.icons.update, icon: $globals.icons.update,
name: i18n.tc("general.updated"), name: i18n.t("general.updated"),
value: "updated_at", value: "updated_at",
}, },
{ {
icon: $globals.icons.diceMultiple, icon: $globals.icons.diceMultiple,
name: i18n.tc("general.random"), name: i18n.t("general.random"),
value: "random", value: "random",
}, },
]; ];
watch( watch(
() => route.value.query, () => route.query,
() => { () => {
if (!Object.keys(route.value.query).length) { if (!Object.keys(route.query).length) {
reset(); reset();
} }
} },
) );
function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) { function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) {
if (urlPrefix === "categories") { if (urlPrefix === "categories") {
const result = categories.store.value.filter((category) => (category.id as string).includes(item.id as string)); const result = categories.store.value.filter(category => (category.id as string).includes(item.id as string));
selectedCategories.value = result as NoUndefinedField<RecipeTag>[]; selectedCategories.value = result as NoUndefinedField<RecipeTag>[];
} else if (urlPrefix === "tags") { }
const result = tags.store.value.filter((tag) => (tag.id as string).includes(item.id as string)); else if (urlPrefix === "tags") {
const result = tags.store.value.filter(tag => (tag.id as string).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[]; selectedTags.value = result as NoUndefinedField<RecipeTag>[];
} else if (urlPrefix === "tools") { }
const result = tools.store.value.filter((tool) => (tool.id ).includes(item.id || "" )); else if (urlPrefix === "tools") {
const result = tools.store.value.filter(tool => (tool.id).includes(item.id || ""));
selectedTags.value = result as NoUndefinedField<RecipeTag>[]; selectedTags.value = result as NoUndefinedField<RecipeTag>[];
} }
} }
async function hydrateSearch() { async function hydrateSearch() {
const query = router.currentRoute.query; const query = router.currentRoute.value.query;
if (query.auto?.length) { if (query.auto?.length) {
state.value.auto = query.auto === "true"; state.value.auto = query.auto === "true";
} }
if (query.search?.length) { if (query.search?.length) {
state.value.search = query.search as string; state.value.search = query.search as string;
} else { }
else {
state.value.search = queryDefaults.search; state.value.search = queryDefaults.search;
} }
if (query.orderBy?.length) { if (query.orderBy?.length) {
state.value.orderBy = query.orderBy as string; state.value.orderBy = query.orderBy as string;
} else { }
else {
state.value.orderBy = queryDefaults.orderBy; state.value.orderBy = queryDefaults.orderBy;
} }
if (query.orderDirection?.length) { if (query.orderDirection?.length) {
state.value.orderDirection = query.orderDirection as "asc" | "desc"; state.value.orderDirection = query.orderDirection as "asc" | "desc";
} else { }
else {
state.value.orderDirection = queryDefaults.orderDirection; state.value.orderDirection = queryDefaults.orderDirection;
} }
if (query.requireAllCategories?.length) { if (query.requireAllCategories?.length) {
state.value.requireAllCategories = query.requireAllCategories === "true"; state.value.requireAllCategories = query.requireAllCategories === "true";
} else { }
else {
state.value.requireAllCategories = queryDefaults.requireAllCategories; state.value.requireAllCategories = queryDefaults.requireAllCategories;
} }
if (query.requireAllTags?.length) { if (query.requireAllTags?.length) {
state.value.requireAllTags = query.requireAllTags === "true"; state.value.requireAllTags = query.requireAllTags === "true";
} else { }
else {
state.value.requireAllTags = queryDefaults.requireAllTags; state.value.requireAllTags = queryDefaults.requireAllTags;
} }
if (query.requireAllTools?.length) { if (query.requireAllTools?.length) {
state.value.requireAllTools = query.requireAllTools === "true"; state.value.requireAllTools = query.requireAllTools === "true";
} else { }
else {
state.value.requireAllTools = queryDefaults.requireAllTools; state.value.requireAllTools = queryDefaults.requireAllTools;
} }
if (query.requireAllFoods?.length) { if (query.requireAllFoods?.length) {
state.value.requireAllFoods = query.requireAllFoods === "true"; state.value.requireAllFoods = query.requireAllFoods === "true";
} else { }
else {
state.value.requireAllFoods = queryDefaults.requireAllFoods; state.value.requireAllFoods = queryDefaults.requireAllFoods;
} }
@@ -456,15 +523,16 @@ export default defineComponent({
waitUntilAndExecute( waitUntilAndExecute(
() => categories.store.value.length > 0, () => categories.store.value.length > 0,
() => { () => {
const result = categories.store.value.filter((item) => const result = categories.store.value.filter(item =>
(query.categories as string[]).includes(item.id as string) (query.categories as string[]).includes(item.id as string),
); );
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[]; selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
} },
) ),
); );
} else { }
else {
selectedCategories.value = []; selectedCategories.value = [];
} }
@@ -473,12 +541,13 @@ export default defineComponent({
waitUntilAndExecute( waitUntilAndExecute(
() => tags.store.value.length > 0, () => tags.store.value.length > 0,
() => { () => {
const result = tags.store.value.filter((item) => (query.tags as string[]).includes(item.id as string)); const result = tags.store.value.filter(item => (query.tags as string[]).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[]; selectedTags.value = result as NoUndefinedField<RecipeTag>[];
} },
) ),
); );
} else { }
else {
selectedTags.value = []; selectedTags.value = [];
} }
@@ -487,12 +556,13 @@ export default defineComponent({
waitUntilAndExecute( waitUntilAndExecute(
() => tools.store.value.length > 0, () => tools.store.value.length > 0,
() => { () => {
const result = tools.store.value.filter((item) => (query.tools as string[]).includes(item.id)); const result = tools.store.value.filter(item => (query.tools as string[]).includes(item.id));
selectedTools.value = result as NoUndefinedField<RecipeTool>[]; selectedTools.value = result as NoUndefinedField<RecipeTool>[];
} },
) ),
); );
} else { }
else {
selectedTools.value = []; selectedTools.value = [];
} }
@@ -506,12 +576,13 @@ export default defineComponent({
return false; return false;
}, },
() => { () => {
const result = foods.store.value?.filter((item) => (query.foods as string[]).includes(item.id)); const result = foods.store.value?.filter(item => (query.foods as string[]).includes(item.id));
selectedFoods.value = result ?? []; selectedFoods.value = result ?? [];
} },
) ),
); );
} else { }
else {
selectedFoods.value = []; selectedFoods.value = [];
} }
@@ -525,12 +596,13 @@ export default defineComponent({
return false; return false;
}, },
() => { () => {
const result = households.store.value?.filter((item) => (query.households as string[]).includes(item.id)); const result = households.store.value?.filter(item => (query.households as string[]).includes(item.id));
selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? []; selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? [];
} },
) ),
); );
} else { }
else {
selectedHouseholds.value = []; selectedHouseholds.value = [];
} }
@@ -539,11 +611,12 @@ export default defineComponent({
onMounted(async () => { onMounted(async () => {
// restore the user's last search query // restore the user's last search query
if (searchQuerySession.value.recipe && !(Object.keys(route.value.query).length > 0)) { if (searchQuerySession.value.recipe && !(Object.keys(route.query).length > 0)) {
try { try {
const query = JSON.parse(searchQuerySession.value.recipe); const query = JSON.parse(searchQuerySession.value.recipe);
await router.replace({ query }); await router.replace({ query });
} catch (error) { }
catch {
searchQuerySession.value.recipe = ""; searchQuerySession.value.recipe = "";
router.replace({ query: {} }); router.replace({ query: {} });
} }
@@ -576,7 +649,7 @@ export default defineComponent({
}, },
{ {
debounce: 500, debounce: 500,
} },
); );
return { return {
@@ -610,7 +683,6 @@ export default defineComponent({
filterItems, filterItems,
}; };
}, },
head: {},
}); });
</script> </script>

View File

@@ -1,17 +1,25 @@
<template> <template>
<v-tooltip bottom nudge-right="50" :color="buttonStyle ? 'info' : 'secondary'"> <v-tooltip
<template #activator="{ on, attrs }"> location="bottom"
nudge-right="50"
:color="buttonStyle ? 'info' : 'secondary'"
>
<template #activator="{ props }">
<v-btn <v-btn
v-if="isFavorite || showAlways" v-if="isFavorite || showAlways"
small icon
:variant="buttonStyle ? 'flat' : undefined"
:rounded="buttonStyle ? 'circle' : undefined"
size="small"
:color="buttonStyle ? 'info' : 'secondary'" :color="buttonStyle ? 'info' : 'secondary'"
:icon="!buttonStyle"
:fab="buttonStyle" :fab="buttonStyle"
v-bind="attrs" v-bind="{ ...props, ...$attrs }"
@click.prevent="toggleFavorite" @click.prevent="toggleFavorite"
v-on="on"
> >
<v-icon :small="!buttonStyle" :color="buttonStyle ? 'white' : 'secondary'"> <v-icon
:size="!buttonStyle ? undefined : 'x-large'"
:color="buttonStyle ? 'white' : 'secondary'"
>
{{ isFavorite ? $globals.icons.heart : $globals.icons.heartOutline }} {{ isFavorite ? $globals.icons.heart : $globals.icons.heartOutline }}
</v-icon> </v-icon>
</v-btn> </v-btn>
@@ -21,11 +29,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import { useUserSelfRatings } from "~/composables/use-users"; import { useUserSelfRatings } from "~/composables/use-users";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { UserOut } from "~/lib/api/types/user";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
recipeId: { recipeId: {
type: String, type: String,
@@ -42,22 +49,21 @@ export default defineComponent({
}, },
setup(props) { setup(props) {
const api = useUserApi(); const api = useUserApi();
const { $auth } = useContext(); const $auth = useMealieAuth();
const { userRatings, refreshUserRatings } = useUserSelfRatings(); const { userRatings, refreshUserRatings } = useUserSelfRatings();
// TODO Setup the correct type for $auth.user
// See https://github.com/nuxt-community/auth-module/issues/1097
const user = computed(() => $auth.user as unknown as UserOut);
const isFavorite = computed(() => { const isFavorite = computed(() => {
const rating = userRatings.value.find((r) => r.recipeId === props.recipeId); const rating = userRatings.value.find(r => r.recipeId === props.recipeId);
return rating?.isFavorite || false; return rating?.isFavorite || false;
}); });
async function toggleFavorite() { async function toggleFavorite() {
if (!$auth.user.value) return;
if (!isFavorite.value) { if (!isFavorite.value) {
await api.users.addFavorite(user.value?.id, props.recipeId); await api.users.addFavorite($auth.user.value?.id, props.recipeId);
} else { }
await api.users.removeFavorite(user.value?.id, props.recipeId); else {
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
} }
await refreshUserRatings(); await refreshUserRatings();
} }

View File

@@ -1,9 +1,19 @@
<template> <template>
<div class="text-center"> <div class="text-center">
<v-menu v-model="menu" offset-y top nudge-top="6" :close-on-content-click="false"> <v-menu
<template #activator="{ on, attrs }"> v-model="menu"
<v-btn color="accent" dark v-bind="attrs" v-on="on"> offset-y
<v-icon left> top
nudge-top="6"
:close-on-content-click="false"
>
<template #activator="{ props }">
<v-btn
color="accent"
dark
v-bind="props"
>
<v-icon start>
{{ $globals.icons.fileImage }} {{ $globals.icons.fileImage }}
</v-icon> </v-icon>
{{ $t("general.image") }} {{ $t("general.image") }}
@@ -25,9 +35,21 @@
</v-card-title> </v-card-title>
<v-card-text class="mt-n5"> <v-card-text class="mt-n5">
<div> <div>
<v-text-field v-model="url" :label="$t('general.url')" class="pt-5" clearable :messages="messages"> <v-text-field
<template #append-outer> v-model="url"
<v-btn class="ml-2" color="primary" :loading="loading" :disabled="!slug" @click="getImageFromURL"> :label="$t('general.url')"
class="pt-5"
clearable
:messages="messages"
>
<template #append>
<v-btn
class="ml-2"
color="primary"
:loading="loading"
:disabled="!slug"
@click="getImageFromURL"
>
{{ $t("general.get") }} {{ $t("general.get") }}
</v-btn> </v-btn>
</template> </template>
@@ -40,13 +62,12 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
const REFRESH_EVENT = "refresh"; const REFRESH_EVENT = "refresh";
const UPLOAD_EVENT = "upload"; const UPLOAD_EVENT = "upload";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
slug: { slug: {
type: String, type: String,
@@ -58,7 +79,7 @@ export default defineComponent({
url: "", url: "",
loading: false, loading: false,
menu: false, menu: false,
}) });
function uploadImage(fileObject: File) { function uploadImage(fileObject: File) {
context.emit(UPLOAD_EVENT, fileObject); context.emit(UPLOAD_EVENT, fileObject);
@@ -75,7 +96,7 @@ export default defineComponent({
state.menu = false; state.menu = false;
} }
const { i18n } = useContext(); const i18n = useI18n();
const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")]; const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")];
return { return {

View File

@@ -1,101 +1,148 @@
<template> <template>
<div> <div>
<v-text-field <v-text-field
v-if="value.title || showTitle" v-if="model.title || showTitle"
v-model="value.title" v-model="model.title"
dense density="compact"
variant="underlined"
hide-details hide-details
class="mx-1 mt-3 mb-4" class="mx-1 mt-3 mb-4"
:placeholder="$t('recipe.section-title')" :placeholder="$t('recipe.section-title')"
style="max-width: 500px" style="max-width: 500px"
@click="$emit('clickIngredientField', 'title')" @click="$emit('clickIngredientField', 'title')"
> />
</v-text-field> <v-row
<v-row :no-gutters="$vuetify.breakpoint.mdAndUp" dense class="d-flex flex-wrap my-1"> :no-gutters="mdAndUp"
<v-col v-if="!disableAmount" sm="12" md="2" cols="12" class="flex-grow-0 flex-shrink-0">
<v-text-field
v-model="value.quantity"
solo
hide-details
dense dense
class="d-flex flex-wrap my-1"
>
<v-col
v-if="!disableAmount"
sm="12"
md="2"
cols="12"
class="flex-grow-0 flex-shrink-0"
>
<v-text-field
v-model="model.quantity"
variant="solo"
hide-details
density="compact"
type="number" type="number"
:placeholder="$t('recipe.quantity')" :placeholder="$t('recipe.quantity')"
@keypress="quantityFilter" @keypress="quantityFilter"
> >
<v-icon v-if="$listeners && $listeners.delete" slot="prepend" class="mr-n1 handle"> <template #prepend>
<v-icon
class="mr-n1 handle"
>
{{ $globals.icons.arrowUpDown }} {{ $globals.icons.arrowUpDown }}
</v-icon> </v-icon>
</template>
</v-text-field> </v-text-field>
</v-col> </v-col>
<v-col v-if="!disableAmount" sm="12" md="3" cols="12"> <v-col
v-if="!disableAmount"
sm="12"
md="3"
cols="12"
>
<v-autocomplete <v-autocomplete
ref="unitAutocomplete" ref="unitAutocomplete"
v-model="value.unit" v-model="model.unit"
:search-input.sync="unitSearch" v-model:search="unitSearch"
auto-select-first auto-select-first
hide-details hide-details
dense density="compact"
solo variant="solo"
return-object return-object
:items="units || []" :items="units || []"
item-text="name" item-title="name"
class="mx-1" class="mx-1"
:placeholder="$t('recipe.choose-unit')" :placeholder="$t('recipe.choose-unit')"
clearable clearable
@keyup.enter="handleUnitEnter" @keyup.enter="handleUnitEnter"
> >
<template #no-data> <template #no-data>
<div class="caption text-center pb-2">{{ $t("recipe.press-enter-to-create") }}</div> <div class="caption text-center pb-2">
{{ $t("recipe.press-enter-to-create") }}
</div>
</template> </template>
<template #append-item> <template #append-item>
<div class="px-2"> <div class="px-2">
<BaseButton block small @click="createAssignUnit()"></BaseButton> <BaseButton
block
size="small"
@click="createAssignUnit()"
/>
</div> </div>
</template> </template>
</v-autocomplete> </v-autocomplete>
</v-col> </v-col>
<!-- Foods Input --> <!-- Foods Input -->
<v-col v-if="!disableAmount" m="12" md="3" cols="12" class=""> <v-col
v-if="!disableAmount"
m="12"
md="3"
cols="12"
class=""
>
<v-autocomplete <v-autocomplete
ref="foodAutocomplete" ref="foodAutocomplete"
v-model="value.food" v-model="model.food"
:search-input.sync="foodSearch" v-model:search="foodSearch"
auto-select-first auto-select-first
hide-details hide-details
dense density="compact"
solo variant="solo"
return-object return-object
:items="foods || []" :items="foods || []"
item-text="name" item-title="name"
class="mx-1 py-0" class="mx-1 py-0"
:placeholder="$t('recipe.choose-food')" :placeholder="$t('recipe.choose-food')"
clearable clearable
@keyup.enter="handleFoodEnter" @keyup.enter="handleFoodEnter"
> >
<template #no-data> <template #no-data>
<div class="caption text-center pb-2">{{ $t("recipe.press-enter-to-create") }}</div> <div class="caption text-center pb-2">
{{ $t("recipe.press-enter-to-create") }}
</div>
</template> </template>
<template #append-item> <template #append-item>
<div class="px-2"> <div class="px-2">
<BaseButton block small @click="createAssignFood()"></BaseButton> <BaseButton
block
size="small"
@click="createAssignFood()"
/>
</div> </div>
</template> </template>
</v-autocomplete> </v-autocomplete>
</v-col> </v-col>
<v-col sm="12" md="" cols="12"> <v-col
sm="12"
md=""
cols="12"
>
<div class="d-flex"> <div class="d-flex">
<v-text-field <v-text-field
v-model="value.note" v-model="model.note"
hide-details hide-details
dense density="compact"
solo variant="solo"
:placeholder="$t('recipe.notes')" :placeholder="$t('recipe.notes')"
class="mb-auto"
@click="$emit('clickIngredientField', 'note')" @click="$emit('clickIngredientField', 'note')"
> >
<v-icon v-if="disableAmount && $listeners && $listeners.delete" slot="prepend" class="mr-n1 handle"> <template #prepend>
<v-icon
v-if="disableAmount && $attrs && $attrs.delete"
class="mr-n1 handle"
>
{{ $globals.icons.arrowUpDown }} {{ $globals.icons.arrowUpDown }}
</v-icon> </v-icon>
</template>
</v-text-field> </v-text-field>
<BaseButtonGroup <BaseButtonGroup
hover hover
@@ -112,26 +159,32 @@
</div> </div>
</v-col> </v-col>
</v-row> </v-row>
<p v-if="showOriginalText" class="text-caption"> <p
{{ $t("recipe.original-text-with-value", { originalText: value.originalText }) }} v-if="showOriginalText"
class="text-caption"
>
{{ $t("recipe.original-text-with-value", { originalText: model.originalText }) }}
</p> </p>
<v-divider v-if="!$vuetify.breakpoint.mdAndUp" class="my-4"></v-divider> <v-divider
v-if="!mdAndUp"
class="my-4"
/>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api"; 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 { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
import { validators } from "~/composables/use-validators"; import { useNuxtApp } from "#app";
import { RecipeIngredient } from "~/lib/api/types/recipe"; import type { RecipeIngredient } from "~/lib/api/types/recipe";
export default defineComponent({ // defineModel replaces modelValue prop
props: { const model = defineModel<RecipeIngredient>({ required: true });
value: {
type: Object as () => RecipeIngredient, const props = defineProps({
required: true,
},
disableAmount: { disableAmount: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -139,45 +192,52 @@ export default defineComponent({
allowInsertIngredient: { allowInsertIngredient: {
type: Boolean, type: Boolean,
default: false, default: false,
}
}, },
setup(props, { listeners }) { });
const { i18n, $globals } = useContext();
defineEmits([
"clickIngredientField",
"insert-above",
"insert-below",
"insert-ingredient",
"delete",
]);
const { mdAndUp } = useDisplay();
const i18n = useI18n();
const { $globals } = useNuxtApp();
const state = reactive({
showTitle: false,
showOriginalText: false,
});
const contextMenuOptions = computed(() => { const contextMenuOptions = computed(() => {
const options = [ const options = [
{ {
text: i18n.tc("recipe.toggle-section"), text: i18n.t("recipe.toggle-section"),
event: "toggle-section", event: "toggle-section",
}, },
{ {
text: i18n.tc("recipe.insert-above"), text: i18n.t("recipe.insert-above"),
event: "insert-above", event: "insert-above",
}, },
{ {
text: i18n.tc("recipe.insert-below"), text: i18n.t("recipe.insert-below"),
event: "insert-below", event: "insert-below",
}, },
]; ];
if (props.allowInsertIngredient) { if (props.allowInsertIngredient) {
options.push({ options.push({
text: i18n.tc("recipe.insert-ingredient") , text: i18n.t("recipe.insert-ingredient"),
event: "insert-ingredient", event: "insert-ingredient",
}) });
} }
// FUTURE: add option to parse a single ingredient if (model.value.originalText) {
// if (!value.food && !value.unit && value.note) {
// options.push({
// text: "Parse Ingredient",
// event: "parse-ingredient",
// });
// }
if (props.value.originalText) {
options.push({ options.push({
text: i18n.tc("recipe.see-original-text"), text: i18n.t("recipe.see-original-text"),
event: "toggle-original", event: "toggle-original",
}); });
} }
@@ -189,25 +249,24 @@ export default defineComponent({
const out = [ const out = [
{ {
icon: $globals.icons.dotsVertical, icon: $globals.icons.dotsVertical,
text: i18n.tc("general.menu"), text: i18n.t("general.menu"),
event: "open", event: "open",
children: contextMenuOptions.value, children: contextMenuOptions.value,
}, },
]; ];
if (listeners && listeners.delete) { // If delete event is being listened for, show delete button
// @ts-expect-error - TODO: fix this // $attrs is not available in <script setup>, so always show if parent listens
out.unshift({ out.unshift({
icon: $globals.icons.delete, icon: $globals.icons.delete,
text: i18n.tc("general.delete"), text: i18n.t("general.delete"),
event: "delete", event: "delete",
children: undefined,
}); });
}
return out; return out;
}); });
// ==================================================
// Foods // Foods
const foodStore = useFoodStore(); const foodStore = useFoodStore();
const foodData = useFoodData(); const foodData = useFoodData();
@@ -216,12 +275,11 @@ export default defineComponent({
async function createAssignFood() { async function createAssignFood() {
foodData.data.name = foodSearch.value; foodData.data.name = foodSearch.value;
props.value.food = await foodStore.actions.createOne(foodData.data) || undefined; model.value.food = await foodStore.actions.createOne(foodData.data) || undefined;
foodData.reset(); foodData.reset();
foodAutocomplete.value?.blur(); foodAutocomplete.value?.blur();
} }
// ==================================================
// Units // Units
const unitStore = useUnitStore(); const unitStore = useUnitStore();
const unitsData = useUnitData(); const unitsData = useUnitData();
@@ -230,19 +288,14 @@ export default defineComponent({
async function createAssignUnit() { async function createAssignUnit() {
unitsData.data.name = unitSearch.value; unitsData.data.name = unitSearch.value;
props.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined; model.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined;
unitsData.reset(); unitsData.reset();
unitAutocomplete.value?.blur(); unitAutocomplete.value?.blur();
} }
const state = reactive({
showTitle: false,
showOriginalText: false,
});
function toggleTitle() { function toggleTitle() {
if (state.showTitle) { if (state.showTitle) {
props.value.title = ""; model.value.title = "";
} }
state.showTitle = !state.showTitle; state.showTitle = !state.showTitle;
} }
@@ -253,9 +306,9 @@ export default defineComponent({
function handleUnitEnter() { function handleUnitEnter() {
if ( if (
props.value.unit === undefined || model.value.unit === undefined
props.value.unit === null || || model.value.unit === null
!props.value.unit.name.includes(unitSearch.value) || !model.value.unit.name.includes(unitSearch.value)
) { ) {
createAssignUnit(); createAssignUnit();
} }
@@ -263,44 +316,24 @@ export default defineComponent({
function handleFoodEnter() { function handleFoodEnter() {
if ( if (
props.value.food === undefined || model.value.food === undefined
props.value.food === null || || model.value.food === null
!props.value.food.name.includes(foodSearch.value) || !model.value.food.name.includes(foodSearch.value)
) { ) {
createAssignFood(); createAssignFood();
} }
} }
function quantityFilter(e: KeyboardEvent) { function quantityFilter(e: KeyboardEvent) {
// if digit is pressed, add to quantity
if (e.key === "-" || e.key === "+" || e.key === "e") { if (e.key === "-" || e.key === "+" || e.key === "e") {
e.preventDefault(); e.preventDefault();
} }
} }
return { const { showTitle, showOriginalText } = toRefs(state);
...toRefs(state),
quantityFilter, const foods = foodStore.store;
toggleOriginalText, const units = unitStore.store;
contextMenuOptions,
handleUnitEnter,
handleFoodEnter,
foodAutocomplete,
createAssignFood,
unitAutocomplete,
createAssignUnit,
foods: foodStore.store,
foodSearch,
toggleTitle,
unitActions: unitStore.actions,
units: unitStore.store,
unitSearch,
validators,
workingUnitData: unitsData.data,
btns,
};
},
});
</script> </script>
<style> <style>

View File

@@ -1,12 +1,12 @@
<template> <template>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="safeMarkup"></div> <div v-html="safeMarkup" />
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients"; import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
export default defineComponent({
export default defineNuxtComponent({
props: { props: {
markup: { markup: {
type: String, type: String,
@@ -17,7 +17,7 @@ export default defineComponent({
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup)); const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
return { return {
safeMarkup, safeMarkup,
} };
} },
}); });
</script> </script>

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