mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-22 21:05:35 -04:00
Compare commits
283 Commits
v3.11.0
...
mealie-nex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
072f93d02d | ||
|
|
70749b740a | ||
|
|
60daa3b4e2 | ||
|
|
2da78b4fb5 | ||
|
|
a5daaaab9e | ||
|
|
ad77b9851f | ||
|
|
3db94d876c | ||
|
|
39529ed606 | ||
|
|
b3fd2ccb33 | ||
|
|
fce0538671 | ||
|
|
b3ce0faf26 | ||
|
|
870b793d5f | ||
|
|
26dfeac956 | ||
|
|
cfb60228f7 | ||
|
|
c9a0cac055 | ||
|
|
83bc2f3889 | ||
|
|
0ffc1e7bf7 | ||
|
|
e166baa33c | ||
|
|
3e25005ea6 | ||
|
|
c92ebf2099 | ||
|
|
8e429834af | ||
|
|
2ca5694391 | ||
|
|
8d8987ab05 | ||
|
|
372474ea2b | ||
|
|
5b93129368 | ||
|
|
ffeb4dceaf | ||
|
|
5fc4851ef5 | ||
|
|
d9e933d5ae | ||
|
|
0a07835338 | ||
|
|
7a85ea6ae9 | ||
|
|
c4c60f1645 | ||
|
|
9f7ba8dc08 | ||
|
|
c4799ceb9e | ||
|
|
828be095a2 | ||
|
|
18718fb647 | ||
|
|
fb545962dd | ||
|
|
781a08ef54 | ||
|
|
a7a08b6b11 | ||
|
|
bd296c3eaf | ||
|
|
8aa016e57b | ||
|
|
480574eb3d | ||
|
|
0573d6fc9c | ||
|
|
f8d08c6785 | ||
|
|
e6368174f0 | ||
|
|
2252875050 | ||
|
|
54c62ec491 | ||
|
|
af79a751fb | ||
|
|
6e2c849412 | ||
|
|
76dbf4df45 | ||
|
|
4e5a2f9fb5 | ||
|
|
daa0b9728b | ||
|
|
0986ce2ca1 | ||
|
|
4972143004 | ||
|
|
499c42a52a | ||
|
|
92cf84f615 | ||
|
|
54511779a2 | ||
|
|
b72ccb8d29 | ||
|
|
9fb3bce792 | ||
|
|
32141187ba | ||
|
|
30014f53de | ||
|
|
d2b0681dbb | ||
|
|
306f2dcfc6 | ||
|
|
0fb5d31a22 | ||
|
|
1d5b263262 | ||
|
|
731e3aef37 | ||
|
|
fb04602a8e | ||
|
|
157b8d2937 | ||
|
|
6b28bb8eb0 | ||
|
|
124d10963e | ||
|
|
7c2ec93d13 | ||
|
|
d3e41582ae | ||
|
|
70a251a331 | ||
|
|
4fd224ade7 | ||
|
|
89694f7e54 | ||
|
|
7a60ad2227 | ||
|
|
eb71b962bc | ||
|
|
fe491bbe56 | ||
|
|
27f2dc1bf6 | ||
|
|
b3ea916192 | ||
|
|
240d681057 | ||
|
|
6932c9ef2d | ||
|
|
1438ba82d5 | ||
|
|
7a5032bf23 | ||
|
|
c3d1cf4c37 | ||
|
|
135a9ca684 | ||
|
|
ef90515ae8 | ||
|
|
a853e445ac | ||
|
|
7dad3777d3 | ||
|
|
a6ab0befba | ||
|
|
2c6997a601 | ||
|
|
9c3b94c019 | ||
|
|
5ce3099cfa | ||
|
|
0d1349cc7f | ||
|
|
7e7d1622dd | ||
|
|
d24aa7f65a | ||
|
|
5172571b2e | ||
|
|
bb278aac35 | ||
|
|
4ee97e5348 | ||
|
|
bac00a30a4 | ||
|
|
1123ec848d | ||
|
|
0f767f2e25 | ||
|
|
058dbdc9d6 | ||
|
|
94cf825a28 | ||
|
|
6ee69b7b3e | ||
|
|
f36c892bb7 | ||
|
|
f6305b785e | ||
|
|
1512a9e555 | ||
|
|
690b6aa57b | ||
|
|
c57af78f8f | ||
|
|
0775156aeb | ||
|
|
3356ebc0b8 | ||
|
|
1b59073dc4 | ||
|
|
ea3856b620 | ||
|
|
4f5d1cf1b4 | ||
|
|
626dee9500 | ||
|
|
1162c700cd | ||
|
|
7b3651d138 | ||
|
|
1a3676c36d | ||
|
|
17d9be3b15 | ||
|
|
7a8a511d48 | ||
|
|
085ecbaae3 | ||
|
|
453d40dab1 | ||
|
|
fc8b1f3719 | ||
|
|
c029a639fb | ||
|
|
63c549ae5c | ||
|
|
b1a846fe62 | ||
|
|
8545cf0c1c | ||
|
|
7c5913b012 | ||
|
|
4dd8d836e1 | ||
|
|
449e3baa07 | ||
|
|
e52a887e30 | ||
|
|
910ac4c81f | ||
|
|
52ad02aad8 | ||
|
|
93d51a2fdb | ||
|
|
41c3f1fced | ||
|
|
9f47f38176 | ||
|
|
a8142a08a1 | ||
|
|
1ede524d90 | ||
|
|
ab3eb6fec2 | ||
|
|
d64dcab9bd | ||
|
|
f9ff29dffc | ||
|
|
47794089da | ||
|
|
b0328ad926 | ||
|
|
18b3c4beab | ||
|
|
27cb585c80 | ||
|
|
f9ddfa94d4 | ||
|
|
5ab6e98f9e | ||
|
|
3ad2d9155d | ||
|
|
6278698ce5 | ||
|
|
3413c23f16 | ||
|
|
2ff2f22060 | ||
|
|
0f8ccdaade | ||
|
|
825c707035 | ||
|
|
9b9a767b00 | ||
|
|
c8793c474a | ||
|
|
b64e27b24b | ||
|
|
94cd6e89cb | ||
|
|
4c02724087 | ||
|
|
33c73feb1c | ||
|
|
002a7e3741 | ||
|
|
be4f71e5df | ||
|
|
78ff4bb875 | ||
|
|
26924ab054 | ||
|
|
c533da1c21 | ||
|
|
1969f50ee6 | ||
|
|
ff7d23d6d4 | ||
|
|
5e239be6fa | ||
|
|
e5520b08e5 | ||
|
|
fce0b47e2c | ||
|
|
e00d2a6e83 | ||
|
|
948914df50 | ||
|
|
0c7d9341bf | ||
|
|
b94b24640b | ||
|
|
c303198857 | ||
|
|
9424858985 | ||
|
|
3711154c44 | ||
|
|
cc12d07576 | ||
|
|
3e96bf7f43 | ||
|
|
ee550f4fe3 | ||
|
|
60d4a62f0a | ||
|
|
69d740e100 | ||
|
|
c4fdab4e05 | ||
|
|
04dd514e6a | ||
|
|
ad5d4b5aba | ||
|
|
b948314c6b | ||
|
|
2568015941 | ||
|
|
235a1f1931 | ||
|
|
8c23fd922a | ||
|
|
537f5ae1dd | ||
|
|
9372f51000 | ||
|
|
1bdb3ce54c | ||
|
|
16e8e8a877 | ||
|
|
6a3b38a31e | ||
|
|
8189416495 | ||
|
|
589c6de053 | ||
|
|
d3d339a4aa | ||
|
|
5c2bbea09b | ||
|
|
f6ca4bb29a | ||
|
|
ddc30fc65f | ||
|
|
a4d1e0a440 | ||
|
|
86b72f4d5b | ||
|
|
1344f1674d | ||
|
|
5a223aa92d | ||
|
|
845637c988 | ||
|
|
b5c089f58c | ||
|
|
96597915ff | ||
|
|
98c555fd20 | ||
|
|
118274ad9d | ||
|
|
50ea601683 | ||
|
|
7cc9010143 | ||
|
|
1f196cf10f | ||
|
|
024e20b4e5 | ||
|
|
5aafebb7a6 | ||
|
|
a89460acdf | ||
|
|
89091678d4 | ||
|
|
562eb89ee7 | ||
|
|
5e40fed623 | ||
|
|
0ddfd9caaf | ||
|
|
56086bdf49 | ||
|
|
a0674dd5d2 | ||
|
|
45c68d160a | ||
|
|
058937e334 | ||
|
|
c91f8e23d7 | ||
|
|
77081d0482 | ||
|
|
bf11729a23 | ||
|
|
b5016857c8 | ||
|
|
26fdf78709 | ||
|
|
455dbb1441 | ||
|
|
f28de01b2e | ||
|
|
ccdf7109e2 | ||
|
|
e574896449 | ||
|
|
15d56c42f7 | ||
|
|
c983e8bd59 | ||
|
|
7584c99591 | ||
|
|
78718dcf26 | ||
|
|
a8ec66f9aa | ||
|
|
3899f85735 | ||
|
|
7cbe17fe09 | ||
|
|
929349d414 | ||
|
|
595a3c66cd | ||
|
|
d4e7dc6e9d | ||
|
|
b719b39c09 | ||
|
|
b208719cb9 | ||
|
|
3ee64c930c | ||
|
|
189e98fb1f | ||
|
|
7da01f7873 | ||
|
|
8144799733 | ||
|
|
669df6bbb4 | ||
|
|
c6171f2cb2 | ||
|
|
e5d930ecb8 | ||
|
|
668246d369 | ||
|
|
b182b50faa | ||
|
|
a3e3fa6f56 | ||
|
|
bd7acabcd1 | ||
|
|
8059cc731f | ||
|
|
3ae455539c | ||
|
|
8fd7995681 | ||
|
|
282eedfe2b | ||
|
|
03f849f20f | ||
|
|
5db3b6ab72 | ||
|
|
353c24ca4b | ||
|
|
216ae8571c | ||
|
|
02d32c8905 | ||
|
|
7e0d083e77 | ||
|
|
b3cea081fe | ||
|
|
d79252752b | ||
|
|
b3c214d102 | ||
|
|
3a01925e48 | ||
|
|
16e2386f5a | ||
|
|
bbfa105e99 | ||
|
|
c94c9940b2 | ||
|
|
29c6176d89 | ||
|
|
0c0d7d11a5 | ||
|
|
e75fc6d391 | ||
|
|
f308869154 | ||
|
|
af30b8bdfa | ||
|
|
de4f22c3f6 | ||
|
|
4c55b282d6 | ||
|
|
8d2b2eb581 | ||
|
|
e9daac5fc4 | ||
|
|
ee1205cfdc | ||
|
|
a165b707af | ||
|
|
564385eb83 |
@@ -21,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
|
||||
14
.github/workflows/auto-merge-l10n.yml
vendored
14
.github/workflows/auto-merge-l10n.yml
vendored
@@ -40,8 +40,8 @@ jobs:
|
||||
|
||||
echo "PR changes: +$ADDITIONS -$DELETIONS (total: $TOTAL lines)"
|
||||
|
||||
if [ "$TOTAL" -gt 400 ]; then
|
||||
echo "::error::PR exceeds 400 line change limit ($TOTAL lines)"
|
||||
if [ "$TOTAL" -gt 6000 ]; then
|
||||
echo "::error::PR exceeds 6000 line change limit ($TOTAL lines)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -55,8 +55,9 @@ jobs:
|
||||
|
||||
for file in $FILES; do
|
||||
# Check if file matches any allowed path
|
||||
if [[ "$file" == "frontend/composables/use-locales/available-locales.ts" ]] || \
|
||||
[[ "$file" =~ ^frontend/lang/ ]] || \
|
||||
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
|
||||
@@ -64,8 +65,9 @@ jobs:
|
||||
# File doesn't match allowed paths
|
||||
echo "::error::Invalid file path: $file"
|
||||
echo "Only the following paths are allowed:"
|
||||
echo " - frontend/composables/use-locales/available-locales.ts"
|
||||
echo " - frontend/lang/"
|
||||
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
|
||||
|
||||
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: .
|
||||
|
||||
6
.github/workflows/locale-sync.yml
vendored
6
.github/workflows/locale-sync.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
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"
|
||||
|
||||
@@ -37,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') }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
18
.github/workflows/publish.yml
vendored
18
.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,17 +23,19 @@ 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 }}
|
||||
@@ -39,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
hkotel/mealie
|
||||
@@ -47,9 +52,10 @@ jobs:
|
||||
# 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
|
||||
@@ -70,4 +76,4 @@ jobs:
|
||||
${{ 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:
|
||||
|
||||
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
|
||||
|
||||
6
.github/workflows/scheduled-checks.yml
vendored
6
.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
|
||||
|
||||
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.15.1
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
10
Taskfile.yml
10
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
|
||||
|
||||
@@ -81,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:
|
||||
@@ -350,4 +342,4 @@ tasks:
|
||||
vars: { WAIT_UNTIL_HEALTHY: true }
|
||||
- defer: { task: e2e:stop-server }
|
||||
- task: e2e:test
|
||||
vars: { PREVENT_REPORT_OPEN: true }
|
||||
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)
|
||||
@@ -78,16 +78,16 @@ class CrowdinApi:
|
||||
headers=self.headers,
|
||||
)
|
||||
data = response.json()["data"]
|
||||
return {p["data"]["languageId"]: p["translationProgress"] for p in 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"
|
||||
|
||||
"""
|
||||
|
||||
@@ -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:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 \
|
||||
FROM node:24@sha256:e9891237dfbb1de60ce19e9ff9fac5d73ad9c37da303ad72ff2a425ad1057e71 \
|
||||
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 \
|
||||
@@ -119,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 \
|
||||
@@ -141,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
|
||||
---
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -79,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%"`
|
||||
|
||||
@@ -42,6 +42,10 @@ Before you can start using OIDC Authentication, you must first configure a new c
|
||||
http://localhost:9091/login
|
||||
https://mealie.example.com/login
|
||||
|
||||
If you are hosting Mealie behind a reverse proxy (nginx, Caddy, ...) to terminate TLS, make sure to start Mealie's Gunicorn server
|
||||
with `--forwarded-allow-ips=<ip-of-proxy>`, otherwise the `X-Forwarded-*` headers will be ignored and the generated OIDC redirect
|
||||
URI will use the wrong scheme (http instead of https). This will lead to authentication errors with strict OIDC providers.
|
||||
|
||||
3. Configure allowed scopes
|
||||
|
||||
The scopes required are `openid profile email`
|
||||
|
||||
@@ -5,8 +5,16 @@
|
||||
## Recipes
|
||||
|
||||
### Creating Recipes
|
||||
Mealie offers several ways to create recipes:
|
||||
|
||||
Mealie offers two main ways to create recipes. You can use the integrated recipe-scraper to create recipes from hundreds of websites, or you can create recipes manually using the recipe editor.
|
||||
- **Recipe Scraper:** Create recipes from hundreds of websites by simply providing a URL.
|
||||
- **Recipe HTML or JSON:** Copy/paste structured HTML or JSON and Mealie can import it.
|
||||
- **Manual Editor:** Create recipes from scratch using the integrated editor.
|
||||
|
||||
Mealie's [AI integration](./installation/open-ai.md) greatly expands the ways you can create recipes:
|
||||
|
||||
- **Image Import:** Upload an image of a written or typed recipe and Mealie will use OCR and AI to import it.
|
||||
- **Video URL Import:** Provide a video URL (e.g., YouTube) and Mealie will transcribe the audio and turn it into a recipe.
|
||||
|
||||
[Creation Demo](https://demo.mealie.io/g/home/r/create/url){ .md-button .md-button--primary .align-right }
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| PGID | 911 | GroupID permissions between host OS and container |
|
||||
| DEFAULT_GROUP | Home | The default group for users |
|
||||
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
|
||||
| BASE_URL | http://localhost:8080 | Used for Notifications |
|
||||
| BASE_URL | http://localhost:8080 | Used for notifications and the OIDC callback url |
|
||||
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid. Must be <= 9600 (400 days, in hours). |
|
||||
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
|
||||
| API_DOCS | True | Turns on/off access to the API documentation locally |
|
||||
@@ -114,6 +114,7 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
|
||||
| OIDC_GROUPS_CLAIM | groups | Optional if not using `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. This is the claim Mealie will request from your IdP and will use to compare to `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP` to allow the user to log in to Mealie or is set as an admin. **Your IdP must be configured to grant this claim** |
|
||||
| OIDC_SCOPES_OVERRIDE | None | Advanced configuration used to override the scopes requested from the IdP. **Most users won't need to change this**. At a minimum, 'openid profile email' are required. |
|
||||
| OIDC_TLS_CACERTFILE | None | File path to Certificate Authority used to verify server certificate (e.g. `/path/to/ca.crt`) |
|
||||
| OIDC_CLIENT_TIMEOUT | default | Configures the timeout value of the httpx client used for OIDC communications. If set to the string `default`, does not configure the value (uses the library's default of 5.0s). If set to the string `None`, disables the timeout entirely. If set to a numeric value, uses that as the timeout. |
|
||||
|
||||
### OpenAI
|
||||
|
||||
@@ -122,18 +123,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 |
|
||||
| OPENAI_CUSTOM_PROMPT_DIR | None | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -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.11.0`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.16.0`
|
||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||
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.11.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.16.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.11.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.16.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">
|
||||
|
||||
@@ -19,6 +19,7 @@ theme:
|
||||
custom_dir: docs/overrides
|
||||
features:
|
||||
- content.code.annotate
|
||||
- content.code.copy
|
||||
- navigation.top
|
||||
- navigation.instant
|
||||
- navigation.expand
|
||||
@@ -93,7 +94,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"
|
||||
|
||||
@@ -20,16 +20,12 @@
|
||||
max-width: 1100px !important;
|
||||
}
|
||||
|
||||
.theme--dark.v-application {
|
||||
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
|
||||
.v-theme--dark.v-application {
|
||||
background-color: rgb(var(--v-theme-background)) !important;
|
||||
}
|
||||
|
||||
.theme--dark.v-navigation-drawer {
|
||||
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
|
||||
}
|
||||
|
||||
.theme--dark.v-card {
|
||||
background-color: #1e1e1e !important;
|
||||
.v-theme--dark .v-navigation-drawer {
|
||||
background-color: rgb(var(--v-theme-background)) !important;
|
||||
}
|
||||
|
||||
.left-border {
|
||||
@@ -61,10 +57,6 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.fill-height {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -72,3 +64,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);
|
||||
});
|
||||
});
|
||||
230
frontend/app/components/Domain/Group/GroupDataPage.vue
Normal file
230
frontend/app/components/Domain/Group/GroupDataPage.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<!-- Create Dialog -->
|
||||
<BaseDialog
|
||||
v-model="createDialog"
|
||||
:title="createTitle || $t('general.create')"
|
||||
:icon="icon"
|
||||
color="primary"
|
||||
max-width="600px"
|
||||
width="100%"
|
||||
:submit-disabled="!createFormValid"
|
||||
can-confirm
|
||||
@confirm="emit('create-one', createForm.data)"
|
||||
>
|
||||
<div class="mx-2 mt-2">
|
||||
<slot name="create-dialog-top" />
|
||||
<AutoForm
|
||||
v-model="createForm.data"
|
||||
v-model:is-valid="createFormValid"
|
||||
:items="createForm.items"
|
||||
class="py-2"
|
||||
/>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<BaseDialog
|
||||
v-model="editDialog"
|
||||
:title="editTitle || $t('general.edit')"
|
||||
:icon="icon"
|
||||
color="primary"
|
||||
max-width="600px"
|
||||
width="100%"
|
||||
:submit-disabled="!editFormValid"
|
||||
can-confirm
|
||||
@confirm="emit('edit-one', editForm.data)"
|
||||
>
|
||||
<div class="mx-2 mt-2">
|
||||
<slot name="edit-dialog-top" />
|
||||
<AutoForm
|
||||
v-model="editForm.data"
|
||||
v-model:is-valid="editFormValid"
|
||||
:items="editForm.items"
|
||||
class="py-2"
|
||||
/>
|
||||
</div>
|
||||
<template #custom-card-action>
|
||||
<slot name="edit-dialog-custom-action" />
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Dialog -->
|
||||
<BaseDialog
|
||||
v-model="deleteDialog"
|
||||
:title="$t('general.confirm')"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
color="error"
|
||||
can-confirm
|
||||
@confirm="$emit('deleteOne', deleteTarget.id)"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("general.confirm-delete-generic") }}
|
||||
<p v-if="deleteTarget" class="mt-4 ml-4">
|
||||
{{ deleteTarget.name || deleteTarget.title || deleteTarget.id }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Bulk Delete Dialog -->
|
||||
<BaseDialog
|
||||
v-model="bulkDeleteDialog"
|
||||
width="650px"
|
||||
:title="$t('general.confirm')"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
color="error"
|
||||
can-confirm
|
||||
@confirm="$emit('bulk-action', 'delete-selected', bulkDeleteTarget)"
|
||||
>
|
||||
<v-card-text>
|
||||
<p class="h4">
|
||||
{{ $t('general.confirm-delete-generic-items') }}
|
||||
</p>
|
||||
<v-card variant="outlined">
|
||||
<v-virtual-scroll height="400" item-height="25" :items="bulkDeleteTarget">
|
||||
<template #default="{ item }">
|
||||
<v-list-item class="pb-2">
|
||||
<v-list-item-title>{{ item.name || item.title || item.id }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseCardSectionTitle
|
||||
:icon="icon"
|
||||
section
|
||||
:title="title"
|
||||
/>
|
||||
|
||||
<CrudTable
|
||||
:headers="tableHeaders"
|
||||
:table-config="tableConfig"
|
||||
:data="data || []"
|
||||
:bulk-actions="bulkActions"
|
||||
:initial-sort="initialSort"
|
||||
@edit-one="editEventHandler"
|
||||
@delete-one="deleteEventHandler"
|
||||
@bulk-action="handleBulkAction"
|
||||
>
|
||||
<template
|
||||
v-for="slotName in itemSlotNames"
|
||||
#[slotName]="slotProps"
|
||||
>
|
||||
<slot
|
||||
:name="slotName"
|
||||
v-bind="slotProps"
|
||||
/>
|
||||
</template>
|
||||
<template #button-row>
|
||||
<BaseButton
|
||||
create
|
||||
@click="createDialog = true"
|
||||
>
|
||||
{{ $t("general.create") }}
|
||||
</BaseButton>
|
||||
<slot name="table-button-row" />
|
||||
</template>
|
||||
<template #button-bottom>
|
||||
<slot name="table-button-bottom" />
|
||||
</template>
|
||||
</CrudTable>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TableHeaders, TableConfig, BulkAction } from "~/components/global/CrudTable.vue";
|
||||
import type { AutoFormItems } from "~/types/auto-forms";
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "deleteOne", id: string): void;
|
||||
(e: "deleteMany", ids: string[]): void;
|
||||
(e: "create-one" | "edit-one", data: any): void;
|
||||
(e: "bulk-action", event: string, items: any[]): void;
|
||||
}>();
|
||||
|
||||
const tableHeaders = defineModel<TableHeaders[]>("tableHeaders", { required: true });
|
||||
const createForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("createForm", { required: true });
|
||||
const createDialog = defineModel("createDialog", { type: Boolean, default: false });
|
||||
|
||||
const editForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("editForm", { required: true });
|
||||
const editDialog = defineModel("editDialog", { type: Boolean, default: false });
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
createTitle: {
|
||||
type: String,
|
||||
},
|
||||
editTitle: {
|
||||
type: String,
|
||||
},
|
||||
tableConfig: {
|
||||
type: Object as PropType<TableConfig>,
|
||||
default: () => ({
|
||||
hideColumns: false,
|
||||
canExport: true,
|
||||
}),
|
||||
},
|
||||
data: {
|
||||
type: Array as PropType<Array<any>>,
|
||||
required: true,
|
||||
},
|
||||
bulkActions: {
|
||||
type: Array as PropType<BulkAction[]>,
|
||||
required: true,
|
||||
},
|
||||
initialSort: {
|
||||
type: String,
|
||||
default: "name",
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Bulk Action Handler
|
||||
function handleBulkAction(event: string, items: any[]) {
|
||||
if (event === "delete-selected") {
|
||||
bulkDeleteEventHandler(items);
|
||||
return;
|
||||
}
|
||||
emit("bulk-action", event, items);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Create & Edit
|
||||
const createFormValid = ref(false);
|
||||
const editFormValid = ref(false);
|
||||
const itemSlotNames = computed(() => Object.keys(slots).filter(slotName => slotName.startsWith("item.")));
|
||||
const editEventHandler = (item: any) => {
|
||||
editForm.value.data = { ...item };
|
||||
editDialog.value = true;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Delete Logic
|
||||
const deleteTarget = ref<any>(null);
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
function deleteEventHandler(item: any) {
|
||||
deleteTarget.value = item;
|
||||
deleteDialog.value = true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Bulk Delete Logic
|
||||
const bulkDeleteTarget = ref<Array<any>>([]);
|
||||
const bulkDeleteDialog = ref(false);
|
||||
|
||||
function bulkDeleteEventHandler(items: Array<any>) {
|
||||
bulkDeleteTarget.value = items;
|
||||
bulkDeleteDialog.value = true;
|
||||
console.log("Bulk Delete Event Handler", items);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div v-if="preferences">
|
||||
<BaseCardSectionTitle :title="$t('group.group-preferences')" />
|
||||
<div class="mb-6">
|
||||
<v-checkbox
|
||||
v-model="local.privateGroup"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
:label="$t('group.private-group')"
|
||||
/>
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("group.private-group-description") }}
|
||||
</p>
|
||||
<DocLink
|
||||
class="mt-2"
|
||||
link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<v-checkbox
|
||||
v-model="local.showAnnouncements"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
:label="$t('announcements.show-announcements-from-mealie')"
|
||||
/>
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("announcements.show-announcements-setting-description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReadGroupPreferences } from "~/lib/api/types/user";
|
||||
|
||||
const preferences = defineModel<ReadGroupPreferences>({ required: true });
|
||||
const local = reactive({ ...preferences.value });
|
||||
watch(local, (newVal) => { preferences.value = { ...newVal }; });
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="preferences">
|
||||
<BaseCardSectionTitle :title="$t('household.household-preferences')" />
|
||||
<div class="mb-6">
|
||||
<v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
|
||||
<v-checkbox v-model="local.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.private-household-description") }}
|
||||
@@ -11,15 +11,29 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
|
||||
<v-checkbox v-model="local.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<v-checkbox
|
||||
v-model="local.showAnnouncements"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
:label="$t('announcements.show-announcements-from-mealie')"
|
||||
/>
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("announcements.show-announcements-setting-description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<v-select
|
||||
v-model="preferences.firstDayOfWeek"
|
||||
v-model="local.firstDayOfWeek"
|
||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||
:items="allDays"
|
||||
item-title="name"
|
||||
@@ -29,10 +43,12 @@
|
||||
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" />
|
||||
<v-checkbox v-model="local[p.key]" hide-details density="compact" :label="p.label" color="primary" />
|
||||
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
||||
{{ p.description }}
|
||||
</p>
|
||||
@@ -45,6 +61,9 @@
|
||||
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
||||
|
||||
const preferences = defineModel<ReadHouseholdPreferences>({ required: true });
|
||||
const local = reactive({ ...preferences.value });
|
||||
watch(local, (newVal) => { preferences.value = { ...newVal }; });
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
type Preference = {
|
||||
@@ -41,19 +41,14 @@
|
||||
>
|
||||
<v-select
|
||||
v-if="index"
|
||||
:model-value="field.logicalOperator"
|
||||
:model-value="field.logicalOperator?.value"
|
||||
:items="[logOps.AND, logOps.OR]"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
variant="underlined"
|
||||
class="text-center"
|
||||
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- left parenthesis -->
|
||||
@@ -67,14 +62,9 @@
|
||||
:model-value="field.leftParenthesis"
|
||||
:items="['', '(', '((', '(((']"
|
||||
variant="underlined"
|
||||
class="text-center"
|
||||
@update:model-value="setLeftParenthesisValue(field, index, $event)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- field name -->
|
||||
@@ -84,19 +74,14 @@
|
||||
:class="config.col.class"
|
||||
>
|
||||
<v-select
|
||||
chips
|
||||
:model-value="field.label"
|
||||
:items="fieldDefs"
|
||||
variant="underlined"
|
||||
item-title="label"
|
||||
item-value="label"
|
||||
class="text-center"
|
||||
@update:model-value="setField(index, $event)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- relational operator -->
|
||||
@@ -107,19 +92,14 @@
|
||||
>
|
||||
<v-select
|
||||
v-if="field.type !== 'boolean'"
|
||||
:model-value="field.relationalOperatorValue"
|
||||
:model-value="field.relationalOperatorValue?.value"
|
||||
:items="field.relationalOperatorChoices"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
variant="underlined"
|
||||
class="text-center"
|
||||
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- field value -->
|
||||
@@ -275,23 +255,14 @@
|
||||
:model-value="field.rightParenthesis"
|
||||
:items="['', ')', '))', ')))']"
|
||||
variant="underlined"
|
||||
class="text-center"
|
||||
@update:model-value="setRightParenthesisValue(field, index, $event)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
|
||||
<!-- field actions -->
|
||||
<v-col
|
||||
/>
|
||||
v-if="!$vuetify.display.smAndDown || index === fields.length - 1"
|
||||
:cols="config.items.fieldActions.cols(index)"
|
||||
:sm="config.items.fieldActions.sm(index)"
|
||||
:class="config.col.class"
|
||||
>
|
||||
>
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
@@ -723,9 +694,6 @@ const config = computed(() => {
|
||||
col: {
|
||||
class: "d-flex justify-center align-end py-0",
|
||||
},
|
||||
select: {
|
||||
textClass: "d-flex justify-center text-center",
|
||||
},
|
||||
items: {
|
||||
icon: {
|
||||
cols: (_index: number) => 2,
|
||||
@@ -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">
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
density="compact"
|
||||
:label="$t('recipe.recipe-name')"
|
||||
autofocus
|
||||
@keyup.enter="duplicateRecipe()"
|
||||
/>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
@@ -9,6 +9,7 @@
|
||||
:items-per-page="15"
|
||||
class="elevation-0"
|
||||
:loading="loading"
|
||||
:search="search"
|
||||
return-object
|
||||
>
|
||||
<template #[`item.name`]="{ item }">
|
||||
@@ -86,6 +87,7 @@ interface Props {
|
||||
loading?: boolean;
|
||||
recipes?: Recipe[];
|
||||
showHeaders?: ShowHeaders;
|
||||
search?: string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
@@ -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 {
|
||||
@@ -258,8 +272,71 @@ watch([dialog, () => preferences.value.viewAllLists], () => {
|
||||
}
|
||||
});
|
||||
|
||||
function buildIngredientSections(ingredients: ShoppingListIngredient[]): ShoppingListIngredientSection[] {
|
||||
let currentTitle = "";
|
||||
const onHandIngs: ShoppingListIngredient[] = [];
|
||||
const sections = ingredients.reduce((acc, ing) => {
|
||||
if (ing.ingredient.title) {
|
||||
currentTitle = ing.ingredient.title;
|
||||
}
|
||||
|
||||
if (!acc.length || currentTitle !== acc[acc.length - 1].sectionName) {
|
||||
if (acc.length) {
|
||||
acc[acc.length - 1].ingredients.push(...onHandIngs);
|
||||
onHandIngs.length = 0;
|
||||
}
|
||||
acc.push({ sectionName: currentTitle, ingredients: [] });
|
||||
}
|
||||
|
||||
const householdsWithFood = ing.ingredient?.food?.householdsWithIngredientFood || [];
|
||||
if (householdsWithFood.includes(currentHouseholdSlug.value)) {
|
||||
onHandIngs.push(ing);
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[acc.length - 1].ingredients.push(ing);
|
||||
return acc;
|
||||
}, [] as ShoppingListIngredientSection[]);
|
||||
|
||||
if (sections.length) {
|
||||
sections[sections.length - 1].ingredients.push(...onHandIngs);
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
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;
|
||||
@@ -291,81 +368,29 @@ 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());
|
||||
@@ -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"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<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-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>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,17 @@
|
||||
<template>
|
||||
<div class="text-center">
|
||||
<BaseDialog
|
||||
v-model="dialogDeleteImage"
|
||||
:title="$t('recipe.delete-image')"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
color="error"
|
||||
can-delete
|
||||
@delete="deleteImage"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("recipe.delete-image-confirmation") }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<v-menu
|
||||
v-model="menu"
|
||||
offset-y
|
||||
@@ -37,18 +49,6 @@
|
||||
delete
|
||||
@click="dialogDeleteImage = true"
|
||||
/>
|
||||
<BaseDialog
|
||||
v-model="dialogDeleteImage"
|
||||
:title="$t('recipe.delete-image')"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
color="error"
|
||||
can-delete
|
||||
@delete="deleteImage"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("recipe.delete-image-confirmation") }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-card-text class="mt-n5">
|
||||
@@ -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
|
||||
@@ -104,7 +103,7 @@
|
||||
<v-col
|
||||
v-if="!state.isRecipe"
|
||||
m="12"
|
||||
md="3"
|
||||
md="4"
|
||||
cols="12"
|
||||
class=""
|
||||
>
|
||||
@@ -162,7 +161,7 @@
|
||||
<v-col
|
||||
v-if="state.isRecipe"
|
||||
m="12"
|
||||
md="6"
|
||||
md="4"
|
||||
cols="12"
|
||||
class=""
|
||||
>
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -21,7 +21,7 @@
|
||||
@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"
|
||||
@@ -68,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"
|
||||
@@ -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";
|
||||
@@ -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