mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-26 11:40:27 -04:00
Compare commits
190 Commits
v3.14.0
...
fix/plan-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91bc1bd263 | ||
|
|
5ce448af7a | ||
|
|
c3f87736d0 | ||
|
|
f6fe92b400 | ||
|
|
823b938a2c | ||
|
|
8eb00c3dc0 | ||
|
|
642c826f2b | ||
|
|
493154caa8 | ||
|
|
71e0d99a46 | ||
|
|
c52a4e10c9 | ||
|
|
8b9149a1ce | ||
|
|
c8ff75c02a | ||
|
|
3d6ff52358 | ||
|
|
f04b0c741c | ||
|
|
742b498c1d | ||
|
|
eddb0c30e0 | ||
|
|
1cebfd56ab | ||
|
|
074ec7aab2 | ||
|
|
23cb4bdf36 | ||
|
|
4039ff6655 | ||
|
|
af75c5f39d | ||
|
|
703db2931f | ||
|
|
52399547d6 | ||
|
|
be4ff86c57 | ||
|
|
8a054b1be8 | ||
|
|
2dbfc7f72b | ||
|
|
e492da67e2 | ||
|
|
811be08996 | ||
|
|
fdd17182d8 | ||
|
|
d340fdd9df | ||
|
|
551a92a031 | ||
|
|
8c06f49b02 | ||
|
|
9fd3fbca8b | ||
|
|
a242aea9f2 | ||
|
|
6e9ad5fef1 | ||
|
|
ee181a598b | ||
|
|
3a84b3f262 | ||
|
|
a616e14bf9 | ||
|
|
b902d2cd98 | ||
|
|
565736e116 | ||
|
|
7f29efc0e4 | ||
|
|
743c15a981 | ||
|
|
3be9193590 | ||
|
|
c880c0865b | ||
|
|
294238f183 | ||
|
|
985b656d3f | ||
|
|
09c2a0b2ad | ||
|
|
f2b087730e | ||
|
|
e71b31e9cc | ||
|
|
41a9a1e018 | ||
|
|
7b2372edfc | ||
|
|
65f109dee4 | ||
|
|
8dc85640e1 | ||
|
|
6c5f1c2413 | ||
|
|
bc3ae3c6c0 | ||
|
|
5b37eb012c | ||
|
|
f354f12853 | ||
|
|
062484dec9 | ||
|
|
b5d991c516 | ||
|
|
b60aeed8dc | ||
|
|
1f42ba4934 | ||
|
|
32e0404564 | ||
|
|
a1a26b23c4 | ||
|
|
e66c0dea58 | ||
|
|
fb5b028b92 | ||
|
|
2854449213 | ||
|
|
1bd423d741 | ||
|
|
a754693787 | ||
|
|
176587079f | ||
|
|
718d232517 | ||
|
|
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 |
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
|
||||
|
||||
|
||||
8
.github/workflows/auto-merge-l10n.yml
vendored
8
.github/workflows/auto-merge-l10n.yml
vendored
@@ -55,8 +55,8 @@ 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
|
||||
@@ -65,8 +65,8 @@ 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
|
||||
|
||||
16
.github/workflows/build-package.yml
vendored
16
.github/workflows/build-package.yml
vendored
@@ -17,12 +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
|
||||
@@ -32,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 }}
|
||||
@@ -49,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
|
||||
@@ -68,12 +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"
|
||||
|
||||
@@ -81,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
|
||||
@@ -97,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"
|
||||
|
||||
11
.github/workflows/publish.yml
vendored
11
.github/workflows/publish.yml
vendored
@@ -23,19 +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 }}
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
hkotel/mealie
|
||||
@@ -52,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
|
||||
|
||||
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 }}
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.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 }}
|
||||
|
||||
@@ -124,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
|
||||
|
||||
6
.github/workflows/test-backend.yml
vendored
6
.github/workflows/test-backend.yml
vendored
@@ -46,12 +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"
|
||||
|
||||
@@ -60,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') }}
|
||||
|
||||
6
.github/workflows/test-frontend.yml
vendored
6
.github/workflows/test-frontend.yml
vendored
@@ -13,12 +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
|
||||
@@ -28,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,7 @@ repos:
|
||||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.7
|
||||
rev: v0.15.8
|
||||
hooks:
|
||||
# Linter
|
||||
- id: ruff-check
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,6 @@ Since this software is still considered beta/WIP support is always only given fo
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
For general security vulnerabilities you're welcome to open a GitHub issues or contribute a fix. If you feel the vulnerability should not be disclosed you can open a generic issue on GitHub and email to the details to [ob92oy0sl@mozmail.com](mailto:ob92oy0sl@mozmail.com) which is monitored by the maintainer.
|
||||
This repository has [private vulnerability reporting](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/privately-reporting-a-security-vulnerability) enabled. To confidentially report a security issue, click the **"Report a vulnerability"** button on the [Security tab](../../security/advisories/new) of this repository. This allows you to submit details directly to the maintainers without public disclosure.
|
||||
|
||||
For non-sensitive issues or general feedback, feel free to open a GitHub issue or contribute a fix via pull request.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,10 +84,10 @@ class CrowdinApi:
|
||||
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:bb20cf73b3ad7212834ec48e2174cdcb5775f6550510a5336b842ae32741ce6c \
|
||||
FROM node:24@sha256:050bf2bbe33c1d6754e060bec89378a79ed831f04a7bb1a53fe45e997df7b3bb \
|
||||
AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
@@ -58,9 +58,6 @@ load_secrets() {
|
||||
"OIDC_CONFIGURATION_URL"
|
||||
"OIDC_CLIENT_ID"
|
||||
"OIDC_CLIENT_SECRET"
|
||||
|
||||
"OPENAI_BASE_URL"
|
||||
"OPENAI_API_KEY"
|
||||
)
|
||||
|
||||
# If any secrets are set, prefer them over base environment variables.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
!!! info
|
||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||
|
||||
An easy way to add recipes to Mealie from an Apple device is via an Apple Shortcut. This is a short guide to install an configure a shortcut able to add recipes via a link or image(s).
|
||||
An easy way to add recipes to Mealie from an Apple device is via an Apple Shortcut. This is a short guide to install an configure a shortcut able to add recipes via a link or image(s).
|
||||
|
||||
!!! note
|
||||
If adding via images make sure to enable [Mealie's OpenAI Integration](https://docs.mealie.io/documentation/getting-started/installation/open-ai/)
|
||||
If adding via images make sure to enable [Mealie's AI Integration](https://docs.mealie.io/documentation/getting-started/installation/ai-providers)
|
||||
|
||||
## Javascript can only be run via Shortcuts on the Safari browser on MacOS and iOS. If you do not use Safari you may skip this section
|
||||
Some sites have begun blocking AI scraping bots, inadvertently blocking the recipe scraping library Mealie uses as well. To circumvent this, the shortcut uses javascript to capture the raw html loaded in the browser and sends that to mealie when possible.
|
||||
@@ -23,7 +23,7 @@ An API key is needed to authenticate with mealie. To create an api key for a use
|
||||
The shortcut can be installed via **[This link](https://www.icloud.com/shortcuts/52834724050b42aebe0f2efd8d067360)**. Upon install, replace "MEALIE_API_KEY" with the API key generated previously and "MEALIE_URI" with the full URL used to access your mealie instance e.g. "http://10.0.0.5:9000" or "https://mealie.domain.com".
|
||||
|
||||
## Using the Shortcut
|
||||
Once installed, the shortcut will automatically appear as an option when sharing an image or webpage. It can also be useful to add the shortcut to the home screen of your device. If selected from the home screen or shortcuts app, a menu will appear with prompts to import via **taking photo(s)**, **selecting photo(s)**, **scanning a URL**, or **pasting a URL**.
|
||||
Once installed, the shortcut will automatically appear as an option when sharing an image or webpage. It can also be useful to add the shortcut to the home screen of your device. If selected from the home screen or shortcuts app, a menu will appear with prompts to import via **taking photo(s)**, **selecting photo(s)**, **scanning a URL**, or **pasting a URL**.
|
||||
|
||||
!!! note
|
||||
Despite the Mealie API being able to accept multiple recipe images for import it is currently impossible to send multiple files in 1 web request via Shortcuts. Instead, the shortcut combines the images into a singular, vertically-concatenated image to send to mealie. This can result in slightly less-accurate text recognition.
|
||||
Despite the Mealie API being able to accept multiple recipe images for import it is currently impossible to send multiple files in 1 web request via Shortcuts. Instead, the shortcut combines the images into a singular, vertically-concatenated image to send to mealie. This can result in slightly less-accurate text recognition.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
6. Click the Edit button/icon again
|
||||
7. Scroll to the ingredients and you should see new fields for Amount, Unit, Food, and Note. The Note in particular will contain the original text of the Recipe.
|
||||
8. Click `Parse` and you will be taken to the ingredient parsing page.
|
||||
9. Choose your parser. The `Natural Language Parser` works very well, but you can also use the `Brute Parser`, or the `OpenAI Parser` if you've [enabled OpenAI support](./installation/backend-config.md#openai).
|
||||
9. Choose your parser. The `Natural Language Parser` works very well, but you can also use the `Brute Parser`, or the `OpenAI Parser` if you've [enabled AI support](./installation/ai-providers.md).
|
||||
10. Click `Parse All`, and your ingredients should be separated out into Units and Foods based on your seeding in Step 1 above.
|
||||
11. For ingredients where the Unit or Food was not found, you can click a button to accept an automatically suggested Food to add to the database. Or, manually enter the Unit/Food and hit `Enter` (or click `Create`) to add it to the database
|
||||
12. When done, click `Save All` and you will be taken back to the recipe. Now the Unit and Food fields of the recipe should be filled out.
|
||||
|
||||
@@ -6,10 +6,16 @@
|
||||
|
||||
### Creating Recipes
|
||||
Mealie offers several ways to create recipes:
|
||||
|
||||
- **Recipe Scraper:** Create recipes from hundreds of websites by simply providing a URL.
|
||||
- **Image Import:** Upload an image of a written or typed recipe and Mealie will use OCR to import it.
|
||||
- **Video URL Import:** Provide a video URL (e.g., YouTube) and Mealie will transcribe the audio and parse the recipe.
|
||||
- **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/ai-providers.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 }
|
||||
|
||||
### Importing Recipes
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# AI Integration
|
||||
|
||||
:octicons-tag-24: v1.7.0
|
||||
|
||||
Mealie's AI integration enables several features and enhancements throughout the application. To enable AI features, you must have access to an AI provider (such as OpenAI). Mealie works with any OpenAI-compatible API.
|
||||
|
||||
## Configuration
|
||||
|
||||
To set up AI providers, visit your group settings.
|
||||
|
||||
[Group Settings Demo](https://demo.mealie.io/group){ .md-button .md-button--primary }
|
||||
|
||||
- To enable AI features at all, you *must* set a default provider (e.g. `gpt-5`)
|
||||
- To enable image recognition features, such as creating a recipe from an image, configure a provider capable of image recognition (e.g. `gpt-5`)
|
||||
- To enable audio transcription features, such as importing a recipe from a video, configure a provider capable of audio transcriptions (e.g. `whisper-1`)
|
||||
|
||||
For most users, choosing an OpenAI model (such as `gpt-5`) and supplying the OpenAI API key is all you need to do. Note that while OpenAI has a free tier, it's not sufficiently capable for Mealie (or most other production use cases). For more information, check out [OpenAI's rate limits](https://platform.openai.com/docs/guides/rate-limits). If you deposit $5 into your OpenAI account, you will be permanently bumped up to Tier 1, which is sufficient for Mealie. Cost per-request is dependant on many factors, but Mealie tries to keep token counts conservative.
|
||||
|
||||
If you have another provider you'd like to use, such as Azure, 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, set your `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.
|
||||
|
||||
Note that some models are capable of handling multiple features (e.g. `gpt-5` can handle both normal chat requests and image recognition requests). You may configure one provider for multiple provider features.
|
||||
|
||||
While Mealie has prompts for each AI task, you can override these with your own prompts if you'd like. For more information, check out the [backend configuration](./installation/ai-providers.md).
|
||||
|
||||
## AI 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 AI, and the transcription is parsed into a recipe (:octicons-tag-24: v3.13.0)
|
||||
@@ -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,27 +114,16 @@ 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
|
||||
|
||||
:octicons-tag-24: v1.7.0
|
||||
|
||||
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"}'`)
|
||||
Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./ai-providers.md).
|
||||
|
||||
| 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
|
||||
@@ -314,7 +303,6 @@ at least these sensitive environment variables when working within shared enviro
|
||||
- `POSTGRES_PASSWORD`
|
||||
- `SMTP_PASSWORD`
|
||||
- `LDAP_QUERY_PASSWORD`
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
[docker-secrets]: https://docs.docker.com/compose/use-secrets/
|
||||
[secrets]: #docker-secrets
|
||||
|
||||
@@ -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.14.0`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.18.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
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# OpenAI Integration
|
||||
|
||||
:octicons-tag-24: v1.7.0
|
||||
|
||||
Mealie's OpenAI integration enables several features and enhancements throughout the application. To enable OpenAI features, you must have an account with OpenAI and configure Mealie to use the OpenAI API key (for more information, check out the [backend configuration](./backend-config.md#openai)).
|
||||
|
||||
## Configuration
|
||||
|
||||
For most users, supplying the OpenAI API key is all you need to do; you will use the regular OpenAI service with the default language model. Note that while OpenAI has a free tier, it's not sufficiently capable for Mealie (or most other production use cases). For more information, check out [OpenAI's rate limits](https://platform.openai.com/docs/guides/rate-limits). If you deposit $5 into your OpenAI account, you will be permanently bumped up to Tier 1, which is sufficient for Mealie. Cost per-request is dependant on many factors, but Mealie tries to keep token counts conservative.
|
||||
|
||||
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`.
|
||||
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.14.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.18.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.14.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.18.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -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
|
||||
@@ -75,7 +76,7 @@ nav:
|
||||
- Backend Configuration: "documentation/getting-started/installation/backend-config.md"
|
||||
- Security: "documentation/getting-started/installation/security.md"
|
||||
- Logs: "documentation/getting-started/installation/logs.md"
|
||||
- OpenAI: "documentation/getting-started/installation/open-ai.md"
|
||||
- AI Providers: "documentation/getting-started/installation/ai-providers.md"
|
||||
- Usage:
|
||||
- Backup and Restoring: "documentation/getting-started/usage/backups-and-restoring.md"
|
||||
- Permissions and Public Access: "documentation/getting-started/usage/permissions-and-public-access.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;
|
||||
}
|
||||
@@ -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,63 @@
|
||||
<template>
|
||||
<div>
|
||||
<p>AI providers can now be configured directly in Mealie, without managing environment variables or secrets.</p>
|
||||
<div class="mb-2">
|
||||
AI providers enable features such as:
|
||||
<ul class="ml-6">
|
||||
<li>Creating recipes from images</li>
|
||||
<li>Importing recipes from videos (YouTube, TikTok, etc.)</li>
|
||||
<li>Enhanced ingredient parsing</li>
|
||||
<li>And more!</li>
|
||||
</ul>
|
||||
</div>
|
||||
<hr class="mt-2 mb-4">
|
||||
<p>
|
||||
<span v-if="group?.aiProviderSettings?.aiEnabled">
|
||||
Your group already has AI providers configured.
|
||||
</span>
|
||||
<span v-else>
|
||||
Your group does not currently have any AI providers configured.
|
||||
</span>
|
||||
<span v-if="user?.canManage">
|
||||
You can manage them here:
|
||||
<br>
|
||||
<v-btn class="mt-2" color="primary" to="/group">
|
||||
{{ $t("profile.group-settings") }}
|
||||
</v-btn>
|
||||
</span>
|
||||
<span v-else-if="!group?.aiProviderSettings?.aiEnabled">
|
||||
Contact a group manager or server admin to set up AI providers for your group.
|
||||
</span>
|
||||
</p>
|
||||
<div v-if="user?.admin">
|
||||
<br>
|
||||
<p>
|
||||
As an admin, you can configure AI providers for any group. Unlike the old environment variable approach, providers are configured per-group:
|
||||
<br>
|
||||
<v-btn class="mt-2" color="primary" to="/admin/manage/groups">
|
||||
{{ $t("group.admin-group-management") }}
|
||||
</v-btn>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGroupSelf } from "~/composables/use-groups";
|
||||
import type { AnnouncementMeta } from "~/composables/use-announcements";
|
||||
|
||||
const { user } = useMealieAuth();
|
||||
const { group } = useGroupSelf();
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export const meta: AnnouncementMeta = {
|
||||
title: "Improved AI Provider Configuration",
|
||||
};
|
||||
</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);
|
||||
});
|
||||
});
|
||||
204
frontend/app/components/Domain/Group/GroupAIProviderDialog.vue
Normal file
204
frontend/app/components/Domain/Group/GroupAIProviderDialog.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
v-model="dialog"
|
||||
:title="isEdit ? $t('group.ai-provider-settings.edit-provider') : $t('group.ai-provider-settings.create-provider')"
|
||||
:icon="$globals.icons.robot"
|
||||
:loading="loading"
|
||||
can-submit
|
||||
:submit-icon="isEdit ? $globals.icons.save : $globals.icons.createAlt"
|
||||
:submit-text="isEdit ? $t('general.update') : $t('general.create')"
|
||||
:submit-disabled="submitDisabled"
|
||||
@submit="handleSubmit"
|
||||
@close="resetForm"
|
||||
>
|
||||
<v-card-text v-if="init" style="max-height: 70vh; overflow-y: auto;">
|
||||
<v-form ref="form" v-no-autofill>
|
||||
<v-text-field
|
||||
v-model="formData.name"
|
||||
:label="$t('group.ai-provider-settings.provider-name')"
|
||||
:rules="[validators.required]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="formData.model"
|
||||
:label="$t('group.ai-provider-settings.model')"
|
||||
:hint="$t('group.ai-provider-settings.model-description')"
|
||||
:rules="[validators.required]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="formData.apiKey"
|
||||
:label="$t('group.ai-provider-settings.api-key')"
|
||||
:hint="$t(
|
||||
isEdit
|
||||
? 'group.ai-provider-settings.api-key-description-edit'
|
||||
: 'group.ai-provider-settings.api-key-description-create',
|
||||
)"
|
||||
:persistent-hint="isEdit"
|
||||
:rules="isEdit ? [] : [validators.required]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
class="mb-4"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="formData.baseUrl"
|
||||
:label="$t('group.ai-provider-settings.base-url')"
|
||||
:hint="$t('group.ai-provider-settings.base-url-description')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
<v-number-input
|
||||
v-model.number="formData.timeout"
|
||||
:label="$t('group.ai-provider-settings.request-timeout-seconds')"
|
||||
type="number"
|
||||
:min="0"
|
||||
hide-details
|
||||
control-variant="stacked"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
<v-expansion-panels v-model="advancedPanel" variant="accordion">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title class="text-subtitle-2" expand-icon="$expand" collapse-icon="$expand">
|
||||
{{ $t('search.advanced') }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text class="px-0">
|
||||
<div class="mb-2 text-subtitle-2">
|
||||
{{ $t('group.ai-provider-settings.request-headers') }}
|
||||
</div>
|
||||
<BaseKeyValueEditor
|
||||
v-model="formData.requestHeaders"
|
||||
class="mb-4"
|
||||
/>
|
||||
<v-divider class="mb-4" />
|
||||
<div class="mb-2 text-subtitle-2">
|
||||
{{ $t('group.ai-provider-settings.request-params') }}
|
||||
</div>
|
||||
<BaseKeyValueEditor
|
||||
v-model="formData.requestParams"
|
||||
/>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<AppLoader v-else waiting-text="" />
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAIProviders } from "~/composables/use-ai-providers";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
providerId?: string;
|
||||
}>(), {
|
||||
providerId: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "create", data: AIProviderCreate): void;
|
||||
(e: "update", id: string, data: AIProviderUpdate): void;
|
||||
}>();
|
||||
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
const { $globals } = useNuxtApp();
|
||||
const { loading, getOne } = useAIProviders();
|
||||
const init = ref(false);
|
||||
|
||||
const form = ref();
|
||||
const advancedPanel = ref<number | undefined>(undefined);
|
||||
|
||||
const isEdit = computed(() => !!props.providerId);
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: "",
|
||||
model: "",
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
timeout: 300,
|
||||
requestHeaders: {} as Record<string, string>,
|
||||
requestParams: {} as Record<string, string>,
|
||||
});
|
||||
|
||||
const formData = reactive(defaultForm());
|
||||
|
||||
const submitDisabled = computed(() => {
|
||||
return !formData.name?.trim() || !formData.model?.trim() || (!isEdit.value && !formData.apiKey?.trim());
|
||||
});
|
||||
|
||||
// Fetch existing provider when editing; reset form for create mode
|
||||
watch(
|
||||
() => [dialog.value, props.providerId] as const,
|
||||
async ([open, id]) => {
|
||||
if (!open) return;
|
||||
if (!id) {
|
||||
// Create mode — just show the empty form
|
||||
resetForm();
|
||||
init.value = true;
|
||||
return;
|
||||
}
|
||||
init.value = false;
|
||||
const { data } = await getOne(id);
|
||||
init.value = true;
|
||||
if (data) {
|
||||
formData.name = data.name;
|
||||
formData.model = data.model;
|
||||
formData.apiKey = "";
|
||||
formData.baseUrl = data.baseUrl ?? "";
|
||||
formData.timeout = data.timeout ?? 300;
|
||||
formData.requestHeaders = { ...(data.requestHeaders ?? {}) };
|
||||
formData.requestParams = { ...(data.requestParams ?? {}) };
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function handleSubmit() {
|
||||
// Required field guard (button is also disabled, but keep as a safeguard)
|
||||
if (!formData.name?.trim() || !formData.model?.trim()) return;
|
||||
if (!isEdit.value && !formData.apiKey?.trim()) return;
|
||||
|
||||
if (isEdit.value && props.providerId) {
|
||||
const payload: AIProviderUpdate & { apiKey?: string } = {
|
||||
name: formData.name,
|
||||
model: formData.model,
|
||||
baseUrl: formData.baseUrl || null,
|
||||
timeout: formData.timeout,
|
||||
requestHeaders: Object.keys(formData.requestHeaders).length ? formData.requestHeaders : undefined,
|
||||
requestParams: Object.keys(formData.requestParams).length ? formData.requestParams : undefined,
|
||||
};
|
||||
if (formData.apiKey) {
|
||||
payload.apiKey = formData.apiKey;
|
||||
}
|
||||
emit("update", props.providerId, payload);
|
||||
}
|
||||
else {
|
||||
const createPayload = {
|
||||
name: formData.name,
|
||||
model: formData.model,
|
||||
apiKey: formData.apiKey,
|
||||
baseUrl: formData.baseUrl || null,
|
||||
timeout: formData.timeout,
|
||||
requestHeaders: Object.keys(formData.requestHeaders).length ? formData.requestHeaders : undefined,
|
||||
requestParams: Object.keys(formData.requestParams).length ? formData.requestParams : undefined,
|
||||
};
|
||||
emit("create", createPayload as AIProviderCreate);
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(formData, defaultForm());
|
||||
form.value?.reset();
|
||||
advancedPanel.value = undefined;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div v-if="providerSettings">
|
||||
<BaseCardSectionTitle v-if="!hideHeader" :title="$t('group.ai-provider-settings.ai-provider-settings')">
|
||||
<template v-if="noDefaultProviderWarning" #append-title>
|
||||
<v-tooltip location="bottom" color="warning">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-icon v-bind="tooltipProps" size="small" color="warning" class="ms-2">
|
||||
{{ $globals.icons.alert }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ $t('group.ai-provider-settings.no-default-provider-warning') }}</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</BaseCardSectionTitle>
|
||||
<v-card-text v-if="!hideHeader" class="pt-0 pb-10 px-0">
|
||||
{{ $t("group.ai-provider-settings.ai-provider-settings-description") }}
|
||||
</v-card-text>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-autocomplete
|
||||
v-model="local.defaultProviderId"
|
||||
:label="$t('group.ai-provider-settings.default-provider')"
|
||||
:items="local.providers"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-card-subtitle class="mt-1">
|
||||
{{ $t("group.ai-provider-settings.default-provider-description") }}
|
||||
</v-card-subtitle>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-autocomplete
|
||||
v-model="local.audioProviderId"
|
||||
:label="$t('group.ai-provider-settings.audio-provider')"
|
||||
:items="local.providers"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-card-subtitle class="mt-1">
|
||||
{{ $t("group.ai-provider-settings.audio-provider-description") }}
|
||||
</v-card-subtitle>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-autocomplete
|
||||
v-model="local.imageProviderId"
|
||||
:label="$t('group.ai-provider-settings.image-provider')"
|
||||
:items="local.providers"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-card-subtitle class="mt-1">
|
||||
{{ $t("group.ai-provider-settings.image-provider-description") }}
|
||||
</v-card-subtitle>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<GroupAIProviderDialog
|
||||
v-model="dialogOpen"
|
||||
:provider-id="editingProviderId ?? undefined"
|
||||
@create="(data) => $emit('create', data)"
|
||||
@update="(id, data) => $emit('update', id, data)"
|
||||
/>
|
||||
|
||||
<BaseCardSectionTitle
|
||||
:title="$t('group.ai-provider-settings.providers')"
|
||||
size="medium"
|
||||
class="pt-2"
|
||||
>
|
||||
<template #append-title>
|
||||
<BaseButton
|
||||
:text="$t('group.ai-provider-settings.create-provider')"
|
||||
class="ms-auto my-2"
|
||||
create
|
||||
small
|
||||
@click="openCreate"
|
||||
/>
|
||||
</template>
|
||||
</BaseCardSectionTitle>
|
||||
|
||||
<v-card
|
||||
v-for="provider in local.providers"
|
||||
:key="provider.id"
|
||||
variant="tonal"
|
||||
class="pa-0 mb-4"
|
||||
>
|
||||
<v-row no-gutters>
|
||||
<v-col :cols="10">
|
||||
<v-card-text>
|
||||
{{ provider.name }}
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
|
||||
<v-col :cols="2">
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.edit,
|
||||
text: $t('general.edit'),
|
||||
event: 'edit',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
]"
|
||||
@edit="openEdit(provider.id)"
|
||||
@delete="$emit('delete', provider.id)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
|
||||
import type { AIProviderSettingsOut } from "~/lib/api/types/user";
|
||||
|
||||
const providerSettings = defineModel<AIProviderSettingsOut>({ required: true });
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
hideHeader?: boolean;
|
||||
}>(), {
|
||||
hideHeader: false,
|
||||
});
|
||||
|
||||
const { hideHeader } = toRefs(props);
|
||||
|
||||
const local = reactive({ ...providerSettings.value });
|
||||
watch(local, (newVal) => { providerSettings.value = { ...newVal }; });
|
||||
// Sync back when the parent refreshes after create/update/delete
|
||||
watch(providerSettings, (newVal) => { if (newVal) Object.assign(local, newVal); });
|
||||
|
||||
const noDefaultProviderWarning = computed(
|
||||
() => local.providers.length > 0 && !local.defaultProviderId,
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
(e: "create", data: AIProviderCreate): void;
|
||||
(e: "update", id: string, data: AIProviderUpdate): void;
|
||||
(e: "delete", id: string): void;
|
||||
}>();
|
||||
|
||||
const dialogOpen = ref(false);
|
||||
const editingProviderId = ref<string | null>(null);
|
||||
|
||||
function openCreate() {
|
||||
editingProviderId.value = null;
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(id: string) {
|
||||
editingProviderId.value = id;
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
</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"
|
||||
@@ -34,7 +48,7 @@
|
||||
</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>
|
||||
@@ -47,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,14 +255,9 @@
|
||||
: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 -->
|
||||
@@ -723,9 +698,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,
|
||||
@@ -321,7 +321,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
const householdsWithFood = subIng.food?.householdsWithIngredientFood || [];
|
||||
ownIngs.push({
|
||||
checked: !householdsWithFood.includes(currentHouseholdSlug.value),
|
||||
ingredient: { ...subIng, quantity: (ing.quantity || 1) * (subIng.quantity || 1) },
|
||||
ingredient: subIng,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -88,7 +88,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #append-item>
|
||||
<div class="px-2">
|
||||
<div v-if="showCreateUnit" class="px-2">
|
||||
<BaseButton
|
||||
block
|
||||
size="small"
|
||||
@@ -147,7 +147,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #append-item>
|
||||
<div class="px-2">
|
||||
<div v-if="showCreateFood" class="px-2">
|
||||
<BaseButton
|
||||
block
|
||||
size="small"
|
||||
@@ -344,6 +344,11 @@ const foodData = useFoodData();
|
||||
const foodAutocomplete = ref<HTMLInputElement>();
|
||||
const { search: foodSearch, filtered: filteredFoods } = useSearch(foodStore.store);
|
||||
|
||||
const showCreateFood = computed(() =>
|
||||
!!foodSearch.value
|
||||
&& !filteredFoods.value.some((f: any) => (f.name ?? "").toLowerCase() === foodSearch.value.toLowerCase()),
|
||||
);
|
||||
|
||||
async function createAssignFood() {
|
||||
foodData.data.name = foodSearch.value;
|
||||
model.value.food = await foodStore.actions.createOne(foodData.data) || undefined;
|
||||
@@ -376,6 +381,11 @@ const unitsData = useUnitData();
|
||||
const unitAutocomplete = ref<HTMLInputElement>();
|
||||
const { search: unitSearch, filtered: filteredUnits } = useSearch(unitStore.store);
|
||||
|
||||
const showCreateUnit = computed(() =>
|
||||
!!unitSearch.value
|
||||
&& !filteredUnits.value.some((u: any) => (u.name ?? "").toLowerCase() === unitSearch.value.toLowerCase()),
|
||||
);
|
||||
|
||||
async function createAssignUnit() {
|
||||
unitsData.data.name = unitSearch.value;
|
||||
model.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined;
|
||||
@@ -19,11 +19,11 @@
|
||||
>
|
||||
<h3
|
||||
v-if="showTitleEditor[index]"
|
||||
class="mt-2"
|
||||
class="mt-4 mb-0"
|
||||
>
|
||||
{{ ingredient.title }}
|
||||
</h3>
|
||||
<v-divider v-if="showTitleEditor[index]" />
|
||||
<v-divider v-if="showTitleEditor[index]" class="my-2" />
|
||||
<v-list-item
|
||||
density="compact"
|
||||
class="pa-0"
|
||||
@@ -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"
|
||||
@@ -19,6 +19,7 @@
|
||||
class="pa-0 ma-0"
|
||||
@update:model-value="resetSearchInput"
|
||||
@click:append="dialog = true"
|
||||
@keyup.enter="handleEnter"
|
||||
>
|
||||
<template #chip="{ item, index }">
|
||||
<v-chip
|
||||
@@ -27,12 +28,30 @@
|
||||
color="accent"
|
||||
variant="flat"
|
||||
label
|
||||
|
||||
:text="item.name"
|
||||
closable
|
||||
@click:close="removeByIndex(index)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</v-chip>
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-if="showAdd"
|
||||
#no-data
|
||||
>
|
||||
<div class="caption text-center pb-2">
|
||||
{{ $t("recipe.press-enter-to-create") }}
|
||||
</div>
|
||||
</template>
|
||||
<template
|
||||
v-if="showAdd && searchInput"
|
||||
#append-item
|
||||
>
|
||||
<div class="px-2">
|
||||
<BaseButton
|
||||
block
|
||||
size="small"
|
||||
@click="createItem()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template
|
||||
v-if="showAdd"
|
||||
@@ -182,6 +201,32 @@ function appendCreated(item: any) {
|
||||
selected.value = [...selected.value, item];
|
||||
}
|
||||
|
||||
function handleEnter() {
|
||||
if (!searchInput.value) {
|
||||
return;
|
||||
}
|
||||
const exactMatch = items.value.some(
|
||||
(item: any) => (item.name ?? "").toLowerCase() === searchInput.value.toLowerCase(),
|
||||
);
|
||||
if (!exactMatch) {
|
||||
createItem();
|
||||
}
|
||||
}
|
||||
|
||||
async function createItem() {
|
||||
if (!searchInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = storeMap[props.selectorType].actions;
|
||||
// @ts-expect-error different organizer types have different required fields
|
||||
const newItem = await actions.createOne({ name: searchInput.value });
|
||||
if (newItem) {
|
||||
appendCreated(newItem);
|
||||
}
|
||||
searchInput.value = "";
|
||||
}
|
||||
|
||||
const dialog = ref(false);
|
||||
|
||||
const searchInput = ref("");
|
||||
@@ -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"
|
||||
@@ -332,8 +336,16 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// When set, the isEditMode watcher skips its URL cleanup because saveRecipe
|
||||
// is navigating to a new slug that naturally omits ?edit=true.
|
||||
const isNavigatingAfterRename = ref(false);
|
||||
|
||||
watch(isEditMode, (newVal) => {
|
||||
if (!newVal) {
|
||||
if (isNavigatingAfterRename.value) {
|
||||
isNavigatingAfterRename.value = false;
|
||||
return;
|
||||
}
|
||||
paramsEdit.value = undefined;
|
||||
}
|
||||
});
|
||||
@@ -351,13 +363,17 @@ watch(isParsing, () => {
|
||||
async function saveRecipe() {
|
||||
const { data, error } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||
if (!error) {
|
||||
if (data?.slug && data.slug !== route.params.slug) {
|
||||
isNavigatingAfterRename.value = true;
|
||||
}
|
||||
setMode(PageMode.VIEW);
|
||||
}
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
||||
recipe.value = data as NoUndefinedField<Recipe>;
|
||||
// Update the snapshot after successful save
|
||||
originalRecipe.value = deepCopy(recipe.value);
|
||||
if (data.slug !== route.params.slug) {
|
||||
router.replace(`/g/${groupSlug.value}/r/` + data.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -62,17 +62,18 @@ const toolStore = isOwnGroup.value ? useToolStore() : null;
|
||||
const { user } = usePageUser();
|
||||
const { isEditMode } = usePageState(props.recipe.slug);
|
||||
|
||||
const recipeTools = computed(() => {
|
||||
const recipeTools = ref<RecipeToolWithOnHand[]>([]);
|
||||
watch(() => props.recipe.tools, () => {
|
||||
if (!(user.householdSlug && toolStore)) {
|
||||
return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
|
||||
recipeTools.value = props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
|
||||
}
|
||||
else {
|
||||
return props.recipe.tools.map((tool) => {
|
||||
recipeTools.value = props.recipe.tools.map((tool) => {
|
||||
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
|
||||
return { ...tool, onHand } as RecipeToolWithOnHand;
|
||||
});
|
||||
}
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
function updateTool(index: number) {
|
||||
if (user.id && user.householdSlug && toolStore) {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user