mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-06-13 12:30:14 -04:00
Compare commits
307 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc4851ef5 | ||
|
|
d9e933d5ae | ||
|
|
0a07835338 | ||
|
|
7a85ea6ae9 | ||
|
|
c4c60f1645 | ||
|
|
9f7ba8dc08 | ||
|
|
c4799ceb9e | ||
|
|
828be095a2 | ||
|
|
18718fb647 | ||
|
|
fb545962dd | ||
|
|
781a08ef54 | ||
|
|
a7a08b6b11 | ||
|
|
bd296c3eaf | ||
|
|
8aa016e57b | ||
|
|
480574eb3d | ||
|
|
0573d6fc9c | ||
|
|
f8d08c6785 | ||
|
|
e6368174f0 | ||
|
|
2252875050 | ||
|
|
54c62ec491 | ||
|
|
af79a751fb | ||
|
|
6e2c849412 | ||
|
|
76dbf4df45 | ||
|
|
4e5a2f9fb5 | ||
|
|
daa0b9728b | ||
|
|
0986ce2ca1 | ||
|
|
4972143004 | ||
|
|
499c42a52a | ||
|
|
92cf84f615 | ||
|
|
54511779a2 | ||
|
|
b72ccb8d29 | ||
|
|
9fb3bce792 | ||
|
|
32141187ba | ||
|
|
30014f53de | ||
|
|
d2b0681dbb | ||
|
|
306f2dcfc6 | ||
|
|
0fb5d31a22 | ||
|
|
1d5b263262 | ||
|
|
731e3aef37 | ||
|
|
fb04602a8e | ||
|
|
157b8d2937 | ||
|
|
6b28bb8eb0 | ||
|
|
124d10963e | ||
|
|
7c2ec93d13 | ||
|
|
d3e41582ae | ||
|
|
70a251a331 | ||
|
|
4fd224ade7 | ||
|
|
89694f7e54 | ||
|
|
7a60ad2227 | ||
|
|
eb71b962bc | ||
|
|
fe491bbe56 | ||
|
|
27f2dc1bf6 | ||
|
|
b3ea916192 | ||
|
|
240d681057 | ||
|
|
6932c9ef2d | ||
|
|
1438ba82d5 | ||
|
|
7a5032bf23 | ||
|
|
c3d1cf4c37 | ||
|
|
135a9ca684 | ||
|
|
ef90515ae8 | ||
|
|
a853e445ac | ||
|
|
7dad3777d3 | ||
|
|
a6ab0befba | ||
|
|
2c6997a601 | ||
|
|
9c3b94c019 | ||
|
|
5ce3099cfa | ||
|
|
0d1349cc7f | ||
|
|
7e7d1622dd | ||
|
|
d24aa7f65a | ||
|
|
5172571b2e | ||
|
|
bb278aac35 | ||
|
|
4ee97e5348 | ||
|
|
bac00a30a4 | ||
|
|
1123ec848d | ||
|
|
0f767f2e25 | ||
|
|
058dbdc9d6 | ||
|
|
94cf825a28 | ||
|
|
6ee69b7b3e | ||
|
|
f36c892bb7 | ||
|
|
f6305b785e | ||
|
|
1512a9e555 | ||
|
|
690b6aa57b | ||
|
|
c57af78f8f | ||
|
|
0775156aeb | ||
|
|
3356ebc0b8 | ||
|
|
1b59073dc4 | ||
|
|
ea3856b620 | ||
|
|
4f5d1cf1b4 | ||
|
|
626dee9500 | ||
|
|
1162c700cd | ||
|
|
7b3651d138 | ||
|
|
1a3676c36d | ||
|
|
17d9be3b15 | ||
|
|
7a8a511d48 | ||
|
|
085ecbaae3 | ||
|
|
453d40dab1 | ||
|
|
fc8b1f3719 | ||
|
|
c029a639fb | ||
|
|
63c549ae5c | ||
|
|
b1a846fe62 | ||
|
|
8545cf0c1c | ||
|
|
7c5913b012 | ||
|
|
4dd8d836e1 | ||
|
|
449e3baa07 | ||
|
|
e52a887e30 | ||
|
|
910ac4c81f | ||
|
|
52ad02aad8 | ||
|
|
93d51a2fdb | ||
|
|
41c3f1fced | ||
|
|
9f47f38176 | ||
|
|
a8142a08a1 | ||
|
|
1ede524d90 | ||
|
|
ab3eb6fec2 | ||
|
|
d64dcab9bd | ||
|
|
f9ff29dffc | ||
|
|
47794089da | ||
|
|
b0328ad926 | ||
|
|
18b3c4beab | ||
|
|
27cb585c80 | ||
|
|
f9ddfa94d4 | ||
|
|
5ab6e98f9e | ||
|
|
3ad2d9155d | ||
|
|
6278698ce5 | ||
|
|
3413c23f16 | ||
|
|
2ff2f22060 | ||
|
|
0f8ccdaade | ||
|
|
825c707035 | ||
|
|
9b9a767b00 | ||
|
|
c8793c474a | ||
|
|
b64e27b24b | ||
|
|
94cd6e89cb | ||
|
|
4c02724087 | ||
|
|
33c73feb1c | ||
|
|
002a7e3741 | ||
|
|
be4f71e5df | ||
|
|
78ff4bb875 | ||
|
|
26924ab054 | ||
|
|
c533da1c21 | ||
|
|
1969f50ee6 | ||
|
|
ff7d23d6d4 | ||
|
|
5e239be6fa | ||
|
|
e5520b08e5 | ||
|
|
fce0b47e2c | ||
|
|
e00d2a6e83 | ||
|
|
948914df50 | ||
|
|
0c7d9341bf | ||
|
|
b94b24640b | ||
|
|
c303198857 | ||
|
|
9424858985 | ||
|
|
3711154c44 | ||
|
|
cc12d07576 | ||
|
|
3e96bf7f43 | ||
|
|
ee550f4fe3 | ||
|
|
60d4a62f0a | ||
|
|
69d740e100 | ||
|
|
c4fdab4e05 | ||
|
|
04dd514e6a | ||
|
|
ad5d4b5aba | ||
|
|
b948314c6b | ||
|
|
2568015941 | ||
|
|
235a1f1931 | ||
|
|
8c23fd922a | ||
|
|
537f5ae1dd | ||
|
|
9372f51000 | ||
|
|
1bdb3ce54c | ||
|
|
16e8e8a877 | ||
|
|
6a3b38a31e | ||
|
|
8189416495 | ||
|
|
589c6de053 | ||
|
|
d3d339a4aa | ||
|
|
5c2bbea09b | ||
|
|
f6ca4bb29a | ||
|
|
ddc30fc65f | ||
|
|
a4d1e0a440 | ||
|
|
86b72f4d5b | ||
|
|
1344f1674d | ||
|
|
5a223aa92d | ||
|
|
845637c988 | ||
|
|
b5c089f58c | ||
|
|
96597915ff | ||
|
|
98c555fd20 | ||
|
|
118274ad9d | ||
|
|
50ea601683 | ||
|
|
7cc9010143 | ||
|
|
1f196cf10f | ||
|
|
024e20b4e5 | ||
|
|
5aafebb7a6 | ||
|
|
a89460acdf | ||
|
|
89091678d4 | ||
|
|
562eb89ee7 | ||
|
|
5e40fed623 | ||
|
|
0ddfd9caaf | ||
|
|
56086bdf49 | ||
|
|
a0674dd5d2 | ||
|
|
45c68d160a | ||
|
|
058937e334 | ||
|
|
c91f8e23d7 | ||
|
|
77081d0482 | ||
|
|
bf11729a23 | ||
|
|
b5016857c8 | ||
|
|
26fdf78709 | ||
|
|
455dbb1441 | ||
|
|
f28de01b2e | ||
|
|
ccdf7109e2 | ||
|
|
e574896449 | ||
|
|
15d56c42f7 | ||
|
|
c983e8bd59 | ||
|
|
7584c99591 | ||
|
|
78718dcf26 | ||
|
|
a8ec66f9aa | ||
|
|
3899f85735 | ||
|
|
7cbe17fe09 | ||
|
|
929349d414 | ||
|
|
595a3c66cd | ||
|
|
d4e7dc6e9d | ||
|
|
b719b39c09 | ||
|
|
b208719cb9 | ||
|
|
3ee64c930c | ||
|
|
189e98fb1f | ||
|
|
7da01f7873 | ||
|
|
8144799733 | ||
|
|
669df6bbb4 | ||
|
|
c6171f2cb2 | ||
|
|
e5d930ecb8 | ||
|
|
668246d369 | ||
|
|
b182b50faa | ||
|
|
a3e3fa6f56 | ||
|
|
bd7acabcd1 | ||
|
|
8059cc731f | ||
|
|
3ae455539c | ||
|
|
8fd7995681 | ||
|
|
282eedfe2b | ||
|
|
03f849f20f | ||
|
|
5db3b6ab72 | ||
|
|
353c24ca4b | ||
|
|
216ae8571c | ||
|
|
02d32c8905 | ||
|
|
7e0d083e77 | ||
|
|
b3cea081fe | ||
|
|
d79252752b | ||
|
|
b3c214d102 | ||
|
|
3a01925e48 | ||
|
|
16e2386f5a | ||
|
|
bbfa105e99 | ||
|
|
c94c9940b2 | ||
|
|
29c6176d89 | ||
|
|
0c0d7d11a5 | ||
|
|
e75fc6d391 | ||
|
|
f308869154 | ||
|
|
af30b8bdfa | ||
|
|
de4f22c3f6 | ||
|
|
4c55b282d6 | ||
|
|
8d2b2eb581 | ||
|
|
e9daac5fc4 | ||
|
|
ee1205cfdc | ||
|
|
a165b707af | ||
|
|
564385eb83 | ||
|
|
c23aa61f17 | ||
|
|
cd39d0c4cb | ||
|
|
20e2d4e1a1 | ||
|
|
c09cc5a323 | ||
|
|
6d7b6bccab | ||
|
|
91fea086e5 | ||
|
|
e2fbe118a7 | ||
|
|
904e6b7d82 | ||
|
|
5aafb56c4f | ||
|
|
b4740d291d | ||
|
|
fc6dc34ace | ||
|
|
73d86f6f6b | ||
|
|
8e225ee796 | ||
|
|
ced233d361 | ||
|
|
b173172e6c | ||
|
|
a66db96eb5 | ||
|
|
dfd5abfb5d | ||
|
|
e2ae5cb5b6 | ||
|
|
634aa5cd25 | ||
|
|
23c7bd7e3d | ||
|
|
9c1ee972c9 | ||
|
|
1b9023c8c0 | ||
|
|
3a37cd6959 | ||
|
|
8da0d010a5 | ||
|
|
37f7f770a8 | ||
|
|
1cebbefd88 | ||
|
|
d55149b904 | ||
|
|
fad7acadfc | ||
|
|
a539c6cd2e | ||
|
|
7b5502d019 | ||
|
|
26d9d8fe24 | ||
|
|
b64f14aaae | ||
|
|
9b686ecd2b | ||
|
|
a956a638f4 | ||
|
|
c9d9e6822e | ||
|
|
4a563b76ad | ||
|
|
73f97c2cca | ||
|
|
75e3c99d72 | ||
|
|
217ddd8814 | ||
|
|
f2cc8dc922 | ||
|
|
b8329def91 | ||
|
|
2ae7dc3b82 | ||
|
|
510a63a71f | ||
|
|
14433819c3 | ||
|
|
96a9dbccb6 | ||
|
|
cfe20214e5 | ||
|
|
eef54879fe | ||
|
|
c789ecf0ba | ||
|
|
008f55e725 | ||
|
|
bcbe32f503 |
@@ -21,6 +21,7 @@ RUN apt-get update \
|
|||||||
&& apt-get install --no-install-recommends -y \
|
&& apt-get install --no-install-recommends -y \
|
||||||
curl \
|
curl \
|
||||||
build-essential \
|
build-essential \
|
||||||
|
ffmpeg \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
libwebp-dev \
|
libwebp-dev \
|
||||||
libsasl2-dev libldap2-dev libssl-dev \
|
libsasl2-dev libldap2-dev libssl-dev \
|
||||||
|
|||||||
20
.github/copilot-instructions.md
vendored
20
.github/copilot-instructions.md
vendored
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Mealie is a self-hosted recipe manager, meal planner, and shopping list application with a FastAPI backend (Python 3.12) and Nuxt 3 frontend (Vue 3 + TypeScript). It uses SQLAlchemy ORM with support for SQLite and PostgreSQL databases.
|
Mealie is a self-hosted recipe manager, meal planner, and shopping list application with a FastAPI backend (Python 3.12) and Nuxt 4 frontend (Vue 3 + TypeScript). It uses SQLAlchemy ORM with support for SQLite and PostgreSQL databases.
|
||||||
|
|
||||||
**Development vs Production:**
|
**Development vs Production:**
|
||||||
- **Development:** Frontend (port 3000) and backend (port 9000) run as separate processes
|
- **Development:** Frontend (port 3000) and backend (port 9000) run as separate processes
|
||||||
@@ -28,7 +28,7 @@ Mealie is a self-hosted recipe manager, meal planner, and shopping list applicat
|
|||||||
**Schemas & Type Generation:**
|
**Schemas & Type Generation:**
|
||||||
- Pydantic schemas in `mealie/schema/` with strict separation: `*In`, `*Out`, `*Create`, `*Update` suffixes
|
- Pydantic schemas in `mealie/schema/` with strict separation: `*In`, `*Out`, `*Create`, `*Update` suffixes
|
||||||
- Auto-exported from submodules via `__init__.py` files (generated by `task dev:generate`)
|
- Auto-exported from submodules via `__init__.py` files (generated by `task dev:generate`)
|
||||||
- TypeScript types auto-generated from Pydantic schemas - **never manually edit** `frontend/lib/api/types/`
|
- TypeScript types auto-generated from Pydantic schemas - **never manually edit** `frontend/app/lib/api/types/`
|
||||||
|
|
||||||
**Database & Sessions:**
|
**Database & Sessions:**
|
||||||
- Session management via `Depends(generate_session)` in FastAPI routes
|
- Session management via `Depends(generate_session)` in FastAPI routes
|
||||||
@@ -45,13 +45,13 @@ Mealie is a self-hosted recipe manager, meal planner, and shopping list applicat
|
|||||||
- **Page Components** (`components/` with page prefix): Last resort for breaking up complex pages
|
- **Page Components** (`components/` with page prefix): Last resort for breaking up complex pages
|
||||||
|
|
||||||
**API Client Pattern:**
|
**API Client Pattern:**
|
||||||
- API clients in `frontend/lib/api/` extend `BaseAPI`, `BaseCRUDAPI`, or `BaseCRUDAPIReadOnly`
|
- API clients in `frontend/app/lib/api/` extend `BaseAPI`, `BaseCRUDAPI`, or `BaseCRUDAPIReadOnly`
|
||||||
- Types imported from auto-generated `frontend/lib/api/types/` (DO NOT EDIT MANUALLY)
|
- Types imported from auto-generated `frontend/app/lib/api/types/` (DO NOT EDIT MANUALLY)
|
||||||
- Composables in `frontend/composables/` for shared state and API logic (e.g., `use-mealie-auth.ts`)
|
- Composables in `frontend/app/composables/` for shared state and API logic (e.g., `use-mealie-auth.ts`)
|
||||||
- Use `useAuthBackend()` for authentication state, `useMealieAuth()` for user management
|
- Use `useAuthBackend()` for authentication state, `useMealieAuth()` for user management
|
||||||
|
|
||||||
**State Management:**
|
**State Management:**
|
||||||
- Nuxt 3 composables for state (no Vuex)
|
- Nuxt 4 composables for state (no Vuex)
|
||||||
- Auth state via `use-mealie-auth.ts` composable
|
- Auth state via `use-mealie-auth.ts` composable
|
||||||
- Prefer composables over global state stores
|
- Prefer composables over global state stores
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ task docker:prod # Build and run production Docker compose
|
|||||||
### Cross-Cutting Concerns
|
### Cross-Cutting Concerns
|
||||||
|
|
||||||
1. **Code generation is source of truth:** After Pydantic schema changes, run `task dev:generate` to update:
|
1. **Code generation is source of truth:** After Pydantic schema changes, run `task dev:generate` to update:
|
||||||
- TypeScript types (`frontend/lib/api/types/`)
|
- TypeScript types (`frontend/app/lib/api/types/`)
|
||||||
- Schema exports (`mealie/schema/*/__init__.py`)
|
- Schema exports (`mealie/schema/*/__init__.py`)
|
||||||
- Test data paths and routes
|
- Test data paths and routes
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ task docker:prod # Build and run production Docker compose
|
|||||||
- For frontend, does TypeScript code pass strict type checking?
|
- For frontend, does TypeScript code pass strict type checking?
|
||||||
|
|
||||||
**Generated Files:**
|
**Generated Files:**
|
||||||
- Verify `frontend/lib/api/types/` files weren't manually edited (they're auto-generated)
|
- Verify `frontend/app/lib/api/types/` files weren't manually edited (they're auto-generated)
|
||||||
- Check that `mealie/schema/*/__init__.py` exports match actual schema files (auto-generated)
|
- Check that `mealie/schema/*/__init__.py` exports match actual schema files (auto-generated)
|
||||||
- If schemas changed, confirm generated files were updated via `task dev:generate`
|
- If schemas changed, confirm generated files were updated via `task dev:generate`
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ task docker:prod # Build and run production Docker compose
|
|||||||
|
|
||||||
## Common Gotchas
|
## Common Gotchas
|
||||||
|
|
||||||
- **Don't manually edit generated files:** `frontend/lib/api/types/`, schema `__init__.py` files
|
- **Don't manually edit generated files:** `frontend/app/lib/api/types/`, schema `__init__.py` files
|
||||||
- **Repository context:** Repos are group/household-scoped - passing wrong IDs causes 404s
|
- **Repository context:** Repos are group/household-scoped - passing wrong IDs causes 404s
|
||||||
- **Session handling:** Don't create sessions manually, use dependency injection or `session_context()`
|
- **Session handling:** Don't create sessions manually, use dependency injection or `session_context()`
|
||||||
- **Schema changes require codegen:** After changing Pydantic models, run `task dev:generate`
|
- **Schema changes require codegen:** After changing Pydantic models, run `task dev:generate`
|
||||||
@@ -229,7 +229,7 @@ task docker:prod # Build and run production Docker compose
|
|||||||
- `Taskfile.yml` - All development commands and workflows
|
- `Taskfile.yml` - All development commands and workflows
|
||||||
- `mealie/routes/_base/base_controllers.py` - Controller base classes and patterns
|
- `mealie/routes/_base/base_controllers.py` - Controller base classes and patterns
|
||||||
- `mealie/repos/repository_factory.py` - Repository factory and available repos
|
- `mealie/repos/repository_factory.py` - Repository factory and available repos
|
||||||
- `frontend/lib/api/base/base-clients.ts` - API client base classes
|
- `frontend/app/lib/api/base/base-clients.ts` - API client base classes
|
||||||
- `tests/conftest.py` - Test fixtures and setup
|
- `tests/conftest.py` - Test fixtures and setup
|
||||||
- `dev/code-generation/main.py` - Code generation entry point
|
- `dev/code-generation/main.py` - Code generation entry point
|
||||||
|
|
||||||
|
|||||||
17
.github/pull_request_template.md
vendored
17
.github/pull_request_template.md
vendored
@@ -8,11 +8,11 @@
|
|||||||
- `chore: `
|
- `chore: `
|
||||||
- `dev:`
|
- `dev:`
|
||||||
|
|
||||||
If a section of the PR template does not apply to this PR, then delete that section.
|
If a section of the PR template does not apply to this PR, and is not marked as "required", then delete that section.
|
||||||
|
|
||||||
PLEASE READ:
|
PLEASE READ:
|
||||||
-------------------------
|
-------------------------
|
||||||
Mealie is moving to a regular, automatic release schedule. This means that all PRs should be in a
|
Mealie uses a regular, automatic release schedule. This means that all PRs should be in a
|
||||||
stable state, ready for release. This includes:
|
stable state, ready for release. This includes:
|
||||||
|
|
||||||
- Ensuring new tests have been added to cover new features, or to prevent regressions.
|
- Ensuring new tests have been added to cover new features, or to prevent regressions.
|
||||||
@@ -28,8 +28,6 @@ _(REQUIRED)_
|
|||||||
What goal is this change working towards?
|
What goal is this change working towards?
|
||||||
Provide a bullet pointed summary of how each file was changed.
|
Provide a bullet pointed summary of how each file was changed.
|
||||||
Briefly explain any decisions you made with respect to the changes.
|
Briefly explain any decisions you made with respect to the changes.
|
||||||
Include anything here that you didn't include in *Release Notes*
|
|
||||||
above, such as changes to CI or changes to internal methods.
|
|
||||||
|
|
||||||
If there is a UI component to the change, please include before/after images.
|
If there is a UI component to the change, please include before/after images.
|
||||||
-->
|
-->
|
||||||
@@ -43,6 +41,8 @@ If this PR fixes one of more issues, list them here.
|
|||||||
One per line, like so:
|
One per line, like so:
|
||||||
Fixes #123
|
Fixes #123
|
||||||
Fixes #39
|
Fixes #39
|
||||||
|
|
||||||
|
Be sure to include the word "fixes" otherwise the associated issue will not be closed.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Special notes for your reviewer:
|
## Special notes for your reviewer:
|
||||||
@@ -61,3 +61,12 @@ _(fill-in or delete this section)_
|
|||||||
<!--
|
<!--
|
||||||
Describe how you tested this change.
|
Describe how you tested this change.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
## AI / LLM Assistance
|
||||||
|
|
||||||
|
_(REQUIRED)_
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Describe to which degree an LLM was used in creating this pull request. Failure to accurately disclose LLM usage may result in
|
||||||
|
review delays or closure of your PR.
|
||||||
|
-->
|
||||||
|
|||||||
99
.github/workflows/auto-merge-dependencies.yml
vendored
Normal file
99
.github/workflows/auto-merge-dependencies.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
name: Auto-merge dependency PRs
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, labeled]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: contains(github.event.pull_request.labels.*.name, 'dependencies')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Validate PR author
|
||||||
|
env:
|
||||||
|
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
|
run: |
|
||||||
|
if [[ "$AUTHOR" != "renovate[bot]" ]]; then
|
||||||
|
echo "::error::PR author must be renovate[bot] for auto-merge (got: $AUTHOR)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Author validated: $AUTHOR"
|
||||||
|
|
||||||
|
- name: Reject major updates
|
||||||
|
env:
|
||||||
|
TITLE: ${{ github.event.pull_request.title }}
|
||||||
|
run: |
|
||||||
|
if echo "$TITLE" | grep -qiE '(major|breaking)'; then
|
||||||
|
echo "::error::Major/breaking updates require manual review"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PR title does not indicate a major update"
|
||||||
|
|
||||||
|
- name: Validate file paths
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
FILES=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json files --jq '.files[].path')
|
||||||
|
|
||||||
|
for file in $FILES; do
|
||||||
|
if [[ "$file" == "pyproject.toml" ]] || \
|
||||||
|
[[ "$file" == "uv.lock" ]] || \
|
||||||
|
[[ "$file" == "frontend/package.json" ]] || \
|
||||||
|
[[ "$file" == "frontend/yarn.lock" ]] || \
|
||||||
|
[[ "$file" =~ ^docker/ ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "::error::Unexpected file path: $file"
|
||||||
|
echo "Only dependency and lock files are allowed for auto-merge"
|
||||||
|
exit 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "All files are in allowed paths"
|
||||||
|
|
||||||
|
- name: Approve PR
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
APPROVED=$(gh pr view "$PR_NUMBER" \
|
||||||
|
--repo "$REPO" \
|
||||||
|
--json reviews \
|
||||||
|
--jq '.reviews[] | select(.state == "APPROVED") | .id' \
|
||||||
|
| wc -l)
|
||||||
|
|
||||||
|
if [ "$APPROVED" -gt 0 ]; then
|
||||||
|
echo "PR already approved"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
gh pr review "$PR_NUMBER" \
|
||||||
|
--repo "$REPO" \
|
||||||
|
--approve \
|
||||||
|
--body "Auto-approved: dependency update from Renovate with valid file paths"
|
||||||
|
|
||||||
|
- name: Generate GitHub App Token
|
||||||
|
id: app-token
|
||||||
|
uses: actions/create-github-app-token@v1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
|
||||||
|
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- name: Enable auto-merge
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
gh pr merge "$PR_NUMBER" \
|
||||||
|
--repo "$REPO" \
|
||||||
|
--auto \
|
||||||
|
--squash
|
||||||
14
.github/workflows/auto-merge-l10n.yml
vendored
14
.github/workflows/auto-merge-l10n.yml
vendored
@@ -40,8 +40,8 @@ jobs:
|
|||||||
|
|
||||||
echo "PR changes: +$ADDITIONS -$DELETIONS (total: $TOTAL lines)"
|
echo "PR changes: +$ADDITIONS -$DELETIONS (total: $TOTAL lines)"
|
||||||
|
|
||||||
if [ "$TOTAL" -gt 400 ]; then
|
if [ "$TOTAL" -gt 6000 ]; then
|
||||||
echo "::error::PR exceeds 400 line change limit ($TOTAL lines)"
|
echo "::error::PR exceeds 6000 line change limit ($TOTAL lines)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -55,8 +55,9 @@ jobs:
|
|||||||
|
|
||||||
for file in $FILES; do
|
for file in $FILES; do
|
||||||
# Check if file matches any allowed path
|
# Check if file matches any allowed path
|
||||||
if [[ "$file" == "frontend/composables/use-locales/available-locales.ts" ]] || \
|
if [[ "$file" == "frontend/app/composables/use-locales/available-locales.ts" ]] || \
|
||||||
[[ "$file" =~ ^frontend/lang/ ]] || \
|
[[ "$file" =~ ^frontend/app/lang/ ]] || \
|
||||||
|
[[ "$file" =~ ^mealie/lang/ ]] || \
|
||||||
[[ "$file" =~ ^mealie/repos/seed/resources/[^/]+/locales/ ]]; then
|
[[ "$file" =~ ^mealie/repos/seed/resources/[^/]+/locales/ ]]; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
@@ -64,8 +65,9 @@ jobs:
|
|||||||
# File doesn't match allowed paths
|
# File doesn't match allowed paths
|
||||||
echo "::error::Invalid file path: $file"
|
echo "::error::Invalid file path: $file"
|
||||||
echo "Only the following paths are allowed:"
|
echo "Only the following paths are allowed:"
|
||||||
echo " - frontend/composables/use-locales/available-locales.ts"
|
echo " - frontend/app/composables/use-locales/available-locales.ts"
|
||||||
echo " - frontend/lang/"
|
echo " - frontend/app/lang/"
|
||||||
|
echo " - mealie/lang/"
|
||||||
echo " - mealie/repos/seed/resources/*/locales/"
|
echo " - mealie/repos/seed/resources/*/locales/"
|
||||||
exit 1
|
exit 1
|
||||||
done
|
done
|
||||||
|
|||||||
23
.github/workflows/build-package.yml
vendored
23
.github/workflows/build-package.yml
vendored
@@ -6,6 +6,9 @@ on:
|
|||||||
tag:
|
tag:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
ref:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-frontend:
|
build-frontend:
|
||||||
@@ -14,10 +17,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout 🛎
|
- name: Checkout 🛎
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
|
|
||||||
- name: Setup node env 🏗
|
- name: Setup node env 🏗
|
||||||
uses: actions/setup-node@v4.0.0
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@@ -27,7 +32,7 @@ jobs:
|
|||||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache node_modules 📦
|
- name: Cache node_modules 📦
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||||
@@ -44,7 +49,7 @@ jobs:
|
|||||||
working-directory: "frontend"
|
working-directory: "frontend"
|
||||||
|
|
||||||
- name: Archive built frontend
|
- name: Archive built frontend
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: frontend-dist
|
name: frontend-dist
|
||||||
path: frontend/dist
|
path: frontend/dist
|
||||||
@@ -63,10 +68,12 @@ jobs:
|
|||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
|
|
||||||
- name: Set up python
|
- name: Set up python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
@@ -74,7 +81,7 @@ jobs:
|
|||||||
run: pip install uv
|
run: pip install uv
|
||||||
|
|
||||||
- name: Retrieve built frontend
|
- name: Retrieve built frontend
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: frontend-dist
|
name: frontend-dist
|
||||||
path: mealie/frontend
|
path: mealie/frontend
|
||||||
@@ -90,7 +97,7 @@ jobs:
|
|||||||
task py:package
|
task py:package
|
||||||
|
|
||||||
- name: Archive built package
|
- name: Archive built package
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: backend-dist
|
name: backend-dist
|
||||||
path: dist
|
path: dist
|
||||||
|
|||||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
@@ -44,11 +44,11 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v4
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v3
|
uses: github/codeql-action/autobuild@v4
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
@@ -75,6 +75,6 @@ jobs:
|
|||||||
# ./location_of_script_within_repo/buildscript.sh
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3
|
uses: github/codeql-action/analyze@v4
|
||||||
with:
|
with:
|
||||||
category: "/language:${{matrix.language}}"
|
category: "/language:${{matrix.language}}"
|
||||||
|
|||||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v4
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|||||||
10
.github/workflows/e2e.yml
vendored
10
.github/workflows/e2e.yml
vendored
@@ -10,21 +10,21 @@ jobs:
|
|||||||
run:
|
run:
|
||||||
working-directory: ./tests/e2e
|
working-directory: ./tests/e2e
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
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
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
- name: Retrieve Python package
|
- name: Retrieve Python package
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: backend-dist
|
name: backend-dist
|
||||||
path: dist
|
path: dist
|
||||||
- name: Build Image
|
- name: Build Image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
file: ./docker/Dockerfile
|
file: ./docker/Dockerfile
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
6
.github/workflows/locale-sync.yml
vendored
6
.github/workflows/locale-sync.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
|||||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Load cached venv
|
- name: Load cached venv
|
||||||
id: cached-python-dependencies
|
id: cached-python-dependencies
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: .venv
|
path: .venv
|
||||||
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
|
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
fail-fast: true
|
fail-fast: true
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Build Dockerfile
|
- name: Build Dockerfile
|
||||||
run: |
|
run: |
|
||||||
@@ -28,6 +28,6 @@ jobs:
|
|||||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db
|
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
- name: Upload Trivy scan results to GitHub Security tab
|
||||||
uses: github/codeql-action/upload-sarif@v3
|
uses: github/codeql-action/upload-sarif@v4
|
||||||
with:
|
with:
|
||||||
sarif_file: "trivy-results.sarif"
|
sarif_file: "trivy-results.sarif"
|
||||||
|
|||||||
28
.github/workflows/publish.yml
vendored
28
.github/workflows/publish.yml
vendored
@@ -9,6 +9,9 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
|
ref:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
secrets:
|
secrets:
|
||||||
DOCKERHUB_USERNAME:
|
DOCKERHUB_USERNAME:
|
||||||
required: true
|
required: true
|
||||||
@@ -20,25 +23,39 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
|
|
||||||
- name: Log in to the Container registry (ghcr.io)
|
- name: Log in to the Container registry (ghcr.io)
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to the Container registry (dockerhub)
|
- name: Log in to the Container registry (dockerhub)
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: depot/setup-action@v1
|
- uses: depot/setup-action@v1
|
||||||
|
|
||||||
|
- name: Generate Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v6
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
hkotel/mealie
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
# Overwrite the image.version label with our tag
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.version=${{ inputs.tag }}
|
||||||
|
org.opencontainers.image.revision=${{ inputs.ref || github.sha }}
|
||||||
|
|
||||||
- name: Retrieve Python package
|
- name: Retrieve Python package
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: backend-dist
|
name: backend-dist
|
||||||
path: dist
|
path: dist
|
||||||
@@ -57,5 +74,6 @@ jobs:
|
|||||||
hkotel/mealie:${{ inputs.tag }}
|
hkotel/mealie:${{ inputs.tag }}
|
||||||
ghcr.io/${{ github.repository }}:${{ inputs.tag }}
|
ghcr.io/${{ github.repository }}:${{ inputs.tag }}
|
||||||
${{ inputs.tags }}
|
${{ inputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT=${{ github.sha }}
|
COMMIT=${{ inputs.ref || github.sha }}
|
||||||
|
|||||||
2
.github/workflows/pull-request-lint.yml
vendored
2
.github/workflows/pull-request-lint.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# https://github.com/amannn/action-semantic-pull-request
|
# https://github.com/amannn/action-semantic-pull-request
|
||||||
- uses: amannn/action-semantic-pull-request@v5
|
- uses: amannn/action-semantic-pull-request@v6
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
28
.github/workflows/release-drafter.yml
vendored
28
.github/workflows/release-drafter.yml
vendored
@@ -5,26 +5,28 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- mealie-next
|
- mealie-next
|
||||||
# pull_request event is required for autolabeler
|
|
||||||
pull_request:
|
|
||||||
types: [opened, labeled, unlabeled, reopened, synchronize]
|
|
||||||
# pull_request_target event is required for autolabeler to support PRs from forks
|
# pull_request_target event is required for autolabeler to support PRs from forks
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types: [opened, labeled, unlabeled, reopened, synchronize]
|
types: [opened, labeled, unlabeled, reopened, synchronize]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update_release_draft:
|
draft_release:
|
||||||
permissions:
|
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||||
# write permission is required to create a github release
|
|
||||||
contents: write
|
|
||||||
# write permission is required for autolabeler
|
|
||||||
# otherwise, read permission is required at least
|
|
||||||
pull-requests: write
|
|
||||||
name: ✏️ Draft release
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: 🚀 Run Release Drafter
|
- uses: release-drafter/release-drafter@v7
|
||||||
uses: release-drafter/release-drafter@v6.0.0
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
auto_label:
|
||||||
|
if: github.event_name == 'pull_request_target'
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: release-drafter/release-drafter/autolabeler@v7
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Checkout 🛎
|
- name: Checkout 🛎
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
@@ -60,12 +60,16 @@ jobs:
|
|||||||
uses: ./.github/workflows/test-backend.yml
|
uses: ./.github/workflows/test-backend.yml
|
||||||
needs:
|
needs:
|
||||||
- commit-version-bump
|
- commit-version-bump
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||||
|
|
||||||
frontend-tests:
|
frontend-tests:
|
||||||
name: "Frontend Tests"
|
name: "Frontend Tests"
|
||||||
uses: ./.github/workflows/test-frontend.yml
|
uses: ./.github/workflows/test-frontend.yml
|
||||||
needs:
|
needs:
|
||||||
- commit-version-bump
|
- commit-version-bump
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||||
|
|
||||||
build-package:
|
build-package:
|
||||||
name: Build Package
|
name: Build Package
|
||||||
@@ -74,6 +78,7 @@ jobs:
|
|||||||
- commit-version-bump
|
- commit-version-bump
|
||||||
with:
|
with:
|
||||||
tag: ${{ github.event.release.tag_name }}
|
tag: ${{ github.event.release.tag_name }}
|
||||||
|
ref: ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
permissions:
|
permissions:
|
||||||
@@ -90,7 +95,9 @@ jobs:
|
|||||||
- backend-tests
|
- backend-tests
|
||||||
- frontend-tests
|
- frontend-tests
|
||||||
- build-package
|
- build-package
|
||||||
|
- commit-version-bump
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||||
tag: ${{ github.event.release.tag_name }}
|
tag: ${{ github.event.release.tag_name }}
|
||||||
tags: |
|
tags: |
|
||||||
hkotel/mealie:latest
|
hkotel/mealie:latest
|
||||||
@@ -117,7 +124,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Checkout 🛎
|
- name: Checkout 🛎
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
6
.github/workflows/scheduled-checks.yml
vendored
6
.github/workflows/scheduled-checks.yml
vendored
@@ -13,10 +13,10 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout 🛎
|
- name: Checkout 🛎
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
|
run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache
|
- name: Cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cache/pre-commit
|
~/.cache/pre-commit
|
||||||
|
|||||||
12
.github/workflows/test-backend.yml
vendored
12
.github/workflows/test-backend.yml
vendored
@@ -2,6 +2,10 @@ name: Backend Lint and Test
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
@@ -42,10 +46,12 @@ jobs:
|
|||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
|
|
||||||
- name: Set up python
|
- name: Set up python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
@@ -54,7 +60,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Load cached venv
|
- name: Load cached venv
|
||||||
id: cached-python-dependencies
|
id: cached-python-dependencies
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: .venv
|
path: .venv
|
||||||
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
|
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
|
||||||
|
|||||||
12
.github/workflows/test-frontend.yml
vendored
12
.github/workflows/test-frontend.yml
vendored
@@ -2,6 +2,10 @@ name: Frontend Lint and Test
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
@@ -9,10 +13,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout 🛎
|
- name: Checkout 🛎
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
|
|
||||||
- name: Setup node env 🏗
|
- name: Setup node env 🏗
|
||||||
uses: actions/setup-node@v4.0.0
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@@ -22,7 +28,7 @@ jobs:
|
|||||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache node_modules 📦
|
- name: Cache node_modules 📦
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ 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.15.0
|
rev: v0.15.8
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
# Linter
|
||||||
|
- id: ruff-check
|
||||||
|
args: [ --fix ]
|
||||||
|
# Formatter
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|||||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -17,6 +17,8 @@
|
|||||||
"source.fixAll.eslint": "explicit",
|
"source.fixAll.eslint": "explicit",
|
||||||
"source.organizeImports": "never"
|
"source.organizeImports": "never"
|
||||||
},
|
},
|
||||||
|
"editor.insertSpaces": true,
|
||||||
|
"editor.tabSize": 2,
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"eslint.useFlatConfig": true,
|
"eslint.useFlatConfig": true,
|
||||||
"eslint.workingDirectories": [
|
"eslint.workingDirectories": [
|
||||||
@@ -30,11 +32,12 @@
|
|||||||
"**/.svn": true,
|
"**/.svn": true,
|
||||||
"**/CVS": true
|
"**/CVS": true
|
||||||
},
|
},
|
||||||
|
"files.insertFinalNewline": true,
|
||||||
"i18n-ally.enabledFrameworks": [
|
"i18n-ally.enabledFrameworks": [
|
||||||
"vue"
|
"vue"
|
||||||
],
|
],
|
||||||
"i18n-ally.keystyle": "nested",
|
"i18n-ally.keystyle": "nested",
|
||||||
"i18n-ally.localesPaths": "frontend/lang/messages",
|
"i18n-ally.localesPaths": "frontend/app/lang/messages",
|
||||||
"i18n-ally.sourceLanguage": "en-US",
|
"i18n-ally.sourceLanguage": "en-US",
|
||||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||||
"python.testing.autoTestDiscoverOnSaveEnabled": false,
|
"python.testing.autoTestDiscoverOnSaveEnabled": false,
|
||||||
@@ -67,6 +70,7 @@
|
|||||||
},
|
},
|
||||||
"[python]": {
|
"[python]": {
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||||
|
"editor.tabSize": 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
Taskfile.yml
10
Taskfile.yml
@@ -25,16 +25,9 @@ dotenv:
|
|||||||
- .env
|
- .env
|
||||||
- .dev.env
|
- .dev.env
|
||||||
tasks:
|
tasks:
|
||||||
docs:gen:
|
|
||||||
desc: runs the API documentation generator
|
|
||||||
cmds:
|
|
||||||
- uv run python dev/code-generation/gen_docs_api.py
|
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
desc: runs the documentation server
|
desc: runs the documentation server
|
||||||
dir: docs
|
dir: docs
|
||||||
deps:
|
|
||||||
- docs:gen
|
|
||||||
cmds:
|
cmds:
|
||||||
- uv run python -m mkdocs serve
|
- uv run python -m mkdocs serve
|
||||||
|
|
||||||
@@ -81,7 +74,6 @@ tasks:
|
|||||||
desc: run code generators
|
desc: run code generators
|
||||||
cmds:
|
cmds:
|
||||||
- uv run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
- uv run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
||||||
- task: docs:gen
|
|
||||||
- task: py:format
|
- task: py:format
|
||||||
|
|
||||||
dev:services:
|
dev:services:
|
||||||
@@ -350,4 +342,4 @@ tasks:
|
|||||||
vars: { WAIT_UNTIL_HEALTHY: true }
|
vars: { WAIT_UNTIL_HEALTHY: true }
|
||||||
- defer: { task: e2e:stop-server }
|
- defer: { task: e2e:stop-server }
|
||||||
- task: e2e:test
|
- task: e2e:test
|
||||||
vars: { PREVENT_REPORT_OPEN: true }
|
vars: { PREVENT_REPORT_OPEN: true }
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ pull_request_labels: [
|
|||||||
"l10n"
|
"l10n"
|
||||||
]
|
]
|
||||||
files:
|
files:
|
||||||
- source: /frontend/lang/messages/en-US.json
|
- source: /frontend/app/lang/messages/en-US.json
|
||||||
translation: /frontend/lang/messages/%locale%.json
|
translation: /frontend/app/lang/messages/%locale%.json
|
||||||
- source: /mealie/lang/messages/en-US.json
|
- source: /mealie/lang/messages/en-US.json
|
||||||
translation: /mealie/lang/messages/%locale%.json
|
translation: /mealie/lang/messages/%locale%.json
|
||||||
- source: /mealie/repos/seed/resources/foods/locales/en-US.json
|
- source: /mealie/repos/seed/resources/foods/locales/en-US.json
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import json
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from mealie.app import app
|
|
||||||
from mealie.core.config import determine_data_dir
|
|
||||||
|
|
||||||
DATA_DIR = determine_data_dir()
|
|
||||||
|
|
||||||
"""Script to export the ReDoc documentation page into a standalone HTML file."""
|
|
||||||
|
|
||||||
HTML_TEMPLATE = """<!-- Custom HTML site displayed as the Home chapter -->
|
|
||||||
{% extends "main.html" %}
|
|
||||||
{% block tabs %}
|
|
||||||
{{ super() }}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
<div id="redoc-container"></div>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"> </script>
|
|
||||||
<script>
|
|
||||||
var spec = MY_SPECIFIC_TEXT;
|
|
||||||
Redoc.init(spec, {}, document.getElementById("redoc-container"));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
{% block footer %}{% endblock %}
|
|
||||||
"""
|
|
||||||
|
|
||||||
HTML_PATH = DATA_DIR.parent.parent.joinpath("docs/docs/overrides/api.html")
|
|
||||||
CONSTANT_DT = datetime(2025, 10, 24, 15, 53, 0, 0, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_timestamps(s: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
field_format = s.get("format")
|
|
||||||
is_timestamp = field_format in ["date-time", "date", "time"]
|
|
||||||
has_default = s.get("default")
|
|
||||||
|
|
||||||
if not is_timestamp:
|
|
||||||
for k, v in s.items():
|
|
||||||
if isinstance(v, dict):
|
|
||||||
s[k] = normalize_timestamps(v)
|
|
||||||
elif isinstance(v, list):
|
|
||||||
s[k] = [normalize_timestamps(i) if isinstance(i, dict) else i for i in v]
|
|
||||||
|
|
||||||
return s
|
|
||||||
elif not has_default:
|
|
||||||
return s
|
|
||||||
|
|
||||||
if field_format == "date-time":
|
|
||||||
s["default"] = CONSTANT_DT.isoformat()
|
|
||||||
elif field_format == "date":
|
|
||||||
s["default"] = CONSTANT_DT.date().isoformat()
|
|
||||||
elif field_format == "time":
|
|
||||||
s["default"] = CONSTANT_DT.time().isoformat()
|
|
||||||
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def generate_api_docs(my_app: FastAPI):
|
|
||||||
openapi_schema = my_app.openapi()
|
|
||||||
openapi_schema = normalize_timestamps(openapi_schema)
|
|
||||||
|
|
||||||
with open(HTML_PATH, "w") as fd:
|
|
||||||
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(openapi_schema))
|
|
||||||
fd.write(text)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
generate_api_docs(app)
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
from dataclasses import dataclass
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import dotenv
|
import dotenv
|
||||||
@@ -10,6 +11,7 @@ from pydantic import ConfigDict
|
|||||||
from requests import Response
|
from requests import Response
|
||||||
from utils import CodeDest, CodeKeys, inject_inline, log
|
from utils import CodeDest, CodeKeys, inject_inline, log
|
||||||
|
|
||||||
|
from mealie.lang.locale_config import LOCALE_CONFIG, LocalePluralFoodHandling, LocaleTextDirection
|
||||||
from mealie.schema._mealie import MealieModel
|
from mealie.schema._mealie import MealieModel
|
||||||
|
|
||||||
BASE = pathlib.Path(__file__).parent.parent.parent
|
BASE = pathlib.Path(__file__).parent.parent.parent
|
||||||
@@ -17,57 +19,6 @@ BASE = pathlib.Path(__file__).parent.parent.parent
|
|||||||
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") or os.environ.get("CROWDIN_API_KEY", "")
|
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") or os.environ.get("CROWDIN_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LocaleData:
|
|
||||||
name: str
|
|
||||||
dir: str = "ltr"
|
|
||||||
|
|
||||||
|
|
||||||
LOCALE_DATA: dict[str, LocaleData] = {
|
|
||||||
"af-ZA": LocaleData(name="Afrikaans (Afrikaans)"),
|
|
||||||
"ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"),
|
|
||||||
"bg-BG": LocaleData(name="Български (Bulgarian)"),
|
|
||||||
"ca-ES": LocaleData(name="Català (Catalan)"),
|
|
||||||
"cs-CZ": LocaleData(name="Čeština (Czech)"),
|
|
||||||
"da-DK": LocaleData(name="Dansk (Danish)"),
|
|
||||||
"de-DE": LocaleData(name="Deutsch (German)"),
|
|
||||||
"el-GR": LocaleData(name="Ελληνικά (Greek)"),
|
|
||||||
"en-GB": LocaleData(name="British English"),
|
|
||||||
"en-US": LocaleData(name="American English"),
|
|
||||||
"es-ES": LocaleData(name="Español (Spanish)"),
|
|
||||||
"et-EE": LocaleData(name="Eesti (Estonian)"),
|
|
||||||
"fi-FI": LocaleData(name="Suomi (Finnish)"),
|
|
||||||
"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)"),
|
|
||||||
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
|
|
||||||
"hr-HR": LocaleData(name="Hrvatski (Croatian)"),
|
|
||||||
"hu-HU": LocaleData(name="Magyar (Hungarian)"),
|
|
||||||
"is-IS": LocaleData(name="Íslenska (Icelandic)"),
|
|
||||||
"it-IT": LocaleData(name="Italiano (Italian)"),
|
|
||||||
"ja-JP": LocaleData(name="日本語 (Japanese)"),
|
|
||||||
"ko-KR": LocaleData(name="한국어 (Korean)"),
|
|
||||||
"lt-LT": LocaleData(name="Lietuvių (Lithuanian)"),
|
|
||||||
"lv-LV": LocaleData(name="Latviešu (Latvian)"),
|
|
||||||
"nl-NL": LocaleData(name="Nederlands (Dutch)"),
|
|
||||||
"no-NO": LocaleData(name="Norsk (Norwegian)"),
|
|
||||||
"pl-PL": LocaleData(name="Polski (Polish)"),
|
|
||||||
"pt-BR": LocaleData(name="Português do Brasil (Brazilian Portuguese)"),
|
|
||||||
"pt-PT": LocaleData(name="Português (Portuguese)"),
|
|
||||||
"ro-RO": LocaleData(name="Română (Romanian)"),
|
|
||||||
"ru-RU": LocaleData(name="Pусский (Russian)"),
|
|
||||||
"sk-SK": LocaleData(name="Slovenčina (Slovak)"),
|
|
||||||
"sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
|
|
||||||
"sr-SP": LocaleData(name="српски (Serbian)"),
|
|
||||||
"sv-SE": LocaleData(name="Svenska (Swedish)"),
|
|
||||||
"tr-TR": LocaleData(name="Türkçe (Turkish)"),
|
|
||||||
"uk-UA": LocaleData(name="Українська (Ukrainian)"),
|
|
||||||
"vi-VN": LocaleData(name="Tiếng Việt (Vietnamese)"),
|
|
||||||
"zh-CN": LocaleData(name="简体中文 (Chinese simplified)"),
|
|
||||||
"zh-TW": LocaleData(name="繁體中文 (Chinese traditional)"),
|
|
||||||
}
|
|
||||||
|
|
||||||
LOCALE_TEMPLATE = """// This Code is auto generated by gen_ts_locales.py
|
LOCALE_TEMPLATE = """// This Code is auto generated by gen_ts_locales.py
|
||||||
export const LOCALES = [{% for locale in locales %}
|
export const LOCALES = [{% for locale in locales %}
|
||||||
{
|
{
|
||||||
@@ -75,6 +26,7 @@ export const LOCALES = [{% for locale in locales %}
|
|||||||
value: "{{ locale.locale }}",
|
value: "{{ locale.locale }}",
|
||||||
progress: {{ locale.progress }},
|
progress: {{ locale.progress }},
|
||||||
dir: "{{ locale.dir }}",
|
dir: "{{ locale.dir }}",
|
||||||
|
pluralFoodHandling: "{{ locale.plural_food_handling }}",
|
||||||
},{% endfor %}
|
},{% endfor %}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -87,10 +39,11 @@ class TargetLanguage(MealieModel):
|
|||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
locale: str
|
locale: str
|
||||||
dir: str = "ltr"
|
dir: LocaleTextDirection = LocaleTextDirection.LTR
|
||||||
|
plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
|
||||||
threeLettersCode: str
|
threeLettersCode: str
|
||||||
twoLettersCode: str
|
twoLettersCode: str
|
||||||
progress: float = 0.0
|
progress: int = 0
|
||||||
|
|
||||||
|
|
||||||
class CrowdinApi:
|
class CrowdinApi:
|
||||||
@@ -117,52 +70,24 @@ class CrowdinApi:
|
|||||||
def get_languages(self) -> list[TargetLanguage]:
|
def get_languages(self) -> list[TargetLanguage]:
|
||||||
response = self.get_project()
|
response = self.get_project()
|
||||||
tls = response.json()["data"]["targetLanguages"]
|
tls = response.json()["data"]["targetLanguages"]
|
||||||
|
return [TargetLanguage(**t) for t in tls]
|
||||||
|
|
||||||
models = [TargetLanguage(**t) for t in tls]
|
def get_progress(self) -> dict[str, int]:
|
||||||
|
|
||||||
models.insert(
|
|
||||||
0,
|
|
||||||
TargetLanguage(
|
|
||||||
id="en-US",
|
|
||||||
name="English",
|
|
||||||
locale="en-US",
|
|
||||||
dir="ltr",
|
|
||||||
threeLettersCode="en",
|
|
||||||
twoLettersCode="en",
|
|
||||||
progress=100,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
progress: list[dict] = self.get_progress()["data"]
|
|
||||||
|
|
||||||
for model in models:
|
|
||||||
if model.locale in LOCALE_DATA:
|
|
||||||
locale_data = LOCALE_DATA[model.locale]
|
|
||||||
model.name = locale_data.name
|
|
||||||
model.dir = locale_data.dir
|
|
||||||
|
|
||||||
for p in progress:
|
|
||||||
if p["data"]["languageId"] == model.id:
|
|
||||||
model.progress = p["data"]["translationProgress"]
|
|
||||||
|
|
||||||
models.sort(key=lambda x: x.locale, reverse=True)
|
|
||||||
return models
|
|
||||||
|
|
||||||
def get_progress(self) -> dict:
|
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500",
|
f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500",
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
)
|
)
|
||||||
return response.json()
|
data = response.json()["data"]
|
||||||
|
return {p["data"]["languageId"]: p["data"]["translationProgress"] for p in data}
|
||||||
|
|
||||||
|
|
||||||
PROJECT_DIR = Path(__file__).parent.parent.parent
|
PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
|
||||||
datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats"
|
datetime_dir = PROJECT_DIR / "frontend" / "app" / "lang" / "dateTimeFormats"
|
||||||
locales_dir = PROJECT_DIR / "frontend" / "lang" / "messages"
|
locales_dir = PROJECT_DIR / "frontend" / "app" / "lang" / "messages"
|
||||||
nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.ts"
|
nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.ts"
|
||||||
i18n_config = PROJECT_DIR / "frontend" / "i18n.config.ts"
|
i18n_config = PROJECT_DIR / "frontend" / "app" / "i18n.config.ts"
|
||||||
reg_valid = PROJECT_DIR / "mealie" / "schema" / "_mealie" / "validators.py"
|
reg_valid = PROJECT_DIR / "mealie" / "schema" / "_mealie" / "validators.py"
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -195,8 +120,8 @@ def inject_nuxt_values():
|
|||||||
|
|
||||||
all_langs = []
|
all_langs = []
|
||||||
for match in locales_dir.glob("*.json"):
|
for match in locales_dir.glob("*.json"):
|
||||||
match_data = LOCALE_DATA.get(match.stem)
|
match_data = LOCALE_CONFIG.get(match.stem)
|
||||||
match_dir = match_data.dir if match_data else "ltr"
|
match_dir = match_data.dir if match_data else LocaleTextDirection.LTR
|
||||||
|
|
||||||
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},'
|
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},'
|
||||||
all_langs.append(lang_string)
|
all_langs.append(lang_string)
|
||||||
@@ -221,9 +146,82 @@ def inject_registration_validation_values():
|
|||||||
inject_inline(reg_valid, CodeKeys.nuxt_local_messages, all_langs)
|
inject_inline(reg_valid, CodeKeys.nuxt_local_messages, all_langs)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_local_models() -> list[TargetLanguage]:
|
||||||
|
return [
|
||||||
|
TargetLanguage(
|
||||||
|
id=locale,
|
||||||
|
name=data.name,
|
||||||
|
locale=locale,
|
||||||
|
threeLettersCode=locale.split("-")[-1],
|
||||||
|
twoLettersCode=locale.split("-")[-1],
|
||||||
|
)
|
||||||
|
for locale, data in LOCALE_CONFIG.items()
|
||||||
|
if locale != "en-US" # Crowdin doesn't include this, so we manually inject it later
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_local_progress() -> dict[str, int]:
|
||||||
|
with open(CodeDest.use_locales) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Extract the array content between [ and ]
|
||||||
|
match = re.search(r"export const LOCALES = (\[.*?\]);", content, re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
raise ValueError("Could not find LOCALES array in file")
|
||||||
|
|
||||||
|
# Convert JS to JSON
|
||||||
|
array_content = match.group(1)
|
||||||
|
|
||||||
|
# Replace unquoted keys with quoted keys for valid JSON
|
||||||
|
# This converts: { name: "value" } to { "name": "value" }
|
||||||
|
json_str = re.sub(r"([,\{\s])([a-zA-Z_][a-zA-Z0-9_]*)\s*:", r'\1"\2":', array_content)
|
||||||
|
|
||||||
|
# Remove trailing commas before } and ]
|
||||||
|
json_str = re.sub(r",(\s*[}\]])", r"\1", json_str)
|
||||||
|
|
||||||
|
locales = json.loads(json_str)
|
||||||
|
return {locale["value"]: locale["progress"] for locale in locales}
|
||||||
|
|
||||||
|
|
||||||
|
def get_languages() -> list[TargetLanguage]:
|
||||||
|
if API_KEY:
|
||||||
|
api = CrowdinApi(None)
|
||||||
|
models = api.get_languages()
|
||||||
|
progress = api.get_progress()
|
||||||
|
else:
|
||||||
|
log.warning("CROWDIN_API_KEY is not set, using local lanugages instead")
|
||||||
|
log.warning("DOUBLE CHECK the output!!! Do not overwrite with bad local locale data!")
|
||||||
|
models = _get_local_models()
|
||||||
|
progress = _get_local_progress()
|
||||||
|
|
||||||
|
models.insert(
|
||||||
|
0,
|
||||||
|
TargetLanguage(
|
||||||
|
id="en-US",
|
||||||
|
name="English",
|
||||||
|
locale="en-US",
|
||||||
|
dir=LocaleTextDirection.LTR,
|
||||||
|
plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT,
|
||||||
|
threeLettersCode="en",
|
||||||
|
twoLettersCode="en",
|
||||||
|
progress=100,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for model in models:
|
||||||
|
if model.locale in LOCALE_CONFIG:
|
||||||
|
locale_data = LOCALE_CONFIG[model.locale]
|
||||||
|
model.name = locale_data.name
|
||||||
|
model.dir = locale_data.dir
|
||||||
|
model.plural_food_handling = locale_data.plural_food_handling
|
||||||
|
model.progress = progress.get(model.id, model.progress)
|
||||||
|
|
||||||
|
models.sort(key=lambda x: x.locale, reverse=True)
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
def generate_locales_ts_file():
|
def generate_locales_ts_file():
|
||||||
api = CrowdinApi(None)
|
models = get_languages()
|
||||||
models = api.get_languages()
|
|
||||||
tmpl = Template(LOCALE_TEMPLATE)
|
tmpl = Template(LOCALE_TEMPLATE)
|
||||||
rendered = tmpl.render(locales=models)
|
rendered = tmpl.render(locales=models)
|
||||||
|
|
||||||
@@ -233,10 +231,6 @@ def generate_locales_ts_file():
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if API_KEY is None or API_KEY == "":
|
|
||||||
log.error("CROWDIN_API_KEY is not set")
|
|
||||||
return
|
|
||||||
|
|
||||||
generate_locales_ts_file()
|
generate_locales_ts_file()
|
||||||
inject_nuxt_values()
|
inject_nuxt_values()
|
||||||
inject_registration_validation_values()
|
inject_registration_validation_values()
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ PROJECT_DIR = Path(__file__).parent.parent.parent
|
|||||||
|
|
||||||
|
|
||||||
def generate_global_components_types() -> None:
|
def generate_global_components_types() -> None:
|
||||||
destination_file = PROJECT_DIR / "frontend" / "types" / "components.d.ts"
|
destination_file = PROJECT_DIR / "frontend" / "app" / "types" / "components.d.ts"
|
||||||
|
|
||||||
component_paths = {
|
component_paths = {
|
||||||
"global": PROJECT_DIR / "frontend" / "components" / "global",
|
"global": PROJECT_DIR / "frontend" / "app" / "components" / "global",
|
||||||
"layout": PROJECT_DIR / "frontend" / "components" / "Layout",
|
"layout": PROJECT_DIR / "frontend" / "app" / "components" / "Layout",
|
||||||
}
|
}
|
||||||
|
|
||||||
def render_template(template: str, data: dict) -> str | None:
|
def render_template(template: str, data: dict) -> str | None:
|
||||||
@@ -182,7 +182,7 @@ def generate_typescript_types() -> None: # noqa: C901
|
|||||||
return str_path
|
return str_path
|
||||||
|
|
||||||
schema_path = PROJECT_DIR / "mealie" / "schema"
|
schema_path = PROJECT_DIR / "mealie" / "schema"
|
||||||
types_dir = PROJECT_DIR / "frontend" / "lib" / "api" / "types"
|
types_dir = PROJECT_DIR / "frontend" / "app" / "lib" / "api" / "types"
|
||||||
|
|
||||||
ignore_dirs = ["__pycache__", "static", "_mealie"]
|
ignore_dirs = ["__pycache__", "static", "_mealie"]
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class CodeTemplates:
|
|||||||
class CodeDest:
|
class CodeDest:
|
||||||
interface = PARENT / "generated" / "interface.js"
|
interface = PARENT / "generated" / "interface.js"
|
||||||
pytest_routes = PARENT / "generated" / "test_routes.py"
|
pytest_routes = PARENT / "generated" / "test_routes.py"
|
||||||
use_locales = PROJECT_DIR / "frontend" / "composables" / "use-locales" / "available-locales.ts"
|
use_locales = PROJECT_DIR / "frontend" / "app" / "composables" / "use-locales" / "available-locales.ts"
|
||||||
|
|
||||||
|
|
||||||
class CodeKeys:
|
class CodeKeys:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
###############################################
|
###############################################
|
||||||
# Frontend Build
|
# Frontend Build
|
||||||
###############################################
|
###############################################
|
||||||
FROM node:24@sha256:1de022d8459f896fff2e7b865823699dc7a8d5567507e8b87b14a7442e07f206 \
|
FROM node:24@sha256:33cf7f057918860b043c307751ef621d74ac96f875b79b6724dcebf2dfd0db6d \
|
||||||
AS frontend-builder
|
AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
@@ -21,7 +21,7 @@ RUN yarn generate
|
|||||||
###############################################
|
###############################################
|
||||||
# Base Image - Python
|
# Base Image - Python
|
||||||
###############################################
|
###############################################
|
||||||
FROM python:3.12-slim@sha256:2267adc248a477c1f1a852a07a5a224d42abe54c28aafa572efa157dfb001bba \
|
FROM python:3.12-slim@sha256:7026274c107626d7e940e0e5d6730481a4600ae95d5ca7eb532dd4180313fea9 \
|
||||||
AS python-base
|
AS python-base
|
||||||
|
|
||||||
ENV MEALIE_HOME="/app"
|
ENV MEALIE_HOME="/app"
|
||||||
@@ -91,6 +91,7 @@ RUN apt-get update \
|
|||||||
build-essential \
|
build-essential \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
libwebp-dev \
|
libwebp-dev \
|
||||||
|
ffmpeg \
|
||||||
# LDAP Dependencies
|
# LDAP Dependencies
|
||||||
libsasl2-dev libldap2-dev libssl-dev \
|
libsasl2-dev libldap2-dev libssl-dev \
|
||||||
gnupg gnupg2 gnupg1 \
|
gnupg gnupg2 gnupg1 \
|
||||||
@@ -111,7 +112,6 @@ 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"
|
|
||||||
ENV PRODUCTION=true
|
ENV PRODUCTION=true
|
||||||
ENV TESTING=false
|
ENV TESTING=false
|
||||||
|
|
||||||
@@ -120,6 +120,8 @@ ENV GIT_COMMIT_HASH=$COMMIT
|
|||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install --no-install-recommends -y \
|
&& apt-get install --no-install-recommends -y \
|
||||||
|
curl \
|
||||||
|
ffmpeg \
|
||||||
gosu \
|
gosu \
|
||||||
iproute2 \
|
iproute2 \
|
||||||
libldap-common \
|
libldap-common \
|
||||||
@@ -142,7 +144,9 @@ ENV APP_PORT=9000
|
|||||||
|
|
||||||
EXPOSE ${APP_PORT}
|
EXPOSE ${APP_PORT}
|
||||||
|
|
||||||
HEALTHCHECK CMD python -m mealie.scripts.healthcheck || exit 1
|
COPY ./docker/healthcheck.sh $MEALIE_HOME/healthcheck.sh
|
||||||
|
RUN chmod +x $MEALIE_HOME/healthcheck.sh
|
||||||
|
HEALTHCHECK CMD $MEALIE_HOME/healthcheck.sh
|
||||||
|
|
||||||
ENV HOST 0.0.0.0
|
ENV HOST 0.0.0.0
|
||||||
|
|
||||||
|
|||||||
12
docker/healthcheck.sh
Executable file
12
docker/healthcheck.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
PORT="${API_PORT:-9000}"
|
||||||
|
|
||||||
|
if [ -n "$TLS_CERTIFICATE_PATH" ] && [ -n "$TLS_PRIVATE_KEY_PATH" ]; then
|
||||||
|
PROTO="https"
|
||||||
|
else
|
||||||
|
PROTO="http"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -k: TLS certificate is likely not issued for 127.0.0.1, so don't verify
|
||||||
|
curl -fsk "${PROTO}://127.0.0.1:${PORT}/api/app/about" > /dev/null
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
title: API
|
|
||||||
template: api.html
|
|
||||||
---
|
|
||||||
@@ -77,7 +77,7 @@ Now you're ready to start the servers. You'll need two shells open, One for the
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
We use vue-i18n package for internationalization. Translations are stored in json format located in [frontend/lang/messages](https://github.com/mealie-recipes/mealie/tree/mealie-next/frontend/lang/messages).
|
We use vue-i18n package for internationalization. Translations are stored in json format located in [frontend/app/lang/messages](https://github.com/mealie-recipes/mealie/tree/mealie-next/frontend/app/lang/messages).
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ This filter will find all foods that are not named "carrot": <br>
|
|||||||
##### Keyword Filters
|
##### Keyword Filters
|
||||||
The API supports many SQL keywords, such as `IS NULL` and `IN`, as well as their negations (e.g. `IS NOT NULL` and `NOT IN`).
|
The API supports many SQL keywords, such as `IS NULL` and `IN`, as well as their negations (e.g. `IS NOT NULL` and `NOT IN`).
|
||||||
|
|
||||||
Here is an example of a filter that returns all recipes where the "last made" value is not null: <br>
|
Here is an example of a filter that returns all shopping list items without a food: <br>
|
||||||
`lastMade IS NOT NULL`
|
`foodId IS NULL`
|
||||||
|
|
||||||
This filter will find all recipes that don't start with the word "Test": <br>
|
This filter will find all recipes that don't start with the word "Test": <br>
|
||||||
`name NOT LIKE "Test%"`
|
`name NOT LIKE "Test%"`
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
## Recipes
|
## Recipes
|
||||||
|
|
||||||
### Creating Recipes
|
### Creating Recipes
|
||||||
|
Mealie offers several ways to create recipes:
|
||||||
Mealie offers two main ways to create recipes. You can use the integrated recipe-scraper to create recipes from hundreds of websites, or you can create recipes manually using the recipe editor.
|
- **Recipe Scraper:** Create recipes from hundreds of websites by simply providing a URL.
|
||||||
|
- **Image Import:** Upload an image of a written or typed recipe and Mealie will use OCR to import it.
|
||||||
|
- **Video URL Import:** Provide a video URL (e.g., YouTube) and Mealie will transcribe the audio and parse the recipe.
|
||||||
|
- **Manual Editor:** Create recipes from scratch using the integrated editor.
|
||||||
[Creation Demo](https://demo.mealie.io/g/home/r/create/url){ .md-button .md-button--primary .align-right }
|
[Creation Demo](https://demo.mealie.io/g/home/r/create/url){ .md-button .md-button--primary .align-right }
|
||||||
|
|
||||||
### Importing Recipes
|
### Importing Recipes
|
||||||
|
|||||||
@@ -122,18 +122,20 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
|
|||||||
Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./open-ai.md).
|
Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./open-ai.md).
|
||||||
For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values as JSON encoded strings (e.g. `OPENAI_CUSTOM_PARAMS='{"k1": "v1", "k2": "v2"}'`)
|
For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values as JSON encoded strings (e.g. `OPENAI_CUSTOM_PARAMS='{"k1": "v1", "k2": "v2"}'`)
|
||||||
|
|
||||||
| Variables | Default | Description |
|
| Variables | Default | Description |
|
||||||
|---------------------------------------------------|:-------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|-------------------------------------------------------------------------|:-----------:|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| OPENAI_BASE_URL<super>[†][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
|
| OPENAI_BASE_URL<super>[†][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
|
||||||
| OPENAI_API_KEY<super>[†][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features |
|
| OPENAI_API_KEY<super>[†][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features |
|
||||||
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
|
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
|
||||||
| OPENAI_CUSTOM_HEADERS | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
| OPENAI_AUDIO_MODEL <br/> :octicons-tag-24: v3.13.0 | whisper-1 | Which OpenAI model to use for audio transcriptions, if enabled. If you're not sure, leave this empty |
|
||||||
| OPENAI_CUSTOM_PARAMS | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
| OPENAI_CUSTOM_HEADERS <br/> :octicons-tag-24: v2.0.0 | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
||||||
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
| OPENAI_CUSTOM_PARAMS <br/> :octicons-tag-24: v2.0.0 | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
||||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
| OPENAI_ENABLE_IMAGE_SERVICES <br/> :octicons-tag-24: v1.12.0 | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
||||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
| OPENAI_ENABLE_TRANSCRIPTION_SERVICES <br/> :octicons-tag-24: v3.13.0 | True | Whether to enable OpenAI transcription services, such as creating recipes via video URL. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
||||||
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||||
| OPENAI_CUSTOM_PROMPT_DIR | None | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. |
|
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||||
|
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||||
|
| OPENAI_CUSTOM_PROMPT_DIR <br/> :octicons-tag-24: v3.10.0 | None. | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. |
|
||||||
|
|
||||||
### Theming
|
### Theming
|
||||||
|
|
||||||
|
|||||||
@@ -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:v3.10.2`
|
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.16.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
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,15 @@ For most users, supplying the OpenAI API key is all you need to do; you will use
|
|||||||
|
|
||||||
Alternatively, if you have another service you'd like to use in-place of OpenAI, you can configure Mealie to use that instead, as long as it has an OpenAI-compatible API. For instance, a common self-hosted alternative to OpenAI is [Ollama](https://ollama.com/). To use Ollama with Mealie, change your `OPENAI_BASE_URL` to `http://localhost:11434/v1` (where `http://localhost:11434` is wherever you're hosting Ollama, and `/v1` enables the OpenAI-compatible endpoints). Note that you *must* provide an API key, even though it is ultimately ignored by Ollama.
|
Alternatively, if you have another service you'd like to use in-place of OpenAI, you can configure Mealie to use that instead, as long as it has an OpenAI-compatible API. For instance, a common self-hosted alternative to OpenAI is [Ollama](https://ollama.com/). To use Ollama with Mealie, change your `OPENAI_BASE_URL` to `http://localhost:11434/v1` (where `http://localhost:11434` is wherever you're hosting Ollama, and `/v1` enables the OpenAI-compatible endpoints). Note that you *must* provide an API key, even though it is ultimately ignored by Ollama.
|
||||||
|
|
||||||
If you wish to disable image recognition features (to save costs, or because your custom model doesn't support them) you can set `OPENAI_ENABLE_IMAGE_SERVICES` to `False`. For more information on what configuration options are available, check out the [backend configuration](./backend-config.md#openai).
|
If you wish to disable image recognition features (to save costs, or because your custom model doesn't support them) you can set `OPENAI_ENABLE_IMAGE_SERVICES` to `False`.
|
||||||
|
If you wish to disable transcription features (to save costs, or because your custom model doesn't support them) you can set `OPENAI_ENABLE_TRANSCRIPTION_SERVICES` to `False`.
|
||||||
|
|
||||||
|
For more information on what configuration options are available, check out the [backend configuration](./backend-config.md#openai).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## OpenAI Features
|
## OpenAI Features
|
||||||
- The OpenAI Ingredient Parser can be used as an alternative to the NLP and Brute Force parsers. Simply choose the OpenAI parser while parsing ingredients (:octicons-tag-24: v1.7.0)
|
- The OpenAI Ingredient Parser can be used as an alternative to the NLP and Brute Force parsers. Simply choose the OpenAI parser while parsing ingredients (:octicons-tag-24: v1.7.0)
|
||||||
- When importing a recipe via URL, if the default recipe scraper is unable to read the recipe data from a webpage, the webpage contents will be parsed by OpenAI (:octicons-tag-24: v1.9.0)
|
- When importing a recipe via URL, if the default recipe scraper is unable to read the recipe data from a webpage, the webpage contents will be parsed by OpenAI (:octicons-tag-24: v1.9.0)
|
||||||
- You can import an image of a written recipe, which is sent to OpenAI and imported into Mealie. The recipe can be hand-written or typed, as long as the text is in the picture. You can also optionally have OpenAI translate the recipe into your own language (:octicons-tag-24: v1.12.0)
|
- You can import an image of a written recipe, which is sent to OpenAI and imported into Mealie. The recipe can be hand-written or typed, as long as the text is in the picture. You can also optionally have OpenAI translate the recipe into your own language (:octicons-tag-24: v1.12.0)
|
||||||
|
- You can import a recipe via a video URL (e.g., a YouTube link). The video is transcribed using OpenAI's Whisper model, and the transcription is parsed into a recipe (:octicons-tag-24: v3.13.0)
|
||||||
|
|||||||
@@ -10,7 +10,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:v3.10.2 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v3.16.0 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -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:v3.10.2 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v3.16.0 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -355,20 +355,20 @@
|
|||||||
title="github.com">
|
title="github.com">
|
||||||
<svg style="width: 32px; height: 32px" viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
|
<svg style="width: 32px; height: 32px" viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
d="M186.1 328.7c0 20.9-10.9 55.1-36.7 55.1s-36.7-34.2-36.7-55.1 10.9-55.1 36.7-55.1 36.7 34.2 36.7 55.1zM480 278.2c0 31.9-3.2 65.7-17.5 95-37.9 76.6-142.1 74.8-216.7 74.8-75.8 0-186.2 2.7-225.6-74.8-14.6-29-20.2-63.1-20.2-95 0-41.9 13.9-81.5 41.5-113.6-5.2-15.8-7.7-32.4-7.7-48.8 0-21.5 4.9-32.3 14.6-51.8 45.3 0 74.3 9 108.8 36 29-6.9 58.8-10 88.7-10 27 0 54.2 2.9 80.4 9.2 34-26.7 63-35.2 107.8-35.2 9.8 19.5 14.6 30.3 14.6 51.8 0 16.4-2.6 32.7-7.7 48.2 27.5 32.4 39 72.3 39 114.2zm-64.3 50.5c0-43.9-26.7-82.6-73.5-82.6-18.9 0-37 3.4-56 6-14.9 2.3-29.8 3.2-45.1 3.2-15.2 0-30.1-.9-45.1-3.2-18.7-2.6-37-6-56-6-46.8 0-73.5 38.7-73.5 82.6 0 87.8 80.4 101.3 150.4 101.3h48.2c70.3 0 150.6-13.4 150.6-101.3zm-82.6-55.1c-25.8 0-36.7 34.2-36.7 55.1s10.9 55.1 36.7 55.1 36.7-34.2 36.7-55.1-10.9-55.1-36.7-55.1z">
|
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6.0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6.0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3.0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1.0-6.2-.3-40.4-.3-61.4.0.0-70 15-84.7-29.8.0.0-11.4-29.1-27.8-36.6.0.0-22.9-15.7 1.6-15.4.0.0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5.0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9.0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4.0 33.7-.3 75.4-.3 83.6.0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6.0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9.0-6.2-1.4-2.3-4-3.3-5.6-2z">
|
||||||
</path>
|
</path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<a class="md-footer-social__link" href="https://twitter.com/kot_hay" rel="noopener" target="_blank"
|
<a class="md-footer-social__link" href="https://bsky.app/profile/haykot.dev" rel="noopener" target="_blank"
|
||||||
title="twitter.com">
|
title="bsky.app">
|
||||||
<svg style="width: 32px; height: 32px" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
<svg style="width: 32px; height: 32px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z">
|
d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.204-.659-.299-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8z">
|
||||||
</path>
|
</path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<a class="md-footer-social__link" href="https://www.linkedin.com/in/hay-kot" rel="noopener" target="_blank"
|
<a class="md-footer-social__link" href="https://www.linkedin.com/in/hay-kot" rel="noopener" target="_blank"
|
||||||
title="www.linkedin.com">
|
title="linkedin.com">
|
||||||
<svg style="width: 32px; height: 32px" viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg">
|
<svg style="width: 32px; height: 32px" viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
d="M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z">
|
d="M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ theme:
|
|||||||
custom_dir: docs/overrides
|
custom_dir: docs/overrides
|
||||||
features:
|
features:
|
||||||
- content.code.annotate
|
- content.code.annotate
|
||||||
|
- content.code.copy
|
||||||
- navigation.top
|
- navigation.top
|
||||||
- navigation.instant
|
- navigation.instant
|
||||||
- navigation.expand
|
- navigation.expand
|
||||||
@@ -93,7 +94,7 @@ nav:
|
|||||||
- iOS Shortcut: "documentation/community-guide/ios-shortcut.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: "https://demo.mealie.io/docs"
|
||||||
|
|
||||||
- Contributors Guide:
|
- Contributors Guide:
|
||||||
- Non-Code: "contributors/non-coders.md"
|
- Non-Code: "contributors/non-coders.md"
|
||||||
|
|||||||
@@ -16,16 +16,16 @@
|
|||||||
max-width: 950px !important;
|
max-width: 950px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme--dark.v-application {
|
.lg-container {
|
||||||
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
|
max-width: 1100px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme--dark.v-navigation-drawer {
|
.v-theme--dark.v-application {
|
||||||
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
|
background-color: rgb(var(--v-theme-background)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme--dark.v-card {
|
.v-theme--dark .v-navigation-drawer {
|
||||||
background-color: #1e1e1e !important;
|
background-color: rgb(var(--v-theme-background)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-border {
|
.left-border {
|
||||||
@@ -57,10 +57,6 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
|
||||||
color: rgb(var(--v-theme-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.fill-height {
|
.fill-height {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
@@ -68,3 +64,8 @@ a {
|
|||||||
.vue-simple-handler {
|
.vue-simple-handler {
|
||||||
background-color: rgb(var(--v-theme-primary)) !important;
|
background-color: rgb(var(--v-theme-primary)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
116
frontend/app/components/Domain/Admin/Setup/EndPageContent.vue
Normal file
116
frontend/app/components/Domain/Admin/Setup/EndPageContent.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<v-container max-width="880" class="end-page-content">
|
||||||
|
<div class="d-flex flex-column ga-6">
|
||||||
|
<div>
|
||||||
|
<v-card-title class="text-h4 justify-center">
|
||||||
|
{{ $t('admin.setup.setup-complete') }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-subtitle class="justify-center">
|
||||||
|
{{ $t('admin.setup.here-are-a-few-things-to-help-you-get-started') }}
|
||||||
|
</v-card-subtitle>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="section, idx in sections"
|
||||||
|
:key="idx"
|
||||||
|
class="d-flex flex-column ga-3"
|
||||||
|
>
|
||||||
|
<v-card-title class="text-h6 pl-0">
|
||||||
|
{{ section.title }}
|
||||||
|
</v-card-title>
|
||||||
|
<div class="sections d-flex flex-column ga-2">
|
||||||
|
<v-card
|
||||||
|
v-for="link, linkIdx in section.links"
|
||||||
|
:key="linkIdx"
|
||||||
|
clas="link-card"
|
||||||
|
:href="link.to"
|
||||||
|
:title="link.text"
|
||||||
|
:subtitle="link.description"
|
||||||
|
:append-icon="$globals.icons.chevronRight"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-avatar :icon="link.icon || undefined" variant="tonal" :color="section.color" />
|
||||||
|
</template>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const i18n = useI18n();
|
||||||
|
const auth = useMealieAuth();
|
||||||
|
const groupSlug = computed(() => auth.user.value?.groupSlug);
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
|
||||||
|
const sections = ref([
|
||||||
|
{
|
||||||
|
title: i18n.t("profile.data-migrations"),
|
||||||
|
color: "info",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
icon: $globals.icons.backupRestore,
|
||||||
|
to: "/admin/backups",
|
||||||
|
text: i18n.t("settings.backup.backup-restore"),
|
||||||
|
description: i18n.t("admin.setup.restore-from-v1-backup"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.import,
|
||||||
|
to: "/group/migrations",
|
||||||
|
text: i18n.t("migration.recipe-migration"),
|
||||||
|
description: i18n.t("migration.coming-from-another-application-or-an-even-older-version-of-mealie"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: i18n.t("recipe.create-recipes"),
|
||||||
|
color: "success",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
icon: $globals.icons.createAlt,
|
||||||
|
to: computed(() => `/g/${groupSlug.value || ""}/r/create/new`),
|
||||||
|
text: i18n.t("recipe.create-recipe"),
|
||||||
|
description: i18n.t("recipe.create-recipe-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.link,
|
||||||
|
to: computed(() => `/g/${groupSlug.value || ""}/r/create/url`),
|
||||||
|
text: i18n.t("recipe.import-with-url"),
|
||||||
|
description: i18n.t("recipe.scrape-recipe-description"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: i18n.t("user.manage-users"),
|
||||||
|
color: "primary",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
icon: $globals.icons.group,
|
||||||
|
to: "/admin/manage/users",
|
||||||
|
text: i18n.t("user.manage-users"),
|
||||||
|
description: i18n.t("user.manage-users-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.user,
|
||||||
|
to: "/user/profile",
|
||||||
|
text: i18n.t("profile.manage-user-profile"),
|
||||||
|
description: i18n.t("admin.setup.manage-profile-or-get-invite-link"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.v-container {
|
||||||
|
.v-card-title,
|
||||||
|
.v-card-subtitle {
|
||||||
|
padding: 0;
|
||||||
|
white-space: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card-item {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<BaseDialog
|
||||||
|
v-if="currentAnnouncement"
|
||||||
|
v-model="dialog"
|
||||||
|
:title="$t('announcements.announcements')"
|
||||||
|
:icon="$globals.icons.bullhornVariant"
|
||||||
|
:cancel-text="$t('general.done')"
|
||||||
|
width="100%"
|
||||||
|
max-width="1200"
|
||||||
|
>
|
||||||
|
<div class="d-flex" :style="{ height: useMobile ? '100%' : '60vh', minHeight: '60vh' }">
|
||||||
|
<!-- Nav list -->
|
||||||
|
<v-list
|
||||||
|
v-show="!useMobile || navOpen"
|
||||||
|
nav
|
||||||
|
density="compact"
|
||||||
|
color="primary"
|
||||||
|
class="overflow-y-auto border-e flex-shrink-0"
|
||||||
|
style="width: 200px; max-height: 60vh"
|
||||||
|
>
|
||||||
|
<v-list-item
|
||||||
|
v-for="announcement in allAnnouncements.toReversed()"
|
||||||
|
:key="announcement.key"
|
||||||
|
:active="currentAnnouncement.key === announcement.key"
|
||||||
|
rounded
|
||||||
|
@click="setCurrentAnnouncement(announcement); navOpen = false"
|
||||||
|
>
|
||||||
|
<v-list-item-title class="text-body-2">
|
||||||
|
{{ announcement.meta?.title }}
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle v-if="announcement.date">
|
||||||
|
{{ $d(announcement.date) }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
|
||||||
|
<template v-if="newAnnouncements.some(a => a.key === announcement.key)" #append>
|
||||||
|
<v-icon size="x-small" color="info">
|
||||||
|
{{ $globals.icons.alertCircle }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div
|
||||||
|
class="flex-grow-1 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
v-if="useMobile"
|
||||||
|
:prepend-icon="navOpen ? $globals.icons.chevronLeft : $globals.icons.chevronRight"
|
||||||
|
density="compact"
|
||||||
|
variant="text"
|
||||||
|
class="mt-2 ms-2"
|
||||||
|
@click="navOpen = !navOpen"
|
||||||
|
>
|
||||||
|
{{ $t("announcements.all-announcements") }}
|
||||||
|
</v-btn>
|
||||||
|
<v-card-title>
|
||||||
|
<v-chip v-if="currentAnnouncement.date" label large class="me-1">
|
||||||
|
<v-icon class="me-1">
|
||||||
|
{{ $globals.icons.calendar }}
|
||||||
|
</v-icon>
|
||||||
|
{{ $d(currentAnnouncement.date) }}
|
||||||
|
</v-chip>
|
||||||
|
{{ currentAnnouncement.meta?.title }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<component :is="currentAnnouncement.component" />
|
||||||
|
</v-card-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #custom-card-action>
|
||||||
|
<BaseButton
|
||||||
|
v-if="newAnnouncements.length"
|
||||||
|
color="success"
|
||||||
|
:icon="$globals.icons.textBoxCheckOutline"
|
||||||
|
:text="$t('announcements.mark-all-as-read')"
|
||||||
|
@click="markAllAsRead"
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
:disabled="isLastAnnouncement(currentAnnouncement.key)"
|
||||||
|
color="info"
|
||||||
|
:icon="$globals.icons.arrowRightBold"
|
||||||
|
icon-right
|
||||||
|
:text="$t('general.next')"
|
||||||
|
@click="nextAnnouncement"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</BaseDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAnnouncements } from "~/composables/use-announcements";
|
||||||
|
import type { Announcement } from "~/composables/use-announcements";
|
||||||
|
|
||||||
|
const dialog = defineModel<boolean>({ default: false });
|
||||||
|
|
||||||
|
const display = useDisplay();
|
||||||
|
const useMobile = computed(() => display.smAndDown.value);
|
||||||
|
const navOpen = ref(false);
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
watch(() => route.fullPath, () => { dialog.value = false; });
|
||||||
|
|
||||||
|
const { newAnnouncements, allAnnouncements, setLastRead, markAllAsRead } = useAnnouncements();
|
||||||
|
|
||||||
|
const currentAnnouncement = shallowRef<Announcement | undefined>();
|
||||||
|
|
||||||
|
watch(dialog, () => {
|
||||||
|
if (!dialog.value || currentAnnouncement.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show first unread on open, or fall back to the newest
|
||||||
|
const next = newAnnouncements.value.at(0) || allAnnouncements.at(-1)!;
|
||||||
|
setCurrentAnnouncement(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
function setCurrentAnnouncement(announcement: Announcement) {
|
||||||
|
currentAnnouncement.value = announcement;
|
||||||
|
setLastRead(announcement.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextAnnouncement() {
|
||||||
|
// Find the first unread announcement after the current one (current is already removed from newAnnouncements)
|
||||||
|
const next = newAnnouncements.value.find(a => a.key > currentAnnouncement.value!.key);
|
||||||
|
if (next) {
|
||||||
|
setCurrentAnnouncement(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLastAnnouncement(key: string) {
|
||||||
|
if (!newAnnouncements.value.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return key >= newAnnouncements.value.at(-1)!.key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Welcome to Mealie! If this is your first time seeing announcements, here's what to expect.
|
||||||
|
</p>
|
||||||
|
<div class="mb-2">
|
||||||
|
Announcements are reserved for things like:
|
||||||
|
<ul class="ml-6">
|
||||||
|
<li>Important new features</li>
|
||||||
|
<li>Major changes</li>
|
||||||
|
<li>Anything that might require additional user actions (such as migration scripts)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
While we generally keep everything in our <a class="text-primary" href="https://github.com/mealie-recipes/mealie/releases" target="_blank">GitHub release notes</a>,
|
||||||
|
sometimes certain changes require some extra attention.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Announcements are English-only; they're one-off messages from the maintainers, not a replacement for our release notes. Some elements may still be translated.
|
||||||
|
</p>
|
||||||
|
<hr class="mt-2 mb-4">
|
||||||
|
<p>
|
||||||
|
You can opt out of announcements in your user settings:
|
||||||
|
<br>
|
||||||
|
<v-btn class="mt-2" color="primary" to="/user/profile/edit">
|
||||||
|
{{ $t("profile.user-settings") }}
|
||||||
|
</v-btn>
|
||||||
|
</p>
|
||||||
|
<p v-if="user?.canManageHousehold" class="mt-3">
|
||||||
|
As {{ user?.admin ? "an admin" : "a household manager" }}, you can disable announcements for your entire household:
|
||||||
|
<br>
|
||||||
|
<v-btn class="mt-2" color="primary" to="/household">
|
||||||
|
{{ $t("profile.household-settings") }}
|
||||||
|
</v-btn>
|
||||||
|
</p>
|
||||||
|
<p v-if="user?.canManage" class="mt-3">
|
||||||
|
{{ user?.admin ? "You can also" : "As a group manager, you can" }} disable announcements for your entire group:
|
||||||
|
<br>
|
||||||
|
<v-btn class="mt-2" color="primary" to="/group">
|
||||||
|
{{ $t("profile.group-settings") }}
|
||||||
|
</v-btn>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AnnouncementMeta } from "~/composables/use-announcements";
|
||||||
|
|
||||||
|
const { user } = useMealieAuth();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export const meta: AnnouncementMeta = {
|
||||||
|
title: "Welcome to Mealie 🎉",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="css">
|
||||||
|
p {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
|
||||||
|
const announcementFiles = import.meta.glob<{ default: unknown }>(
|
||||||
|
"~/components/Domain/Announcement/Announcements/*.vue",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expected format: YYYY-MM-DD_N_slug e.g. 2026-03-27_1_welcome
|
||||||
|
const FILE_FORMAT = /^\d{4}-\d{2}-\d{2}_\d+_.+$/;
|
||||||
|
|
||||||
|
describe("Announcement files", () => {
|
||||||
|
const filenames = Object.keys(announcementFiles).map(path =>
|
||||||
|
path.split("/").at(-1)!.replace(".vue", ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
test("directory is not empty", () => {
|
||||||
|
expect(filenames.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all filenames match YYYY-MM-DD_N_slug format", () => {
|
||||||
|
for (const name of filenames) {
|
||||||
|
expect(name, `"${name}" does not match the expected format`).toMatch(FILE_FORMAT);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all date prefixes are valid dates", () => {
|
||||||
|
for (const name of filenames) {
|
||||||
|
const datePart = name.split("_", 1)[0]!;
|
||||||
|
const date = new Date(datePart);
|
||||||
|
expect(isNaN(date.getTime()), `"${name}" has an invalid date prefix "${datePart}"`).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all filenames are unique", () => {
|
||||||
|
const unique = new Set(filenames);
|
||||||
|
expect(unique.size).toBe(filenames.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -73,11 +73,11 @@ import { useLoggedInState } from "~/composables/use-logged-in-state";
|
|||||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||||
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||||
|
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.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.params.slug as string;
|
const slug = route.params.slug as string;
|
||||||
@@ -88,11 +88,11 @@ const router = useRouter();
|
|||||||
const book = getOne(slug);
|
const book = getOne(slug);
|
||||||
|
|
||||||
const isOwnHousehold = computed(() => {
|
const isOwnHousehold = computed(() => {
|
||||||
if (!($auth.user.value && book.value?.householdId)) {
|
if (!(auth.user.value && book.value?.householdId)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $auth.user.value.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);
|
||||||
|
|
||||||
230
frontend/app/components/Domain/Group/GroupDataPage.vue
Normal file
230
frontend/app/components/Domain/Group/GroupDataPage.vue
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Create Dialog -->
|
||||||
|
<BaseDialog
|
||||||
|
v-model="createDialog"
|
||||||
|
:title="createTitle || $t('general.create')"
|
||||||
|
:icon="icon"
|
||||||
|
color="primary"
|
||||||
|
max-width="600px"
|
||||||
|
width="100%"
|
||||||
|
:submit-disabled="!createFormValid"
|
||||||
|
can-confirm
|
||||||
|
@confirm="emit('create-one', createForm.data)"
|
||||||
|
>
|
||||||
|
<div class="mx-2 mt-2">
|
||||||
|
<slot name="create-dialog-top" />
|
||||||
|
<AutoForm
|
||||||
|
v-model="createForm.data"
|
||||||
|
v-model:is-valid="createFormValid"
|
||||||
|
:items="createForm.items"
|
||||||
|
class="py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</BaseDialog>
|
||||||
|
|
||||||
|
<!-- Edit Dialog -->
|
||||||
|
<BaseDialog
|
||||||
|
v-model="editDialog"
|
||||||
|
:title="editTitle || $t('general.edit')"
|
||||||
|
:icon="icon"
|
||||||
|
color="primary"
|
||||||
|
max-width="600px"
|
||||||
|
width="100%"
|
||||||
|
:submit-disabled="!editFormValid"
|
||||||
|
can-confirm
|
||||||
|
@confirm="emit('edit-one', editForm.data)"
|
||||||
|
>
|
||||||
|
<div class="mx-2 mt-2">
|
||||||
|
<slot name="edit-dialog-top" />
|
||||||
|
<AutoForm
|
||||||
|
v-model="editForm.data"
|
||||||
|
v-model:is-valid="editFormValid"
|
||||||
|
:items="editForm.items"
|
||||||
|
class="py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #custom-card-action>
|
||||||
|
<slot name="edit-dialog-custom-action" />
|
||||||
|
</template>
|
||||||
|
</BaseDialog>
|
||||||
|
|
||||||
|
<!-- Delete Dialog -->
|
||||||
|
<BaseDialog
|
||||||
|
v-model="deleteDialog"
|
||||||
|
:title="$t('general.confirm')"
|
||||||
|
:icon="$globals.icons.alertCircle"
|
||||||
|
color="error"
|
||||||
|
can-confirm
|
||||||
|
@confirm="$emit('deleteOne', deleteTarget.id)"
|
||||||
|
>
|
||||||
|
<v-card-text>
|
||||||
|
{{ $t("general.confirm-delete-generic") }}
|
||||||
|
<p v-if="deleteTarget" class="mt-4 ml-4">
|
||||||
|
{{ deleteTarget.name || deleteTarget.title || deleteTarget.id }}
|
||||||
|
</p>
|
||||||
|
</v-card-text>
|
||||||
|
</BaseDialog>
|
||||||
|
|
||||||
|
<!-- Bulk Delete Dialog -->
|
||||||
|
<BaseDialog
|
||||||
|
v-model="bulkDeleteDialog"
|
||||||
|
width="650px"
|
||||||
|
:title="$t('general.confirm')"
|
||||||
|
:icon="$globals.icons.alertCircle"
|
||||||
|
color="error"
|
||||||
|
can-confirm
|
||||||
|
@confirm="$emit('bulk-action', 'delete-selected', bulkDeleteTarget)"
|
||||||
|
>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="h4">
|
||||||
|
{{ $t('general.confirm-delete-generic-items') }}
|
||||||
|
</p>
|
||||||
|
<v-card variant="outlined">
|
||||||
|
<v-virtual-scroll height="400" item-height="25" :items="bulkDeleteTarget">
|
||||||
|
<template #default="{ item }">
|
||||||
|
<v-list-item class="pb-2">
|
||||||
|
<v-list-item-title>{{ item.name || item.title || item.id }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-virtual-scroll>
|
||||||
|
</v-card>
|
||||||
|
</v-card-text>
|
||||||
|
</BaseDialog>
|
||||||
|
|
||||||
|
<BaseCardSectionTitle
|
||||||
|
:icon="icon"
|
||||||
|
section
|
||||||
|
:title="title"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CrudTable
|
||||||
|
:headers="tableHeaders"
|
||||||
|
:table-config="tableConfig"
|
||||||
|
:data="data || []"
|
||||||
|
:bulk-actions="bulkActions"
|
||||||
|
:initial-sort="initialSort"
|
||||||
|
@edit-one="editEventHandler"
|
||||||
|
@delete-one="deleteEventHandler"
|
||||||
|
@bulk-action="handleBulkAction"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="slotName in itemSlotNames"
|
||||||
|
#[slotName]="slotProps"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
:name="slotName"
|
||||||
|
v-bind="slotProps"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #button-row>
|
||||||
|
<BaseButton
|
||||||
|
create
|
||||||
|
@click="createDialog = true"
|
||||||
|
>
|
||||||
|
{{ $t("general.create") }}
|
||||||
|
</BaseButton>
|
||||||
|
<slot name="table-button-row" />
|
||||||
|
</template>
|
||||||
|
<template #button-bottom>
|
||||||
|
<slot name="table-button-bottom" />
|
||||||
|
</template>
|
||||||
|
</CrudTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TableHeaders, TableConfig, BulkAction } from "~/components/global/CrudTable.vue";
|
||||||
|
import type { AutoFormItems } from "~/types/auto-forms";
|
||||||
|
|
||||||
|
const slots = useSlots();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "deleteOne", id: string): void;
|
||||||
|
(e: "deleteMany", ids: string[]): void;
|
||||||
|
(e: "create-one" | "edit-one", data: any): void;
|
||||||
|
(e: "bulk-action", event: string, items: any[]): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const tableHeaders = defineModel<TableHeaders[]>("tableHeaders", { required: true });
|
||||||
|
const createForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("createForm", { required: true });
|
||||||
|
const createDialog = defineModel("createDialog", { type: Boolean, default: false });
|
||||||
|
|
||||||
|
const editForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("editForm", { required: true });
|
||||||
|
const editDialog = defineModel("editDialog", { type: Boolean, default: false });
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
createTitle: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
editTitle: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
tableConfig: {
|
||||||
|
type: Object as PropType<TableConfig>,
|
||||||
|
default: () => ({
|
||||||
|
hideColumns: false,
|
||||||
|
canExport: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Array as PropType<Array<any>>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
bulkActions: {
|
||||||
|
type: Array as PropType<BulkAction[]>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
initialSort: {
|
||||||
|
type: String,
|
||||||
|
default: "name",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Bulk Action Handler
|
||||||
|
function handleBulkAction(event: string, items: any[]) {
|
||||||
|
if (event === "delete-selected") {
|
||||||
|
bulkDeleteEventHandler(items);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("bulk-action", event, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Create & Edit
|
||||||
|
const createFormValid = ref(false);
|
||||||
|
const editFormValid = ref(false);
|
||||||
|
const itemSlotNames = computed(() => Object.keys(slots).filter(slotName => slotName.startsWith("item.")));
|
||||||
|
const editEventHandler = (item: any) => {
|
||||||
|
editForm.value.data = { ...item };
|
||||||
|
editDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Delete Logic
|
||||||
|
const deleteTarget = ref<any>(null);
|
||||||
|
const deleteDialog = ref(false);
|
||||||
|
|
||||||
|
function deleteEventHandler(item: any) {
|
||||||
|
deleteTarget.value = item;
|
||||||
|
deleteDialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Bulk Delete Logic
|
||||||
|
const bulkDeleteTarget = ref<Array<any>>([]);
|
||||||
|
const bulkDeleteDialog = ref(false);
|
||||||
|
|
||||||
|
function bulkDeleteEventHandler(items: Array<any>) {
|
||||||
|
bulkDeleteTarget.value = items;
|
||||||
|
bulkDeleteDialog.value = true;
|
||||||
|
console.log("Bulk Delete Event Handler", items);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="preferences">
|
||||||
|
<BaseCardSectionTitle :title="$t('group.group-preferences')" />
|
||||||
|
<div class="mb-6">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="local.privateGroup"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
color="primary"
|
||||||
|
:label="$t('group.private-group')"
|
||||||
|
/>
|
||||||
|
<div class="ml-8">
|
||||||
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
|
{{ $t("group.private-group-description") }}
|
||||||
|
</p>
|
||||||
|
<DocLink
|
||||||
|
class="mt-2"
|
||||||
|
link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="local.showAnnouncements"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
color="primary"
|
||||||
|
:label="$t('announcements.show-announcements-from-mealie')"
|
||||||
|
/>
|
||||||
|
<div class="ml-8">
|
||||||
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
|
{{ $t("announcements.show-announcements-setting-description") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ReadGroupPreferences } from "~/lib/api/types/user";
|
||||||
|
|
||||||
|
const preferences = defineModel<ReadGroupPreferences>({ required: true });
|
||||||
|
const local = reactive({ ...preferences.value });
|
||||||
|
watch(local, (newVal) => { preferences.value = { ...newVal }; });
|
||||||
|
</script>
|
||||||
@@ -76,7 +76,6 @@ const MEAL_DAY_OPTIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function handleQueryFilterInput(value: string | undefined) {
|
function handleQueryFilterInput(value: string | undefined) {
|
||||||
console.warn("handleQueryFilterInput called with value:", value);
|
|
||||||
queryFilterString.value = value || "";
|
queryFilterString.value = value || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +113,7 @@ const fieldDefs: FieldDefinition[] = [
|
|||||||
{
|
{
|
||||||
name: "last_made",
|
name: "last_made",
|
||||||
label: i18n.t("general.last-made"),
|
label: i18n.t("general.last-made"),
|
||||||
type: "date",
|
type: "relativeDate",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "created_at",
|
name: "created_at",
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<div v-if="preferences">
|
<div v-if="preferences">
|
||||||
<BaseCardSectionTitle :title="$t('household.household-preferences')" />
|
<BaseCardSectionTitle :title="$t('household.household-preferences')" />
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
|
<v-checkbox v-model="local.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
|
||||||
<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") }}
|
||||||
@@ -11,15 +11,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
|
<v-checkbox v-model="local.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
|
||||||
<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") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="local.showAnnouncements"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
color="primary"
|
||||||
|
:label="$t('announcements.show-announcements-from-mealie')"
|
||||||
|
/>
|
||||||
|
<div class="ml-8">
|
||||||
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
|
{{ $t("announcements.show-announcements-setting-description") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="preferences.firstDayOfWeek"
|
v-model="local.firstDayOfWeek"
|
||||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||||
:items="allDays"
|
:items="allDays"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
@@ -29,10 +43,12 @@
|
|||||||
flat
|
flat
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')" />
|
<BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')">
|
||||||
|
{{ $t("household.default-recipe-preferences-description") }}
|
||||||
|
</BaseCardSectionTitle>
|
||||||
<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-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
|
<v-checkbox v-model="local[p.key]" hide-details density="compact" :label="p.label" color="primary" />
|
||||||
<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>
|
||||||
@@ -45,6 +61,9 @@
|
|||||||
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
||||||
|
|
||||||
const preferences = defineModel<ReadHouseholdPreferences>({ required: true });
|
const preferences = defineModel<ReadHouseholdPreferences>({ required: true });
|
||||||
|
const local = reactive({ ...preferences.value });
|
||||||
|
watch(local, (newVal) => { preferences.value = { ...newVal }; });
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
type Preference = {
|
type Preference = {
|
||||||
@@ -41,19 +41,14 @@
|
|||||||
>
|
>
|
||||||
<v-select
|
<v-select
|
||||||
v-if="index"
|
v-if="index"
|
||||||
:model-value="field.logicalOperator"
|
:model-value="field.logicalOperator?.value"
|
||||||
:items="[logOps.AND, logOps.OR]"
|
:items="[logOps.AND, logOps.OR]"
|
||||||
item-title="label"
|
item-title="label"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
|
class="text-center"
|
||||||
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
|
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
|
||||||
>
|
/>
|
||||||
<template #chip="{ item }">
|
|
||||||
<span :class="config.select.textClass" style="width: 100%;">
|
|
||||||
{{ item.raw.label }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</v-select>
|
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- left parenthesis -->
|
<!-- left parenthesis -->
|
||||||
@@ -67,14 +62,9 @@
|
|||||||
:model-value="field.leftParenthesis"
|
:model-value="field.leftParenthesis"
|
||||||
:items="['', '(', '((', '(((']"
|
:items="['', '(', '((', '(((']"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
|
class="text-center"
|
||||||
@update:model-value="setLeftParenthesisValue(field, index, $event)"
|
@update:model-value="setLeftParenthesisValue(field, index, $event)"
|
||||||
>
|
/>
|
||||||
<template #chip="{ item }">
|
|
||||||
<span :class="config.select.textClass" style="width: 100%;">
|
|
||||||
{{ item.raw }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</v-select>
|
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- field name -->
|
<!-- field name -->
|
||||||
@@ -84,19 +74,14 @@
|
|||||||
:class="config.col.class"
|
:class="config.col.class"
|
||||||
>
|
>
|
||||||
<v-select
|
<v-select
|
||||||
chips
|
|
||||||
:model-value="field.label"
|
:model-value="field.label"
|
||||||
:items="fieldDefs"
|
:items="fieldDefs"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
item-title="label"
|
item-title="label"
|
||||||
|
item-value="label"
|
||||||
|
class="text-center"
|
||||||
@update:model-value="setField(index, $event)"
|
@update:model-value="setField(index, $event)"
|
||||||
>
|
/>
|
||||||
<template #chip="{ item }">
|
|
||||||
<span :class="config.select.textClass" style="width: 100%;">
|
|
||||||
{{ item.raw.label }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</v-select>
|
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- relational operator -->
|
<!-- relational operator -->
|
||||||
@@ -107,19 +92,14 @@
|
|||||||
>
|
>
|
||||||
<v-select
|
<v-select
|
||||||
v-if="field.type !== 'boolean'"
|
v-if="field.type !== 'boolean'"
|
||||||
:model-value="field.relationalOperatorValue"
|
:model-value="field.relationalOperatorValue?.value"
|
||||||
:items="field.relationalOperatorOptions"
|
:items="field.relationalOperatorChoices"
|
||||||
item-title="label"
|
item-title="label"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
|
class="text-center"
|
||||||
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
|
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
|
||||||
>
|
/>
|
||||||
<template #chip="{ item }">
|
|
||||||
<span :class="config.select.textClass" style="width: 100%;">
|
|
||||||
{{ item.raw.label }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</v-select>
|
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- field value -->
|
<!-- field value -->
|
||||||
@@ -129,9 +109,9 @@
|
|||||||
:class="config.col.class"
|
:class="config.col.class"
|
||||||
>
|
>
|
||||||
<v-select
|
<v-select
|
||||||
v-if="field.fieldOptions"
|
v-if="field.fieldChoices"
|
||||||
:model-value="field.values"
|
:model-value="field.values"
|
||||||
:items="field.fieldOptions"
|
:items="field.fieldChoices"
|
||||||
item-title="label"
|
item-title="label"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
multiple
|
multiple
|
||||||
@@ -169,23 +149,39 @@
|
|||||||
>
|
>
|
||||||
<template #activator="{ props: activatorProps }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null"
|
:model-value="$d(safeNewDate(field.value + 'T00:00:00'))"
|
||||||
persistent-hint
|
|
||||||
:prepend-icon="$globals.icons.calendar"
|
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
class="date-input"
|
||||||
v-bind="activatorProps"
|
v-bind="activatorProps"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<v-date-picker
|
<v-date-picker
|
||||||
:model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
|
:model-value="safeNewDate(field.value + 'T00:00:00')"
|
||||||
hide-header
|
hide-header
|
||||||
:first-day-of-week="firstDayOfWeek"
|
:first-day-of-week="firstDayOfWeek"
|
||||||
:local="$i18n.locale"
|
:local="$i18n.locale"
|
||||||
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
|
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
|
||||||
/>
|
/>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
|
<!--
|
||||||
|
Relative dates are assumed to be negative intervals with a unit of days.
|
||||||
|
The input is a *positive*, interpreted internally as a *negative* offset.
|
||||||
|
-->
|
||||||
|
<v-number-input
|
||||||
|
v-else-if="field.type === 'relativeDate'"
|
||||||
|
:model-value="parseRelativeDateOffset(field.value)"
|
||||||
|
:suffix="$t('query-filter.dates.days-ago', parseRelativeDateOffset(field.value))"
|
||||||
|
variant="underlined"
|
||||||
|
control-variant="stacked"
|
||||||
|
density="compact"
|
||||||
|
inset
|
||||||
|
:min="0"
|
||||||
|
:precision="0"
|
||||||
|
class="date-input"
|
||||||
|
@update:model-value="setFieldValue(field, index, $event)"
|
||||||
|
/>
|
||||||
<RecipeOrganizerSelector
|
<RecipeOrganizerSelector
|
||||||
v-else-if="field.type === Organizer.Category"
|
v-else-if="field.type === Organizer.Category"
|
||||||
v-model="field.organizers"
|
v-model="field.organizers"
|
||||||
@@ -259,23 +255,14 @@
|
|||||||
:model-value="field.rightParenthesis"
|
:model-value="field.rightParenthesis"
|
||||||
:items="['', ')', '))', ')))']"
|
:items="['', ')', '))', ')))']"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
|
class="text-center"
|
||||||
@update:model-value="setRightParenthesisValue(field, index, $event)"
|
@update:model-value="setRightParenthesisValue(field, index, $event)"
|
||||||
>
|
/>
|
||||||
<template #chip="{ item }">
|
|
||||||
<span :class="config.select.textClass" style="width: 100%;">
|
|
||||||
{{ item.raw }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</v-select>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<!-- field actions -->
|
|
||||||
<v-col
|
|
||||||
v-if="!$vuetify.display.smAndDown || index === fields.length - 1"
|
v-if="!$vuetify.display.smAndDown || index === fields.length - 1"
|
||||||
:cols="config.items.fieldActions.cols(index)"
|
:cols="config.items.fieldActions.cols(index)"
|
||||||
:sm="config.items.fieldActions.sm(index)"
|
:sm="config.items.fieldActions.sm(index)"
|
||||||
:class="config.col.class"
|
:class="config.col.class"
|
||||||
>
|
>
|
||||||
<BaseButtonGroup
|
<BaseButtonGroup
|
||||||
:buttons="[
|
:buttons="[
|
||||||
{
|
{
|
||||||
@@ -319,7 +306,13 @@ 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 type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated";
|
import type {
|
||||||
|
LogicalOperator,
|
||||||
|
QueryFilterJSON,
|
||||||
|
QueryFilterJSONPart,
|
||||||
|
RelationalKeyword,
|
||||||
|
RelationalOperator,
|
||||||
|
} from "~/lib/api/types/non-generated";
|
||||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||||
import { useUserStore } from "~/composables/store/use-user-store";
|
import { useUserStore } from "~/composables/store/use-user-store";
|
||||||
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||||
@@ -341,7 +334,14 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { household } = useHouseholdSelf();
|
const { household } = useHouseholdSelf();
|
||||||
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
const {
|
||||||
|
logOps,
|
||||||
|
placeholderKeywords,
|
||||||
|
getRelOps,
|
||||||
|
buildQueryFilterString,
|
||||||
|
getFieldFromFieldDef,
|
||||||
|
isOrganizerType,
|
||||||
|
} = useQueryFilterBuilder();
|
||||||
|
|
||||||
const firstDayOfWeek = computed(() => {
|
const firstDayOfWeek = computed(() => {
|
||||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||||
@@ -396,16 +396,29 @@ function setField(index: number, fieldLabel: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
|
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldChoices !== fields.value[index].fieldChoices);
|
||||||
const updatedField = { ...fields.value[index], ...fieldDef };
|
const updatedField = { ...fields.value[index], ...fieldDef };
|
||||||
|
|
||||||
// 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.fieldChoices = fieldDef.fieldChoices;
|
||||||
|
|
||||||
fields.value[index] = {
|
fields.value[index] = {
|
||||||
...getFieldFromFieldDef(updatedField, resetValue),
|
...getFieldFromFieldDef(updatedField, resetValue),
|
||||||
id: fields.value[index].id, // keep the id
|
id: fields.value[index].id, // keep the id
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
switch (fields.value[index].type) {
|
||||||
|
case "date":
|
||||||
|
fields.value[index].value = safeNewDate("");
|
||||||
|
break;
|
||||||
|
case "relativeDate":
|
||||||
|
fields.value[index].value = "$NOW-30d";
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
|
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||||
@@ -425,12 +438,21 @@ function setLogicalOperatorValue(field: FieldWithId, index: number, value: Logic
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
|
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
|
||||||
|
const relOps = getRelOps(field.type);
|
||||||
fields.value[index].relationalOperatorValue = relOps.value[value];
|
fields.value[index].relationalOperatorValue = relOps.value[value];
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
|
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
|
||||||
state.datePickers[index] = false;
|
state.datePickers[index] = false;
|
||||||
fields.value[index].value = value;
|
|
||||||
|
if (field.type === "relativeDate") {
|
||||||
|
// Value is set to an int representing the offset from $NOW
|
||||||
|
// Values are assumed to be negative offsets ('-') with a unit of days ('d')
|
||||||
|
fields.value[index].value = `$NOW-${Math.abs(value)}d`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
fields.value[index].value = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
|
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
|
||||||
@@ -448,12 +470,7 @@ function removeField(index: number) {
|
|||||||
state.datePickers.splice(index, 1);
|
state.datePickers.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
const fieldsUpdater = useDebounceFn(() => {
|
||||||
/* newFields.forEach((field, index) => {
|
|
||||||
const updatedField = getFieldFromFieldDef(field);
|
|
||||||
fields.value[index] = updatedField; // recursive!!!
|
|
||||||
}); */
|
|
||||||
|
|
||||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||||
if (qf) {
|
if (qf) {
|
||||||
console.debug(`Set query filter: ${qf}`);
|
console.debug(`Set query filter: ${qf}`);
|
||||||
@@ -519,6 +536,9 @@ async function initializeFields() {
|
|||||||
...getFieldFromFieldDef(fieldDef),
|
...getFieldFromFieldDef(fieldDef),
|
||||||
id: useUid(),
|
id: useUid(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const relOps = getRelOps(field.type);
|
||||||
|
|
||||||
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
|
||||||
@@ -527,12 +547,15 @@ async function initializeFields() {
|
|||||||
field.relationalOperatorValue = part.relationalOperator
|
field.relationalOperatorValue = part.relationalOperator
|
||||||
? relOps.value[part.relationalOperator]
|
? relOps.value[part.relationalOperator]
|
||||||
: field.relationalOperatorValue;
|
: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
if (field.fieldChoices?.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] : [];
|
||||||
}
|
}
|
||||||
@@ -601,7 +624,7 @@ function buildQueryFilterJSON(): QueryFilterJSON {
|
|||||||
relationalOperator: field.relationalOperatorValue?.value,
|
relationalOperator: field.relationalOperatorValue?.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
if (field.fieldChoices?.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") {
|
||||||
@@ -619,6 +642,50 @@ function buildQueryFilterJSON(): QueryFilterJSON {
|
|||||||
return qfJSON;
|
return qfJSON;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeNewDate(input: string): Date {
|
||||||
|
const date = new Date(input);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return today;
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a relative date string offset (e.g. $NOW-30d --> 30)
|
||||||
|
*
|
||||||
|
* Currently only values with a negative offset ('-') and a unit of days ('d') are supported
|
||||||
|
*/
|
||||||
|
function parseRelativeDateOffset(value: string): number {
|
||||||
|
const defaultVal = 30;
|
||||||
|
if (!value) {
|
||||||
|
return defaultVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!value.startsWith(placeholderKeywords.value["$NOW"].value)) {
|
||||||
|
return defaultVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainder = value.slice(placeholderKeywords.value["$NOW"].value.length);
|
||||||
|
if (!remainder.startsWith("-")) {
|
||||||
|
throw new Error("Invalid operator (not '-')");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainder.slice(-1) !== "d") {
|
||||||
|
throw new Error("Invalid unit (not 'd')");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slice off sign and unit
|
||||||
|
return parseInt(remainder.slice(1, -1));
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn(`Unable to parse relative date offset from '${value}': ${error}`);
|
||||||
|
return defaultVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const config = computed(() => {
|
const config = computed(() => {
|
||||||
const multiple = fields.value.length > 1;
|
const multiple = fields.value.length > 1;
|
||||||
const adv = state.showAdvanced;
|
const adv = state.showAdvanced;
|
||||||
@@ -627,9 +694,6 @@ const config = computed(() => {
|
|||||||
col: {
|
col: {
|
||||||
class: "d-flex justify-center align-end py-0",
|
class: "d-flex justify-center align-end py-0",
|
||||||
},
|
},
|
||||||
select: {
|
|
||||||
textClass: "d-flex justify-center text-center",
|
|
||||||
},
|
|
||||||
items: {
|
items: {
|
||||||
icon: {
|
icon: {
|
||||||
cols: (_index: number) => 2,
|
cols: (_index: number) => 2,
|
||||||
@@ -689,4 +753,13 @@ const config = computed(() => {
|
|||||||
.bg-light {
|
.bg-light {
|
||||||
background-color: rgba(255, 255, 255, var(--bg-opactity));
|
background-color: rgba(255, 255, 255, var(--bg-opactity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.date-input input) {
|
||||||
|
text-align: end;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.date-input .v-field__field) {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -36,10 +36,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</v-expand-transition>
|
</v-expand-transition>
|
||||||
</RecipeCardImage>
|
</RecipeCardImage>
|
||||||
<v-card-title class="mb-n3 px-4">
|
<v-card-title class="mb-n3 px-4" style="font-size: 1.25rem;">
|
||||||
<div class="headerClass">
|
{{ name }}
|
||||||
{{ name }}
|
|
||||||
</div>
|
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<slot name="actions">
|
<slot name="actions">
|
||||||
@@ -130,11 +128,11 @@ defineEmits<{
|
|||||||
delete: [slug: string];
|
delete: [slug: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.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}` : "";
|
||||||
@@ -160,11 +160,11 @@ defineEmits<{
|
|||||||
delete: [slug: string];
|
delete: [slug: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.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}` : "";
|
||||||
@@ -1,24 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-app-bar
|
<v-row
|
||||||
v-if="!disableToolbar"
|
v-if="!disableToolbar"
|
||||||
color="transparent"
|
class="align-center pb-2"
|
||||||
:absolute="false"
|
|
||||||
flat
|
|
||||||
class="mt-n1 flex-sm-wrap rounded position-relative w-100 left-0 top-0"
|
|
||||||
>
|
>
|
||||||
<slot name="title">
|
<v-icon
|
||||||
<v-icon
|
v-if="title"
|
||||||
v-if="title"
|
size="large"
|
||||||
size="large"
|
start
|
||||||
start
|
>
|
||||||
>
|
{{ displayTitleIcon }}
|
||||||
{{ displayTitleIcon }}
|
</v-icon>
|
||||||
</v-icon>
|
<span class="text-headline-small">{{ title }}</span>
|
||||||
<v-toolbar-title class="headline">
|
|
||||||
{{ title }}
|
|
||||||
</v-toolbar-title>
|
|
||||||
</slot>
|
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
:icon="$vuetify.display.xs"
|
:icon="$vuetify.display.xs"
|
||||||
@@ -111,7 +104,7 @@
|
|||||||
]"
|
]"
|
||||||
@toggle-dense-view="toggleMobileCards()"
|
@toggle-dense-view="toggleMobileCards()"
|
||||||
/>
|
/>
|
||||||
</v-app-bar>
|
</v-row>
|
||||||
<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">
|
||||||
@@ -136,7 +129,7 @@
|
|||||||
</v-row>
|
</v-row>
|
||||||
<v-row
|
<v-row
|
||||||
v-else
|
v-else
|
||||||
dense
|
density="comfortable"
|
||||||
>
|
>
|
||||||
<v-col
|
<v-col
|
||||||
v-for="recipe in recipes"
|
v-for="recipe in recipes"
|
||||||
@@ -159,14 +152,15 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
<v-card v-intersect="infiniteScroll" />
|
<v-card v-intersect="infiniteScroll" variant="flat" />
|
||||||
<v-fade-transition>
|
|
||||||
<AppLoader
|
|
||||||
v-if="loading"
|
|
||||||
:loading="loading"
|
|
||||||
/>
|
|
||||||
</v-fade-transition>
|
|
||||||
</div>
|
</div>
|
||||||
|
<v-fade-transition>
|
||||||
|
<AppLoader
|
||||||
|
v-if="loading"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
|
</v-fade-transition>
|
||||||
|
<AppScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -219,7 +213,7 @@ const EVENTS = {
|
|||||||
shuffle: "shuffle",
|
shuffle: "shuffle",
|
||||||
};
|
};
|
||||||
|
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const { $globals } = useNuxtApp();
|
const { $globals } = useNuxtApp();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
const useMobileCards = computed(() => {
|
const useMobileCards = computed(() => {
|
||||||
@@ -234,7 +228,7 @@ const sortLoading = ref(false);
|
|||||||
const randomSeed = ref(Date.now().toString());
|
const randomSeed = ref(Date.now().toString());
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.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;
|
||||||
@@ -243,6 +237,7 @@ const ready = ref(false);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||||
|
const { savePosition, getSavedPage, restorePosition } = useScrollPosition();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const queryFilter = computed(() => {
|
const queryFilter = computed(() => {
|
||||||
@@ -283,8 +278,29 @@ async function fetchRecipes(pageCount = 1) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await initRecipes();
|
loading.value = true;
|
||||||
ready.value = true;
|
const savedPage = getSavedPage(route.path);
|
||||||
|
|
||||||
|
if (savedPage && savedPage > 2) {
|
||||||
|
page.value = 1;
|
||||||
|
hasMore.value = true;
|
||||||
|
const newRecipes = await fetchRecipes(savedPage);
|
||||||
|
if (newRecipes.length < perPage * savedPage) {
|
||||||
|
hasMore.value = false;
|
||||||
|
}
|
||||||
|
page.value = savedPage;
|
||||||
|
emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||||
|
ready.value = true;
|
||||||
|
restorePosition(route.path);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await initRecipes();
|
||||||
|
ready.value = true;
|
||||||
|
if (savedPage) {
|
||||||
|
restorePosition(route.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
let lastQuery: string | undefined = JSON.stringify(props.query);
|
let lastQuery: string | undefined = JSON.stringify(props.query);
|
||||||
@@ -337,6 +353,8 @@ const infiniteScroll = useThrottleFn(async () => {
|
|||||||
emit(APPEND_RECIPES_EVENT, newRecipes);
|
emit(APPEND_RECIPES_EVENT, newRecipes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
savePosition(route.path, page.value);
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
@@ -202,13 +202,13 @@ const newMealdateString = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const { $globals } = useNuxtApp();
|
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.params.groupSlug || $auth.user.value?.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;
|
||||||
@@ -296,12 +296,12 @@ const recipeRefWithScale = computed(() =>
|
|||||||
);
|
);
|
||||||
const isAdminAndNotOwner = computed(() => {
|
const isAdminAndNotOwner = computed(() => {
|
||||||
return (
|
return (
|
||||||
$auth.user.value?.admin
|
auth.user.value?.admin
|
||||||
&& $auth.user.value?.id !== recipeRef.value?.userId
|
&& auth.user.value?.id !== recipeRef.value?.userId
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const canDelete = computed(() => {
|
const canDelete = computed(() => {
|
||||||
const user = $auth.user.value;
|
const user = auth.user.value;
|
||||||
const recipe = recipeRef.value;
|
const recipe = recipeRef.value;
|
||||||
return user && recipe && (user.admin || user.id === recipe.userId);
|
return user && recipe && (user.admin || user.id === recipe.userId);
|
||||||
});
|
});
|
||||||
@@ -110,8 +110,8 @@ defineEmits<{
|
|||||||
const selected = defineModel<Recipe[]>({ default: () => [] });
|
const selected = defineModel<Recipe[]>({ default: () => [] });
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const groupSlug = $auth.user.value?.groupSlug;
|
const groupSlug = auth.user.value?.groupSlug;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Initialize sort state with default sorting by dateAdded descending
|
// Initialize sort state with default sorting by dateAdded descending
|
||||||
@@ -86,6 +86,19 @@
|
|||||||
class="text-center"
|
class="text-center"
|
||||||
>
|
>
|
||||||
{{ recipeSection.recipeName }}
|
{{ recipeSection.recipeName }}
|
||||||
|
<v-tooltip v-if="recipeSection.parentRecipe?.name" location="top">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<v-icon
|
||||||
|
v-bind="tooltipProps"
|
||||||
|
size="tiny"
|
||||||
|
class="mb-2 ml-2"
|
||||||
|
style="cursor: pointer"
|
||||||
|
>
|
||||||
|
{{ $globals.icons.potSteam }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
<span>{{ $t("shopping-list.ingredient-of-recipe", { recipe: recipeSection.parentRecipe.name }) }}</span>
|
||||||
|
</v-tooltip>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row
|
<v-row
|
||||||
@@ -203,6 +216,7 @@ export interface ShoppingListRecipeIngredientSection {
|
|||||||
recipeName: string;
|
recipeName: string;
|
||||||
recipeScale: number;
|
recipeScale: number;
|
||||||
ingredientSections: ShoppingListIngredientSection[];
|
ingredientSections: ShoppingListIngredientSection[];
|
||||||
|
parentRecipe?: Recipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -217,7 +231,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
const dialog = defineModel<boolean>({ default: false });
|
const dialog = defineModel<boolean>({ default: false });
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const preferences = useShoppingListPreferences();
|
const preferences = useShoppingListPreferences();
|
||||||
const ready = ref(false);
|
const ready = ref(false);
|
||||||
@@ -239,9 +253,9 @@ const selectedShoppingList = ref<ShoppingListSummary | null>(null);
|
|||||||
|
|
||||||
watch([dialog, () => preferences.value.viewAllLists], () => {
|
watch([dialog, () => preferences.value.viewAllLists], () => {
|
||||||
if (dialog.value) {
|
if (dialog.value) {
|
||||||
currentHouseholdSlug.value = $auth.user.value?.householdSlug || "";
|
currentHouseholdSlug.value = auth.user.value?.householdSlug || "";
|
||||||
filteredShoppingLists.value = props.shoppingLists.filter(
|
filteredShoppingLists.value = props.shoppingLists.filter(
|
||||||
list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id,
|
list => preferences.value.viewAllLists || list.userId === auth.user.value?.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (filteredShoppingLists.value.length === 1 && !state.shoppingListShowAllToggled) {
|
if (filteredShoppingLists.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||||
@@ -258,8 +272,71 @@ watch([dialog, () => preferences.value.viewAllLists], () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function buildIngredientSections(ingredients: ShoppingListIngredient[]): ShoppingListIngredientSection[] {
|
||||||
|
let currentTitle = "";
|
||||||
|
const onHandIngs: ShoppingListIngredient[] = [];
|
||||||
|
const sections = ingredients.reduce((acc, ing) => {
|
||||||
|
if (ing.ingredient.title) {
|
||||||
|
currentTitle = ing.ingredient.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acc.length || currentTitle !== acc[acc.length - 1].sectionName) {
|
||||||
|
if (acc.length) {
|
||||||
|
acc[acc.length - 1].ingredients.push(...onHandIngs);
|
||||||
|
onHandIngs.length = 0;
|
||||||
|
}
|
||||||
|
acc.push({ sectionName: currentTitle, ingredients: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const householdsWithFood = ing.ingredient?.food?.householdsWithIngredientFood || [];
|
||||||
|
if (householdsWithFood.includes(currentHouseholdSlug.value)) {
|
||||||
|
onHandIngs.push(ing);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[acc.length - 1].ingredients.push(ing);
|
||||||
|
return acc;
|
||||||
|
}, [] as ShoppingListIngredientSection[]);
|
||||||
|
|
||||||
|
if (sections.length) {
|
||||||
|
sections[sections.length - 1].ingredients.push(...onHandIngs);
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||||
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
|
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
|
||||||
|
|
||||||
|
function addSubRecipeToMap(ing: RecipeIngredient, parentQuantity: number, parentScale: number, parentRecipe: Recipe) {
|
||||||
|
const ref = ing.referencedRecipe!;
|
||||||
|
const key = ref.id || ref.slug || "";
|
||||||
|
const ownIngs: ShoppingListIngredient[] = [];
|
||||||
|
const subRefIngs: RecipeIngredient[] = [];
|
||||||
|
|
||||||
|
for (const subIng of ref.recipeIngredient ?? []) {
|
||||||
|
if (subIng.referencedRecipe) {
|
||||||
|
subRefIngs.push(subIng);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const householdsWithFood = subIng.food?.householdsWithIngredientFood || [];
|
||||||
|
ownIngs.push({
|
||||||
|
checked: !householdsWithFood.includes(currentHouseholdSlug.value),
|
||||||
|
ingredient: { ...subIng, quantity: (ing.quantity || 1) * (subIng.quantity || 1) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recipeSectionMap.set(key, {
|
||||||
|
recipeId: ref.id || "",
|
||||||
|
recipeName: ref.name || "",
|
||||||
|
recipeScale: parentQuantity * parentScale,
|
||||||
|
ingredientSections: buildIngredientSections(ownIngs),
|
||||||
|
parentRecipe,
|
||||||
|
});
|
||||||
|
|
||||||
|
subRefIngs.forEach(subIng => addSubRecipeToMap(subIng, (ing.quantity || 1) * (subIng.quantity || 1), parentScale, ref));
|
||||||
|
}
|
||||||
|
|
||||||
for (const recipe of recipes) {
|
for (const recipe of recipes) {
|
||||||
if (!recipe.slug) {
|
if (!recipe.slug) {
|
||||||
continue;
|
continue;
|
||||||
@@ -291,81 +368,29 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shoppingListIngredients: ShoppingListIngredient[] = [];
|
const ownIngs: ShoppingListIngredient[] = [];
|
||||||
function flattenRecipeIngredients(ing: RecipeIngredient, parentTitle = ""): ShoppingListIngredient[] {
|
const subRefIngs: RecipeIngredient[] = [];
|
||||||
|
recipeData.recipeIngredient.forEach((ing) => {
|
||||||
if (ing.referencedRecipe) {
|
if (ing.referencedRecipe) {
|
||||||
// Recursively flatten all ingredients in the referenced recipe
|
subRefIngs.push(ing);
|
||||||
return (ing.referencedRecipe.recipeIngredient ?? []).flatMap((subIng) => {
|
|
||||||
const calculatedQty = (ing.quantity || 1) * (subIng.quantity || 1);
|
|
||||||
// Pass the referenced recipe name as the section title
|
|
||||||
return flattenRecipeIngredients(
|
|
||||||
{ ...subIng, quantity: calculatedQty },
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Regular ingredient
|
|
||||||
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
|
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
|
||||||
return [{
|
ownIngs.push({
|
||||||
checked: !householdsWithFood.includes(currentHouseholdSlug.value),
|
checked: !householdsWithFood.includes(currentHouseholdSlug.value),
|
||||||
ingredient: {
|
ingredient: ing,
|
||||||
...ing,
|
|
||||||
title: ing.title || parentTitle,
|
|
||||||
},
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recipeData.recipeIngredient.forEach((ing) => {
|
|
||||||
const flattened = flattenRecipeIngredients(ing, "");
|
|
||||||
shoppingListIngredients.push(...flattened);
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentTitle = "";
|
|
||||||
const onHandIngs: ShoppingListIngredient[] = [];
|
|
||||||
const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
|
|
||||||
if (ing.ingredient.title) {
|
|
||||||
currentTitle = ing.ingredient.title;
|
|
||||||
}
|
|
||||||
else if (ing.ingredient.referencedRecipe?.name) {
|
|
||||||
currentTitle = ing.ingredient.referencedRecipe.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this is the first item in the section, create a new section
|
|
||||||
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
|
|
||||||
if (sections.length) {
|
|
||||||
// Add the on-hand ingredients to the previous section
|
|
||||||
sections[sections.length - 1].ingredients.push(...onHandIngs);
|
|
||||||
onHandIngs.length = 0;
|
|
||||||
}
|
|
||||||
sections.push({
|
|
||||||
sectionName: currentTitle,
|
|
||||||
ingredients: [],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// Store the on-hand ingredients for later
|
|
||||||
const householdsWithFood = (ing.ingredient?.food?.householdsWithIngredientFood || []);
|
|
||||||
if (householdsWithFood.includes(currentHouseholdSlug.value)) {
|
|
||||||
onHandIngs.push(ing);
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the ingredient to previous section
|
|
||||||
sections[sections.length - 1].ingredients.push(ing);
|
|
||||||
return sections;
|
|
||||||
}, [] as ShoppingListIngredientSection[]);
|
|
||||||
|
|
||||||
// Add remaining on-hand ingredients to the previous section
|
|
||||||
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
|
|
||||||
|
|
||||||
recipeSectionMap.set(recipe.slug, {
|
recipeSectionMap.set(recipe.slug, {
|
||||||
recipeId: recipeData.id,
|
recipeId: recipeData.id,
|
||||||
recipeName: recipeData.name,
|
recipeName: recipeData.name,
|
||||||
recipeScale: recipeData.scale,
|
recipeScale: recipeData.scale,
|
||||||
ingredientSections: shoppingListIngredientSections,
|
ingredientSections: buildIngredientSections(ownIngs),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
subRefIngs.forEach(ing => addSubRecipeToMap(ing, ing.quantity || 1, recipeData.scale, recipeData));
|
||||||
}
|
}
|
||||||
|
|
||||||
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
|
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
|
||||||
@@ -1,91 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<v-dialog
|
<BaseButton @click="dialog = true">
|
||||||
|
{{ $t("new-recipe.bulk-add") }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseDialog
|
||||||
v-model="dialog"
|
v-model="dialog"
|
||||||
width="800"
|
width="800"
|
||||||
|
:title="$t('new-recipe.bulk-add')"
|
||||||
|
:icon="$globals.icons.createAlt"
|
||||||
|
:submit-text="$t('general.add')"
|
||||||
|
:disable-submit-on-enter="true"
|
||||||
|
can-submit
|
||||||
|
@submit="save"
|
||||||
>
|
>
|
||||||
<template #activator="{ props: activatorProps }">
|
<v-card-text>
|
||||||
<BaseButton
|
<v-textarea
|
||||||
v-bind="activatorProps"
|
v-model="inputText"
|
||||||
@click="inputText = inputTextProp"
|
variant="outlined"
|
||||||
>
|
rows="12"
|
||||||
{{ $t("new-recipe.bulk-add") }}
|
hide-details
|
||||||
</BaseButton>
|
autofocus
|
||||||
</template>
|
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-card>
|
<v-divider />
|
||||||
<v-app-bar
|
<v-list lines="two">
|
||||||
density="compact"
|
|
||||||
dark
|
|
||||||
color="primary"
|
|
||||||
class="mb-2 position-relative left-0 top-0 w-100"
|
|
||||||
>
|
|
||||||
<v-icon
|
|
||||||
size="large"
|
|
||||||
start
|
|
||||||
>
|
|
||||||
{{ $globals.icons.createAlt }}
|
|
||||||
</v-icon>
|
|
||||||
<v-toolbar-title class="headline">
|
|
||||||
{{ $t("new-recipe.bulk-add") }}
|
|
||||||
</v-toolbar-title>
|
|
||||||
<v-spacer />
|
|
||||||
</v-app-bar>
|
|
||||||
|
|
||||||
<v-card-text>
|
|
||||||
<v-textarea
|
|
||||||
v-model="inputText"
|
|
||||||
variant="outlined"
|
|
||||||
rows="12"
|
|
||||||
hide-details
|
|
||||||
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
<template
|
<template
|
||||||
v-for="(util) in utilities"
|
v-for="(util) in utilities"
|
||||||
:key="util.id"
|
:key="util.id"
|
||||||
>
|
>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
density="compact"
|
class="px-0"
|
||||||
class="py-1"
|
|
||||||
>
|
>
|
||||||
<v-list-item-title>
|
<template #prepend>
|
||||||
<v-list-item-subtitle class="wrap-word">
|
<v-avatar>
|
||||||
{{ util.description }}
|
<v-btn
|
||||||
</v-list-item-subtitle>
|
icon
|
||||||
|
variant="tonal"
|
||||||
|
base-color="info"
|
||||||
|
:title="$t('general.run')"
|
||||||
|
@click="util.action"
|
||||||
|
>
|
||||||
|
<v-icon>
|
||||||
|
{{ $globals.icons.play }}
|
||||||
|
</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title class="text-pre-wrap">
|
||||||
|
{{ util.description }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<BaseButton
|
|
||||||
size="small"
|
|
||||||
color="info"
|
|
||||||
@click="util.action"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
{{ $globals.icons.robot }}
|
|
||||||
</template>
|
|
||||||
{{ $t("general.run") }}
|
|
||||||
</BaseButton>
|
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-divider class="mx-2" />
|
|
||||||
</template>
|
</template>
|
||||||
</v-card-text>
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
<v-divider />
|
</BaseDialog>
|
||||||
|
|
||||||
<v-card-actions>
|
|
||||||
<BaseButton
|
|
||||||
cancel
|
|
||||||
@click="dialog = false"
|
|
||||||
/>
|
|
||||||
<v-spacer />
|
|
||||||
<BaseButton
|
|
||||||
save
|
|
||||||
color="success"
|
|
||||||
@click="save"
|
|
||||||
/>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -7,66 +7,64 @@
|
|||||||
content-class="top-dialog"
|
content-class="top-dialog"
|
||||||
:scrollable="false"
|
:scrollable="false"
|
||||||
>
|
>
|
||||||
<v-app-bar
|
|
||||||
sticky
|
|
||||||
dark
|
|
||||||
color="primary-lighten-1 top-0 position-relative left-0"
|
|
||||||
:rounded="!$vuetify.display.xs"
|
|
||||||
>
|
|
||||||
<v-text-field
|
|
||||||
id="arrow-search"
|
|
||||||
v-model="search.query.value"
|
|
||||||
autofocus
|
|
||||||
variant="solo"
|
|
||||||
flat
|
|
||||||
autocomplete="off"
|
|
||||||
bg-color="primary-lighten-1"
|
|
||||||
color="white"
|
|
||||||
density="compact"
|
|
||||||
class="mx-2 arrow-search"
|
|
||||||
hide-details
|
|
||||||
single-line
|
|
||||||
:placeholder="$t('search.search')"
|
|
||||||
:prepend-inner-icon="$globals.icons.search"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
v-if="$vuetify.display.xs"
|
|
||||||
size="x-small"
|
|
||||||
class="rounded-circle"
|
|
||||||
light
|
|
||||||
@click="dialog = false"
|
|
||||||
>
|
|
||||||
<v-icon>
|
|
||||||
{{ $globals.icons.close }}
|
|
||||||
</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</v-app-bar>
|
|
||||||
<v-card
|
<v-card
|
||||||
class="position-relative mt-1 pa-1 scroll"
|
:rounded="!$vuetify.display.xs"
|
||||||
max-height="700px"
|
|
||||||
relative
|
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
>
|
>
|
||||||
|
<v-toolbar
|
||||||
|
dark
|
||||||
|
color="primary-lighten-1"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
id="arrow-search"
|
||||||
|
v-model="search.query.value"
|
||||||
|
autofocus
|
||||||
|
variant="solo"
|
||||||
|
flat
|
||||||
|
autocomplete="off"
|
||||||
|
bg-color="primary-lighten-1"
|
||||||
|
color="white"
|
||||||
|
density="compact"
|
||||||
|
class="mx-2 arrow-search"
|
||||||
|
hide-details
|
||||||
|
single-line
|
||||||
|
:placeholder="$t('search.search')"
|
||||||
|
:prepend-inner-icon="$globals.icons.search"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-if="$vuetify.display.xs"
|
||||||
|
icon
|
||||||
|
size="x-small"
|
||||||
|
@click="dialog = false"
|
||||||
|
>
|
||||||
|
<v-icon>
|
||||||
|
{{ $globals.icons.close }}
|
||||||
|
</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<div class="mr-auto">
|
<div class="mr-auto">
|
||||||
{{ $t("search.results") }}
|
{{ $t("search.results") }}
|
||||||
</div>
|
</div>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
|
|
||||||
<RecipeCardMobile
|
<div class="scroll pa-1" style="max-height: 700px;">
|
||||||
v-for="(recipe, index) in search.data.value"
|
<RecipeCardMobile
|
||||||
:key="index"
|
v-for="(recipe, index) in search.data.value"
|
||||||
:tabindex="index"
|
:key="index"
|
||||||
class="ma-1 arrow-nav"
|
:tabindex="index"
|
||||||
:name="recipe.name ?? ''"
|
class="ma-1 arrow-nav"
|
||||||
:description="recipe.description ?? ''"
|
:name="recipe.name ?? ''"
|
||||||
:slug="recipe.slug ?? ''"
|
:description="recipe.description ?? ''"
|
||||||
:rating="recipe.rating ?? 0"
|
:slug="recipe.slug ?? ''"
|
||||||
:image="recipe.image"
|
:rating="recipe.rating ?? 0"
|
||||||
:recipe-id="recipe.id ?? ''"
|
:image="recipe.image"
|
||||||
v-bind="$attrs.selected ? { selected: () => handleSelect(recipe) } : {}"
|
:recipe-id="recipe.id ?? ''"
|
||||||
/>
|
v-bind="$attrs.selected ? { selected: () => handleSelect(recipe) } : {}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +85,7 @@ const emit = defineEmits<{
|
|||||||
selected: [recipe: RecipeSummary];
|
selected: [recipe: RecipeSummary];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const selectedIndex = ref(-1);
|
const selectedIndex = ref(-1);
|
||||||
|
|
||||||
@@ -153,7 +151,7 @@ watch(dialog, (val) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||||
watch(route, close);
|
watch(route, close);
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
@@ -119,10 +119,10 @@ whenever(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const { household } = useHouseholdSelf();
|
const { household } = useHouseholdSelf();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.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;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<v-container
|
||||||
|
fluid
|
||||||
|
class="px-0"
|
||||||
|
>
|
||||||
|
<RecipeExplorerPageSearch
|
||||||
|
ref="searchComponent"
|
||||||
|
@ready="onSearchReady"
|
||||||
|
/>
|
||||||
|
<v-divider />
|
||||||
|
<v-container class="mt-6 px-md-6">
|
||||||
|
<RecipeCardSection
|
||||||
|
v-if="ready"
|
||||||
|
class="mt-n5"
|
||||||
|
:icon="$globals.icons.silverwareForkKnife"
|
||||||
|
:title="$t('general.recipes')"
|
||||||
|
:recipes="recipes"
|
||||||
|
:query="searchQuery"
|
||||||
|
disable-sort
|
||||||
|
@item-selected="onItemSelected"
|
||||||
|
@replace-recipes="replaceRecipes"
|
||||||
|
@append-recipes="appendRecipes"
|
||||||
|
/>
|
||||||
|
</v-container>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import RecipeExplorerPageSearch from "./RecipeExplorerPageParts/RecipeExplorerPageSearch.vue";
|
||||||
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
||||||
|
import { useLazyRecipes } from "~/composables/recipes";
|
||||||
|
|
||||||
|
const auth = useMealieAuth();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
|
const { recipes, appendRecipes, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||||
|
|
||||||
|
const ready = ref(false);
|
||||||
|
const searchComponent = ref<InstanceType<typeof RecipeExplorerPageSearch>>();
|
||||||
|
|
||||||
|
const searchQuery = computed(() => {
|
||||||
|
return searchComponent.value?.passedQueryWithSeed || {};
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSearchReady() {
|
||||||
|
ready.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onItemSelected(item: any, urlPrefix: string) {
|
||||||
|
searchComponent.value?.filterItems(item, urlPrefix);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -141,13 +141,13 @@ const emit = defineEmits<{
|
|||||||
ready: [];
|
ready: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { $globals } = useNuxtApp();
|
const { $globals } = useNuxtApp();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const showRandomLoading = ref(false);
|
const showRandomLoading = ref(false);
|
||||||
|
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
@@ -81,11 +81,11 @@ import {
|
|||||||
usePublicToolStore,
|
usePublicToolStore,
|
||||||
} from "~/composables/store";
|
} from "~/composables/store";
|
||||||
|
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
@@ -52,14 +52,14 @@ const isFavorite = computed(() => {
|
|||||||
|
|
||||||
async function toggleFavorite() {
|
async function toggleFavorite() {
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
|
|
||||||
if (!$auth.user.value) return;
|
if (!auth.user.value) return;
|
||||||
if (!isFavorite.value) {
|
if (!isFavorite.value) {
|
||||||
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
await api.users.addFavorite(auth.user.value?.id, props.recipeId);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
await api.users.removeFavorite(auth.user.value?.id, props.recipeId);
|
||||||
}
|
}
|
||||||
await refreshUserRatings();
|
await refreshUserRatings();
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
/>
|
/>
|
||||||
<v-row
|
<v-row
|
||||||
:no-gutters="mdAndUp"
|
:no-gutters="mdAndUp"
|
||||||
dense
|
density="comfortable"
|
||||||
class="d-flex flex-wrap my-1"
|
class="d-flex flex-wrap my-1"
|
||||||
>
|
>
|
||||||
<v-col
|
<v-col
|
||||||
@@ -44,9 +44,8 @@
|
|||||||
</v-number-input>
|
</v-number-input>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col
|
<v-col
|
||||||
v-if="!state.isRecipe"
|
|
||||||
sm="12"
|
sm="12"
|
||||||
md="3"
|
md="2"
|
||||||
cols="12"
|
cols="12"
|
||||||
>
|
>
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
@@ -58,8 +57,8 @@
|
|||||||
density="compact"
|
density="compact"
|
||||||
variant="solo"
|
variant="solo"
|
||||||
return-object
|
return-object
|
||||||
:items="units || []"
|
:items="filteredUnits"
|
||||||
:custom-filter="normalizeFilter"
|
:custom-filter="() => true"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
class="mx-1"
|
class="mx-1"
|
||||||
:placeholder="$t('recipe.choose-unit')"
|
:placeholder="$t('recipe.choose-unit')"
|
||||||
@@ -104,7 +103,7 @@
|
|||||||
<v-col
|
<v-col
|
||||||
v-if="!state.isRecipe"
|
v-if="!state.isRecipe"
|
||||||
m="12"
|
m="12"
|
||||||
md="3"
|
md="4"
|
||||||
cols="12"
|
cols="12"
|
||||||
class=""
|
class=""
|
||||||
>
|
>
|
||||||
@@ -117,8 +116,8 @@
|
|||||||
density="compact"
|
density="compact"
|
||||||
variant="solo"
|
variant="solo"
|
||||||
return-object
|
return-object
|
||||||
:items="foods || []"
|
:items="filteredFoods"
|
||||||
:custom-filter="normalizeFilter"
|
:custom-filter="() => true"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
class="mx-1 py-0"
|
class="mx-1 py-0"
|
||||||
:placeholder="$t('recipe.choose-food')"
|
:placeholder="$t('recipe.choose-food')"
|
||||||
@@ -162,7 +161,7 @@
|
|||||||
<v-col
|
<v-col
|
||||||
v-if="state.isRecipe"
|
v-if="state.isRecipe"
|
||||||
m="12"
|
m="12"
|
||||||
md="6"
|
md="4"
|
||||||
cols="12"
|
cols="12"
|
||||||
class=""
|
class=""
|
||||||
>
|
>
|
||||||
@@ -176,7 +175,6 @@
|
|||||||
variant="solo"
|
variant="solo"
|
||||||
return-object
|
return-object
|
||||||
:items="search.data.value || []"
|
:items="search.data.value || []"
|
||||||
:custom-filter="normalizeFilter"
|
|
||||||
item-title="name"
|
item-title="name"
|
||||||
class="mx-1 py-0"
|
class="mx-1 py-0"
|
||||||
:placeholder="$t('search.type-to-search')"
|
:placeholder="$t('search.type-to-search')"
|
||||||
@@ -227,11 +225,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, reactive, toRefs } from "vue";
|
import { ref, computed, reactive, toRefs, watch } from "vue";
|
||||||
import { useDisplay } from "vuetify";
|
import { useDisplay } from "vuetify";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||||
import { normalizeFilter } from "~/composables/use-utils";
|
import { useSearch } from "~/composables/use-search";
|
||||||
import { useNuxtApp } from "#app";
|
import { useNuxtApp } from "#app";
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
||||||
@@ -343,8 +341,8 @@ const btns = computed(() => {
|
|||||||
// Foods
|
// Foods
|
||||||
const foodStore = useFoodStore();
|
const foodStore = useFoodStore();
|
||||||
const foodData = useFoodData();
|
const foodData = useFoodData();
|
||||||
const foodSearch = ref("");
|
|
||||||
const foodAutocomplete = ref<HTMLInputElement>();
|
const foodAutocomplete = ref<HTMLInputElement>();
|
||||||
|
const { search: foodSearch, filtered: filteredFoods } = useSearch(foodStore.store);
|
||||||
|
|
||||||
async function createAssignFood() {
|
async function createAssignFood() {
|
||||||
foodData.data.name = foodSearch.value;
|
foodData.data.name = foodSearch.value;
|
||||||
@@ -355,8 +353,8 @@ async function createAssignFood() {
|
|||||||
|
|
||||||
// Recipes
|
// Recipes
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||||
@@ -375,8 +373,8 @@ watch(loading, (val) => {
|
|||||||
// Units
|
// Units
|
||||||
const unitStore = useUnitStore();
|
const unitStore = useUnitStore();
|
||||||
const unitsData = useUnitData();
|
const unitsData = useUnitData();
|
||||||
const unitSearch = ref("");
|
|
||||||
const unitAutocomplete = ref<HTMLInputElement>();
|
const unitAutocomplete = ref<HTMLInputElement>();
|
||||||
|
const { search: unitSearch, filtered: filteredUnits } = useSearch(unitStore.store);
|
||||||
|
|
||||||
async function createAssignUnit() {
|
async function createAssignUnit() {
|
||||||
unitsData.data.name = unitSearch.value;
|
unitsData.data.name = unitSearch.value;
|
||||||
@@ -430,9 +428,6 @@ function quantityFilter(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { showTitle } = toRefs(state);
|
const { showTitle } = toRefs(state);
|
||||||
|
|
||||||
const foods = foodStore.store;
|
|
||||||
const units = unitStore.store;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
import { useParsedIngredientText } from "~/composables/recipes";
|
import { useIngredientTextParser } from "~/composables/recipes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ingredient?: RecipeIngredient;
|
ingredient?: RecipeIngredient;
|
||||||
@@ -20,6 +20,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { ingredient, scale = 1 } = defineProps<Props>();
|
const { ingredient, scale = 1 } = defineProps<Props>();
|
||||||
|
const { useParsedIngredientText } = useIngredientTextParser();
|
||||||
|
|
||||||
const baseText = computed(() => {
|
const baseText = computed(() => {
|
||||||
if (!ingredient) return "";
|
if (!ingredient) return "";
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/household";
|
import type { RecipeIngredient } from "~/lib/api/types/household";
|
||||||
import { useParsedIngredientText } from "~/composables/recipes";
|
import { useIngredientTextParser } from "~/composables/recipes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ingredient: RecipeIngredient;
|
ingredient: RecipeIngredient;
|
||||||
@@ -44,8 +44,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
});
|
});
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
|
||||||
|
const { useParsedIngredientText } = useIngredientTextParser();
|
||||||
|
|
||||||
const parsedIng = computed(() => {
|
const parsedIng = computed(() => {
|
||||||
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
|
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||||
import { parseIngredientText } from "~/composables/recipes";
|
import { useIngredientTextParser } from "~/composables/recipes";
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -66,6 +66,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
isCookMode: false,
|
isCookMode: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { parseIngredientText } = useIngredientTextParser();
|
||||||
|
|
||||||
function validateTitle(title?: string | null) {
|
function validateTitle(title?: string | null) {
|
||||||
return !(title === undefined || title === "" || title === null);
|
return !(title === undefined || title === "" || title === null);
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ const madeThisDialog = ref(false);
|
|||||||
const userApi = useUserApi();
|
const userApi = useUserApi();
|
||||||
const { household } = useHouseholdSelf();
|
const { household } = useHouseholdSelf();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const domMadeThisForm = ref<VForm>();
|
const domMadeThisForm = ref<VForm>();
|
||||||
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
||||||
subject: "",
|
subject: "",
|
||||||
@@ -179,7 +179,7 @@ const newTimelineEventTimestampString = computed(() => {
|
|||||||
const lastMade = ref(props.recipe.lastMade);
|
const lastMade = ref(props.recipe.lastMade);
|
||||||
const lastMadeReady = ref(false);
|
const lastMadeReady = ref(false);
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!$auth.user?.value?.householdSlug) {
|
if (!auth.user?.value?.householdSlug) {
|
||||||
lastMade.value = props.recipe.lastMade;
|
lastMade.value = props.recipe.lastMade;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -255,8 +255,8 @@ async function createTimelineEvent() {
|
|||||||
madeThisFormLoading.value = true;
|
madeThisFormLoading.value = true;
|
||||||
|
|
||||||
newTimelineEvent.value.recipeId = props.recipe.id;
|
newTimelineEvent.value.recipeId = props.recipe.id;
|
||||||
// Note: $auth.user is now a ref
|
// Note: auth.user is now a ref
|
||||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
|
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: auth.user.value?.fullName });
|
||||||
|
|
||||||
// the user only selects the date, so we set the time to end of day local time
|
// the user only selects the date, so we set the time to end of day local time
|
||||||
// we choose the end of day so it always comes after "new recipe" events
|
// we choose the end of day so it always comes after "new recipe" events
|
||||||
@@ -73,10 +73,10 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const { frac } = useFraction();
|
const { frac } = useFraction();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
|
||||||
|
|
||||||
const attrs = computed(() => {
|
const attrs = computed(() => {
|
||||||
return props.small
|
return props.small
|
||||||
@@ -1,62 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-dialog
|
<BaseDialog
|
||||||
v-model="dialog"
|
v-model="dialog"
|
||||||
width="500"
|
width="500"
|
||||||
|
:title="properties.title"
|
||||||
|
:icon="properties.icon"
|
||||||
|
can-submit
|
||||||
|
:submit-disabled="!name"
|
||||||
|
@submit="select"
|
||||||
>
|
>
|
||||||
<v-card>
|
<v-form>
|
||||||
<v-app-bar
|
<v-card-text>
|
||||||
density="compact"
|
<v-text-field
|
||||||
dark
|
v-model="name"
|
||||||
color="primary mb-2 position-relative left-0 top-0 w-100 pl-3"
|
:label="properties.label"
|
||||||
>
|
:rules="[rules.required]"
|
||||||
<v-icon
|
autofocus
|
||||||
size="large"
|
/>
|
||||||
start
|
<v-checkbox
|
||||||
class="mt-1"
|
v-if="itemType === Organizer.Tool"
|
||||||
>
|
v-model="onHand"
|
||||||
{{ itemType === Organizer.Tool ? $globals.icons.potSteam
|
:label="$t('tool.on-hand')"
|
||||||
: itemType === Organizer.Category ? $globals.icons.categories
|
/>
|
||||||
: $globals.icons.tags }}
|
</v-card-text>
|
||||||
</v-icon>
|
</v-form>
|
||||||
|
</BaseDialog>
|
||||||
<v-toolbar-title class="headline">
|
|
||||||
{{ properties.title }}
|
|
||||||
</v-toolbar-title>
|
|
||||||
|
|
||||||
<v-spacer />
|
|
||||||
</v-app-bar>
|
|
||||||
<v-card-title />
|
|
||||||
<v-form @submit.prevent="select">
|
|
||||||
<v-card-text>
|
|
||||||
<v-text-field
|
|
||||||
v-model="name"
|
|
||||||
density="compact"
|
|
||||||
:label="properties.label"
|
|
||||||
:rules="[rules.required]"
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
<v-checkbox
|
|
||||||
v-if="itemType === Organizer.Tool"
|
|
||||||
v-model="onHand"
|
|
||||||
:label="$t('tool.on-hand')"
|
|
||||||
/>
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-actions>
|
|
||||||
<BaseButton
|
|
||||||
cancel
|
|
||||||
@click="dialog = false"
|
|
||||||
/>
|
|
||||||
<v-spacer />
|
|
||||||
<BaseButton
|
|
||||||
type="submit"
|
|
||||||
create
|
|
||||||
:disabled="!name"
|
|
||||||
/>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-form>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -65,6 +33,8 @@ import { useUserApi } from "~/composables/api";
|
|||||||
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
|
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
|
||||||
import { type RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
|
import { type RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
|
||||||
|
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
|
||||||
const CREATED_ITEM_EVENT = "created-item";
|
const CREATED_ITEM_EVENT = "created-item";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -115,18 +85,21 @@ const properties = computed(() => {
|
|||||||
return {
|
return {
|
||||||
title: i18n.t("tag.create-a-tag"),
|
title: i18n.t("tag.create-a-tag"),
|
||||||
label: i18n.t("tag.tag-name"),
|
label: i18n.t("tag.tag-name"),
|
||||||
|
icon: $globals.icons.tags,
|
||||||
api: userApi.tags,
|
api: userApi.tags,
|
||||||
};
|
};
|
||||||
case Organizer.Tool:
|
case Organizer.Tool:
|
||||||
return {
|
return {
|
||||||
title: i18n.t("tool.create-a-tool"),
|
title: i18n.t("tool.create-a-tool"),
|
||||||
label: i18n.t("tool.tool-name"),
|
label: i18n.t("tool.tool-name"),
|
||||||
|
icon: $globals.icons.potSteam,
|
||||||
api: userApi.tools,
|
api: userApi.tools,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
title: i18n.t("category.create-a-category"),
|
title: i18n.t("category.create-a-category"),
|
||||||
label: i18n.t("category.category-name"),
|
label: i18n.t("category.category-name"),
|
||||||
|
icon: $globals.icons.categories,
|
||||||
api: userApi.categories,
|
api: userApi.categories,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -139,12 +112,9 @@ const rules = {
|
|||||||
async function select() {
|
async function select() {
|
||||||
if (store) {
|
if (store) {
|
||||||
// @ts-expect-error the same state is used for different organizer types, which have different requirements
|
// @ts-expect-error the same state is used for different organizer types, which have different requirements
|
||||||
await store.actions.createOne({ name: name.value, onHand: onHand.value });
|
const newItem = await store.actions.createOne({ name: name.value, onHand: onHand.value });
|
||||||
|
emit(CREATED_ITEM_EVENT, newItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newItem = store.store.value.find(item => item.name === name.value);
|
|
||||||
|
|
||||||
emit(CREATED_ITEM_EVENT, newItem);
|
|
||||||
dialog.value = false;
|
dialog.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
v-if="updateTarget"
|
v-if="updateTarget"
|
||||||
v-model="dialogs.update"
|
v-model="dialogs.update"
|
||||||
:title="$t('general.update')"
|
:title="$t('general.update')"
|
||||||
|
:icon="$globals.icons.edit"
|
||||||
can-confirm
|
can-confirm
|
||||||
@confirm="updateOne()"
|
@confirm="updateOne()"
|
||||||
>
|
>
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
</v-card-text>
|
</v-card-text>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
<v-row dense>
|
<v-row density="comfortable">
|
||||||
<v-col>
|
<v-col>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="searchString"
|
v-model="searchString"
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<v-app-bar
|
<v-row
|
||||||
color="transparent"
|
color="transparent"
|
||||||
flat
|
flat
|
||||||
class="mt-n1 rounded align-center position-relative w-100 left-0 top-0"
|
class="mt-n1 rounded align-center position-relative w-100 left-0 top-0"
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
create
|
create
|
||||||
@click="dialogs.organizer = true"
|
@click="dialogs.organizer = true"
|
||||||
/>
|
/>
|
||||||
</v-app-bar>
|
</v-row>
|
||||||
<section
|
<section
|
||||||
v-for="(itms, key, idx) in itemsSorted"
|
v-for="(itms, key, idx) in itemsSorted"
|
||||||
:key="'header' + idx"
|
:key="'header' + idx"
|
||||||
@@ -162,9 +163,9 @@ const state = reactive({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || auth.user?.value?.groupSlug || "");
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// Context Menu
|
// Context Menu
|
||||||
@@ -27,12 +27,10 @@
|
|||||||
color="accent"
|
color="accent"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
label
|
label
|
||||||
|
:text="item.name"
|
||||||
closable
|
closable
|
||||||
@click:close="removeByIndex(index)"
|
@click:close="removeByIndex(index)"
|
||||||
>
|
/>
|
||||||
{{ item.value }}
|
|
||||||
</v-chip>
|
|
||||||
</template>
|
</template>
|
||||||
<template
|
<template
|
||||||
v-if="showAdd"
|
v-if="showAdd"
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
@save="saveParsedIngredients"
|
@save="saveParsedIngredients"
|
||||||
/>
|
/>
|
||||||
<v-container v-show="!isCookMode" key="recipe-page" class="px-0" :class="{ 'pa-0': $vuetify.display.smAndDown }">
|
<v-container v-show="!isCookMode" key="recipe-page" class="px-0" :class="{ 'pa-0': $vuetify.display.smAndDown }">
|
||||||
<v-card :flat="$vuetify.display.smAndDown" class="d-print-none">
|
<v-card flat class="d-print-none">
|
||||||
<RecipePageHeader
|
<RecipePageHeader
|
||||||
:recipe="recipe"
|
:recipe="recipe"
|
||||||
:recipe-scale="scale"
|
:recipe-scale="scale"
|
||||||
@@ -68,17 +68,21 @@
|
|||||||
<!--
|
<!--
|
||||||
The left column is conditionally rendered based on cook mode.
|
The left column is conditionally rendered based on cook mode.
|
||||||
-->
|
-->
|
||||||
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4">
|
<v-col
|
||||||
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
|
v-if="!isCookMode || isEditForm"
|
||||||
<RecipePageOrganizers v-if="$vuetify.display.mdAndUp" v-model="recipe" @item-selected="chipClicked" />
|
cols="12"
|
||||||
|
sm="12"
|
||||||
|
md="4"
|
||||||
|
:class="$vuetify.display.mdAndUp ? 'border-e-thin' : null"
|
||||||
|
>
|
||||||
|
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" class="pr-2" />
|
||||||
|
<RecipePageOrganizers v-if="$vuetify.display.mdAndUp" v-model="recipe" class="pr-2" @item-selected="chipClicked" />
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-divider v-if="$vuetify.display.mdAndUp && !isCookMode" class="my-divider" :vertical="true" />
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
the right column is always rendered, but it's layout width is determined by where the left column is
|
the right column is always rendered, but it's layout width is determined by where the left column is
|
||||||
rendered.
|
rendered.
|
||||||
-->
|
-->
|
||||||
<v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4" :lg="8 + (isCookMode ? 1 : 0) * 4">
|
<v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4">
|
||||||
<RecipePageInstructions
|
<RecipePageInstructions
|
||||||
v-model="recipe.recipeInstructions"
|
v-model="recipe.recipeInstructions"
|
||||||
v-model:assets="recipe.assets"
|
v-model:assets="recipe.assets"
|
||||||
@@ -220,11 +224,11 @@ import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
|||||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||||
|
|
||||||
const display = useDisplay();
|
const display = useDisplay();
|
||||||
const $auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
|
const groupSlug = computed(() => (route.params.groupSlug as string) || auth.user?.value?.groupSlug || "");
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script setup lang="ts">
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import type { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</v-card-title>
|
</v-card-title>
|
||||||
<RecipeRating
|
<RecipeRating
|
||||||
:key="recipe.slug"
|
:key="recipe.slug"
|
||||||
:value="recipe.rating"
|
:model-value="recipe.rating"
|
||||||
:recipe-id="recipe.id"
|
:recipe-id="recipe.id"
|
||||||
:slug="recipe.slug"
|
:slug="recipe.slug"
|
||||||
/>
|
/>
|
||||||
@@ -62,17 +62,18 @@ const toolStore = isOwnGroup.value ? useToolStore() : null;
|
|||||||
const { user } = usePageUser();
|
const { user } = usePageUser();
|
||||||
const { isEditMode } = usePageState(props.recipe.slug);
|
const { isEditMode } = usePageState(props.recipe.slug);
|
||||||
|
|
||||||
const recipeTools = computed(() => {
|
const recipeTools = ref<RecipeToolWithOnHand[]>([]);
|
||||||
|
watch(() => props.recipe.tools, () => {
|
||||||
if (!(user.householdSlug && toolStore)) {
|
if (!(user.householdSlug && toolStore)) {
|
||||||
return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
|
recipeTools.value = props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return props.recipe.tools.map((tool) => {
|
recipeTools.value = props.recipe.tools.map((tool) => {
|
||||||
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
|
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
|
||||||
return { ...tool, onHand } as RecipeToolWithOnHand;
|
return { ...tool, onHand } as RecipeToolWithOnHand;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}, { immediate: true });
|
||||||
|
|
||||||
function updateTool(index: number) {
|
function updateTool(index: number) {
|
||||||
if (user.id && user.householdSlug && toolStore) {
|
if (user.id && user.householdSlug && toolStore) {
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user