mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-21 12:25:36 -04:00
Compare commits
411 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4101797c0e | ||
|
|
6110200a04 | ||
|
|
49f1e76776 | ||
|
|
24e9417d02 | ||
|
|
69d6985f3b | ||
|
|
84cdeb2398 | ||
|
|
6d439de144 | ||
|
|
1b586f8c67 | ||
|
|
f82f387146 | ||
|
|
d31c07a6c5 | ||
|
|
84372c2f4f | ||
|
|
168ac79daa | ||
|
|
22296277a8 | ||
|
|
6e006458be | ||
|
|
76a2fea076 | ||
|
|
3de4024619 | ||
|
|
194771653d | ||
|
|
24aa8f3525 | ||
|
|
fb8e318739 | ||
|
|
6255c71609 | ||
|
|
f2d1569488 | ||
|
|
987c7209fc | ||
|
|
f6dbd1f1f1 | ||
|
|
d30118899d | ||
|
|
af241dad57 | ||
|
|
b86de79c6f | ||
|
|
86e86f8c81 | ||
|
|
d795f91938 | ||
|
|
a59511cc81 | ||
|
|
a5d4cae6d0 | ||
|
|
2987cf8ba6 | ||
|
|
46b46978ff | ||
|
|
12857883a9 | ||
|
|
60fff3b5b8 | ||
|
|
b42e888929 | ||
|
|
570d6f1433 | ||
|
|
dcf410739e | ||
|
|
1929d630a1 | ||
|
|
c4c7bf2aed | ||
|
|
47034d18c5 | ||
|
|
7ebe491f74 | ||
|
|
719bd89eb1 | ||
|
|
9030c7e6b9 | ||
|
|
0202cc7ef8 | ||
|
|
381ac9bfde | ||
|
|
e9fe71c1b7 | ||
|
|
79bbc20cd6 | ||
|
|
c7be4a452a | ||
|
|
731ee8ae3d | ||
|
|
c7ae67e7cd | ||
|
|
e83891e3ca | ||
|
|
e3e45c534e | ||
|
|
279cf65673 | ||
|
|
cb44ecf394 | ||
|
|
920eeb26d6 | ||
|
|
9738d9f363 | ||
|
|
37e6123f9e | ||
|
|
0a2cabb348 | ||
|
|
447a1fb239 | ||
|
|
b5358896eb | ||
|
|
78fbbf0264 | ||
|
|
a33d8204df | ||
|
|
c8046bbdf0 | ||
|
|
329ad4d8ed | ||
|
|
4ccf649aa1 | ||
|
|
5994328a8b | ||
|
|
15b5917054 | ||
|
|
e48b150f7c | ||
|
|
adbc66316f | ||
|
|
0dc7337972 | ||
|
|
58d4b95a56 | ||
|
|
0e74bc6cd0 | ||
|
|
4866eec62d | ||
|
|
c0d659724a | ||
|
|
5f0996734a | ||
|
|
8cd0286ca1 | ||
|
|
f214e8843a | ||
|
|
66fea60341 | ||
|
|
69b4684bce | ||
|
|
b75d6812a3 | ||
|
|
ed000c2cc6 | ||
|
|
d43a2020b3 | ||
|
|
ff5e65b323 | ||
|
|
e1b07a250b | ||
|
|
e68486a0e1 | ||
|
|
271915ee23 | ||
|
|
a3d64c0761 | ||
|
|
73c664649d | ||
|
|
d887e68228 | ||
|
|
a8d3ed3310 | ||
|
|
00bd45c8f1 | ||
|
|
05003a5c6f | ||
|
|
e2be09b5d3 | ||
|
|
b81e0ac03b | ||
|
|
c5d822cded | ||
|
|
0fc66fee9a | ||
|
|
612c07e6f3 | ||
|
|
a0ac2923d6 | ||
|
|
7107c08021 | ||
|
|
a0e336edcb | ||
|
|
3e306638d0 | ||
|
|
a72641b32e | ||
|
|
f4ed9d92bf | ||
|
|
5ae35c3500 | ||
|
|
08666e6c21 | ||
|
|
5ae530a637 | ||
|
|
2b07497486 | ||
|
|
3b65642325 | ||
|
|
fdd1057e79 | ||
|
|
f1afebcc04 | ||
|
|
e711be7efa | ||
|
|
ec94b8179c | ||
|
|
a7c1d6f486 | ||
|
|
df0b792c52 | ||
|
|
1f5054fcbd | ||
|
|
ca483b9cbe | ||
|
|
03dc459162 | ||
|
|
cf8f5fe2a2 | ||
|
|
760350ef88 | ||
|
|
706d4ee0b5 | ||
|
|
5fd8545cbe | ||
|
|
3397c06db2 | ||
|
|
22df7a1ec7 | ||
|
|
e87b0c75b6 | ||
|
|
b406b7fa16 | ||
|
|
7114ed1122 |
@@ -1,9 +1,10 @@
|
||||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.224.2/containers/python-3/.devcontainer/base.Dockerfile
|
||||
|
||||
# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster
|
||||
ARG VARIANT="3.12-bullseye"
|
||||
FROM mcr.microsoft.com/devcontainers/python:${VARIANT}
|
||||
|
||||
# Remove outdated yarn GPG key, if it exists
|
||||
RUN rm -f /etc/apt/sources.list.d/yarn.list /usr/share/keyrings/yarn-archive-keyring.gpg || true
|
||||
|
||||
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
|
||||
ARG NODE_VERSION="none"
|
||||
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||
@@ -20,6 +21,7 @@ RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
curl \
|
||||
build-essential \
|
||||
ffmpeg \
|
||||
libpq-dev \
|
||||
libwebp-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
|
||||
|
||||
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:** 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:**
|
||||
- Pydantic schemas in `mealie/schema/` with strict separation: `*In`, `*Out`, `*Create`, `*Update` suffixes
|
||||
- Auto-exported from submodules via `__init__.py` files (generated by `task dev:generate`)
|
||||
- TypeScript types auto-generated from Pydantic schemas - **never manually edit** `frontend/lib/api/types/`
|
||||
- TypeScript types auto-generated from Pydantic schemas - **never manually edit** `frontend/app/lib/api/types/`
|
||||
|
||||
**Database & Sessions:**
|
||||
- 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
|
||||
|
||||
**API Client Pattern:**
|
||||
- API clients in `frontend/lib/api/` extend `BaseAPI`, `BaseCRUDAPI`, or `BaseCRUDAPIReadOnly`
|
||||
- Types imported from auto-generated `frontend/lib/api/types/` (DO NOT EDIT MANUALLY)
|
||||
- Composables in `frontend/composables/` for shared state and API logic (e.g., `use-mealie-auth.ts`)
|
||||
- API clients in `frontend/app/lib/api/` extend `BaseAPI`, `BaseCRUDAPI`, or `BaseCRUDAPIReadOnly`
|
||||
- Types imported from auto-generated `frontend/app/lib/api/types/` (DO NOT EDIT MANUALLY)
|
||||
- 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
|
||||
|
||||
**State Management:**
|
||||
- Nuxt 3 composables for state (no Vuex)
|
||||
- Nuxt 4 composables for state (no Vuex)
|
||||
- Auth state via `use-mealie-auth.ts` composable
|
||||
- Prefer composables over global state stores
|
||||
|
||||
@@ -148,7 +148,7 @@ task docker:prod # Build and run production Docker compose
|
||||
### Cross-Cutting Concerns
|
||||
|
||||
1. **Code generation is source of truth:** After Pydantic schema changes, run `task dev:generate` to update:
|
||||
- TypeScript types (`frontend/lib/api/types/`)
|
||||
- TypeScript types (`frontend/app/lib/api/types/`)
|
||||
- Schema exports (`mealie/schema/*/__init__.py`)
|
||||
- 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?
|
||||
|
||||
**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)
|
||||
- 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
|
||||
|
||||
- **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
|
||||
- **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`
|
||||
@@ -229,7 +229,7 @@ task docker:prod # Build and run production Docker compose
|
||||
- `Taskfile.yml` - All development commands and workflows
|
||||
- `mealie/routes/_base/base_controllers.py` - Controller base classes and patterns
|
||||
- `mealie/repos/repository_factory.py` - Repository factory and available repos
|
||||
- `frontend/lib/api/base/base-clients.ts` - API client base classes
|
||||
- `frontend/app/lib/api/base/base-clients.ts` - API client base classes
|
||||
- `tests/conftest.py` - Test fixtures and setup
|
||||
- `dev/code-generation/main.py` - Code generation entry point
|
||||
|
||||
|
||||
17
.github/pull_request_template.md
vendored
17
.github/pull_request_template.md
vendored
@@ -8,11 +8,11 @@
|
||||
- `chore: `
|
||||
- `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:
|
||||
-------------------------
|
||||
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:
|
||||
|
||||
- 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?
|
||||
Provide a bullet pointed summary of how each file was changed.
|
||||
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.
|
||||
-->
|
||||
@@ -43,6 +41,8 @@ If this PR fixes one of more issues, list them here.
|
||||
One per line, like so:
|
||||
Fixes #123
|
||||
Fixes #39
|
||||
|
||||
Be sure to include the word "fixes" otherwise the associated issue will not be closed.
|
||||
-->
|
||||
|
||||
## Special notes for your reviewer:
|
||||
@@ -61,3 +61,12 @@ _(fill-in or delete this section)_
|
||||
<!--
|
||||
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
|
||||
115
.github/workflows/auto-merge-l10n.yml
vendored
Normal file
115
.github/workflows/auto-merge-l10n.yml
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
name: Auto-merge l10n 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, 'l10n')
|
||||
|
||||
steps:
|
||||
- name: Validate PR author
|
||||
env:
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
if [[
|
||||
"$AUTHOR" != "hay-kot" &&
|
||||
"$AUTHOR" != "github-actions[bot]" &&
|
||||
"$AUTHOR" != "mealie-actions[bot]"
|
||||
]]; then
|
||||
echo "::error::PR author must be hay-kot, github-actions[bot], or mealie-actions[bot] for auto-merge (got: $AUTHOR)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Author validated: $AUTHOR"
|
||||
|
||||
- name: Validate PR size
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
ADDITIONS=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json additions --jq '.additions')
|
||||
DELETIONS=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json deletions --jq '.deletions')
|
||||
TOTAL=$((ADDITIONS + DELETIONS))
|
||||
|
||||
echo "PR changes: +$ADDITIONS -$DELETIONS (total: $TOTAL lines)"
|
||||
|
||||
if [ "$TOTAL" -gt 6000 ]; then
|
||||
echo "::error::PR exceeds 6000 line change limit ($TOTAL lines)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- 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
|
||||
# Check if file matches any allowed path
|
||||
if [[ "$file" == "frontend/app/composables/use-locales/available-locales.ts" ]] || \
|
||||
[[ "$file" =~ ^frontend/app/lang/ ]] || \
|
||||
[[ "$file" =~ ^mealie/lang/ ]] || \
|
||||
[[ "$file" =~ ^mealie/repos/seed/resources/[^/]+/locales/ ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# File doesn't match allowed paths
|
||||
echo "::error::Invalid file path: $file"
|
||||
echo "Only the following paths are allowed:"
|
||||
echo " - frontend/app/composables/use-locales/available-locales.ts"
|
||||
echo " - frontend/app/lang/"
|
||||
echo " - mealie/lang/"
|
||||
echo " - mealie/repos/seed/resources/*/locales/"
|
||||
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: l10n PR from trusted author 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
|
||||
23
.github/workflows/build-package.yml
vendored
23
.github/workflows/build-package.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
tag:
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
@@ -14,10 +17,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout 🛎
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Setup node env 🏗
|
||||
uses: actions/setup-node@v4.0.0
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
check-latest: true
|
||||
@@ -27,7 +32,7 @@ jobs:
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- 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'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
@@ -44,7 +49,7 @@ jobs:
|
||||
working-directory: "frontend"
|
||||
|
||||
- name: Archive built frontend
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
@@ -63,10 +68,12 @@ jobs:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -74,7 +81,7 @@ jobs:
|
||||
run: pip install uv
|
||||
|
||||
- name: Retrieve built frontend
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: mealie/frontend
|
||||
@@ -90,7 +97,7 @@ jobs:
|
||||
task py:package
|
||||
|
||||
- name: Archive built package
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: backend-dist
|
||||
path: dist
|
||||
|
||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
@@ -44,11 +44,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# 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).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
# ℹ️ 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
|
||||
@@ -75,6 +75,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
10
.github/workflows/e2e.yml
vendored
10
.github/workflows/e2e.yml
vendored
@@ -10,21 +10,21 @@ jobs:
|
||||
run:
|
||||
working-directory: ./tests/e2e
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: ./tests/e2e/yarn.lock
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Retrieve Python package
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: backend-dist
|
||||
path: dist
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
file: ./docker/Dockerfile
|
||||
context: .
|
||||
|
||||
19
.github/workflows/locale-sync.yml
vendored
19
.github/workflows/locale-sync.yml
vendored
@@ -15,13 +15,20 @@ jobs:
|
||||
sync-locales:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -30,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Load cached venv
|
||||
id: cached-python-dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
|
||||
@@ -105,7 +112,7 @@ jobs:
|
||||
- Updated frontend locale files
|
||||
- Generated from latest translation sources" \
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: No changes detected
|
||||
if: steps.changes.outputs.has_changes == 'false'
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build Dockerfile
|
||||
run: |
|
||||
@@ -28,6 +28,6 @@ jobs:
|
||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: "trivy-results.sarif"
|
||||
|
||||
28
.github/workflows/publish.yml
vendored
28
.github/workflows/publish.yml
vendored
@@ -9,6 +9,9 @@ on:
|
||||
tags:
|
||||
required: false
|
||||
type: string
|
||||
ref:
|
||||
required: false
|
||||
type: string
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME:
|
||||
required: true
|
||||
@@ -20,25 +23,39 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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)
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to the Container registry (dockerhub)
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- 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
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: backend-dist
|
||||
path: dist
|
||||
@@ -57,5 +74,6 @@ jobs:
|
||||
hkotel/mealie:${{ inputs.tag }}
|
||||
ghcr.io/${{ github.repository }}:${{ inputs.tag }}
|
||||
${{ inputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
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
|
||||
steps:
|
||||
# https://github.com/amannn/action-semantic-pull-request
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
- uses: amannn/action-semantic-pull-request@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
||||
13
.github/workflows/pull-requests.yml
vendored
13
.github/workflows/pull-requests.yml
vendored
@@ -4,14 +4,19 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- mealie-next
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
branches:
|
||||
- mealie-next
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.merge_group.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pull-request-lint:
|
||||
name: "Lint PR"
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/pull-request-lint.yml
|
||||
|
||||
backend-tests:
|
||||
@@ -24,6 +29,7 @@ jobs:
|
||||
|
||||
container-scanning:
|
||||
name: "Trivy Container Scanning"
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/partial-trivy-container-scanning.yml
|
||||
|
||||
code-ql:
|
||||
@@ -47,7 +53,10 @@ jobs:
|
||||
|
||||
publish-image:
|
||||
name: "Publish PR Image"
|
||||
if: contains(github.event.pull_request.labels.*.name, 'build-image') && github.repository == 'mealie-recipes/mealie'
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'build-image') &&
|
||||
github.repository == 'mealie-recipes/mealie'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
28
.github/workflows/release-drafter.yml
vendored
28
.github/workflows/release-drafter.yml
vendored
@@ -5,26 +5,28 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 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:
|
||||
types: [opened, labeled, unlabeled, reopened, synchronize]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update_release_draft:
|
||||
permissions:
|
||||
# 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
|
||||
draft_release:
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: 🚀 Run Release Drafter
|
||||
uses: release-drafter/release-drafter@v6.0.0
|
||||
- uses: release-drafter/release-drafter@v7
|
||||
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:
|
||||
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 }}
|
||||
|
||||
- name: Checkout 🛎
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
@@ -60,12 +60,16 @@ jobs:
|
||||
uses: ./.github/workflows/test-backend.yml
|
||||
needs:
|
||||
- commit-version-bump
|
||||
with:
|
||||
ref: ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||
|
||||
frontend-tests:
|
||||
name: "Frontend Tests"
|
||||
uses: ./.github/workflows/test-frontend.yml
|
||||
needs:
|
||||
- commit-version-bump
|
||||
with:
|
||||
ref: ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||
|
||||
build-package:
|
||||
name: Build Package
|
||||
@@ -74,6 +78,7 @@ jobs:
|
||||
- commit-version-bump
|
||||
with:
|
||||
tag: ${{ github.event.release.tag_name }}
|
||||
ref: ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||
|
||||
publish:
|
||||
permissions:
|
||||
@@ -90,7 +95,9 @@ jobs:
|
||||
- backend-tests
|
||||
- frontend-tests
|
||||
- build-package
|
||||
- commit-version-bump
|
||||
with:
|
||||
ref: ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||
tag: ${{ github.event.release.tag_name }}
|
||||
tags: |
|
||||
hkotel/mealie:latest
|
||||
@@ -117,7 +124,7 @@ jobs:
|
||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout 🛎
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
57
.github/workflows/scheduled-checks.yml
vendored
57
.github/workflows/scheduled-checks.yml
vendored
@@ -13,10 +13,10 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout 🛎
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pre-commit
|
||||
@@ -40,12 +40,18 @@ jobs:
|
||||
shell: bash
|
||||
run: pre-commit autoupdate --color=always
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
# This doesn't currently work for us because it creates the PR but the workflows don't run.
|
||||
# TODO: Provide a personal access token as a parameter here, that solves that problem.
|
||||
# https://github.com/peter-evans/create-pull-request
|
||||
- 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: Create Pull Request
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
commit-message: "Update pre-commit hooks"
|
||||
branch: "fix/update-pre-commit-hooks"
|
||||
labels: |
|
||||
@@ -54,3 +60,38 @@ jobs:
|
||||
base: mealie-next
|
||||
title: "chore(auto): Update pre-commit hooks"
|
||||
body: "Auto-generated by `.github/workflows/scheduled-checks.yml`"
|
||||
|
||||
- name: Approve PR
|
||||
if: steps.create-pr.outputs.pull-request-number
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.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: Pre-commit hook updates"
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.create-pr.outputs.pull-request-number
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr merge "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--auto \
|
||||
--squash
|
||||
|
||||
12
.github/workflows/test-backend.yml
vendored
12
.github/workflows/test-backend.yml
vendored
@@ -2,6 +2,10 @@ name: Backend Lint and Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
@@ -42,10 +46,12 @@ jobs:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -54,7 +60,7 @@ jobs:
|
||||
|
||||
- name: Load cached venv
|
||||
id: cached-python-dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .venv
|
||||
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:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -9,10 +13,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout 🛎
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Setup node env 🏗
|
||||
uses: actions/setup-node@v4.0.0
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
check-latest: true
|
||||
@@ -22,7 +28,7 @@ jobs:
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- 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'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
|
||||
@@ -12,7 +12,10 @@ repos:
|
||||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.14.10
|
||||
rev: v0.15.8
|
||||
hooks:
|
||||
- id: ruff
|
||||
# Linter
|
||||
- id: ruff-check
|
||||
args: [ --fix ]
|
||||
# Formatter
|
||||
- id: ruff-format
|
||||
|
||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -17,6 +17,8 @@
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.useFlatConfig": true,
|
||||
"eslint.workingDirectories": [
|
||||
@@ -30,11 +32,12 @@
|
||||
"**/.svn": true,
|
||||
"**/CVS": true
|
||||
},
|
||||
"files.insertFinalNewline": true,
|
||||
"i18n-ally.enabledFrameworks": [
|
||||
"vue"
|
||||
],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.localesPaths": "frontend/lang/messages",
|
||||
"i18n-ally.localesPaths": "frontend/app/lang/messages",
|
||||
"i18n-ally.sourceLanguage": "en-US",
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||
"python.testing.autoTestDiscoverOnSaveEnabled": false,
|
||||
@@ -67,6 +70,7 @@
|
||||
},
|
||||
"[python]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.tabSize": 4
|
||||
}
|
||||
}
|
||||
|
||||
104
Taskfile.yml
104
Taskfile.yml
@@ -25,16 +25,9 @@ dotenv:
|
||||
- .env
|
||||
- .dev.env
|
||||
tasks:
|
||||
docs:gen:
|
||||
desc: runs the API documentation generator
|
||||
cmds:
|
||||
- uv run python dev/code-generation/gen_docs_api.py
|
||||
|
||||
docs:
|
||||
desc: runs the documentation server
|
||||
dir: docs
|
||||
deps:
|
||||
- docs:gen
|
||||
cmds:
|
||||
- uv run python -m mkdocs serve
|
||||
|
||||
@@ -47,8 +40,6 @@ tasks:
|
||||
sources:
|
||||
- package.json
|
||||
- yarn.lock
|
||||
generates:
|
||||
- node_modules/**
|
||||
|
||||
setup:py:
|
||||
desc: setup python dependencies
|
||||
@@ -61,6 +52,18 @@ tasks:
|
||||
- pyproject.toml
|
||||
- .pre-commit-config.yaml
|
||||
|
||||
setup:e2e:
|
||||
desc: setup e2e test dependencies
|
||||
dir: tests/e2e
|
||||
run: once
|
||||
cmds:
|
||||
- yarn install
|
||||
- yarn playwright install --with-deps
|
||||
sources:
|
||||
- package.json
|
||||
- playwright.config.ts
|
||||
- yarn.lock
|
||||
|
||||
setup:
|
||||
desc: setup all dependencies
|
||||
deps:
|
||||
@@ -71,7 +74,6 @@ tasks:
|
||||
desc: run code generators
|
||||
cmds:
|
||||
- uv run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
||||
- task: docs:gen
|
||||
- task: py:format
|
||||
|
||||
dev:services:
|
||||
@@ -179,12 +181,21 @@ tasks:
|
||||
status:
|
||||
- '{{ .SKIP_PACKAGE_DEPS | default "false"}}'
|
||||
|
||||
py:package:
|
||||
desc: builds Python packages (sdist and wheel) in top-level dist directory
|
||||
py:package:build:
|
||||
internal: true
|
||||
deps:
|
||||
- py:package:deps
|
||||
cmds:
|
||||
- uv build --out-dir dist
|
||||
sources:
|
||||
- uv.lock
|
||||
- pyproject.toml
|
||||
- mealie/**
|
||||
|
||||
py:package:
|
||||
desc: builds Python packages (sdist and wheel) in top-level dist directory
|
||||
cmds:
|
||||
- task: py:package:build
|
||||
- task: py:package:generate-requirements
|
||||
|
||||
py:
|
||||
@@ -215,6 +226,12 @@ tasks:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn build
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:generate:
|
||||
desc: generates a static version of the frontend in frontend/dist
|
||||
@@ -223,18 +240,36 @@ tasks:
|
||||
- setup:ui
|
||||
cmds:
|
||||
- yarn generate
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:lint:
|
||||
desc: runs the frontend linter
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn lint --max-warnings=0
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:test:
|
||||
desc: runs the frontend tests
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn test
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:check:
|
||||
desc: runs all frontend checks
|
||||
@@ -263,3 +298,48 @@ tasks:
|
||||
dir: docker
|
||||
cmds:
|
||||
- docker compose -f docker-compose.yml -p mealie up -d --build
|
||||
|
||||
e2e:build-image:
|
||||
desc: builds the e2e test docker image
|
||||
deps:
|
||||
- py:package
|
||||
cmds:
|
||||
- docker build --tag mealie:e2e --file docker/Dockerfile --build-context packages=dist .
|
||||
sources:
|
||||
- docker/Dockerfile
|
||||
- dist/**
|
||||
|
||||
e2e:start-server:
|
||||
desc: Builds the image and starts the containers for e2e testing
|
||||
dir: tests/e2e/docker
|
||||
deps:
|
||||
- e2e:build-image
|
||||
vars:
|
||||
WAIT_UNTIL_HEALTHY: '{{if .WAIT_UNTIL_HEALTHY}}--wait{{else}}{{end}}'
|
||||
cmds:
|
||||
- docker compose up -d {{.WAIT_UNTIL_HEALTHY}}
|
||||
|
||||
e2e:stop-server:
|
||||
desc: Shuts down the e2e testing containers
|
||||
dir: tests/e2e/docker
|
||||
cmds:
|
||||
- docker compose down --volumes
|
||||
|
||||
e2e:test:
|
||||
desc: runs the e2e tests
|
||||
dir: tests/e2e
|
||||
deps:
|
||||
- setup:e2e
|
||||
vars:
|
||||
PREVENT_REPORT_OPEN: '{{if .PREVENT_REPORT_OPEN}}PLAYWRIGHT_HTML_OPEN=never{{else}}{{end}}'
|
||||
cmds:
|
||||
- '{{.PREVENT_REPORT_OPEN}} yarn playwright test'
|
||||
|
||||
e2e:
|
||||
desc: runs the full e2e test suite
|
||||
cmds:
|
||||
- task: e2e:start-server
|
||||
vars: { WAIT_UNTIL_HEALTHY: true }
|
||||
- defer: { task: e2e:stop-server }
|
||||
- task: e2e:test
|
||||
vars: { PREVENT_REPORT_OPEN: true }
|
||||
|
||||
@@ -4,8 +4,8 @@ pull_request_labels: [
|
||||
"l10n"
|
||||
]
|
||||
files:
|
||||
- source: /frontend/lang/messages/en-US.json
|
||||
translation: /frontend/lang/messages/%locale%.json
|
||||
- source: /frontend/app/lang/messages/en-US.json
|
||||
translation: /frontend/app/lang/messages/%locale%.json
|
||||
- source: /mealie/lang/messages/en-US.json
|
||||
translation: /mealie/lang/messages/%locale%.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 pathlib
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import dotenv
|
||||
@@ -10,6 +11,7 @@ from pydantic import ConfigDict
|
||||
from requests import Response
|
||||
from utils import CodeDest, CodeKeys, inject_inline, log
|
||||
|
||||
from mealie.lang.locale_config import LOCALE_CONFIG, LocalePluralFoodHandling, LocaleTextDirection
|
||||
from mealie.schema._mealie import MealieModel
|
||||
|
||||
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", "")
|
||||
|
||||
|
||||
@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
|
||||
export const LOCALES = [{% for locale in locales %}
|
||||
{
|
||||
@@ -75,6 +26,7 @@ export const LOCALES = [{% for locale in locales %}
|
||||
value: "{{ locale.locale }}",
|
||||
progress: {{ locale.progress }},
|
||||
dir: "{{ locale.dir }}",
|
||||
pluralFoodHandling: "{{ locale.plural_food_handling }}",
|
||||
},{% endfor %}
|
||||
];
|
||||
|
||||
@@ -87,10 +39,11 @@ class TargetLanguage(MealieModel):
|
||||
id: str
|
||||
name: str
|
||||
locale: str
|
||||
dir: str = "ltr"
|
||||
dir: LocaleTextDirection = LocaleTextDirection.LTR
|
||||
plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
|
||||
threeLettersCode: str
|
||||
twoLettersCode: str
|
||||
progress: float = 0.0
|
||||
progress: int = 0
|
||||
|
||||
|
||||
class CrowdinApi:
|
||||
@@ -117,52 +70,24 @@ class CrowdinApi:
|
||||
def get_languages(self) -> list[TargetLanguage]:
|
||||
response = self.get_project()
|
||||
tls = response.json()["data"]["targetLanguages"]
|
||||
return [TargetLanguage(**t) for t in tls]
|
||||
|
||||
models = [TargetLanguage(**t) for t in tls]
|
||||
|
||||
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:
|
||||
def get_progress(self) -> dict[str, int]:
|
||||
response = requests.get(
|
||||
f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500",
|
||||
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
|
||||
|
||||
|
||||
datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats"
|
||||
locales_dir = PROJECT_DIR / "frontend" / "lang" / "messages"
|
||||
datetime_dir = PROJECT_DIR / "frontend" / "app" / "lang" / "dateTimeFormats"
|
||||
locales_dir = PROJECT_DIR / "frontend" / "app" / "lang" / "messages"
|
||||
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"
|
||||
|
||||
"""
|
||||
@@ -195,8 +120,8 @@ def inject_nuxt_values():
|
||||
|
||||
all_langs = []
|
||||
for match in locales_dir.glob("*.json"):
|
||||
match_data = LOCALE_DATA.get(match.stem)
|
||||
match_dir = match_data.dir if match_data else "ltr"
|
||||
match_data = LOCALE_CONFIG.get(match.stem)
|
||||
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}" }},'
|
||||
all_langs.append(lang_string)
|
||||
@@ -221,9 +146,82 @@ def inject_registration_validation_values():
|
||||
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():
|
||||
api = CrowdinApi(None)
|
||||
models = api.get_languages()
|
||||
models = get_languages()
|
||||
tmpl = Template(LOCALE_TEMPLATE)
|
||||
rendered = tmpl.render(locales=models)
|
||||
|
||||
@@ -233,10 +231,6 @@ def generate_locales_ts_file():
|
||||
|
||||
|
||||
def main():
|
||||
if API_KEY is None or API_KEY == "":
|
||||
log.error("CROWDIN_API_KEY is not set")
|
||||
return
|
||||
|
||||
generate_locales_ts_file()
|
||||
inject_nuxt_values()
|
||||
inject_registration_validation_values()
|
||||
|
||||
@@ -33,11 +33,11 @@ PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
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 = {
|
||||
"global": PROJECT_DIR / "frontend" / "components" / "global",
|
||||
"layout": PROJECT_DIR / "frontend" / "components" / "Layout",
|
||||
"global": PROJECT_DIR / "frontend" / "app" / "components" / "global",
|
||||
"layout": PROJECT_DIR / "frontend" / "app" / "components" / "Layout",
|
||||
}
|
||||
|
||||
def render_template(template: str, data: dict) -> str | None:
|
||||
@@ -182,7 +182,7 @@ def generate_typescript_types() -> None: # noqa: C901
|
||||
return str_path
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class CodeTemplates:
|
||||
class CodeDest:
|
||||
interface = PARENT / "generated" / "interface.js"
|
||||
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:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###############################################
|
||||
# Frontend Build
|
||||
###############################################
|
||||
FROM node:24@sha256:b52a8d1206132b36d60e51e413d9a81336e8a0206d3b648cabd6d5a49c4c0f54 \
|
||||
FROM node:24@sha256:80fc934952c8f1b2b4d39907af7211f8a9fff1a4c2cf673fb49099292c251cec \
|
||||
AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
@@ -21,7 +21,7 @@ RUN yarn generate
|
||||
###############################################
|
||||
# Base Image - Python
|
||||
###############################################
|
||||
FROM python:3.12-slim@sha256:2267adc248a477c1f1a852a07a5a224d42abe54c28aafa572efa157dfb001bba \
|
||||
FROM python:3.12-slim@sha256:7026274c107626d7e940e0e5d6730481a4600ae95d5ca7eb532dd4180313fea9 \
|
||||
AS python-base
|
||||
|
||||
ENV MEALIE_HOME="/app"
|
||||
@@ -91,6 +91,7 @@ RUN apt-get update \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
libwebp-dev \
|
||||
ffmpeg \
|
||||
# LDAP Dependencies
|
||||
libsasl2-dev libldap2-dev libssl-dev \
|
||||
gnupg gnupg2 gnupg1 \
|
||||
@@ -111,7 +112,6 @@ RUN . $VENV_PATH/bin/activate \
|
||||
# Production Image
|
||||
###############################################
|
||||
FROM python-base AS production
|
||||
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
|
||||
ENV PRODUCTION=true
|
||||
ENV TESTING=false
|
||||
|
||||
@@ -120,6 +120,8 @@ ENV GIT_COMMIT_HASH=$COMMIT
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
curl \
|
||||
ffmpeg \
|
||||
gosu \
|
||||
iproute2 \
|
||||
libldap-common \
|
||||
@@ -142,7 +144,9 @@ ENV APP_PORT=9000
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
---
|
||||
@@ -6,7 +6,7 @@ While this guide aims to simplify the migration process for developers, it's not
|
||||
|
||||
## V1 → V2
|
||||
|
||||
The biggest change between V1 and V2 is the introduction of Households. For more information on how households work in relation to groups/users, check out the [Groups and Households](./features.md#groups-and-households) section in the Features guide.
|
||||
The biggest change between V1 and V2 is the introduction of Households. For more information on how households work in relation to groups/users, check out the [Groups and Households](../../documentation/getting-started/features.md#groups-and-households) section in the Features guide.
|
||||
|
||||
### `updateAt` is now `updatedAt`
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ Now you're ready to start the servers. You'll need two shells open, One for the
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Recipes extras are a key feature of the Mealie API. They allow you to create cus
|
||||
For example you could add `{"message": "Remember to thaw the chicken"}` to a recipe and use the webhooks built into mealie to send that message payload to a destination to be processed.
|
||||
|
||||
#### Shopping List and Food Extras
|
||||
Similarly to recipes, extras are supported on shopping lists, shopping list items, and foods. At this time they are only accessible through the API. Extras for these objects allow for rich integrations between the Mealie shopping list and your favorite list manager, such as Alexa, ToDoist, Trello, or any other list manager with an API.
|
||||
Similarly to recipes, extras are supported on shopping lists, shopping list items, and foods. At this time they are only accessible through the API. Extras for these objects allow for rich integrations between the Mealie shopping list and your favorite list manager, such as Todoist, Trello, or any other list manager with an API.
|
||||
|
||||
To keep shopping lists in sync, for instance, you can store your Trello list id on your Mealie shopping list: <br />
|
||||
`{"trello_list_id": "5abbe4b7ddc1b351ef961414"}`
|
||||
@@ -52,6 +52,7 @@ Many applications will keep track of the query and adjust the page parameter app
|
||||
Notice that the route does not contain the baseurl (e.g. `https://mymealieapplication.com/api`).
|
||||
|
||||
There are a few shorthands available to reduce the number of calls for certain common requests:
|
||||
|
||||
- if you want to return _all_ results, effectively disabling pagination, set `perPage = -1` (and fetch the first page)
|
||||
- if you want to fetch the _last_ page, set `page = -1`
|
||||
|
||||
@@ -78,8 +79,8 @@ This filter will find all foods that are not named "carrot": <br>
|
||||
##### 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`).
|
||||
|
||||
Here is an example of a filter that returns all recipes where the "last made" value is not null: <br>
|
||||
`lastMade IS NOT NULL`
|
||||
Here is an example of a filter that returns all shopping list items without a food: <br>
|
||||
`foodId IS NULL`
|
||||
|
||||
This filter will find all recipes that don't start with the word "Test": <br>
|
||||
`name NOT LIKE "Test%"`
|
||||
@@ -89,6 +90,28 @@ This filter will find all recipes that don't start with the word "Test": <br>
|
||||
This filter will find all recipes that have particular slugs: <br>
|
||||
`slug IN ["pasta-fagioli", "delicious-ramen"]`
|
||||
|
||||
##### Placeholder Keywords
|
||||
You can use placeholders to insert dynamic values as opposed to static values. Currently the only supported placeholder keyword is `$NOW`, to insert the current date/time.
|
||||
|
||||
`$NOW` can optionally be paired with basic offsets. Here is an example of a filter which gives you recipes not made within the past 30 days: <br>
|
||||
`lastMade <= "$NOW-30d"`
|
||||
|
||||
Supported offset operations include:
|
||||
|
||||
- `-` for subtracting a time (i.e. in the past)
|
||||
- `+` for adding a time (i.e. in the future)
|
||||
|
||||
Supported offset intervals include:
|
||||
|
||||
- `y` for years
|
||||
- `m` for months
|
||||
- `d` for days
|
||||
- `H` for hours
|
||||
- `M` for minutes
|
||||
- `S` for seconds
|
||||
|
||||
Note that intervals are _case sensitive_ (e.g. `s` is an invalid interval).
|
||||
|
||||
##### Nested Property filters
|
||||
When querying tables with relationships, you can filter properties on related tables. For instance, if you want to query all recipes owned by a particular user: <br>
|
||||
`user.username = "SousChef20220320"`
|
||||
@@ -96,7 +119,7 @@ When querying tables with relationships, you can filter properties on related ta
|
||||
This timeline event filter will return all timeline events for recipes that were created after a particular date: <br>
|
||||
`recipe.createdAt >= "2023-02-25"`
|
||||
|
||||
This recipe filter will return all recipes that contains a particular set of tags: <br>
|
||||
This recipe filter will return all recipes that contain a particular set of tags: <br>
|
||||
`tags.name CONTAINS ALL ["Easy", "Cajun"]`
|
||||
|
||||
##### Compound Filters
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many Identity Providers (IdP), including:
|
||||
|
||||
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
||||
- [Authentik](https://integrations.goauthentik.io/documentation/mealie/)
|
||||
- [Authelia](https://www.authelia.com/integration/openid-connect/mealie/)
|
||||
- [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc)
|
||||
- [Okta](https://www.okta.com/openid-connect/)
|
||||
@@ -68,7 +68,6 @@ Example configurations for several Identity Providers have been provided by the
|
||||
|
||||
If you don't see your provider and have successfully set it up, please consider [creating your own example](https://github.com/mealie-recipes/mealie/discussions/new?category=oauth-provider-example) so that others can have a smoother setup.
|
||||
|
||||
|
||||
## Migration from Mealie v1.x
|
||||
|
||||
**High level changes**
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
## Recipes
|
||||
|
||||
### Creating 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.
|
||||
|
||||
Mealie offers several ways to create recipes:
|
||||
- **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 }
|
||||
|
||||
### Importing Recipes
|
||||
@@ -85,13 +87,13 @@ The meal planner has the concept of plan rules. These offer a flexible way to us
|
||||
|
||||
The shopping lists feature is a great way to keep track of what you need to buy for your next meal. You can add items directly to the shopping list or link a recipe and all of it's ingredients to track meals during the week.
|
||||
|
||||
Managing shopping lists can be done from the Sidebar > Shopping Lists.
|
||||
Managing shopping lists can be done from the Sidebar > Shopping Lists.
|
||||
|
||||
Here you will be able to:
|
||||
|
||||
- See items already on the Shopping List
|
||||
- See linked recipes with ingredients
|
||||
- Toggling via the 'Pot' icon will show you the linked recipe, allowing you to click to access it.
|
||||
- Toggling via the 'Pot' icon will show you the linked recipe, allowing you to click to access it.
|
||||
- Check off an item
|
||||
- Add / Change / Remove / Sort Items via the grid icon
|
||||
- Be sure if you are modifying an ingredient to click the 'Save' icon.
|
||||
@@ -103,13 +105,10 @@ Here you will be able to:
|
||||
|
||||
!!! tip
|
||||
You can use Labels to categorize your ingredients. You may want to Label by Food Type (Frozen, Fresh, etc), by Store, Tool, Recipe, or more. Play around with this to see what works best for you.
|
||||
|
||||
!!! tip
|
||||
You can toggle 'Food' on items so that if you add multiple of the same food / ingredient, Mealie will automatically combine them together. Do this by editing an item in the Shopping List and clicking the 'Apple' icon. If you then have recipes that contain "1 | cup | cheese" and "2 | cup | cheese" this would be combined to show "3 cups of cheese."
|
||||
|
||||
[See FAQ for more information](../getting-started/faq.md)
|
||||
|
||||
|
||||
|
||||
[Shopping List Demo](https://demo.mealie.io/shopping-lists){ .md-button .md-button--primary }
|
||||
|
||||
## Integrations
|
||||
@@ -198,7 +197,7 @@ Mealie lets you fully customize how you organize your users. You can use Groups
|
||||
|
||||
### Groups
|
||||
|
||||
Groups are fully isolated instances of Mealie. Think of a goup as a completely separate, fully self-contained site. There is no data shared between groups. Each group has its own users, recipes, tags, categories, etc. A user logged-in to one group cannot make any changes to another.
|
||||
Groups are fully isolated instances of Mealie. Think of a group as a completely separate, fully self-contained site. There is no data shared between groups. Each group has its own users, recipes, tags, categories, etc. A user logged-in to one group cannot make any changes to another.
|
||||
|
||||
Common use cases for groups include:
|
||||
|
||||
|
||||
@@ -122,17 +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).
|
||||
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 |
|
||||
| ------------------------------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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_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_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_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||
| OPENAI_REQUEST_TIMEOUT | 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 |
|
||||
| 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_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_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_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_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_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_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_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||
| OPENAI_REQUEST_TIMEOUT | 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
|
||||
|
||||
@@ -235,6 +238,10 @@ The examples below provide copy-ready Docker Compose environment configurations
|
||||
THEME_DARK_ERROR: '#E57373'
|
||||
```
|
||||
|
||||
!!! info
|
||||
Browser cookies may cause the client to keep outdated settings.
|
||||
Clearing the cookies can be required for the change to take effect.
|
||||
|
||||
### Docker Secrets
|
||||
|
||||
> <super>†</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger
|
||||
|
||||
@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
||||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||
|
||||
1. Take a backup just in case!
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.9.2`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.15.0`
|
||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||
4. Restart the container
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
- 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)
|
||||
- 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
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.9.2 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.15.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.9.2 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.15.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -355,20 +355,20 @@
|
||||
title="github.com">
|
||||
<svg style="width: 32px; height: 32px" viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<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>
|
||||
</svg>
|
||||
</a>
|
||||
<a class="md-footer-social__link" href="https://twitter.com/kot_hay" rel="noopener" target="_blank"
|
||||
title="twitter.com">
|
||||
<svg style="width: 32px; height: 32px" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<a class="md-footer-social__link" href="https://bsky.app/profile/haykot.dev" rel="noopener" target="_blank"
|
||||
title="bsky.app">
|
||||
<svg style="width: 32px; height: 32px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<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>
|
||||
</svg>
|
||||
</a>
|
||||
<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">
|
||||
<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">
|
||||
|
||||
@@ -93,7 +93,7 @@ nav:
|
||||
- iOS Shortcut: "documentation/community-guide/ios-shortcut.md"
|
||||
- Reverse Proxy (SWAG): "documentation/community-guide/swag.md"
|
||||
|
||||
- API Reference: "api/redoc.md"
|
||||
- API Reference: "https://demo.mealie.io/docs"
|
||||
|
||||
- Contributors Guide:
|
||||
- Non-Code: "contributors/non-coders.md"
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
max-width: 950px !important;
|
||||
}
|
||||
|
||||
.lg-container {
|
||||
max-width: 1100px !important;
|
||||
}
|
||||
|
||||
.theme--dark.v-application {
|
||||
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
|
||||
}
|
||||
@@ -57,10 +61,6 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.fill-height {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -68,3 +68,8 @@ a {
|
||||
.vue-simple-handler {
|
||||
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 CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
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 slug = route.params.slug as string;
|
||||
@@ -88,11 +88,11 @@ const router = useRouter();
|
||||
const book = getOne(slug);
|
||||
|
||||
const isOwnHousehold = computed(() => {
|
||||
if (!($auth.user.value && book.value?.householdId)) {
|
||||
if (!(auth.user.value && book.value?.householdId)) {
|
||||
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);
|
||||
|
||||
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,43 @@
|
||||
<template>
|
||||
<div v-if="preferences">
|
||||
<BaseCardSectionTitle :title="$t('group.group-preferences')" />
|
||||
<div class="mb-6">
|
||||
<v-checkbox
|
||||
v-model="preferences.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="preferences.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 });
|
||||
</script>
|
||||
@@ -15,7 +15,6 @@
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="mdAndUp"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
@@ -83,8 +82,6 @@ const emit = defineEmits<{
|
||||
[key: string]: [];
|
||||
}>();
|
||||
|
||||
const { mdAndUp } = useDisplay();
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const api = useUserApi();
|
||||
@@ -94,7 +91,7 @@ const state = reactive({
|
||||
shoppingListDialog: false,
|
||||
menuItems: [
|
||||
{
|
||||
title: i18n.t("recipe.add-to-list"),
|
||||
title: i18n.t("meal-plan.add-day-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
@@ -126,8 +123,8 @@ async function getShoppingLists() {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||
shoppingList: () => {
|
||||
getShoppingLists();
|
||||
shoppingList: async () => {
|
||||
await getShoppingLists();
|
||||
state.shoppingListDialog = true;
|
||||
},
|
||||
};
|
||||
@@ -36,7 +36,7 @@
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
|
||||
|
||||
interface Props {
|
||||
queryFilter?: QueryFilterJSON | null;
|
||||
@@ -76,7 +76,6 @@ const MEAL_DAY_OPTIONS = [
|
||||
];
|
||||
|
||||
function handleQueryFilterInput(value: string | undefined) {
|
||||
console.warn("handleQueryFilterInput called with value:", value);
|
||||
queryFilterString.value = value || "";
|
||||
}
|
||||
|
||||
@@ -114,7 +113,7 @@ const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "last_made",
|
||||
label: i18n.t("general.last-made"),
|
||||
type: "date",
|
||||
type: "relativeDate",
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
@@ -18,6 +18,20 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<v-checkbox
|
||||
v-model="preferences.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-model="preferences.firstDayOfWeek"
|
||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||
@@ -29,7 +43,9 @@
|
||||
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 v-for="p in recipePreferences" :key="p.key">
|
||||
<v-checkbox v-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
|
||||
@@ -108,7 +108,7 @@
|
||||
<v-select
|
||||
v-if="field.type !== 'boolean'"
|
||||
:model-value="field.relationalOperatorValue"
|
||||
:items="field.relationalOperatorOptions"
|
||||
:items="field.relationalOperatorChoices"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
variant="underlined"
|
||||
@@ -129,9 +129,9 @@
|
||||
:class="config.col.class"
|
||||
>
|
||||
<v-select
|
||||
v-if="field.fieldOptions"
|
||||
v-if="field.fieldChoices"
|
||||
:model-value="field.values"
|
||||
:items="field.fieldOptions"
|
||||
:items="field.fieldChoices"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
multiple
|
||||
@@ -169,23 +169,39 @@
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
:model-value="$d(safeNewDate(field.value + 'T00:00:00'))"
|
||||
variant="underlined"
|
||||
color="primary"
|
||||
class="date-input"
|
||||
v-bind="activatorProps"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
<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
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
|
||||
/>
|
||||
</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
|
||||
v-else-if="field.type === Organizer.Category"
|
||||
v-model="field.organizers"
|
||||
@@ -319,7 +335,13 @@ import { useDebounceFn } from "@vueuse/core";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||
import type {
|
||||
LogicalOperator,
|
||||
QueryFilterJSON,
|
||||
QueryFilterJSONPart,
|
||||
RelationalKeyword,
|
||||
RelationalOperator,
|
||||
} from "~/lib/api/types/non-generated";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { useUserStore } from "~/composables/store/use-user-store";
|
||||
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||
@@ -341,7 +363,14 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { household } = useHouseholdSelf();
|
||||
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
||||
const {
|
||||
logOps,
|
||||
placeholderKeywords,
|
||||
getRelOps,
|
||||
buildQueryFilterString,
|
||||
getFieldFromFieldDef,
|
||||
isOrganizerType,
|
||||
} = useQueryFilterBuilder();
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
@@ -396,16 +425,29 @@ function setField(index: number, fieldLabel: string) {
|
||||
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 };
|
||||
|
||||
// we have to set this explicitly since it might be undefined
|
||||
updatedField.fieldOptions = fieldDef.fieldOptions;
|
||||
updatedField.fieldChoices = fieldDef.fieldChoices;
|
||||
|
||||
fields.value[index] = {
|
||||
...getFieldFromFieldDef(updatedField, resetValue),
|
||||
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) {
|
||||
@@ -425,12 +467,21 @@ function setLogicalOperatorValue(field: FieldWithId, index: number, value: Logic
|
||||
}
|
||||
|
||||
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
|
||||
const relOps = getRelOps(field.type);
|
||||
fields.value[index].relationalOperatorValue = relOps.value[value];
|
||||
}
|
||||
|
||||
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
|
||||
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[]) {
|
||||
@@ -448,12 +499,7 @@ function removeField(index: number) {
|
||||
state.datePickers.splice(index, 1);
|
||||
}
|
||||
|
||||
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
||||
/* newFields.forEach((field, index) => {
|
||||
const updatedField = getFieldFromFieldDef(field);
|
||||
fields.value[index] = updatedField; // recursive!!!
|
||||
}); */
|
||||
|
||||
const fieldsUpdater = useDebounceFn(() => {
|
||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||
if (qf) {
|
||||
console.debug(`Set query filter: ${qf}`);
|
||||
@@ -519,6 +565,9 @@ async function initializeFields() {
|
||||
...getFieldFromFieldDef(fieldDef),
|
||||
id: useUid(),
|
||||
};
|
||||
|
||||
const relOps = getRelOps(field.type);
|
||||
|
||||
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
|
||||
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
|
||||
field.logicalOperator = part.logicalOperator
|
||||
@@ -527,12 +576,15 @@ async function initializeFields() {
|
||||
field.relationalOperatorValue = part.relationalOperator
|
||||
? relOps.value[part.relationalOperator]
|
||||
: field.relationalOperatorValue;
|
||||
field.relationalOperatorValue = part.relationalOperator
|
||||
? relOps.value[part.relationalOperator]
|
||||
: field.relationalOperatorValue;
|
||||
|
||||
if (field.leftParenthesis || field.rightParenthesis) {
|
||||
state.showAdvanced = true;
|
||||
}
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
|
||||
if (typeof part.value === "string") {
|
||||
field.values = part.value ? [part.value] : [];
|
||||
}
|
||||
@@ -601,7 +653,7 @@ function buildQueryFilterJSON(): QueryFilterJSON {
|
||||
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());
|
||||
}
|
||||
else if (field.type === "boolean") {
|
||||
@@ -619,6 +671,50 @@ function buildQueryFilterJSON(): QueryFilterJSON {
|
||||
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 multiple = fields.value.length > 1;
|
||||
const adv = state.showAdvanced;
|
||||
@@ -689,4 +785,13 @@ const config = computed(() => {
|
||||
.bg-light {
|
||||
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>
|
||||
@@ -79,7 +79,7 @@
|
||||
@print="$emit('print')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="open" class="custom-btn-group gapped">
|
||||
<div v-if="open" class="custom-btn-group gapped ma-1">
|
||||
<v-btn
|
||||
v-for="(btn, index) in editorButtons"
|
||||
:key="index"
|
||||
@@ -1,60 +1,97 @@
|
||||
<template>
|
||||
<div v-if="model.length > 0 || edit">
|
||||
<v-card class="mt-4">
|
||||
<v-card-title class="py-2">
|
||||
{{ $t("asset.assets") }}
|
||||
</v-card-title>
|
||||
<v-list-item class="pr-2 pl-0">
|
||||
<v-card-title>
|
||||
{{ $t("asset.assets") }}
|
||||
</v-card-title>
|
||||
<template #append>
|
||||
<v-btn
|
||||
v-if="edit"
|
||||
variant="plain"
|
||||
:icon="$globals.icons.create"
|
||||
@click="state.newAssetDialog = true"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-divider class="mx-2" />
|
||||
<v-list
|
||||
v-if="model.length > 0"
|
||||
lines="two"
|
||||
:flat="!edit"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(item, i) in model"
|
||||
:key="i"
|
||||
:href="!edit ? assetURL(item.fileName ?? '') : ''"
|
||||
target="_blank"
|
||||
class="pr-2"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="ma-auto">
|
||||
<v-tooltip location="bottom">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-icon v-bind="tooltipProps">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ getIconDefinition(item.icon).title }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<v-avatar size="48" rounded="lg" class="elevation-1">
|
||||
<v-img
|
||||
v-if="isImage(item.fileName)"
|
||||
:src="assetURL(item.fileName ?? '')"
|
||||
:alt="item.name"
|
||||
loading="lazy"
|
||||
cover
|
||||
/>
|
||||
<v-icon v-else size="large">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="pl-2">
|
||||
|
||||
<v-list-item-title>
|
||||
{{ item.name }}
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<v-menu v-if="edit" location="bottom end">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon
|
||||
variant="plain"
|
||||
>
|
||||
<v-icon :icon="$globals.icons.dotsVertical" />
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" min-width="220">
|
||||
<v-list-item
|
||||
:href="assetURL(item.fileName ?? '')"
|
||||
:prepend-icon="$globals.icons.eye"
|
||||
:title="$t('general.view')"
|
||||
target="_blank"
|
||||
/>
|
||||
<v-list-item
|
||||
:href="assetURL(item.fileName ?? '')"
|
||||
:prepend-icon="$globals.icons.download"
|
||||
:title="$t('general.download')"
|
||||
download
|
||||
/>
|
||||
<v-list-item
|
||||
v-if="edit"
|
||||
:prepend-icon="$globals.icons.contentCopy"
|
||||
:title="$t('general.copy')"
|
||||
@click="copyText(assetEmbed(item.fileName ?? ''))"
|
||||
/>
|
||||
<v-list-item
|
||||
v-if="edit"
|
||||
:prepend-icon="$globals.icons.delete"
|
||||
:title="$t('general.delete')"
|
||||
@click="model.splice(i, 1)"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
v-if="!edit"
|
||||
color="primary"
|
||||
icon
|
||||
size="small"
|
||||
variant="plain"
|
||||
:href="assetURL(item.fileName ?? '')"
|
||||
target="_blank"
|
||||
top
|
||||
download
|
||||
>
|
||||
<v-icon> {{ $globals.icons.download }} </v-icon>
|
||||
</v-btn>
|
||||
<div v-else>
|
||||
<v-btn
|
||||
color="error"
|
||||
icon
|
||||
size="small"
|
||||
top
|
||||
@click="model.splice(i, 1)"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
<AppButtonCopy
|
||||
color=""
|
||||
:copy-text="assetEmbed(item.fileName ?? '')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
@@ -68,18 +105,9 @@
|
||||
can-submit
|
||||
@submit="addAsset"
|
||||
>
|
||||
<template #activator>
|
||||
<BaseButton
|
||||
v-if="edit"
|
||||
size="small"
|
||||
create
|
||||
@click="state.newAssetDialog = true"
|
||||
/>
|
||||
</template>
|
||||
<v-card-text class="pt-4">
|
||||
<v-text-field
|
||||
v-model="state.newAsset.name"
|
||||
density="compact"
|
||||
:label="$t('general.name')"
|
||||
/>
|
||||
<div class="d-flex justify-space-between">
|
||||
@@ -92,10 +120,14 @@
|
||||
item-value="name"
|
||||
class="mr-2"
|
||||
>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #prepend>
|
||||
<v-icon>{{ item.raw.icon }}</v-icon>
|
||||
<v-avatar>
|
||||
<v-icon>
|
||||
{{ item.raw.icon }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
@@ -107,7 +139,6 @@
|
||||
@uploaded="setFileObject"
|
||||
/>
|
||||
</div>
|
||||
{{ state.fileObject.name }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
@@ -118,6 +149,7 @@
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import type { RecipeAsset } from "~/lib/api/types/recipe";
|
||||
import { useCopy } from "~/composables/use-copy";
|
||||
|
||||
const props = defineProps({
|
||||
slug: {
|
||||
@@ -149,6 +181,7 @@ const state = reactive({
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { copyText } = useCopy();
|
||||
|
||||
const iconOptions = [
|
||||
{
|
||||
@@ -184,21 +217,31 @@ function getIconDefinition(icon: string) {
|
||||
return iconOptions.find(item => item.name === icon) || iconOptions[0];
|
||||
}
|
||||
|
||||
function isImage(fileName?: string | null) {
|
||||
if (!fileName) return false;
|
||||
return /\.(png|jpe?g|gif|webp|bmp|avif)$/i.test(fileName);
|
||||
}
|
||||
|
||||
const { recipeAssetPath } = useStaticRoutes();
|
||||
function assetURL(assetName: string) {
|
||||
return recipeAssetPath(props.recipeId, assetName);
|
||||
}
|
||||
|
||||
function assetEmbed(name: string) {
|
||||
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%"> </img>`;
|
||||
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%" />`;
|
||||
}
|
||||
|
||||
function setFileObject(fileObject: File) {
|
||||
state.fileObject = fileObject;
|
||||
// If the user didn't provide a name, default to the file base name
|
||||
if (!state.newAsset.name?.trim()) {
|
||||
state.newAsset.name = fileObject.name.substring(0, fileObject.name.lastIndexOf("."));
|
||||
}
|
||||
}
|
||||
|
||||
function validFields() {
|
||||
return state.newAsset.name.length > 0 && state.fileObject.name.length > 0;
|
||||
// Only require a file; name will fall back to the file name if empty
|
||||
return Boolean(state.fileObject?.name);
|
||||
}
|
||||
|
||||
async function addAsset() {
|
||||
@@ -207,8 +250,10 @@ async function addAsset() {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameToUse = state.newAsset.name?.trim() || state.fileObject.name;
|
||||
|
||||
const { data } = await api.recipes.createAsset(props.slug, {
|
||||
name: state.newAsset.name,
|
||||
name: nameToUse,
|
||||
icon: state.newAsset.icon,
|
||||
file: state.fileObject,
|
||||
extension: state.fileObject.name.split(".").pop() || "",
|
||||
@@ -36,10 +36,8 @@
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</RecipeCardImage>
|
||||
<v-card-title class="mb-n3 px-4">
|
||||
<div class="headerClass">
|
||||
{{ name }}
|
||||
</div>
|
||||
<v-card-title class="mb-n3 px-4" style="font-size: 1.25rem;">
|
||||
{{ name }}
|
||||
</v-card-title>
|
||||
|
||||
<slot name="actions">
|
||||
@@ -130,11 +128,11 @@ defineEmits<{
|
||||
delete: [slug: string];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
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 recipeRoute = computed<string>(() => {
|
||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||
@@ -160,11 +160,11 @@ defineEmits<{
|
||||
delete: [slug: string];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
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 recipeRoute = computed<string>(() => {
|
||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||
@@ -1,24 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-app-bar
|
||||
<v-row
|
||||
v-if="!disableToolbar"
|
||||
color="transparent"
|
||||
:absolute="false"
|
||||
flat
|
||||
class="mt-n1 flex-sm-wrap rounded position-relative w-100 left-0 top-0"
|
||||
class="align-center pb-2"
|
||||
>
|
||||
<slot name="title">
|
||||
<v-icon
|
||||
v-if="title"
|
||||
size="large"
|
||||
start
|
||||
>
|
||||
{{ displayTitleIcon }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline">
|
||||
{{ title }}
|
||||
</v-toolbar-title>
|
||||
</slot>
|
||||
<v-icon
|
||||
v-if="title"
|
||||
size="large"
|
||||
start
|
||||
>
|
||||
{{ displayTitleIcon }}
|
||||
</v-icon>
|
||||
<span class="text-headline-small">{{ title }}</span>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
:icon="$vuetify.display.xs"
|
||||
@@ -111,7 +104,7 @@
|
||||
]"
|
||||
@toggle-dense-view="toggleMobileCards()"
|
||||
/>
|
||||
</v-app-bar>
|
||||
</v-row>
|
||||
<div v-if="recipes && ready">
|
||||
<div class="mt-2">
|
||||
<v-row v-if="!useMobileCards">
|
||||
@@ -136,7 +129,7 @@
|
||||
</v-row>
|
||||
<v-row
|
||||
v-else
|
||||
dense
|
||||
density="comfortable"
|
||||
>
|
||||
<v-col
|
||||
v-for="recipe in recipes"
|
||||
@@ -159,14 +152,15 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
<v-card v-intersect="infiniteScroll" />
|
||||
<v-fade-transition>
|
||||
<AppLoader
|
||||
v-if="loading"
|
||||
:loading="loading"
|
||||
/>
|
||||
</v-fade-transition>
|
||||
<v-card v-intersect="infiniteScroll" variant="flat" />
|
||||
</div>
|
||||
<v-fade-transition>
|
||||
<AppLoader
|
||||
v-if="loading"
|
||||
:loading="loading"
|
||||
/>
|
||||
</v-fade-transition>
|
||||
<AppScrollToTop />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -219,7 +213,7 @@ const EVENTS = {
|
||||
shuffle: "shuffle",
|
||||
};
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const useMobileCards = computed(() => {
|
||||
@@ -234,7 +228,7 @@ const sortLoading = ref(false);
|
||||
const randomSeed = ref(Date.now().toString());
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const page = ref(1);
|
||||
const perPage = 32;
|
||||
@@ -243,6 +237,7 @@ const ready = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const { savePosition, getSavedPage, restorePosition } = useScrollPosition();
|
||||
const router = useRouter();
|
||||
|
||||
const queryFilter = computed(() => {
|
||||
@@ -283,8 +278,29 @@ async function fetchRecipes(pageCount = 1) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await initRecipes();
|
||||
ready.value = true;
|
||||
loading.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);
|
||||
@@ -337,6 +353,8 @@ const infiniteScroll = useThrottleFn(async () => {
|
||||
emit(APPEND_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
|
||||
savePosition(route.path, page.value);
|
||||
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="$vuetify.display.mdAndUp"
|
||||
content-class="d-print-none"
|
||||
@update:model-value="onMenuToggle"
|
||||
>
|
||||
@@ -24,7 +23,6 @@
|
||||
:fab="fab"
|
||||
v-bind="activatorProps"
|
||||
@click.prevent
|
||||
@mouseenter="onHover"
|
||||
>
|
||||
<v-icon
|
||||
:size="!fab ? undefined : 'x-large'"
|
||||
@@ -127,12 +125,6 @@ const contentProps = computed(() => {
|
||||
return rest;
|
||||
});
|
||||
|
||||
function onHover() {
|
||||
if (!isMenuContentLoaded.value) {
|
||||
isMenuContentLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuToggle(isOpen: boolean) {
|
||||
if (isOpen && !isMenuContentLoaded.value) {
|
||||
isMenuContentLoaded.value = true;
|
||||
@@ -176,6 +176,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<{
|
||||
[key: string]: any;
|
||||
deleted: [slug: string];
|
||||
print: [];
|
||||
}>();
|
||||
|
||||
const api = useUserApi();
|
||||
@@ -201,13 +202,13 @@ const newMealdateString = computed(() => {
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { household } = useHouseholdSelf();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
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(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
@@ -295,12 +296,12 @@ const recipeRefWithScale = computed(() =>
|
||||
);
|
||||
const isAdminAndNotOwner = computed(() => {
|
||||
return (
|
||||
$auth.user.value?.admin
|
||||
&& $auth.user.value?.id !== recipeRef.value?.userId
|
||||
auth.user.value?.admin
|
||||
&& auth.user.value?.id !== recipeRef.value?.userId
|
||||
);
|
||||
});
|
||||
const canDelete = computed(() => {
|
||||
const user = $auth.user.value;
|
||||
const user = auth.user.value;
|
||||
const recipe = recipeRef.value;
|
||||
return user && recipe && (user.admin || user.id === recipe.userId);
|
||||
});
|
||||
@@ -110,8 +110,8 @@ defineEmits<{
|
||||
const selected = defineModel<Recipe[]>({ default: () => [] });
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = $auth.user.value?.groupSlug;
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = auth.user.value?.groupSlug;
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize sort state with default sorting by dateAdded descending
|
||||
@@ -86,6 +86,19 @@
|
||||
class="text-center"
|
||||
>
|
||||
{{ 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-row>
|
||||
<v-row
|
||||
@@ -203,6 +216,7 @@ export interface ShoppingListRecipeIngredientSection {
|
||||
recipeName: string;
|
||||
recipeScale: number;
|
||||
ingredientSections: ShoppingListIngredientSection[];
|
||||
parentRecipe?: Recipe;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -217,7 +231,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const api = useUserApi();
|
||||
const preferences = useShoppingListPreferences();
|
||||
const ready = ref(false);
|
||||
@@ -227,7 +241,7 @@ const currentHouseholdSlug = ref("");
|
||||
const filteredShoppingLists = ref<ShoppingListSummary[]>([]);
|
||||
|
||||
const state = reactive({
|
||||
shoppingListDialog: true,
|
||||
shoppingListDialog: false,
|
||||
shoppingListIngredientDialog: false,
|
||||
shoppingListShowAllToggled: false,
|
||||
});
|
||||
@@ -237,11 +251,11 @@ const { shoppingListDialog, shoppingListIngredientDialog, shoppingListShowAllTog
|
||||
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
||||
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
|
||||
|
||||
watch(dialog, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
currentHouseholdSlug.value = $auth.user.value?.householdSlug || "";
|
||||
watch([dialog, () => preferences.value.viewAllLists], () => {
|
||||
if (dialog.value) {
|
||||
currentHouseholdSlug.value = auth.user.value?.householdSlug || "";
|
||||
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) {
|
||||
@@ -249,16 +263,80 @@ watch(dialog, (newVal, oldVal) => {
|
||||
openShoppingListIngredientDialog(selectedShoppingList.value);
|
||||
}
|
||||
else {
|
||||
state.shoppingListDialog = true;
|
||||
ready.value = true;
|
||||
}
|
||||
}
|
||||
else if (!newVal) {
|
||||
else if (!dialog.value) {
|
||||
initState();
|
||||
}
|
||||
});
|
||||
|
||||
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[]) {
|
||||
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) {
|
||||
if (!recipe.slug) {
|
||||
continue;
|
||||
@@ -290,88 +368,36 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shoppingListIngredients: ShoppingListIngredient[] = [];
|
||||
function flattenRecipeIngredients(ing: RecipeIngredient, parentTitle = ""): ShoppingListIngredient[] {
|
||||
const ownIngs: ShoppingListIngredient[] = [];
|
||||
const subRefIngs: RecipeIngredient[] = [];
|
||||
recipeData.recipeIngredient.forEach((ing) => {
|
||||
if (ing.referencedRecipe) {
|
||||
// Recursively flatten all ingredients in the referenced recipe
|
||||
return (ing.referencedRecipe.recipeIngredient ?? []).flatMap((subIng) => {
|
||||
const calculatedQty = (ing.quantity || 1) * (subIng.quantity || 1);
|
||||
// Pass the referenced recipe name as the section title
|
||||
return flattenRecipeIngredients(
|
||||
{ ...subIng, quantity: calculatedQty },
|
||||
"",
|
||||
);
|
||||
});
|
||||
subRefIngs.push(ing);
|
||||
}
|
||||
else {
|
||||
// Regular ingredient
|
||||
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
|
||||
return [{
|
||||
ownIngs.push({
|
||||
checked: !householdsWithFood.includes(currentHouseholdSlug.value),
|
||||
ingredient: {
|
||||
...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: [],
|
||||
ingredient: ing,
|
||||
});
|
||||
}
|
||||
|
||||
// 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, {
|
||||
recipeId: recipeData.id,
|
||||
recipeName: recipeData.name,
|
||||
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());
|
||||
}
|
||||
|
||||
function initState() {
|
||||
state.shoppingListDialog = true;
|
||||
state.shoppingListDialog = false;
|
||||
state.shoppingListIngredientDialog = false;
|
||||
state.shoppingListShowAllToggled = false;
|
||||
recipeIngredientSections.value = [];
|
||||
@@ -1,91 +1,60 @@
|
||||
<template>
|
||||
<div class="text-center">
|
||||
<v-dialog
|
||||
<BaseButton @click="dialog = true">
|
||||
{{ $t("new-recipe.bulk-add") }}
|
||||
</BaseButton>
|
||||
<BaseDialog
|
||||
v-model="dialog"
|
||||
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 }">
|
||||
<BaseButton
|
||||
v-bind="activatorProps"
|
||||
@click="inputText = inputTextProp"
|
||||
>
|
||||
{{ $t("new-recipe.bulk-add") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
<v-card-text>
|
||||
<v-textarea
|
||||
v-model="inputText"
|
||||
variant="outlined"
|
||||
rows="12"
|
||||
hide-details
|
||||
autofocus
|
||||
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
|
||||
/>
|
||||
|
||||
<v-card>
|
||||
<v-app-bar
|
||||
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 />
|
||||
<v-divider />
|
||||
<v-list lines="two">
|
||||
<template
|
||||
v-for="(util) in utilities"
|
||||
:key="util.id"
|
||||
>
|
||||
<v-list-item
|
||||
density="compact"
|
||||
class="py-1"
|
||||
class="px-0"
|
||||
>
|
||||
<v-list-item-title>
|
||||
<v-list-item-subtitle class="wrap-word">
|
||||
{{ util.description }}
|
||||
</v-list-item-subtitle>
|
||||
<template #prepend>
|
||||
<v-avatar>
|
||||
<v-btn
|
||||
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>
|
||||
<BaseButton
|
||||
size="small"
|
||||
color="info"
|
||||
@click="util.action"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.robot }}
|
||||
</template>
|
||||
{{ $t("general.run") }}
|
||||
</BaseButton>
|
||||
</v-list-item>
|
||||
<v-divider class="mx-2" />
|
||||
</template>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions>
|
||||
<BaseButton
|
||||
cancel
|
||||
@click="dialog = false"
|
||||
/>
|
||||
<v-spacer />
|
||||
<BaseButton
|
||||
save
|
||||
color="success"
|
||||
@click="save"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,66 +7,64 @@
|
||||
content-class="top-dialog"
|
||||
:scrollable="false"
|
||||
>
|
||||
<v-app-bar
|
||||
sticky
|
||||
dark
|
||||
color="primary-lighten-1 top-0 position-relative left-0"
|
||||
:rounded="!$vuetify.display.xs"
|
||||
>
|
||||
<v-text-field
|
||||
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
|
||||
class="position-relative mt-1 pa-1 scroll"
|
||||
max-height="700px"
|
||||
relative
|
||||
:rounded="!$vuetify.display.xs"
|
||||
: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>
|
||||
<div class="mr-auto">
|
||||
{{ $t("search.results") }}
|
||||
</div>
|
||||
</v-card-actions>
|
||||
|
||||
<RecipeCardMobile
|
||||
v-for="(recipe, index) in search.data.value"
|
||||
:key="index"
|
||||
:tabindex="index"
|
||||
class="ma-1 arrow-nav"
|
||||
:name="recipe.name ?? ''"
|
||||
:description="recipe.description ?? ''"
|
||||
:slug="recipe.slug ?? ''"
|
||||
:rating="recipe.rating ?? 0"
|
||||
:image="recipe.image"
|
||||
:recipe-id="recipe.id ?? ''"
|
||||
v-bind="$attrs.selected ? { selected: () => handleSelect(recipe) } : {}"
|
||||
/>
|
||||
<div class="scroll pa-1" style="max-height: 700px;">
|
||||
<RecipeCardMobile
|
||||
v-for="(recipe, index) in search.data.value"
|
||||
:key="index"
|
||||
:tabindex="index"
|
||||
class="ma-1 arrow-nav"
|
||||
:name="recipe.name ?? ''"
|
||||
:description="recipe.description ?? ''"
|
||||
:slug="recipe.slug ?? ''"
|
||||
:rating="recipe.rating ?? 0"
|
||||
:image="recipe.image"
|
||||
:recipe-id="recipe.id ?? ''"
|
||||
v-bind="$attrs.selected ? { selected: () => handleSelect(recipe) } : {}"
|
||||
/>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
@@ -87,7 +85,7 @@ const emit = defineEmits<{
|
||||
selected: [recipe: RecipeSummary];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const loading = ref(false);
|
||||
const selectedIndex = ref(-1);
|
||||
|
||||
@@ -153,7 +151,7 @@ watch(dialog, (val) => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
function open() {
|
||||
@@ -119,10 +119,10 @@ whenever(
|
||||
);
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { household } = useHouseholdSelf();
|
||||
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(() => {
|
||||
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>
|
||||
@@ -100,6 +100,7 @@
|
||||
v-model="state.auto"
|
||||
:label="$t('search.auto-search')"
|
||||
single-line
|
||||
color="primary"
|
||||
/>
|
||||
<v-btn
|
||||
block
|
||||
@@ -140,13 +141,13 @@ const emit = defineEmits<{
|
||||
ready: [];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const { $globals } = useNuxtApp();
|
||||
const i18n = useI18n();
|
||||
const showRandomLoading = ref(false);
|
||||
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const {
|
||||
state,
|
||||
@@ -81,11 +81,11 @@ import {
|
||||
usePublicToolStore,
|
||||
} from "~/composables/store";
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
|
||||
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 {
|
||||
state,
|
||||
@@ -52,14 +52,14 @@ const isFavorite = computed(() => {
|
||||
|
||||
async function toggleFavorite() {
|
||||
const api = useUserApi();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
|
||||
if (!$auth.user.value) return;
|
||||
if (!auth.user.value) return;
|
||||
if (!isFavorite.value) {
|
||||
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
||||
await api.users.addFavorite(auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
else {
|
||||
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
||||
await api.users.removeFavorite(auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
await refreshUserRatings();
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
/>
|
||||
<v-row
|
||||
:no-gutters="mdAndUp"
|
||||
dense
|
||||
density="comfortable"
|
||||
class="d-flex flex-wrap my-1"
|
||||
>
|
||||
<v-col
|
||||
@@ -44,9 +44,8 @@
|
||||
</v-number-input>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="!state.isRecipe"
|
||||
sm="12"
|
||||
md="3"
|
||||
md="2"
|
||||
cols="12"
|
||||
>
|
||||
<v-autocomplete
|
||||
@@ -58,8 +57,8 @@
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="units || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
:items="filteredUnits"
|
||||
:custom-filter="() => true"
|
||||
item-title="name"
|
||||
class="mx-1"
|
||||
:placeholder="$t('recipe.choose-unit')"
|
||||
@@ -104,7 +103,7 @@
|
||||
<v-col
|
||||
v-if="!state.isRecipe"
|
||||
m="12"
|
||||
md="3"
|
||||
md="4"
|
||||
cols="12"
|
||||
class=""
|
||||
>
|
||||
@@ -117,8 +116,8 @@
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="foods || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
:items="filteredFoods"
|
||||
:custom-filter="() => true"
|
||||
item-title="name"
|
||||
class="mx-1 py-0"
|
||||
:placeholder="$t('recipe.choose-food')"
|
||||
@@ -162,7 +161,7 @@
|
||||
<v-col
|
||||
v-if="state.isRecipe"
|
||||
m="12"
|
||||
md="6"
|
||||
md="4"
|
||||
cols="12"
|
||||
class=""
|
||||
>
|
||||
@@ -176,7 +175,6 @@
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="search.data.value || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
item-title="name"
|
||||
class="mx-1 py-0"
|
||||
:placeholder="$t('search.type-to-search')"
|
||||
@@ -227,11 +225,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, toRefs } from "vue";
|
||||
import { ref, computed, reactive, toRefs, watch } from "vue";
|
||||
import { useDisplay } from "vuetify";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||
import { normalizeFilter } from "~/composables/use-utils";
|
||||
import { useSearch } from "~/composables/use-search";
|
||||
import { useNuxtApp } from "#app";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
||||
@@ -343,8 +341,8 @@ const btns = computed(() => {
|
||||
// Foods
|
||||
const foodStore = useFoodStore();
|
||||
const foodData = useFoodData();
|
||||
const foodSearch = ref("");
|
||||
const foodAutocomplete = ref<HTMLInputElement>();
|
||||
const { search: foodSearch, filtered: filteredFoods } = useSearch(foodStore.store);
|
||||
|
||||
async function createAssignFood() {
|
||||
foodData.data.name = foodSearch.value;
|
||||
@@ -355,8 +353,8 @@ async function createAssignFood() {
|
||||
|
||||
// Recipes
|
||||
const route = useRoute();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||
@@ -375,8 +373,8 @@ watch(loading, (val) => {
|
||||
// Units
|
||||
const unitStore = useUnitStore();
|
||||
const unitsData = useUnitData();
|
||||
const unitSearch = ref("");
|
||||
const unitAutocomplete = ref<HTMLInputElement>();
|
||||
const { search: unitSearch, filtered: filteredUnits } = useSearch(unitStore.store);
|
||||
|
||||
async function createAssignUnit() {
|
||||
unitsData.data.name = unitSearch.value;
|
||||
@@ -430,9 +428,6 @@ function quantityFilter(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
const { showTitle } = toRefs(state);
|
||||
|
||||
const foods = foodStore.store;
|
||||
const units = unitStore.store;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -12,7 +12,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
|
||||
interface Props {
|
||||
ingredient?: RecipeIngredient;
|
||||
@@ -20,6 +20,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { ingredient, scale = 1 } = defineProps<Props>();
|
||||
const { useParsedIngredientText } = useIngredientTextParser();
|
||||
|
||||
const baseText = computed(() => {
|
||||
if (!ingredient) return "";
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RecipeIngredient } from "~/lib/api/types/household";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
|
||||
interface Props {
|
||||
ingredient: RecipeIngredient;
|
||||
@@ -44,8 +44,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
});
|
||||
const route = useRoute();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
|
||||
const { useParsedIngredientText } = useIngredientTextParser();
|
||||
|
||||
const parsedIng = computed(() => {
|
||||
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
|
||||
@@ -17,15 +17,13 @@
|
||||
v-for="(ingredient, index) in value"
|
||||
:key="'ingredient' + index"
|
||||
>
|
||||
<template v-if="!isCookMode">
|
||||
<h3
|
||||
v-if="showTitleEditor[index]"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ ingredient.title }}
|
||||
</h3>
|
||||
<v-divider v-if="showTitleEditor[index]" />
|
||||
</template>
|
||||
<h3
|
||||
v-if="showTitleEditor[index]"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ ingredient.title }}
|
||||
</h3>
|
||||
<v-divider v-if="showTitleEditor[index]" />
|
||||
<v-list-item
|
||||
density="compact"
|
||||
class="pa-0"
|
||||
@@ -54,7 +52,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
|
||||
interface Props {
|
||||
@@ -68,6 +66,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isCookMode: false,
|
||||
});
|
||||
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
function validateTitle(title?: string | null) {
|
||||
return !(title === undefined || title === "" || title === null);
|
||||
}
|
||||
@@ -159,7 +159,7 @@ const madeThisDialog = ref(false);
|
||||
const userApi = useUserApi();
|
||||
const { household } = useHouseholdSelf();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const domMadeThisForm = ref<VForm>();
|
||||
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
||||
subject: "",
|
||||
@@ -179,7 +179,7 @@ const newTimelineEventTimestampString = computed(() => {
|
||||
const lastMade = ref(props.recipe.lastMade);
|
||||
const lastMadeReady = ref(false);
|
||||
onMounted(async () => {
|
||||
if (!$auth.user?.value?.householdSlug) {
|
||||
if (!auth.user?.value?.householdSlug) {
|
||||
lastMade.value = props.recipe.lastMade;
|
||||
}
|
||||
else {
|
||||
@@ -255,8 +255,8 @@ async function createTimelineEvent() {
|
||||
madeThisFormLoading.value = true;
|
||||
|
||||
newTimelineEvent.value.recipeId = props.recipe.id;
|
||||
// Note: $auth.user is now a ref
|
||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
|
||||
// Note: auth.user is now a ref
|
||||
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
|
||||
// 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,
|
||||
});
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { frac } = useFraction();
|
||||
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(() => {
|
||||
return props.small
|
||||
@@ -1,62 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-dialog
|
||||
<BaseDialog
|
||||
v-model="dialog"
|
||||
width="500"
|
||||
:title="properties.title"
|
||||
:icon="properties.icon"
|
||||
can-submit
|
||||
:submit-disabled="!name"
|
||||
@submit="select"
|
||||
>
|
||||
<v-card>
|
||||
<v-app-bar
|
||||
density="compact"
|
||||
dark
|
||||
color="primary mb-2 position-relative left-0 top-0 w-100 pl-3"
|
||||
>
|
||||
<v-icon
|
||||
size="large"
|
||||
start
|
||||
class="mt-1"
|
||||
>
|
||||
{{ itemType === Organizer.Tool ? $globals.icons.potSteam
|
||||
: itemType === Organizer.Category ? $globals.icons.categories
|
||||
: $globals.icons.tags }}
|
||||
</v-icon>
|
||||
|
||||
<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>
|
||||
<v-form>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="name"
|
||||
: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-form>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -65,6 +33,8 @@ import { useUserApi } from "~/composables/api";
|
||||
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { type RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
|
||||
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const CREATED_ITEM_EVENT = "created-item";
|
||||
|
||||
interface Props {
|
||||
@@ -115,18 +85,21 @@ const properties = computed(() => {
|
||||
return {
|
||||
title: i18n.t("tag.create-a-tag"),
|
||||
label: i18n.t("tag.tag-name"),
|
||||
icon: $globals.icons.tags,
|
||||
api: userApi.tags,
|
||||
};
|
||||
case Organizer.Tool:
|
||||
return {
|
||||
title: i18n.t("tool.create-a-tool"),
|
||||
label: i18n.t("tool.tool-name"),
|
||||
icon: $globals.icons.potSteam,
|
||||
api: userApi.tools,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: i18n.t("category.create-a-category"),
|
||||
label: i18n.t("category.category-name"),
|
||||
icon: $globals.icons.categories,
|
||||
api: userApi.categories,
|
||||
};
|
||||
}
|
||||
@@ -139,12 +112,9 @@ const rules = {
|
||||
async function select() {
|
||||
if (store) {
|
||||
// @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;
|
||||
}
|
||||
</script>
|
||||
@@ -26,6 +26,7 @@
|
||||
v-if="updateTarget"
|
||||
v-model="dialogs.update"
|
||||
:title="$t('general.update')"
|
||||
:icon="$globals.icons.edit"
|
||||
can-confirm
|
||||
@confirm="updateOne()"
|
||||
>
|
||||
@@ -42,7 +43,7 @@
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<v-row dense>
|
||||
<v-row density="comfortable">
|
||||
<v-col>
|
||||
<v-text-field
|
||||
v-model="searchString"
|
||||
@@ -56,7 +57,7 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-app-bar
|
||||
<v-row
|
||||
color="transparent"
|
||||
flat
|
||||
class="mt-n1 rounded align-center position-relative w-100 left-0 top-0"
|
||||
@@ -75,7 +76,7 @@
|
||||
create
|
||||
@click="dialogs.organizer = true"
|
||||
/>
|
||||
</v-app-bar>
|
||||
</v-row>
|
||||
<section
|
||||
v-for="(itms, key, idx) in itemsSorted"
|
||||
:key="'header' + idx"
|
||||
@@ -162,9 +163,9 @@ const state = reactive({
|
||||
},
|
||||
});
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
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
|
||||
@@ -27,12 +27,10 @@
|
||||
color="accent"
|
||||
variant="flat"
|
||||
label
|
||||
|
||||
:text="item.name"
|
||||
closable
|
||||
@click:close="removeByIndex(index)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</v-chip>
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-if="showAdd"
|
||||
@@ -1,5 +1,18 @@
|
||||
<template>
|
||||
<div>
|
||||
<BaseDialog
|
||||
v-model="discardDialog"
|
||||
:title="$t('general.discard-changes')"
|
||||
color="warning"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
can-confirm
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("general.discard-changes-description") }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<RecipePageParseDialog
|
||||
:model-value="isParsing"
|
||||
:ingredients="recipe.recipeIngredient"
|
||||
@@ -8,13 +21,14 @@
|
||||
@save="saveParsedIngredients"
|
||||
/>
|
||||
<v-container v-show="!isCookMode" key="recipe-page" class="px-0" :class="{ 'pa-0': $vuetify.display.smAndDown }">
|
||||
<v-card :flat="$vuetify.display.smAndDown" class="d-print-none">
|
||||
<v-card flat class="d-print-none">
|
||||
<RecipePageHeader
|
||||
:recipe="recipe"
|
||||
:recipe-scale="scale"
|
||||
:landscape="landscape"
|
||||
@save="saveRecipe"
|
||||
@delete="deleteRecipe"
|
||||
@close="closeEditor"
|
||||
/>
|
||||
<RecipeJsonEditor
|
||||
v-if="isEditJSON"
|
||||
@@ -54,17 +68,21 @@
|
||||
<!--
|
||||
The left column is conditionally rendered based on cook mode.
|
||||
-->
|
||||
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4">
|
||||
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
|
||||
<RecipePageOrganizers v-if="$vuetify.display.mdAndUp" v-model="recipe" @item-selected="chipClicked" />
|
||||
<v-col
|
||||
v-if="!isCookMode || isEditForm"
|
||||
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-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
|
||||
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
|
||||
v-model="recipe.recipeInstructions"
|
||||
v-model:assets="recipe.assets"
|
||||
@@ -174,6 +192,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { invoke, until } from "@vueuse/core";
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import RecipeIngredients from "../RecipeIngredients.vue";
|
||||
import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue";
|
||||
import RecipePageFooter from "./RecipePageParts/RecipePageFooter.vue";
|
||||
@@ -205,12 +224,11 @@ import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
const display = useDisplay();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
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 api = useUserApi();
|
||||
@@ -231,26 +249,68 @@ const notLinkedIngredients = computed(() => {
|
||||
* and prompts the user to save if they have unsaved changes.
|
||||
*/
|
||||
const originalRecipe = ref<Recipe | null>(null);
|
||||
const discardDialog = ref(false);
|
||||
const pendingRoute = ref<RouteLocationNormalized | null>(null);
|
||||
|
||||
invoke(async () => {
|
||||
await until(recipe.value).not.toBeNull();
|
||||
originalRecipe.value = deepCopy(recipe.value);
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
const isSame = JSON.stringify(recipe.value) === JSON.stringify(originalRecipe.value);
|
||||
if (isEditMode.value && !isSame && recipe.value?.slug !== undefined) {
|
||||
const save = window.confirm(i18n.t("general.unsaved-changes"));
|
||||
|
||||
if (save) {
|
||||
await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||
}
|
||||
function hasUnsavedChanges(): boolean {
|
||||
if (originalRecipe.value === null) {
|
||||
return false;
|
||||
}
|
||||
return JSON.stringify(recipe.value) !== JSON.stringify(originalRecipe.value);
|
||||
}
|
||||
|
||||
function restoreOriginalRecipe() {
|
||||
if (originalRecipe.value) {
|
||||
recipe.value = deepCopy(originalRecipe.value) as NoUndefinedField<Recipe>;
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
if (hasUnsavedChanges()) {
|
||||
pendingRoute.value = null;
|
||||
discardDialog.value = true;
|
||||
}
|
||||
else {
|
||||
setMode(PageMode.VIEW);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDiscard() {
|
||||
restoreOriginalRecipe();
|
||||
discardDialog.value = false;
|
||||
|
||||
if (pendingRoute.value) {
|
||||
const destination = pendingRoute.value;
|
||||
pendingRoute.value = null;
|
||||
router.push(destination);
|
||||
}
|
||||
else {
|
||||
setMode(PageMode.VIEW);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDiscard() {
|
||||
discardDialog.value = false;
|
||||
pendingRoute.value = null;
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (isEditMode.value && hasUnsavedChanges()) {
|
||||
pendingRoute.value = to;
|
||||
discardDialog.value = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
deactivateNavigationWarning();
|
||||
toggleCookMode();
|
||||
|
||||
clearPageState(recipe.value.slug || "");
|
||||
console.debug("reset RecipePage state during unmount");
|
||||
});
|
||||
const hasLinkedIngredients = computed(() => {
|
||||
return recipe.value.recipeInstructions.some(
|
||||
@@ -300,6 +360,8 @@ async function saveRecipe() {
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
||||
recipe.value = data as NoUndefinedField<Recipe>;
|
||||
// Update the snapshot after successful save
|
||||
originalRecipe.value = deepCopy(recipe.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
<script setup lang="ts">
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||
@@ -16,7 +16,7 @@
|
||||
:open="isEditMode"
|
||||
:recipe-id="recipe.id"
|
||||
class="ml-auto mt-n7 pb-4"
|
||||
@close="setMode(PageMode.VIEW)"
|
||||
@close="$emit('close')"
|
||||
@json="toggleEditMode()"
|
||||
@edit="setMode(PageMode.EDIT)"
|
||||
@save="$emit('save')"
|
||||
@@ -47,7 +47,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
landscape: false,
|
||||
});
|
||||
|
||||
defineEmits(["save", "delete", "print"]);
|
||||
defineEmits(["save", "delete", "print", "close"]);
|
||||
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
||||
@@ -17,7 +17,7 @@
|
||||
</v-card-title>
|
||||
<RecipeRating
|
||||
:key="recipe.slug"
|
||||
:value="recipe.rating"
|
||||
:model-value="recipe.rating"
|
||||
:recipe-id="recipe.id"
|
||||
:slug="recipe.slug"
|
||||
/>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user