mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-06-14 04:50:13 -04:00
Compare commits
267 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acd0c2cb3e | ||
|
|
28d00f7dd5 | ||
|
|
fdd3d4b37a | ||
|
|
b09a85dfab | ||
|
|
b6ceece901 | ||
|
|
54b8760d15 | ||
|
|
187e0300a0 | ||
|
|
c398316b55 | ||
|
|
eb093a755b | ||
|
|
2e982fad82 | ||
|
|
f5570bf9b2 | ||
|
|
ddd7ee0696 | ||
|
|
f1b5b999b9 | ||
|
|
47892f84be | ||
|
|
18002351b6 | ||
|
|
9605c448e7 | ||
|
|
9499c2942c | ||
|
|
f04bd7b777 | ||
|
|
710708ea68 | ||
|
|
bb196da83b | ||
|
|
d500fbf0b4 | ||
|
|
ca94ca973c | ||
|
|
454d1eff1c | ||
|
|
280be88fc5 | ||
|
|
e24c37957b | ||
|
|
46b069ba71 | ||
|
|
2caed5e192 | ||
|
|
406f44e6a7 | ||
|
|
f6787f18ba | ||
|
|
1d64f428db | ||
|
|
77906da9f1 | ||
|
|
35d470f5ea | ||
|
|
d7cdcfa734 | ||
|
|
bfbdf76c2d | ||
|
|
7cc0fafbaa | ||
|
|
5b65ceda93 | ||
|
|
07ecd88685 | ||
|
|
8f1ce1a1c3 | ||
|
|
3146e99b03 | ||
|
|
fe53cc28ba | ||
|
|
d85635997b | ||
|
|
1ca29df52e | ||
|
|
ee5de10ffb | ||
|
|
201ab4b8ac | ||
|
|
45af609161 | ||
|
|
c4a3068492 | ||
|
|
6d4f573526 | ||
|
|
3c14df453e | ||
|
|
9826f3483e | ||
|
|
caf0f5f441 | ||
|
|
b599de9c22 | ||
|
|
fd7aa44c13 | ||
|
|
82b7bacdb7 | ||
|
|
84f86c2682 | ||
|
|
527edb1a92 | ||
|
|
6e11b92e74 | ||
|
|
3f5b25a30e | ||
|
|
662d06b5a8 | ||
|
|
9003d0f1d1 | ||
|
|
1cf7e37ada | ||
|
|
930c92365d | ||
|
|
6f1fee5511 | ||
|
|
f5de126d86 | ||
|
|
725dae41b1 | ||
|
|
39e919526a | ||
|
|
1978ad2c96 | ||
|
|
23e8dc1941 | ||
|
|
96b408a661 | ||
|
|
20a9a94770 | ||
|
|
b280e2d1a0 | ||
|
|
735162d042 | ||
|
|
60d9294861 | ||
|
|
ff42964537 | ||
|
|
bb67d993a0 | ||
|
|
7bb0f0801a | ||
|
|
3a4875a54f | ||
|
|
0371874670 | ||
|
|
3d177566ed | ||
|
|
14e87918fb | ||
|
|
ac75b0254d | ||
|
|
7f2927600b | ||
|
|
5e8c4a6cee | ||
|
|
a460c32674 | ||
|
|
973cd5ab02 | ||
|
|
ac355c1071 | ||
|
|
3a617cd3c3 | ||
|
|
3c874c2f85 | ||
|
|
fb3be73163 | ||
|
|
14b783852e | ||
|
|
75616d66b8 | ||
|
|
01713b0416 | ||
|
|
123a8b99f8 | ||
|
|
6732fcd696 | ||
|
|
5fcbfbf361 | ||
|
|
1318998bc9 | ||
|
|
0947212271 | ||
|
|
92ac5c6253 | ||
|
|
5f96f4b47f | ||
|
|
dbcd430425 | ||
|
|
4c9164594b | ||
|
|
e5a13f8b43 | ||
|
|
726ad10c7e | ||
|
|
df53310f2e | ||
|
|
82bf5c1bae | ||
|
|
c70a63f0ff | ||
|
|
14bfa6bcae | ||
|
|
adbafef157 | ||
|
|
62d52f53e4 | ||
|
|
4370319fec | ||
|
|
15908d190d | ||
|
|
fcb909e072 | ||
|
|
8e532af4d9 | ||
|
|
831cb6dd17 | ||
|
|
089bb24c0f | ||
|
|
107dfc34de | ||
|
|
144d4caea6 | ||
|
|
b3db81b9a4 | ||
|
|
dc2bbdc494 | ||
|
|
8f17a08923 | ||
|
|
f6209bff54 | ||
|
|
33865285d1 | ||
|
|
e226b9b1d5 | ||
|
|
201c63d1e4 | ||
|
|
a242f567ad | ||
|
|
67ead2e8a1 | ||
|
|
7b273b77e2 | ||
|
|
b4cd095360 | ||
|
|
a9bb27c782 | ||
|
|
9df1523911 | ||
|
|
0c8a1ae608 | ||
|
|
7d54404bf0 | ||
|
|
8bbe70d245 | ||
|
|
6c87f7fe33 | ||
|
|
7e168eb75b | ||
|
|
64d481b4fc | ||
|
|
a9926557bc | ||
|
|
2a908c0dd2 | ||
|
|
c64a0dc769 | ||
|
|
7ce9c35ef5 | ||
|
|
0acca2021d | ||
|
|
5de0b48aa9 | ||
|
|
ffe199c083 | ||
|
|
215a18be42 | ||
|
|
a1b065e5d1 | ||
|
|
d660d89a1b | ||
|
|
ade1f797a9 | ||
|
|
192872b9ec | ||
|
|
25ebcb1a05 | ||
|
|
89d95ca5e1 | ||
|
|
b705652af3 | ||
|
|
b0c78de2da | ||
|
|
c4b1f9fd01 | ||
|
|
2b0d8227f4 | ||
|
|
42517e9f8a | ||
|
|
37c97c8aba | ||
|
|
64d36a2608 | ||
|
|
563defe074 | ||
|
|
f5ffb760d3 | ||
|
|
3118a0c0cf | ||
|
|
444beb68f9 | ||
|
|
49a97ebc0e | ||
|
|
6f682b742e | ||
|
|
32d4d22bb8 | ||
|
|
71d86489f4 | ||
|
|
a95eaf3d2e | ||
|
|
414af989e7 | ||
|
|
b7b191a5ee | ||
|
|
5620370ade | ||
|
|
d333d47e34 | ||
|
|
b34b1c9be3 | ||
|
|
8c5010148d | ||
|
|
a17b0e329e | ||
|
|
8ab69a7d7a | ||
|
|
f4ecf74b91 | ||
|
|
ba9d816f64 | ||
|
|
6895b49543 | ||
|
|
fffe7b05e0 | ||
|
|
1271e0e49b | ||
|
|
478054b724 | ||
|
|
57d259a7a3 | ||
|
|
a4a6d4dfb1 | ||
|
|
f7b4f79312 | ||
|
|
434d312f7c | ||
|
|
bda460b49e | ||
|
|
d3e1c48655 | ||
|
|
b2a3430f2c | ||
|
|
3d792d9333 | ||
|
|
2e028d7e12 | ||
|
|
c63932e8b3 | ||
|
|
3ba2227bc7 | ||
|
|
67af391c6b | ||
|
|
70ae0dac25 | ||
|
|
e15a9c3c9f | ||
|
|
9d40d60b3b | ||
|
|
e2760f7247 | ||
|
|
83bf21b947 | ||
|
|
d1824affff | ||
|
|
4827e1092f | ||
|
|
7db767b075 | ||
|
|
afdd0b15dc | ||
|
|
37c9166a77 | ||
|
|
ba0b9d4cd9 | ||
|
|
9fd99a86b8 | ||
|
|
824603a578 | ||
|
|
e3f120c680 | ||
|
|
d16a10440d | ||
|
|
ecdf7de386 | ||
|
|
0e10ed8461 | ||
|
|
1684169e7b | ||
|
|
3d9f2bef82 | ||
|
|
a722b05fb5 | ||
|
|
187e83eeb5 | ||
|
|
f3cc51190c | ||
|
|
33aedd6904 | ||
|
|
ea9a25a891 | ||
|
|
3a237258a1 | ||
|
|
d29de8e679 | ||
|
|
79367872ac | ||
|
|
f058dec27b | ||
|
|
c87acf54db | ||
|
|
84c144e40f | ||
|
|
474cf299cd | ||
|
|
1cababc5a5 | ||
|
|
8705bcf195 | ||
|
|
bdb511c1c8 | ||
|
|
c9f3f65f36 | ||
|
|
3ec55f0e48 | ||
|
|
7d43c7c7a2 | ||
|
|
c710e9d3f5 | ||
|
|
0313e6b3b8 | ||
|
|
24b890136d | ||
|
|
4b67554b36 | ||
|
|
679a42a7cc | ||
|
|
4dfc32a314 | ||
|
|
96acc6fc4b | ||
|
|
249c9e8f23 | ||
|
|
7413185300 | ||
|
|
6168ea0150 | ||
|
|
f7ba7862d4 | ||
|
|
cec6d2c5ec | ||
|
|
b27977fbdf | ||
|
|
2a60b330ac | ||
|
|
72ec5bd13e | ||
|
|
bb45cbb0a2 | ||
|
|
c929a03b57 | ||
|
|
9e5a54477f | ||
|
|
078b4563b3 | ||
|
|
a9090bc2bd | ||
|
|
cb8c1423c5 | ||
|
|
f6a1b5f4eb | ||
|
|
7623b72c4c | ||
|
|
17d40e34df | ||
|
|
bade6968a3 | ||
|
|
92a142125f | ||
|
|
d39c2a2874 | ||
|
|
324de7fb10 | ||
|
|
c4544ea042 | ||
|
|
a5dda74812 | ||
|
|
fd7e58e40c | ||
|
|
5e42841a7d | ||
|
|
ae9306b8c2 | ||
|
|
7f0c5cbcc4 | ||
|
|
a7d8bcc6ba | ||
|
|
b94ef78a12 | ||
|
|
db2c14093d | ||
|
|
9a0525c3a0 | ||
|
|
a2e5826da0 |
@@ -8,28 +8,13 @@ FROM mcr.microsoft.com/devcontainers/python:${VARIANT}
|
|||||||
ARG NODE_VERSION="none"
|
ARG NODE_VERSION="none"
|
||||||
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||||
|
|
||||||
# install poetry - respects $POETRY_VERSION & $POETRY_HOME
|
|
||||||
|
|
||||||
RUN echo "export PROMPT_COMMAND='history -a'" >> /home/vscode/.bashrc \
|
RUN echo "export PROMPT_COMMAND='history -a'" >> /home/vscode/.bashrc \
|
||||||
&& echo "export HISTFILE=~/commandhistory/.bash_history" >> /home/vscode/.bashrc \
|
&& echo "export HISTFILE=~/commandhistory/.bash_history" >> /home/vscode/.bashrc \
|
||||||
&& chown vscode:vscode -R /home/vscode/
|
&& chown vscode:vscode -R /home/vscode/
|
||||||
|
|
||||||
RUN npm install -g @go-task/cli
|
RUN npm install -g @go-task/cli
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
# Install additional OS packages
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PIP_NO_CACHE_DIR=off \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=on \
|
|
||||||
PIP_DEFAULT_TIMEOUT=100 \
|
|
||||||
POETRY_HOME="/opt/poetry" \
|
|
||||||
POETRY_VIRTUALENVS_IN_PROJECT=true
|
|
||||||
|
|
||||||
# prepend poetry and venv to path
|
|
||||||
ENV PATH="$POETRY_HOME/bin:$PATH"
|
|
||||||
|
|
||||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
|
||||||
# RUN poetry config virtualenvs.create false
|
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install --no-install-recommends -y \
|
&& apt-get install --no-install-recommends -y \
|
||||||
curl \
|
curl \
|
||||||
@@ -39,5 +24,9 @@ RUN apt-get update \
|
|||||||
libsasl2-dev libldap2-dev libssl-dev \
|
libsasl2-dev libldap2-dev libssl-dev \
|
||||||
gnupg gnupg2 gnupg1
|
gnupg gnupg2 gnupg1
|
||||||
|
|
||||||
# create directory used for Docker Secrets
|
# Install uv
|
||||||
|
RUN pip install uv
|
||||||
|
ENV UV_LINK_MODE=copy
|
||||||
|
|
||||||
|
# Create directory for Docker Secrets
|
||||||
RUN mkdir -p /run/secrets
|
RUN mkdir -p /run/secrets
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
// Use -bullseye variants on local on arm64/Apple Silicon.
|
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||||
"VARIANT": "3.12-bullseye",
|
"VARIANT": "3.12-bullseye",
|
||||||
// Options
|
// Options
|
||||||
"NODE_VERSION": "20"
|
"NODE_VERSION": "22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mounts": [
|
"mounts": [
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
],
|
],
|
||||||
// Use 'onCreateCommand' to run commands at the end of container creation.
|
// Use 'onCreateCommand' to run commands at the end of container creation.
|
||||||
// Use 'postCreateCommand' to run commands after the container is created.
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
"onCreateCommand": "sudo chown -R vscode:vscode /workspaces/mealie/frontend/node_modules /home/vscode/commandhistory && task setup",
|
"onCreateCommand": "sudo chown -R vscode:vscode /workspaces/mealie/frontend/node_modules /home/vscode/commandhistory && task setup --force",
|
||||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||||
"remoteUser": "vscode",
|
"remoteUser": "vscode",
|
||||||
"features": {
|
"features": {
|
||||||
@@ -56,5 +56,8 @@
|
|||||||
"dockerDashComposeVersion": "v2"
|
"dockerDashComposeVersion": "v2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"appPort": 3000
|
"appPort": [
|
||||||
|
"3000:3000",
|
||||||
|
"9000:9000"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
11
.github/workflows/build-package.yml
vendored
11
.github/workflows/build-package.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- name: Setup node env 🏗
|
- name: Setup node env 🏗
|
||||||
uses: actions/setup-node@v4.0.0
|
uses: actions/setup-node@v4.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Get yarn cache directory path 🛠
|
- name: Get yarn cache directory path 🛠
|
||||||
@@ -70,13 +70,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install Poetry
|
- name: Install uv
|
||||||
uses: snok/install-poetry@v1
|
run: pip install uv
|
||||||
with:
|
|
||||||
virtualenvs-create: true
|
|
||||||
virtualenvs-in-project: true
|
|
||||||
plugins: |
|
|
||||||
poetry-plugin-export
|
|
||||||
|
|
||||||
- name: Retrieve built frontend
|
- name: Retrieve built frontend
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
|||||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
cache-dependency-path: ./tests/e2e/yarn.lock
|
cache-dependency-path: ./tests/e2e/yarn.lock
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
|||||||
21
.github/workflows/locale-sync.yml
vendored
21
.github/workflows/locale-sync.yml
vendored
@@ -25,24 +25,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install Poetry
|
- name: Install uv
|
||||||
uses: snok/install-poetry@v1
|
run: pip install uv
|
||||||
with:
|
|
||||||
virtualenvs-create: true
|
|
||||||
virtualenvs-in-project: true
|
|
||||||
|
|
||||||
- name: Load cached venv
|
- name: Load cached venv
|
||||||
id: cached-poetry-dependencies
|
id: cached-python-dependencies
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: .venv
|
path: .venv
|
||||||
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
|
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
|
||||||
|
|
||||||
- name: Check venv cache
|
- name: Check venv cache
|
||||||
id: cache-validate
|
id: cache-validate
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
|
if: steps.cached-python-dependencies.outputs.cache-hit == 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
echo "import fastapi;print('venv good?')" > test.py && uv run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
||||||
rm test.py
|
rm test.py
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
@@ -50,13 +47,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
||||||
poetry install
|
uv sync --group dev
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
if: steps.cached-python-dependencies.outputs.cache-hit != 'true'
|
||||||
|
|
||||||
- name: Run locale generation
|
- name: Run locale generation
|
||||||
run: |
|
run: |
|
||||||
cd dev/code-generation
|
cd dev/code-generation
|
||||||
poetry run python main.py locales
|
uv run python main.py locales
|
||||||
env:
|
env:
|
||||||
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
|
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
|
||||||
|
|
||||||
|
|||||||
132
.github/workflows/release.yml
vendored
132
.github/workflows/release.yml
vendored
@@ -5,17 +5,73 @@ on:
|
|||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
commit-version-bump:
|
||||||
|
name: Commit version bump to repository
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
outputs:
|
||||||
|
commit-sha: ${{ steps.commit.outputs.commit-sha }}
|
||||||
|
steps:
|
||||||
|
- name: Generate GitHub App Token
|
||||||
|
id: app-token
|
||||||
|
uses: actions/create-github-app-token@v1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
|
||||||
|
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout 🛎
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Extract Version From Tag Name
|
||||||
|
run: echo "VERSION_NUM=$(echo ${{ github.event.release.tag_name }} | sed 's/^v//')" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config user.name "mealie-commit-bot[bot]"
|
||||||
|
git config user.email "mealie-commit-bot[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Update all version strings
|
||||||
|
run: |
|
||||||
|
sed -i 's/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' pyproject.toml
|
||||||
|
sed -i '/^name = "mealie"$/,/^version = / s/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' uv.lock
|
||||||
|
sed -i 's/\("version": "\)[^"]*"/\1${{ env.VERSION_NUM }}"/' frontend/package.json
|
||||||
|
sed -i 's/:v[0-9]*\.[0-9]*\.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/installation-checklist.md
|
||||||
|
sed -i 's/:v[0-9]*\.[0-9]*\.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/sqlite.md
|
||||||
|
sed -i 's/:v[0-9]*\.[0-9]*\.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/postgres.md
|
||||||
|
|
||||||
|
- name: Commit and push changes
|
||||||
|
id: commit
|
||||||
|
run: |
|
||||||
|
git add pyproject.toml frontend/package.json uv.lock docs/
|
||||||
|
git commit -m "chore: bump version to ${{ github.event.release.tag_name }}"
|
||||||
|
git push origin HEAD:${{ github.event.repository.default_branch }}
|
||||||
|
echo "commit-sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Move release tag to new commit
|
||||||
|
run: |
|
||||||
|
git tag -f ${{ github.event.release.tag_name }}
|
||||||
|
git push -f origin ${{ github.event.release.tag_name }}
|
||||||
|
|
||||||
backend-tests:
|
backend-tests:
|
||||||
name: "Backend Server Tests"
|
name: "Backend Server Tests"
|
||||||
uses: ./.github/workflows/test-backend.yml
|
uses: ./.github/workflows/test-backend.yml
|
||||||
|
needs:
|
||||||
|
- commit-version-bump
|
||||||
|
|
||||||
frontend-tests:
|
frontend-tests:
|
||||||
name: "Frontend Tests"
|
name: "Frontend Tests"
|
||||||
uses: ./.github/workflows/test-frontend.yml
|
uses: ./.github/workflows/test-frontend.yml
|
||||||
|
needs:
|
||||||
|
- commit-version-bump
|
||||||
|
|
||||||
build-package:
|
build-package:
|
||||||
name: Build Package
|
name: Build Package
|
||||||
uses: ./.github/workflows/build-package.yml
|
uses: ./.github/workflows/build-package.yml
|
||||||
|
needs:
|
||||||
|
- commit-version-bump
|
||||||
with:
|
with:
|
||||||
tag: ${{ github.event.release.tag_name }}
|
tag: ${{ github.event.release.tag_name }}
|
||||||
|
|
||||||
@@ -43,10 +99,48 @@ jobs:
|
|||||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
rollback-on-failure:
|
||||||
|
name: Rollback version commit if deployment fails
|
||||||
|
needs:
|
||||||
|
- commit-version-bump
|
||||||
|
- publish
|
||||||
|
if: always() && needs.publish.result == 'failure'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Generate GitHub App Token
|
||||||
|
id: app-token
|
||||||
|
uses: actions/create-github-app-token@v1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
|
||||||
|
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout 🛎
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config user.name "mealie-commit-bot[bot]"
|
||||||
|
git config user.email "mealie-commit-bot[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Delete release tag
|
||||||
|
run: |
|
||||||
|
git push --delete origin ${{ github.event.release.tag_name }}
|
||||||
|
|
||||||
|
- name: Revert version bump commit
|
||||||
|
run: |
|
||||||
|
git revert --no-edit ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||||
|
git push origin HEAD:${{ github.event.repository.default_branch }}
|
||||||
|
|
||||||
notify-discord:
|
notify-discord:
|
||||||
name: Notify Discord
|
name: Notify Discord
|
||||||
needs:
|
needs:
|
||||||
- publish
|
- publish
|
||||||
|
if: success()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
@@ -55,41 +149,3 @@ jobs:
|
|||||||
uses: Ilshidur/action-discord@0.3.2
|
uses: Ilshidur/action-discord@0.3.2
|
||||||
with:
|
with:
|
||||||
args: "🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of Mealie has been released. See the release notes https://github.com/mealie-recipes/mealie/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}"
|
args: "🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of Mealie has been released. See the release notes https://github.com/mealie-recipes/mealie/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}"
|
||||||
|
|
||||||
update-image-tags:
|
|
||||||
name: Update image tag in sample docker-compose files
|
|
||||||
needs:
|
|
||||||
- publish
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout 🛎
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Extract Version From Tag Name
|
|
||||||
run: echo "VERSION_NUM=$(echo ${{ github.event.release.tag_name }} | sed 's/^v//')" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Modify version strings
|
|
||||||
run: |
|
|
||||||
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/installation-checklist.md
|
|
||||||
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/sqlite.md
|
|
||||||
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/postgres.md
|
|
||||||
sed -i 's/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' pyproject.toml
|
|
||||||
sed -i 's/^\s*"version": "[^"]*"/"version": "${{ env.VERSION_NUM }}"/' frontend/package.json
|
|
||||||
|
|
||||||
- name: Create Pull Request
|
|
||||||
uses: peter-evans/create-pull-request@v6
|
|
||||||
# This doesn't currently work for us because it creates the PR but the workflows don't run.
|
|
||||||
# TODO: Provide a personal access token as a parameter here, that solves that problem.
|
|
||||||
# https://github.com/peter-evans/create-pull-request
|
|
||||||
with:
|
|
||||||
commit-message: "Update image tag, for release ${{ github.event.release.tag_name }}"
|
|
||||||
branch: "docs/newrelease-update-version-${{ github.event.release.tag_name }}"
|
|
||||||
labels: |
|
|
||||||
documentation
|
|
||||||
delete-branch: true
|
|
||||||
base: mealie-next
|
|
||||||
title: "docs(auto): Update image tag, for release ${{ github.event.release.tag_name }}"
|
|
||||||
body: "Auto-generated by `.github/workflows/release.yml`, on publish of release ${{ github.event.release.tag_name }}"
|
|
||||||
|
|||||||
22
.github/workflows/test-backend.yml
vendored
22
.github/workflows/test-backend.yml
vendored
@@ -49,24 +49,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install Poetry
|
- name: Install uv
|
||||||
uses: snok/install-poetry@v1
|
run: pip install uv
|
||||||
with:
|
|
||||||
virtualenvs-create: true
|
|
||||||
virtualenvs-in-project: true
|
|
||||||
|
|
||||||
- name: Load cached venv
|
- name: Load cached venv
|
||||||
id: cached-poetry-dependencies
|
id: cached-python-dependencies
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: .venv
|
path: .venv
|
||||||
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
|
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
|
||||||
|
|
||||||
- name: Check venv cache
|
- name: Check venv cache
|
||||||
id: cache-validate
|
id: cache-validate
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
|
if: steps.cached-python-dependencies.outputs.cache-hit == 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
echo "import fastapi;print('venv good?')" > test.py && uv run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
||||||
rm test.py
|
rm test.py
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
@@ -74,13 +71,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
||||||
poetry install
|
uv sync --group dev --extra pgsql
|
||||||
poetry add "psycopg2-binary==2.9.9"
|
if: steps.cached-python-dependencies.outputs.cache-hit != 'true' || steps.cache-validate.outputs.cache-hit-success != 'true'
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' || steps.cache-validate.outputs.cache-hit-success != 'true'
|
|
||||||
|
|
||||||
- name: Formatting (Ruff)
|
- name: Formatting (Ruff)
|
||||||
run: |
|
run: |
|
||||||
poetry run ruff format . --check
|
uv run ruff format . --check
|
||||||
|
|
||||||
- name: Lint (Ruff)
|
- name: Lint (Ruff)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
4
.github/workflows/test-frontend.yml
vendored
4
.github/workflows/test-frontend.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
- name: Setup node env 🏗
|
- name: Setup node env 🏗
|
||||||
uses: actions/setup-node@v4.0.0
|
uses: actions/setup-node@v4.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Get yarn cache directory path 🛠
|
- name: Get yarn cache directory path 🛠
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
working-directory: "frontend"
|
working-directory: "frontend"
|
||||||
|
|
||||||
- name: Run linter 👀
|
- name: Run linter 👀
|
||||||
run: yarn lint
|
run: yarn lint --max-warnings=0
|
||||||
working-directory: "frontend"
|
working-directory: "frontend"
|
||||||
|
|
||||||
- name: Run tests 🧪
|
- name: Run tests 🧪
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ repos:
|
|||||||
exclude: ^tests/data/
|
exclude: ^tests/data/
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.12.12
|
rev: v0.14.7
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -55,12 +55,15 @@
|
|||||||
"explorer.fileNesting.enabled": true,
|
"explorer.fileNesting.enabled": true,
|
||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig",
|
"package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig",
|
||||||
"pyproject.toml": "poetry.lock, alembic.ini, .pylintrc",
|
"pyproject.toml": "uv.lock, alembic.ini, .pylintrc",
|
||||||
"netlify.toml": "runtime.txt",
|
"netlify.toml": "runtime.txt",
|
||||||
"README.md": "LICENSE, SECURITY.md"
|
"README.md": "LICENSE, SECURITY.md"
|
||||||
},
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
"[vue]": {
|
"[vue]": {
|
||||||
"editor.formatOnSave": false
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
"[python]": {
|
"[python]": {
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
|
|||||||
48
Taskfile.yml
48
Taskfile.yml
@@ -28,7 +28,7 @@ tasks:
|
|||||||
docs:gen:
|
docs:gen:
|
||||||
desc: runs the API documentation generator
|
desc: runs the API documentation generator
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python dev/code-generation/gen_docs_api.py
|
- uv run python dev/code-generation/gen_docs_api.py
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
desc: runs the documentation server
|
desc: runs the documentation server
|
||||||
@@ -36,7 +36,7 @@ tasks:
|
|||||||
deps:
|
deps:
|
||||||
- docs:gen
|
- docs:gen
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python -m mkdocs serve
|
- uv run python -m mkdocs serve
|
||||||
|
|
||||||
setup:ui:
|
setup:ui:
|
||||||
desc: setup frontend dependencies
|
desc: setup frontend dependencies
|
||||||
@@ -54,10 +54,10 @@ tasks:
|
|||||||
desc: setup python dependencies
|
desc: setup python dependencies
|
||||||
run: once
|
run: once
|
||||||
cmds:
|
cmds:
|
||||||
- poetry install --with main,dev,postgres
|
- uv sync --extra pgsql --group dev
|
||||||
- poetry run pre-commit install
|
- uv run pre-commit install
|
||||||
sources:
|
sources:
|
||||||
- poetry.lock
|
- uv.lock
|
||||||
- pyproject.toml
|
- pyproject.toml
|
||||||
- .pre-commit-config.yaml
|
- .pre-commit-config.yaml
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ tasks:
|
|||||||
dev:generate:
|
dev:generate:
|
||||||
desc: run code generators
|
desc: run code generators
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
- uv run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
||||||
- task: docs:gen
|
- task: docs:gen
|
||||||
- task: py:format
|
- task: py:format
|
||||||
|
|
||||||
@@ -96,22 +96,22 @@ tasks:
|
|||||||
py:mypy:
|
py:mypy:
|
||||||
desc: runs python type checking
|
desc: runs python type checking
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run mypy mealie
|
- uv run mypy mealie
|
||||||
|
|
||||||
py:test:
|
py:test:
|
||||||
desc: runs python tests (support args after '--')
|
desc: runs python tests (support args after '--')
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run pytest {{ .CLI_ARGS }}
|
- uv run pytest {{ .CLI_ARGS }}
|
||||||
|
|
||||||
py:format:
|
py:format:
|
||||||
desc: runs python code formatter
|
desc: runs python code formatter
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run ruff format .
|
- uv run ruff format .
|
||||||
|
|
||||||
py:lint:
|
py:lint:
|
||||||
desc: runs python linter
|
desc: runs python linter
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run ruff check mealie
|
- uv run ruff check mealie
|
||||||
|
|
||||||
py:check:
|
py:check:
|
||||||
desc: runs all linters, type checkers, and formatters
|
desc: runs all linters, type checkers, and formatters
|
||||||
@@ -124,10 +124,10 @@ tasks:
|
|||||||
py:coverage:
|
py:coverage:
|
||||||
desc: runs python coverage and generates html report
|
desc: runs python coverage and generates html report
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run pytest
|
- uv run pytest
|
||||||
- poetry run coverage report -m
|
- uv run coverage report -m
|
||||||
- poetry run coveragepy-lcov
|
- uv run coveragepy-lcov
|
||||||
- poetry run coverage html
|
- uv run coverage html
|
||||||
- open htmlcov/index.html
|
- open htmlcov/index.html
|
||||||
|
|
||||||
py:package:copy-frontend:
|
py:package:copy-frontend:
|
||||||
@@ -147,17 +147,17 @@ tasks:
|
|||||||
desc: Generate requirements file to pin all packages, effectively a "pip freeze" before installation begins
|
desc: Generate requirements file to pin all packages, effectively a "pip freeze" before installation begins
|
||||||
internal: true
|
internal: true
|
||||||
cmds:
|
cmds:
|
||||||
- poetry export -n --only=main --extras=pgsql --output=dist/requirements.txt
|
- uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file dist/requirements.txt
|
||||||
# Include mealie in the requirements, hashing the package that was just built to ensure it's the one installed
|
# Include mealie in the requirements, hashing the package that was just built to ensure it's the one installed
|
||||||
- echo "mealie[pgsql]=={{.MEALIE_VERSION}} \\" >> dist/requirements.txt
|
- echo "mealie[pgsql]=={{.MEALIE_VERSION}} \\" >> dist/requirements.txt
|
||||||
- poetry run pip hash dist/mealie-{{.MEALIE_VERSION}}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
|
- pip hash dist/mealie-{{.MEALIE_VERSION}}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
|
||||||
- echo " \\" >> dist/requirements.txt
|
- echo " \\" >> dist/requirements.txt
|
||||||
- poetry run pip hash dist/mealie-{{.MEALIE_VERSION}}.tar.gz | tail -n1 >> dist/requirements.txt
|
- pip hash dist/mealie-{{.MEALIE_VERSION}}.tar.gz | tail -n1 >> dist/requirements.txt
|
||||||
vars:
|
vars:
|
||||||
MEALIE_VERSION:
|
MEALIE_VERSION:
|
||||||
sh: poetry version --short
|
sh: python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])"
|
||||||
sources:
|
sources:
|
||||||
- poetry.lock
|
- uv.lock
|
||||||
- pyproject.toml
|
- pyproject.toml
|
||||||
- dist/mealie-*.whl
|
- dist/mealie-*.whl
|
||||||
- dist/mealie-*.tar.gz
|
- dist/mealie-*.tar.gz
|
||||||
@@ -184,13 +184,13 @@ tasks:
|
|||||||
deps:
|
deps:
|
||||||
- py:package:deps
|
- py:package:deps
|
||||||
cmds:
|
cmds:
|
||||||
- poetry build -n --output=dist
|
- uv build --out-dir dist
|
||||||
- task: py:package:generate-requirements
|
- task: py:package:generate-requirements
|
||||||
|
|
||||||
py:
|
py:
|
||||||
desc: runs the backend server
|
desc: runs the backend server
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python mealie/app.py
|
- uv run python mealie/app.py
|
||||||
|
|
||||||
py:postgres:
|
py:postgres:
|
||||||
desc: runs the backend server configured for containerized postgres
|
desc: runs the backend server configured for containerized postgres
|
||||||
@@ -202,12 +202,12 @@ tasks:
|
|||||||
POSTGRES_PORT: 5432
|
POSTGRES_PORT: 5432
|
||||||
POSTGRES_DB: mealie
|
POSTGRES_DB: mealie
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python mealie/app.py
|
- uv run python mealie/app.py
|
||||||
|
|
||||||
py:migrate:
|
py:migrate:
|
||||||
desc: generates a new database migration file e.g. task py:migrate -- "add new column"
|
desc: generates a new database migration file e.g. task py:migrate -- "add new column"
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run alembic --config mealie/alembic/alembic.ini revision --autogenerate -m "{{ .CLI_ARGS }}"
|
- uv run alembic --config mealie/alembic/alembic.ini revision --autogenerate -m "{{ .CLI_ARGS }}"
|
||||||
- task: py:format
|
- task: py:format
|
||||||
|
|
||||||
ui:build:
|
ui:build:
|
||||||
@@ -228,7 +228,7 @@ tasks:
|
|||||||
desc: runs the frontend linter
|
desc: runs the frontend linter
|
||||||
dir: frontend
|
dir: frontend
|
||||||
cmds:
|
cmds:
|
||||||
- yarn lint
|
- yarn lint --max-warnings=0
|
||||||
|
|
||||||
ui:test:
|
ui:test:
|
||||||
desc: runs the frontend tests
|
desc: runs the frontend tests
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from freezegun import freeze_time
|
|
||||||
|
|
||||||
from mealie.app import app
|
from mealie.app import app
|
||||||
from mealie.core.config import determine_data_dir
|
from mealie.core.config import determine_data_dir
|
||||||
@@ -37,14 +38,43 @@ HTML_TEMPLATE = """<!-- Custom HTML site displayed as the Home chapter -->
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
HTML_PATH = DATA_DIR.parent.parent.joinpath("docs/docs/overrides/api.html")
|
HTML_PATH = DATA_DIR.parent.parent.joinpath("docs/docs/overrides/api.html")
|
||||||
|
CONSTANT_DT = datetime(2025, 10, 24, 15, 53, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_timestamps(s: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
field_format = s.get("format")
|
||||||
|
is_timestamp = field_format in ["date-time", "date", "time"]
|
||||||
|
has_default = s.get("default")
|
||||||
|
|
||||||
|
if not is_timestamp:
|
||||||
|
for k, v in s.items():
|
||||||
|
if isinstance(v, dict):
|
||||||
|
s[k] = normalize_timestamps(v)
|
||||||
|
elif isinstance(v, list):
|
||||||
|
s[k] = [normalize_timestamps(i) if isinstance(i, dict) else i for i in v]
|
||||||
|
|
||||||
|
return s
|
||||||
|
elif not has_default:
|
||||||
|
return s
|
||||||
|
|
||||||
|
if field_format == "date-time":
|
||||||
|
s["default"] = CONSTANT_DT.isoformat()
|
||||||
|
elif field_format == "date":
|
||||||
|
s["default"] = CONSTANT_DT.date().isoformat()
|
||||||
|
elif field_format == "time":
|
||||||
|
s["default"] = CONSTANT_DT.time().isoformat()
|
||||||
|
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
def generate_api_docs(my_app: FastAPI):
|
def generate_api_docs(my_app: FastAPI):
|
||||||
|
openapi_schema = my_app.openapi()
|
||||||
|
openapi_schema = normalize_timestamps(openapi_schema)
|
||||||
|
|
||||||
with open(HTML_PATH, "w") as fd:
|
with open(HTML_PATH, "w") as fd:
|
||||||
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(my_app.openapi()))
|
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(openapi_schema))
|
||||||
fd.write(text)
|
fd.write(text)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
with freeze_time("2024-01-20T17:00:55Z"):
|
generate_api_docs(app)
|
||||||
generate_api_docs(app)
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -105,12 +106,16 @@ def main():
|
|||||||
# Flatten list of lists
|
# Flatten list of lists
|
||||||
all_children = [item for sublist in all_children for item in sublist]
|
all_children = [item for sublist in all_children for item in sublist]
|
||||||
|
|
||||||
|
out_path = GENERATED / "__init__.py"
|
||||||
render_python_template(
|
render_python_template(
|
||||||
TEMPLATE,
|
TEMPLATE,
|
||||||
GENERATED / "__init__.py",
|
out_path,
|
||||||
{"children": all_children},
|
{"children": all_children},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
subprocess.run(["uv", "run", "ruff", "check", str(out_path), "--fix"])
|
||||||
|
subprocess.run(["uv", "run", "ruff", "format", str(out_path)])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from utils import PROJECT_DIR, log, render_python_template
|
from utils import PROJECT_DIR, log, render_python_template
|
||||||
@@ -84,16 +85,23 @@ def find_modules(root: pathlib.Path) -> list[Modules]:
|
|||||||
return modules
|
return modules
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
modules = find_modules(SCHEMA_PATH)
|
modules = find_modules(SCHEMA_PATH)
|
||||||
|
|
||||||
|
template_paths: list[pathlib.Path] = []
|
||||||
for module in modules:
|
for module in modules:
|
||||||
log.debug(f"Module: {module.directory.name}")
|
log.debug(f"Module: {module.directory.name}")
|
||||||
for file in module.files:
|
for file in module.files:
|
||||||
log.debug(f" File: {file.import_path}")
|
log.debug(f" File: {file.import_path}")
|
||||||
log.debug(f" Classes: [{', '.join(file.classes)}]")
|
log.debug(f" Classes: [{', '.join(file.classes)}]")
|
||||||
|
|
||||||
render_python_template(template, module.directory / "__init__.py", {"module": module})
|
template_path = module.directory / "__init__.py"
|
||||||
|
template_paths.append(template_path)
|
||||||
|
render_python_template(template, template_path, {"module": module})
|
||||||
|
|
||||||
|
path_args = (str(p) for p in template_paths)
|
||||||
|
subprocess.run(["uv", "run", "ruff", "check", *path_args, "--fix"])
|
||||||
|
subprocess.run(["uv", "run", "ruff", "format", *path_args])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
@@ -189,6 +190,7 @@ def generate_typescript_types() -> None: # noqa: C901
|
|||||||
skipped_dirs: list[Path] = []
|
skipped_dirs: list[Path] = []
|
||||||
failed_modules: list[Path] = []
|
failed_modules: list[Path] = []
|
||||||
|
|
||||||
|
out_paths: list[Path] = []
|
||||||
for module in schema_path.iterdir():
|
for module in schema_path.iterdir():
|
||||||
if module.is_dir() and module.stem in ignore_dirs:
|
if module.is_dir() and module.stem in ignore_dirs:
|
||||||
skipped_dirs.append(module)
|
skipped_dirs.append(module)
|
||||||
@@ -205,10 +207,18 @@ def generate_typescript_types() -> None: # noqa: C901
|
|||||||
path_as_module = path_to_module(module)
|
path_as_module = path_to_module(module)
|
||||||
generate_typescript_defs(path_as_module, str(out_path), exclude=("MealieModel")) # type: ignore
|
generate_typescript_defs(path_as_module, str(out_path), exclude=("MealieModel")) # type: ignore
|
||||||
clean_output_file(out_path)
|
clean_output_file(out_path)
|
||||||
|
out_paths.append(out_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
failed_modules.append(module)
|
failed_modules.append(module)
|
||||||
log.exception(f"Module Error: {module}")
|
log.exception(f"Module Error: {module}")
|
||||||
|
|
||||||
|
# Run ESLint --fix on the files to clean up any formatting issues
|
||||||
|
subprocess.run(
|
||||||
|
["yarn", "lint", "--fix", *(str(path) for path in out_paths)],
|
||||||
|
check=True,
|
||||||
|
cwd=PROJECT_DIR / "frontend",
|
||||||
|
)
|
||||||
|
|
||||||
log.debug("\n📁 Skipped Directories:")
|
log.debug("\n📁 Skipped Directories:")
|
||||||
for skipped_dir in skipped_dirs:
|
for skipped_dir in skipped_dirs:
|
||||||
log.debug(f" 📁 {skipped_dir.name}")
|
log.debug(f" 📁 {skipped_dir.name}")
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -23,11 +22,6 @@ def render_python_template(template_file: Path | str, dest: Path, data: dict):
|
|||||||
|
|
||||||
dest.write_text(text)
|
dest.write_text(text)
|
||||||
|
|
||||||
# lint/format file with Ruff
|
|
||||||
log.info(f"Formatting {dest}")
|
|
||||||
subprocess.run(["poetry", "run", "ruff", "check", str(dest), "--fix"])
|
|
||||||
subprocess.run(["poetry", "run", "ruff", "format", str(dest)])
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CodeSlicer:
|
class CodeSlicer:
|
||||||
@@ -37,7 +31,7 @@ class CodeSlicer:
|
|||||||
indentation: str | None
|
indentation: str | None
|
||||||
text: list[str]
|
text: list[str]
|
||||||
|
|
||||||
_next_line = None
|
_next_line: int | None = None
|
||||||
|
|
||||||
def purge_lines(self) -> None:
|
def purge_lines(self) -> None:
|
||||||
start = self.start + 1
|
start = self.start + 1
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
###############################################
|
###############################################
|
||||||
# Frontend Build
|
# Frontend Build
|
||||||
###############################################
|
###############################################
|
||||||
FROM node:20@sha256:f3e50c7689a1b6982fab45b1b23ba5adf1fd725e233dc640918fb59f7a57b174 \
|
FROM node:24@sha256:aa648b387728c25f81ff811799bbf8de39df66d7e2d9b3ab55cc6300cb9175d9 \
|
||||||
AS frontend-builder
|
AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
@@ -50,40 +50,29 @@ RUN apt-get update \
|
|||||||
curl \
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV POETRY_HOME="/opt/poetry" \
|
RUN pip install uv
|
||||||
POETRY_NO_INTERACTION=1
|
|
||||||
|
|
||||||
# prepend poetry to path
|
|
||||||
ENV PATH="$POETRY_HOME/bin:$PATH"
|
|
||||||
|
|
||||||
# install poetry - respects $POETRY_VERSION & $POETRY_HOME
|
|
||||||
ENV POETRY_VERSION=2.0.1
|
|
||||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
|
||||||
|
|
||||||
# install poetry plugins needed to build the package
|
|
||||||
RUN poetry self add "poetry-plugin-export>=1.9"
|
|
||||||
|
|
||||||
WORKDIR /mealie
|
WORKDIR /mealie
|
||||||
|
|
||||||
# copy project files here to ensure they will be cached.
|
# copy project files here to ensure they will be cached.
|
||||||
COPY poetry.lock pyproject.toml ./
|
COPY uv.lock pyproject.toml ./
|
||||||
COPY mealie ./mealie
|
COPY mealie ./mealie
|
||||||
|
|
||||||
# Copy frontend to package it into the wheel
|
# Copy frontend to package it into the wheel
|
||||||
COPY --from=frontend-builder /frontend/dist ./mealie/frontend
|
COPY --from=frontend-builder /frontend/dist ./mealie/frontend
|
||||||
|
|
||||||
# Build the source and binary package
|
# Build the source and binary package
|
||||||
RUN poetry build --output=dist
|
RUN uv build --out-dir dist
|
||||||
|
|
||||||
# Create the requirements file, which is used to install the built package and
|
# Create the requirements file, which is used to install the built package and
|
||||||
# its pinned dependencies later. mealie is included to ensure the built one is
|
# its pinned dependencies later. mealie is included to ensure the built one is
|
||||||
# what's installed.
|
# what's installed.
|
||||||
RUN export MEALIE_VERSION=$(poetry version --short) \
|
RUN uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file dist/requirements.txt \
|
||||||
&& poetry export --only=main --extras=pgsql --output=dist/requirements.txt \
|
&& MEALIE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") \
|
||||||
&& echo "mealie[pgsql]==$MEALIE_VERSION \\" >> dist/requirements.txt \
|
&& echo "mealie[pgsql]==${MEALIE_VERSION} \\" >> dist/requirements.txt \
|
||||||
&& poetry run pip hash dist/mealie-$MEALIE_VERSION-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt \
|
&& pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt \
|
||||||
&& echo " \\" >> dist/requirements.txt \
|
&& echo " \\" >> dist/requirements.txt \
|
||||||
&& poetry run pip hash dist/mealie-$MEALIE_VERSION.tar.gz | tail -n1 >> dist/requirements.txt
|
&& pip hash dist/mealie-${MEALIE_VERSION}.tar.gz | tail -n1 >> dist/requirements.txt
|
||||||
|
|
||||||
###############################################
|
###############################################
|
||||||
# Package Container
|
# Package Container
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ yarnpkg generate
|
|||||||
popd
|
popd
|
||||||
rm -r mealie/frontend
|
rm -r mealie/frontend
|
||||||
cp -a frontend/dist mealie/frontend
|
cp -a frontend/dist mealie/frontend
|
||||||
poetry build
|
uv build --out-dir dist
|
||||||
poetry export -n --only=main --extras=pgsql --output=dist/requirements.txt
|
uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file dist/requirements.txt
|
||||||
MEALIE_VERSION=$(poetry version --short)
|
MEALIE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
||||||
echo "mealie[pgsql]==${MEALIE_VERSION} \\" >> dist/requirements.txt
|
echo "mealie[pgsql]==${MEALIE_VERSION} \\" >> dist/requirements.txt
|
||||||
poetry run pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
|
pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
|
||||||
echo " \\" >> dist/requirements.txt
|
echo " \\" >> dist/requirements.txt
|
||||||
poetry run pip hash dist/mealie-${MEALIE_VERSION}.tar.gz | tail -n1 >> dist/requirements.txt
|
pip hash dist/mealie-${MEALIE_VERSION}.tar.gz | tail -n1 >> dist/requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
The Python package can be installed with all of its dependencies pinned to the versions tested by the developers with:
|
The Python package can be installed with all of its dependencies pinned to the versions tested by the developers with:
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ Make sure the VSCode Dev Containers extension is installed, then select "Dev Con
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Python 3.12](https://www.python.org/downloads/)
|
- [Python 3.12](https://www.python.org/downloads/)
|
||||||
- [Poetry](https://python-poetry.org/docs/#installation)
|
- [uv](https://docs.astral.sh/uv/)
|
||||||
- [Node v16.x](https://nodejs.org/en/)
|
- [Node](https://nodejs.org/en/)
|
||||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)
|
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)
|
||||||
- [task](https://taskfile.dev/#/installation)
|
- [task](https://taskfile.dev/#/installation)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
!!! info
|
!!! info
|
||||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||||
|
|
||||||
Mealie supports adding the ingredients of a recipe to your [Bring](https://www.getbring.com/) shopping list, as you can
|
Mealie supports adding the ingredients of a recipe to your [Bring](https://www.getbring.com/) shopping list, as you can
|
||||||
see [here](https://docs.mealie.io/documentation/getting-started/features/#recipe-actions).
|
see [here](https://docs.mealie.io/documentation/getting-started/features/#recipe-actions).
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
!!! info
|
!!! info
|
||||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||||
|
|
||||||
In a lot of ways, Home Assistant is why this project exists! Since Mealie has a robust API it makes it a great fit for interacting with Home Assistant and pulling information into your dashboard.
|
In a lot of ways, Home Assistant is why this project exists! Since Mealie has a robust API it makes it a great fit for interacting with Home Assistant and pulling information into your dashboard.
|
||||||
|
|
||||||
### Display Today's Meal in Lovelace
|
## Display Today's Meal in Lovelace
|
||||||
|
|
||||||
You can use the Mealie API to get access to meal plans in Home Assistant like in the image below.
|
You can use the Mealie API to get access to meal plans in Home Assistant like in the image below.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Steps:
|
## Steps:
|
||||||
|
|
||||||
#### 1. Get your API Token
|
### 1. Get your API Token
|
||||||
|
|
||||||
Create an API token from Mealie's User Settings page (https://docs.mealie.io/documentation/getting-started/api-usage/#getting-a-token)
|
Create an API token from Mealie's User Settings page (see [this page](https://docs.mealie.io/documentation/getting-started/api-usage/#getting-a-token) to learn how).
|
||||||
|
|
||||||
#### 2. Create Home Assistant Sensors
|
### 2. Create Home Assistant Sensors
|
||||||
|
|
||||||
Create REST sensors in home assistant to get the details of today's meal.
|
Create REST sensors in home assistant to get the details of today's meal.
|
||||||
We will create sensors to get the name and ID of the first meal in today's meal plan (note that this may not be what is wanted if there is more than one meal planned for the day). We need the ID as well as the name to be able to retrieve the image for the meal.
|
We will create sensors to get the name and ID of the first meal in today's meal plan (note that this may not be what is wanted if there is more than one meal planned for the day). We need the ID as well as the name to be able to retrieve the image for the meal.
|
||||||
@@ -40,7 +40,7 @@ rest:
|
|||||||
unique_id: mealie_todays_meal_id
|
unique_id: mealie_todays_meal_id
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. Create a Camera Entity
|
### 3. Create a Camera Entity
|
||||||
|
|
||||||
We will create a camera entity to display the image of today's meal in Lovelace.
|
We will create a camera entity to display the image of today's meal in Lovelace.
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ In the still image url field put in:
|
|||||||
Under the entity page for the new camera, rename it.
|
Under the entity page for the new camera, rename it.
|
||||||
e.g. `camera.mealie_todays_meal_image`
|
e.g. `camera.mealie_todays_meal_image`
|
||||||
|
|
||||||
#### 4. Create a Lovelace Card
|
### 4. Create a Lovelace Card
|
||||||
|
|
||||||
Create a picture entity card and set the entity to `mealie_todays_meal` and the camera entity to `camera.mealie_todays_meal_image` or set in the yaml directly.
|
Create a picture entity card and set the entity to `mealie_todays_meal` and the camera entity to `camera.mealie_todays_meal_image` or set in the yaml directly.
|
||||||
|
|
||||||
@@ -76,4 +76,4 @@ card_mod:
|
|||||||
```
|
```
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
Due to how Home Assistant works with images, I had to include the additional styling to get the images to not appear distorted. This requires an [additional installation](https://github.com/thomasloven/lovelace-card-mod) from HACS.
|
Due to how Home Assistant works with images, I had to include the additional styling to get the images to not appear distorted. This requires an [additional installation](https://github.com/thomasloven/lovelace-card-mod) from HACS.
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ var url = document.URL.endsWith('/') ?
|
|||||||
document.URL;
|
document.URL;
|
||||||
var mealie = "http://localhost:8080";
|
var mealie = "http://localhost:8080";
|
||||||
var group_slug = "home" // Change this to your group slug. You can obtain this from your URL after logging-in to Mealie
|
var group_slug = "home" // Change this to your group slug. You can obtain this from your URL after logging-in to Mealie
|
||||||
var use_keywords= "&use_keywords=1" // Optional - use keywords from recipe - update to "" if you don't want that
|
|
||||||
var edity = "&edit=1" // Optional - keep in edit mode - update to "" if you don't want that
|
|
||||||
|
|
||||||
if (mealie.slice(-1) === "/") {
|
if (mealie.slice(-1) === "/") {
|
||||||
mealie = mealie.slice(0, -1)
|
mealie = mealie.slice(0, -1)
|
||||||
}
|
}
|
||||||
var dest = mealie + "/g/" + group_slug + "/r/create/url?recipe_import_url=" + url + use_keywords + edity;
|
var dest = mealie + "/g/" + group_slug + "/r/create/url?recipe_import_url=" + url;
|
||||||
window.open(dest, "_blank");
|
window.open(dest, "_blank");
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
!!! info
|
!!! info
|
||||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
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/)*
|
!!! note
|
||||||
|
If adding via images make sure to enable [Mealie's OpenAI Integration](https://docs.mealie.io/documentation/getting-started/installation/open-ai/)
|
||||||
|
|
||||||
## Javascript can only be run via Shortcuts on the Safari browser on MacOS and iOS. If you do not use Safari you may skip this section
|
## 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.
|
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.
|
||||||
@@ -16,12 +17,13 @@ Settings app -> apps -> Shortcuts -> Advanced -> Allow Running Scripts
|
|||||||
|
|
||||||
Shortcuts app -> Settings (CMD ,) -> Advanced -> Allow Running Scripts
|
Shortcuts app -> Settings (CMD ,) -> Advanced -> Allow Running Scripts
|
||||||
|
|
||||||
## Initial setup
|
## Initial Setup
|
||||||
An API key is needed to authenticate with mealie. To create an api key for a user, navigate to http://YOUR_MEALIE_URL/user/profile/api-tokens. Alternatively you can create a key via the mealie home page by clicking the user's profile pic in the top left -> Api Tokens
|
An API key is needed to authenticate with mealie. To create an api key for a user, navigate to http://YOUR_MEALIE_URL/user/profile/api-tokens. Alternatively you can create a key via the mealie home page by clicking the user's profile pic in the top left -> Api Tokens
|
||||||
|
|
||||||
The shortcut can be installed via **[This link](https://www.icloud.com/shortcuts/52834724050b42aebe0f2efd8d067360)**. Upon install, replace "MEALIE_API_KEY" with the API key generated previously and "MEALIE_URI" with the full URL used to access your mealie instance e.g. "http://10.0.0.5:9000" or "https://mealie.domain.com".
|
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
|
## 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.*
|
!!! 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.
|
||||||
@@ -1,71 +1,77 @@
|
|||||||
# Automating Backups with n8n
|
# Automating Backups with n8n
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||||
|
|
||||||
> [n8n](https://github.com/n8n-io/n8n) is a free and source-available fair-code licensed workflow automation tool. Alternative to Zapier or Make, allowing you to use a UI to create automated workflows.
|
[n8n](https://github.com/n8n-io/n8n) is a free and source-available fair-code licensed workflow automation tool. It's an alternative to tools like Zapier or Make, allowing you to use a UI to create automated workflows.
|
||||||
|
|
||||||
This example workflow:
|
This example workflow:
|
||||||
|
|
||||||
1. Backups Mealie every morning via an API call
|
1. Creates a Mealie backup every morning via an API call
|
||||||
2. Deletes all but the last 7 backups
|
2. Keeps the last 7 backups, deleting older ones
|
||||||
|
|
||||||
> [!CAUTION]
|
!!! warning "Important"
|
||||||
> This only automates the backup function, this does not backup your data to anywhere except your local instance. Please make sure you are backing up your data to an external source.
|
This only automates the backup function, this does not backup your data to anywhere except your local instance. Please make sure you are backing up your data to an external source.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# Setup
|
## Setup
|
||||||
|
|
||||||
## Deploying n8n
|
### Deploying n8n
|
||||||
|
|
||||||
Follow the relevant guide in the [n8n Documentation](https://docs.n8n.io/)
|
Follow the relevant guide in the [n8n Documentation](https://docs.n8n.io/)
|
||||||
|
|
||||||
## Importing n8n workflow
|
### Importing n8n workflow
|
||||||
|
|
||||||
1. In n8n, add a new workflow
|
1. In n8n, add a new workflow
|
||||||
2. In the top right hit the 3 dot menu and select 'Import from URL...'
|
2. In the top right hit the 3 dot menu and select 'Import from URL...'
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
3. Paste `https://github.com/mealie-recipes/mealie/blob/mealie-next/docs/docs/assets/other/n8n/n8n-mealie-backup.json` and click Import
|
3. Paste `https://github.com/mealie-recipes/mealie/blob/mealie-next/docs/docs/assets/other/n8n/n8n-mealie-backup.json` and click 'Import'
|
||||||
4. Click through the nodes and update the URLs for your environment
|
4. Click through the nodes and update the URLs for your environment
|
||||||
|
|
||||||
## API Credentials
|
### API Credentials
|
||||||
|
|
||||||
#### Generate Mealie API Token
|
#### Generate Mealie API Token
|
||||||
|
|
||||||
1. Head to https://mealie.example.com/user/profile/api-tokens
|
1. Head to `<YOUR MEALIE INSTANCE>/user/profile/api-tokens`
|
||||||
> If you dont see this screen make sure that "Show advanced features" is checked under https://mealie.example.com/user/profile/edit
|
|
||||||
2. Under token name, enter the name of the token i.e. 'n8n' and hit Generate
|
!!! tip
|
||||||
|
If you dont see this screen make sure that "Show advanced features" is checked under `<YOUR MEALIE INSTANCE>/user/profile/edit`
|
||||||
|
|
||||||
|
2. Under token name, enter the name of the token (for example, 'n8n') and hit 'Generate'
|
||||||
|
|
||||||
3. Copy and keep this API Token somewhere safe, this is like your password!
|
3. Copy and keep this API Token somewhere safe, this is like your password!
|
||||||
|
|
||||||
> You can use your normal user for this, but assuming you're an admin you could also choose to create a user named n8n and generate the API key against that user.
|
!!! tip
|
||||||
|
You can use your normal user for this, but assuming you're an admin you could also choose to create a user named n8n and generate the API key against that user.
|
||||||
|
|
||||||
#### Setup Credentials in n8n
|
#### Setup Credentials in n8n
|
||||||
|
|
||||||
> [n8n Docs](https://docs.n8n.io/credentials/add-edit-credentials/)
|
See also [n8n Docs](https://docs.n8n.io/credentials/add-edit-credentials/).
|
||||||
|
|
||||||
1. Create a new "Header Auth" Credential
|
1. Create a new "Header Auth" Credential
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
2. In the connection screen set - Name as `Authorization` - Value as `Bearer {INSERT MEALIE API KEY}`
|
2. In the connection screen set - Name as `Authorization` - Value as `Bearer {INSERT MEALIE API KEY}`
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
3. In the workflow you created, for the "Run Backup", "Get All backups", and "Delete Oldies" nodes, update:
|
3. In the workflow you created, for the "Run Backup", "Get All backups", and "Delete Oldies" nodes, update:
|
||||||
- Authentication to `Generic Credential Type`
|
|
||||||
- Generic Auth Type to `Header Auth`
|
|
||||||
- Header Auth to `Mealie API` or whatever you named your credentials
|
|
||||||
|
|
||||||

|
- Authentication to `Generic Credential Type`
|
||||||
|
- Generic Auth Type to `Header Auth`
|
||||||
|
- Header Auth to `Mealie API` or whatever you named your credentials
|
||||||
|
|
||||||
## Notification Node
|

|
||||||
|
|
||||||
> Please use error notifications of some kind. It's very easy to set and forget an automation, then have the worst happen and lose data.
|
### Notification Node
|
||||||
|
|
||||||
|
!!! warning "Important"
|
||||||
|
Please use error notifications of some kind. It's very easy to set and forget an automation, then have the worst happen and lose data.
|
||||||
|
|
||||||
[ntfy](https://github.com/binwiederhier/ntfy) is a great open source, self-hostable tool for sending notifications.
|
[ntfy](https://github.com/binwiederhier/ntfy) is a great open source, self-hostable tool for sending notifications.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
# Using SWAG as Reverse Proxy
|
# Using SWAG as Reverse Proxy
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||||
|
|
||||||
|
To make the setup of a Reverse Proxy much easier, Linuxserver.io developed [SWAG](https://github.com/linuxserver/docker-swag).
|
||||||
|
|
||||||
|
|
||||||
To make the setup of a Reverse Proxy much easier, Linuxserver.io developed [SWAG](https://github.com/linuxserver/docker-swag)
|
|
||||||
SWAG - Secure Web Application Gateway (formerly known as letsencrypt, no relation to Let's Encrypt™) sets up an Nginx web server and reverse proxy with PHP support and a built-in certbot client that automates free SSL server certificate generation and renewal processes (Let's Encrypt and ZeroSSL). It also contains fail2ban for intrusion prevention.
|
SWAG - Secure Web Application Gateway (formerly known as letsencrypt, no relation to Let's Encrypt™) sets up an Nginx web server and reverse proxy with PHP support and a built-in certbot client that automates free SSL server certificate generation and renewal processes (Let's Encrypt and ZeroSSL). It also contains fail2ban for intrusion prevention.
|
||||||
|
|
||||||
## Step 1: Get a domain
|
## Step 1: Get a domain
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
Mealie allows you to link ingredients to specific steps in a recipe, ensuring you know exactly when to add each ingredient during the cooking process.
|
Mealie allows you to link ingredients to specific steps in a recipe, ensuring you know exactly when to add each ingredient during the cooking process.
|
||||||
|
|
||||||
**Link Ingredients to Steps in a Recipe**
|
**Link Ingredients to Steps in a Recipe**
|
||||||
|
|
||||||
1. Go to a recipe
|
1. Go to a recipe
|
||||||
2. Click the Edit button/icon
|
2. Click the Edit button/icon
|
||||||
3. Scroll down to the step you want to link ingredients to
|
3. Scroll down to the step you want to link ingredients to
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
7. Click 'Save' on the Recipe
|
7. Click 'Save' on the Recipe
|
||||||
|
|
||||||
You can optionally link the same ingredient to multiple steps, which is useful for prepping an ingredient in one step and using it in another.
|
You can optionally link the same ingredient to multiple steps, which is useful for prepping an ingredient in one step and using it in another.
|
||||||
|
|
||||||
??? question "What is fuzzy search and how do I use it?"
|
??? question "What is fuzzy search and how do I use it?"
|
||||||
|
|
||||||
### What is fuzzy search and how do I use it?
|
### What is fuzzy search and how do I use it?
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
|
|
||||||
You can change the theme by settings the environment variables.
|
You can change the theme by settings the environment variables.
|
||||||
|
|
||||||
- [Backend Config - Themeing](./installation/backend-config.md#themeing)
|
- [Backend Config - Theming](./installation/backend-config.md#theming)
|
||||||
|
|
||||||
|
|
||||||
??? question "How can I change the login session timeout?"
|
??? question "How can I change the login session timeout?"
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
|
|
||||||
### How can I use Mealie externally
|
### How can I use Mealie externally
|
||||||
|
|
||||||
Exposing Mealie or any service to the internet can pose significant security risks. Before proceeding, carefully evaluate the potential impacts on your system. Due to the unique nature of each network, we cannot provide specific steps for your setup.
|
Exposing Mealie or any service to the internet can pose significant security risks. Before proceeding, carefully evaluate the potential impacts on your system. Due to the unique nature of each network, we cannot provide specific steps for your setup.
|
||||||
|
|
||||||
There is a community guide available for one way to potentially set this up, and you could reach out on Discord for further discussion on what may be best for your network.
|
There is a community guide available for one way to potentially set this up, and you could reach out on Discord for further discussion on what may be best for your network.
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
|
|
||||||
### Why setup Email?
|
### Why setup Email?
|
||||||
|
|
||||||
Mealie uses email to send account invites and password resets. If you don't use these features, you don't need to set up email. There are also other methods to perform these actions that do not require the setup of Email.
|
Mealie uses email to send account invites and password resets. If you don't use these features, you don't need to set up email. There are also other methods to perform these actions that do not require the setup of Email.
|
||||||
|
|
||||||
Email settings can be adjusted via environment variables on the backend container:
|
Email settings can be adjusted via environment variables on the backend container:
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Mealie supports importing recipes from a few other sources besides websites. Cur
|
|||||||
- Recipe Keeper
|
- Recipe Keeper
|
||||||
- Copy Me That
|
- Copy Me That
|
||||||
- My Recipe Box
|
- My Recipe Box
|
||||||
|
- DVO Cook'n X3
|
||||||
|
|
||||||
You can access these options on your installation at the `/group/migrations` page on your installation. If you'd like to see another source added, feel free to request so on Github.
|
You can access these options on your installation at the `/group/migrations` page on your installation. If you'd like to see another source added, feel free to request so on Github.
|
||||||
|
|
||||||
|
|||||||
@@ -4,22 +4,22 @@
|
|||||||
|
|
||||||
### General
|
### General
|
||||||
|
|
||||||
| Variables | Default | Description |
|
| Variables | Default | Description |
|
||||||
| ----------------------------- | :-------------------: | -------------------------------------------------------------------------------------------------- |
|
| ----------------------------- | :-------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| PUID | 911 | UserID permissions between host OS and container |
|
| PUID | 911 | UserID permissions between host OS and container |
|
||||||
| PGID | 911 | GroupID permissions between host OS and container |
|
| PGID | 911 | GroupID permissions between host OS and container |
|
||||||
| DEFAULT_GROUP | Home | The default group for users |
|
| DEFAULT_GROUP | Home | The default group for users |
|
||||||
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
|
| 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 |
|
||||||
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid |
|
| 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_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 |
|
| API_DOCS | True | Turns on/off access to the API documentation locally |
|
||||||
| TZ | UTC | Must be set to get correct date/time on the server |
|
| TZ | UTC | Must be set to get correct date/time on the server |
|
||||||
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
|
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
|
||||||
| ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication |
|
| ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication |
|
||||||
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
|
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
|
||||||
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
|
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
|
||||||
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
|
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
|
||||||
|
|
||||||
<super>\*</super> Starting in v1.4.0 this was changed to default to `false` as part of a security review of the application.
|
<super>\*</super> Starting in v1.4.0 this was changed to default to `false` as part of a security review of the application.
|
||||||
|
|
||||||
@@ -138,6 +138,13 @@ For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values
|
|||||||
|
|
||||||
Setting the following environmental variables will change the theme of the frontend. Note that the themes are the same for all users. This is a break-change when migration from v0.x.x -> 1.x.x.
|
Setting the following environmental variables will change the theme of the frontend. Note that the themes are the same for all users. This is a break-change when migration from v0.x.x -> 1.x.x.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
If you're setting these variables but not seeing these changes persist, try removing the `#` character. Also, depending on which syntax you're using, double-check you're using quotes correctly.
|
||||||
|
|
||||||
|
If using YAML mapping syntax, be sure to include quotes around these values, otherwise they will be treated as comments in your YAML file:<br>`THEME_LIGHT_PRIMARY: '#E58325'` or `THEME_LIGHT_PRIMARY: 'E58325'`
|
||||||
|
|
||||||
|
If using YAML sequence syntax, don't include any quotes:<br>`THEME_LIGHT_PRIMARY=#E58325` or `THEME_LIGHT_PRIMARY=E58325`
|
||||||
|
|
||||||
| Variables | Default | Description |
|
| Variables | Default | Description |
|
||||||
| --------------------- | :-----: | --------------------------- |
|
| --------------------- | :-----: | --------------------------- |
|
||||||
| THEME_LIGHT_PRIMARY | #E58325 | Light Theme Config Variable |
|
| THEME_LIGHT_PRIMARY | #E58325 | Light Theme Config Variable |
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
|||||||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||||
|
|
||||||
1. Take a backup just in case!
|
1. Take a backup just in case!
|
||||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.1.2`
|
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.6.1`
|
||||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||||
4. Restart the container
|
4. Restart the container
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mealie:
|
mealie:
|
||||||
image: ghcr.io/mealie-recipes/mealie:v3.1.2 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v3.6.1 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mealie:
|
mealie:
|
||||||
image: ghcr.io/mealie-recipes/mealie:v3.1.2 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v3.6.1 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Mealie is a self hosted recipe manager and meal planner with a RestAPI backend a
|
|||||||
- Copy Me That
|
- Copy Me That
|
||||||
- Paprika
|
- Paprika
|
||||||
- Tandoor Recipes
|
- Tandoor Recipes
|
||||||
|
- DVO Cook'n X3
|
||||||
- Random Meal Plan generation
|
- Random Meal Plan generation
|
||||||
- Advanced rule configuration to fine tune random recipes
|
- Advanced rule configuration to fine tune random recipes
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Backups and Restores
|
# Backups and Restores
|
||||||
|
|
||||||
Mealie provides an integrated mechanic for doing full installation backups of the database.
|
Mealie provides an integrated mechanic for doing full installation backups of the database.
|
||||||
|
|
||||||
Navigate to Settings > Backups or manually by adding `/admin/backups` to your instance URL.
|
Navigate to Settings > Admin Settings > Backups or manually by adding `/admin/backups` to your instance URL.
|
||||||
|
|
||||||
From this page, you will be able to:
|
From this page, you will be able to:
|
||||||
|
|
||||||
- See a list of available backups
|
- See a list of available backups
|
||||||
- Create a backup
|
- Create a backup
|
||||||
@@ -39,7 +39,7 @@ Restoring the Database when using Postgres requires Mealie to be configured with
|
|||||||
```sql
|
```sql
|
||||||
ALTER USER mealie WITH SUPERUSER;
|
ALTER USER mealie WITH SUPERUSER;
|
||||||
|
|
||||||
# Run restore from Mealie
|
-- Run restore from Mealie
|
||||||
|
|
||||||
ALTER USER mealie WITH NOSUPERUSER;
|
ALTER USER mealie WITH NOSUPERUSER;
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Permissions and Public Access
|
# Permissions and Public Access
|
||||||
|
|
||||||
Mealie provides various levels of user access and permissions. This includes:
|
Mealie provides various levels of user access and permissions. This includes:
|
||||||
|
|
||||||
- Authentication and registration ([LDAP](../authentication/ldap.md) and [OpenID Connect](../authentication/oidc.md) are both supported)
|
- Authentication and registration ([LDAP](../authentication/ldap.md) and [OpenID Connect](../authentication/oidc.md) are both supported)
|
||||||
- Customizable user permissions
|
- Customizable user permissions
|
||||||
- Fine-tuned public access for non-users
|
- Fine-tuned public access for non-users
|
||||||
@@ -8,12 +9,12 @@ Mealie provides various levels of user access and permissions. This includes:
|
|||||||
## Customizable User Permissions
|
## Customizable User Permissions
|
||||||
|
|
||||||
Each user can be configured to have varying levels of access. Some of these permissions include:
|
Each user can be configured to have varying levels of access. Some of these permissions include:
|
||||||
|
|
||||||
- Access to Administrator tools
|
- Access to Administrator tools
|
||||||
- Access to inviting other users
|
- Access to inviting other users
|
||||||
- Access to manage their group and group data
|
- Access to manage their group and group data
|
||||||
|
|
||||||
Administrators can navigate to the Settings page and access the User Management page to configure these settings.
|
Administrators can configure these settings on the User Management page (navigate to Settings > Admin Settings > Users or append `/admin/manage/users` to your instance URL).
|
||||||
|
|
||||||
|
|
||||||
[User Management Demo](https://demo.mealie.io/admin/manage/users){ .md-button .md-button--primary }
|
[User Management Demo](https://demo.mealie.io/admin/manage/users){ .md-button .md-button--primary }
|
||||||
|
|
||||||
@@ -22,8 +23,8 @@ Administrators can navigate to the Settings page and access the User Management
|
|||||||
By default, groups and households are set to private, meaning only logged-in users may access the group/household. In order for a recipe to be viewable by public (not logged-in) users, three criteria must be met:
|
By default, groups and households are set to private, meaning only logged-in users may access the group/household. In order for a recipe to be viewable by public (not logged-in) users, three criteria must be met:
|
||||||
|
|
||||||
1. The group must not be private
|
1. The group must not be private
|
||||||
2. The household must not be private, *and* the household setting for allowing users outside of your group to see your recipes must be enabled. These can be toggled on the Household Settings page
|
2. The household must not be private, _and_ the household setting for allowing users outside of your group to see your recipes must be enabled. These can be toggled on the Household Management page (navigate to Settings > Admin Settings > Households or append `/admin/manage/households` to your instance URL)
|
||||||
2. The recipe must be set to public. This can be toggled for each recipe individually, or in bulk using the Recipe Data Management page
|
3. The recipe must be set to public. This can be toggled for each recipe individually, or in bulk using the Recipe Data Management page
|
||||||
|
|
||||||
Additionally, if the group is not private, public users can view all public group data (public recipes, public cookbooks, etc.) from the home page ([e.g. the demo home page](https://demo.mealie.io/g/home)).
|
Additionally, if the group is not private, public users can view all public group data (public recipes, public cookbooks, etc.) from the home page ([e.g. the demo home page](https://demo.mealie.io/g/home)).
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -86,7 +86,7 @@ nav:
|
|||||||
|
|
||||||
- Community Guides:
|
- Community Guides:
|
||||||
- Bring API without internet exposure: "documentation/community-guide/bring-api.md"
|
- Bring API without internet exposure: "documentation/community-guide/bring-api.md"
|
||||||
- Automate Backups with n8n: "documentation/community-guide/n8n-backup-automation.md"
|
- Automating Backups with n8n: "documentation/community-guide/n8n-backup-automation.md"
|
||||||
- Bulk Url Import: "documentation/community-guide/bulk-url-import.md"
|
- Bulk Url Import: "documentation/community-guide/bulk-url-import.md"
|
||||||
- Home Assistant: "documentation/community-guide/home-assistant.md"
|
- Home Assistant: "documentation/community-guide/home-assistant.md"
|
||||||
- Import Bookmarklet: "documentation/community-guide/import-recipe-bookmarklet.md"
|
- Import Bookmarklet: "documentation/community-guide/import-recipe-bookmarklet.md"
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.handle {
|
.handle {
|
||||||
cursor: grab;
|
cursor: grab !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
|
|||||||
@@ -32,9 +32,9 @@
|
|||||||
>
|
>
|
||||||
<div class="d-flex align-center w-100 mb-2">
|
<div class="d-flex align-center w-100 mb-2">
|
||||||
<v-toolbar-title class="headline mb-0">
|
<v-toolbar-title class="headline mb-0">
|
||||||
<v-icon size="large" class="mr-3">
|
<v-icon size="large" class="mr-3">
|
||||||
{{ $globals.icons.pages }}
|
{{ $globals.icons.pages }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ book.name }}
|
{{ book.name }}
|
||||||
</v-toolbar-title>
|
</v-toolbar-title>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="preferences">
|
<div v-if="preferences">
|
||||||
<BaseCardSectionTitle :title="$t('household.household-preferences')" />
|
<BaseCardSectionTitle :title="$t('household.household-preferences')" />
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
|
<v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
|
||||||
<div class="ml-8">
|
<div class="ml-8">
|
||||||
<p class="text-subtitle-2 my-0 py-0">
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
{{ $t("household.private-household-description") }}
|
{{ $t("household.private-household-description") }}
|
||||||
</p>
|
</p>
|
||||||
<DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
|
<DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
|
<v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
|
||||||
<div class="ml-8">
|
<div class="ml-8">
|
||||||
<p class="text-subtitle-2 my-0 py-0">
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="preferences.firstDayOfWeek"
|
v-model="preferences.firstDayOfWeek"
|
||||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||||
:items="allDays"
|
:items="allDays"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
:label="$t('settings.first-day-of-week')"
|
:label="$t('settings.first-day-of-week')"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
flat
|
flat
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')" />
|
<BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')" />
|
||||||
<div class="preference-container">
|
<div class="preference-container">
|
||||||
<div v-for="p in recipePreferences" :key="p.key">
|
<div v-for="p in recipePreferences" :key="p.key">
|
||||||
<v-checkbox v-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
|
<v-checkbox v-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
|
||||||
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
||||||
{{ p.description }}
|
{{ p.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -165,7 +165,7 @@
|
|||||||
>
|
>
|
||||||
<template #activator="{ props: activatorProps }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="field.value"
|
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
:prepend-icon="$globals.icons.calendar"
|
:prepend-icon="$globals.icons.calendar"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
@@ -432,9 +432,9 @@ function removeField(index: number) {
|
|||||||
|
|
||||||
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
||||||
/* newFields.forEach((field, index) => {
|
/* newFields.forEach((field, index) => {
|
||||||
const updatedField = getFieldFromFieldDef(field);
|
const updatedField = getFieldFromFieldDef(field);
|
||||||
fields.value[index] = updatedField; // recursive!!!
|
fields.value[index] = updatedField; // recursive!!!
|
||||||
}); */
|
}); */
|
||||||
|
|
||||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||||
if (qf) {
|
if (qf) {
|
||||||
|
|||||||
@@ -5,8 +5,14 @@
|
|||||||
density="compact"
|
density="compact"
|
||||||
elevation="0"
|
elevation="0"
|
||||||
>
|
>
|
||||||
<BaseDialog v-model="deleteDialog" :title="$t('recipe.delete-recipe')" color="error"
|
<BaseDialog
|
||||||
:icon="$globals.icons.alertCircle" can-confirm @confirm="emitDelete()">
|
v-model="deleteDialog"
|
||||||
|
:title="$t('recipe.delete-recipe')"
|
||||||
|
color="error"
|
||||||
|
:icon="$globals.icons.alertCircle"
|
||||||
|
can-confirm
|
||||||
|
@confirm="emitDelete()"
|
||||||
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
{{ $t("recipe.delete-confirmation") }}
|
{{ $t("recipe.delete-confirmation") }}
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
@@ -15,7 +21,14 @@
|
|||||||
<v-spacer />
|
<v-spacer />
|
||||||
<div v-if="!open" class="custom-btn-group ma-1">
|
<div v-if="!open" class="custom-btn-group ma-1">
|
||||||
<RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always />
|
<RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always />
|
||||||
<RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" />
|
<RecipeTimelineBadge
|
||||||
|
v-if="loggedIn"
|
||||||
|
class="ml-1"
|
||||||
|
color="info"
|
||||||
|
button-style
|
||||||
|
:slug="recipe.slug"
|
||||||
|
:recipe-name="recipe.name!"
|
||||||
|
/>
|
||||||
<div v-if="loggedIn">
|
<div v-if="loggedIn">
|
||||||
<v-tooltip v-if="canEdit" location="bottom" color="info">
|
<v-tooltip v-if="canEdit" location="bottom" color="info">
|
||||||
<template #activator="{ props: tooltipProps }">
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
|||||||
@@ -28,11 +28,12 @@
|
|||||||
<v-list-item-title class="pl-2">
|
<v-list-item-title class="pl-2">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<v-list-item-action>
|
<template #append>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="!edit"
|
v-if="!edit"
|
||||||
color="primary"
|
color="primary"
|
||||||
icon
|
icon
|
||||||
|
size="small"
|
||||||
:href="assetURL(item.fileName ?? '')"
|
:href="assetURL(item.fileName ?? '')"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
top
|
top
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
<v-btn
|
<v-btn
|
||||||
color="error"
|
color="error"
|
||||||
icon
|
icon
|
||||||
|
size="small"
|
||||||
top
|
top
|
||||||
@click="model.splice(i, 1)"
|
@click="model.splice(i, 1)"
|
||||||
>
|
>
|
||||||
@@ -53,7 +55,7 @@
|
|||||||
:copy-text="assetEmbed(item.fileName ?? '')"
|
:copy-text="assetEmbed(item.fileName ?? '')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</v-list-item-action>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -90,13 +92,12 @@
|
|||||||
item-value="name"
|
item-value="name"
|
||||||
class="mr-2"
|
class="mr-2"
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #item="{ item, props: itemProps }">
|
||||||
<v-avatar>
|
<v-list-item v-bind="itemProps">
|
||||||
<v-icon class="mr-auto">
|
<template #prepend>
|
||||||
{{ item.raw.icon }}
|
<v-icon>{{ item.raw.icon }}</v-icon>
|
||||||
</v-icon>
|
</template>
|
||||||
</v-avatar>
|
</v-list-item>
|
||||||
{{ item.title }}
|
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
<AppButtonUpload
|
<AppButtonUpload
|
||||||
|
|||||||
@@ -1,101 +1,100 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
|
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
|
||||||
<div>
|
<div>
|
||||||
<v-hover
|
<v-hover
|
||||||
v-slot="{ isHovering, props: hoverProps }"
|
v-slot="{ isHovering, props: hoverProps }"
|
||||||
:open-delay="50"
|
:open-delay="50"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
v-bind="hoverProps"
|
||||||
|
:class="{ 'on-hover': isHovering }"
|
||||||
|
:style="{ cursor }"
|
||||||
|
:elevation="isHovering ? 12 : 2"
|
||||||
|
:to="recipeRoute"
|
||||||
|
:min-height="imageHeight + 75"
|
||||||
|
@click.self="$emit('click')"
|
||||||
>
|
>
|
||||||
<v-card
|
<RecipeCardImage
|
||||||
v-bind="hoverProps"
|
:icon-size="imageHeight"
|
||||||
:class="{ 'on-hover': isHovering }"
|
:height="imageHeight"
|
||||||
:style="{ cursor }"
|
:slug="slug"
|
||||||
:elevation="isHovering ? 12 : 2"
|
:recipe-id="recipeId"
|
||||||
:to="recipeRoute"
|
size="small"
|
||||||
:min-height="imageHeight + 75"
|
:image-version="image"
|
||||||
@click.self="$emit('click')"
|
|
||||||
>
|
>
|
||||||
<RecipeCardImage
|
<v-expand-transition v-if="description">
|
||||||
:icon-size="imageHeight"
|
<div
|
||||||
:height="imageHeight"
|
v-if="isHovering"
|
||||||
:slug="slug"
|
class="d-flex transition-fast-in-fast-out bg-secondary v-card--reveal"
|
||||||
:recipe-id="recipeId"
|
style="height: 100%"
|
||||||
size="small"
|
|
||||||
:image-version="image"
|
|
||||||
>
|
|
||||||
<v-expand-transition v-if="description">
|
|
||||||
<div
|
|
||||||
v-if="isHovering"
|
|
||||||
class="d-flex transition-fast-in-fast-out bg-secondary v-card--reveal"
|
|
||||||
style="height: 100%"
|
|
||||||
>
|
|
||||||
<v-card-text class="v-card--text-show white--text">
|
|
||||||
<div class="descriptionWrapper">
|
|
||||||
<SafeMarkdown :source="description" />
|
|
||||||
</div>
|
|
||||||
</v-card-text>
|
|
||||||
</div>
|
|
||||||
</v-expand-transition>
|
|
||||||
</RecipeCardImage>
|
|
||||||
<v-card-title class="mb-n3 px-4">
|
|
||||||
<div class="headerClass">
|
|
||||||
{{ name }}
|
|
||||||
</div>
|
|
||||||
</v-card-title>
|
|
||||||
|
|
||||||
<slot name="actions">
|
|
||||||
<v-card-actions
|
|
||||||
v-if="showRecipeContent"
|
|
||||||
class="px-1"
|
|
||||||
>
|
>
|
||||||
<RecipeFavoriteBadge
|
<v-card-text class="v-card--text-show white--text">
|
||||||
v-if="isOwnGroup"
|
<div class="descriptionWrapper">
|
||||||
class="absolute"
|
<SafeMarkdown :source="description" />
|
||||||
:recipe-id="recipeId"
|
</div>
|
||||||
show-always
|
</v-card-text>
|
||||||
/>
|
</div>
|
||||||
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
|
</v-expand-transition>
|
||||||
|
</RecipeCardImage>
|
||||||
|
<v-card-title class="mb-n3 px-4">
|
||||||
|
<div class="headerClass">
|
||||||
|
{{ name }}
|
||||||
|
</div>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
<RecipeCardRating
|
<slot name="actions">
|
||||||
:model-value="rating"
|
<v-card-actions
|
||||||
:recipe-id="recipeId"
|
v-if="showRecipeContent"
|
||||||
/>
|
class="px-1"
|
||||||
<v-spacer />
|
>
|
||||||
<RecipeChips
|
<RecipeFavoriteBadge
|
||||||
:truncate="true"
|
v-if="isOwnGroup"
|
||||||
:items="tags"
|
:recipe-id="recipeId"
|
||||||
:title="false"
|
show-always
|
||||||
:limit="2"
|
/>
|
||||||
small
|
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
|
||||||
url-prefix="tags"
|
|
||||||
v-bind="$attrs"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
<RecipeCardRating
|
||||||
<RecipeContextMenu
|
:model-value="rating"
|
||||||
v-if="isOwnGroup && showRecipeContent"
|
:recipe-id="recipeId"
|
||||||
color="grey-darken-2"
|
/>
|
||||||
:slug="slug"
|
<v-spacer />
|
||||||
:menu-icon="$globals.icons.dotsVertical"
|
<RecipeChips
|
||||||
:name="name"
|
:truncate="true"
|
||||||
:recipe-id="recipeId"
|
:items="tags"
|
||||||
:use-items="{
|
:title="false"
|
||||||
delete: false,
|
:limit="2"
|
||||||
edit: false,
|
small
|
||||||
download: true,
|
url-prefix="tags"
|
||||||
mealplanner: true,
|
v-bind="$attrs"
|
||||||
shoppingList: true,
|
/>
|
||||||
print: false,
|
|
||||||
printPreferences: false,
|
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||||
share: true,
|
<RecipeContextMenu
|
||||||
}"
|
v-if="isOwnGroup && showRecipeContent"
|
||||||
@deleted="$emit('delete', slug)"
|
color="grey-darken-2"
|
||||||
/>
|
:slug="slug"
|
||||||
</v-card-actions>
|
:menu-icon="$globals.icons.dotsVertical"
|
||||||
</slot>
|
:name="name"
|
||||||
<slot />
|
:recipe-id="recipeId"
|
||||||
</v-card>
|
:use-items="{
|
||||||
</v-hover>
|
delete: false,
|
||||||
</div>
|
edit: false,
|
||||||
|
download: true,
|
||||||
|
mealplanner: true,
|
||||||
|
shoppingList: true,
|
||||||
|
print: false,
|
||||||
|
printPreferences: false,
|
||||||
|
share: true,
|
||||||
|
}"
|
||||||
|
@deleted="$emit('delete', slug)"
|
||||||
|
/>
|
||||||
|
</v-card-actions>
|
||||||
|
</slot>
|
||||||
|
<slot />
|
||||||
|
</v-card>
|
||||||
|
</v-hover>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
@deleted="$emit('delete', slug)"
|
@deleted="$emit('delete', slug)"
|
||||||
/>
|
/>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</slot>
|
</slot>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<slot />
|
<slot />
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|||||||
@@ -90,6 +90,14 @@
|
|||||||
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
|
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
|
||||||
</div>
|
</div>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
<v-list-item @click="sortRecipes(EVENTS.shuffle)">
|
||||||
|
<div class="d-flex align-center flex-nowrap">
|
||||||
|
<v-icon class="mr-2" inline>
|
||||||
|
{{ $globals.icons.diceMultiple }}
|
||||||
|
</v-icon>
|
||||||
|
<v-list-item-title>{{ $t("general.random") }}</v-list-item-title>
|
||||||
|
</div>
|
||||||
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
@@ -223,6 +231,7 @@ const displayTitleIcon = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sortLoading = ref(false);
|
const sortLoading = ref(false);
|
||||||
|
const randomSeed = ref(Date.now().toString());
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
@@ -256,13 +265,18 @@ const queryFilter = computed(() => {
|
|||||||
async function fetchRecipes(pageCount = 1) {
|
async function fetchRecipes(pageCount = 1) {
|
||||||
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
|
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
|
||||||
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
|
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
|
||||||
|
const orderBy = props.query?.orderBy || preferences.value.orderBy;
|
||||||
|
const localQuery = { ...props.query };
|
||||||
|
if (orderBy === "random") {
|
||||||
|
localQuery._searchSeed = randomSeed.value;
|
||||||
|
}
|
||||||
return await fetchMore(
|
return await fetchMore(
|
||||||
page.value,
|
page.value,
|
||||||
perPage * pageCount,
|
perPage * pageCount,
|
||||||
props.query?.orderBy || preferences.value.orderBy,
|
orderBy,
|
||||||
orderDir,
|
orderDir,
|
||||||
orderByNullPosition,
|
orderByNullPosition,
|
||||||
props.query,
|
localQuery,
|
||||||
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
|
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
|
||||||
queryFilter.value,
|
queryFilter.value,
|
||||||
);
|
);
|
||||||
@@ -288,6 +302,9 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function initRecipes() {
|
async function initRecipes() {
|
||||||
|
if (preferences.value.orderBy === "random") {
|
||||||
|
randomSeed.value = Date.now().toString();
|
||||||
|
}
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
hasMore.value = true;
|
hasMore.value = true;
|
||||||
|
|
||||||
@@ -380,6 +397,15 @@ async function sortRecipes(sortType: string) {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case EVENTS.shuffle:
|
||||||
|
setter(
|
||||||
|
"random",
|
||||||
|
$globals.icons.diceMultiple,
|
||||||
|
$globals.icons.diceMultiple, // icon in asc and desc is the same for random
|
||||||
|
);
|
||||||
|
// We update the seed value to have a different order
|
||||||
|
randomSeed.value = Date.now().toString();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.log("Unknown Event", sortType);
|
console.log("Unknown Event", sortType);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
<RecipeContextMenuContent
|
<RecipeContextMenuContent
|
||||||
v-if="isMenuContentLoaded"
|
v-if="isMenuContentLoaded"
|
||||||
v-bind="contentProps"
|
v-bind="contentProps"
|
||||||
|
@print="$emit('print')"
|
||||||
@deleted="$emit('deleted', $event)"
|
@deleted="$emit('deleted', $event)"
|
||||||
/>
|
/>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
@@ -108,6 +109,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
|
print: [];
|
||||||
deleted: [slug: string];
|
deleted: [slug: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
>
|
>
|
||||||
<template #activator="{ props: activatorProps }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="newMealdateString"
|
:model-value="$d(newMealdate)"
|
||||||
:label="$t('general.date')"
|
:label="$t('general.date')"
|
||||||
:prepend-icon="$globals.icons.calendar"
|
:prepend-icon="$globals.icons.calendar"
|
||||||
v-bind="activatorProps"
|
v-bind="activatorProps"
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
hide-header
|
hide-header
|
||||||
:first-day-of-week="firstDayOfWeek"
|
:first-day-of-week="firstDayOfWeek"
|
||||||
:local="$i18n.locale"
|
:local="$i18n.locale"
|
||||||
@update:model-value="pickerMenu = false"
|
@update:model-value="pickerMenu = false"
|
||||||
/>
|
/>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
<v-select
|
<v-select
|
||||||
@@ -377,11 +377,14 @@ async function deleteRecipe() {
|
|||||||
const download = useDownloader();
|
const download = useDownloader();
|
||||||
|
|
||||||
async function handleDownloadEvent() {
|
async function handleDownloadEvent() {
|
||||||
const { data } = await api.recipes.getZipToken(props.slug);
|
const { data: shareToken } = await api.recipes.share.createOne({ recipeId: props.recipeId });
|
||||||
|
if (!shareToken) {
|
||||||
if (data) {
|
console.error("No share token received");
|
||||||
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
download(api.recipes.share.getZipRedirectUrl(shareToken.id), `${props.slug}.zip`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRecipeToPlan() {
|
async function addRecipeToPlan() {
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<BaseDialog v-model="dialog" :title="$t('data-pages.manage-aliases')" :icon="$globals.icons.edit"
|
<BaseDialog
|
||||||
:submit-icon="$globals.icons.check" :submit-text="$t('general.confirm')" can-submit @submit="saveAliases"
|
v-model="dialog"
|
||||||
@cancel="$emit('cancel')">
|
:title="$t('data-pages.manage-aliases')"
|
||||||
|
:icon="$globals.icons.edit"
|
||||||
|
:submit-icon="$globals.icons.check"
|
||||||
|
:submit-text="$t('general.confirm')"
|
||||||
|
can-submit
|
||||||
|
@submit="saveAliases"
|
||||||
|
@cancel="$emit('cancel')"
|
||||||
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-container>
|
<v-container>
|
||||||
<v-row v-for="alias, i in aliases" :key="i">
|
<v-row v-for="alias, i in aliases" :key="i">
|
||||||
@@ -10,13 +17,16 @@
|
|||||||
<v-text-field v-model="alias.name" :label="$t('general.name')" :rules="[validators.required]" />
|
<v-text-field v-model="alias.name" :label="$t('general.name')" :rules="[validators.required]" />
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="2">
|
<v-col cols="2">
|
||||||
<BaseButtonGroup :buttons="[
|
<BaseButtonGroup
|
||||||
{
|
:buttons="[
|
||||||
icon: $globals.icons.delete,
|
{
|
||||||
text: $t('general.delete'),
|
icon: $globals.icons.delete,
|
||||||
event: 'delete',
|
text: $t('general.delete'),
|
||||||
},
|
event: 'delete',
|
||||||
]" @delete="deleteAlias(i)" />
|
},
|
||||||
|
]"
|
||||||
|
@delete="deleteAlias(i)"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-container>
|
</v-container>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #[`item.dateAdded`]="{ item }">
|
<template #[`item.dateAdded`]="{ item }">
|
||||||
{{ formatDate(item.dateAdded!) }}
|
{{ item.dateAdded ? $d(new Date(item.dateAdded)) : '' }}
|
||||||
</template>
|
</template>
|
||||||
</v-data-table>
|
</v-data-table>
|
||||||
</template>
|
</template>
|
||||||
@@ -153,15 +153,6 @@ const headers = computed(() => {
|
|||||||
return hdrs;
|
return hdrs;
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDate(date: string) {
|
|
||||||
try {
|
|
||||||
return i18n.d(Date.parse(date), "medium");
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Group Members
|
// Group Members
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
density="compact"
|
density="compact"
|
||||||
/>
|
/>
|
||||||
<div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
|
<div :key="`${ingredientData.ingredient?.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
|
||||||
<RecipeIngredientListItem
|
<RecipeIngredientListItem
|
||||||
:ingredient="ingredientData.ingredient"
|
:ingredient="ingredientData.ingredient"
|
||||||
:scale="recipeSection.recipeScale"
|
:scale="recipeSection.recipeScale"
|
||||||
@@ -287,12 +287,35 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
|
const shoppingListIngredients: ShoppingListIngredient[] = [];
|
||||||
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
|
function flattenRecipeIngredients(ing: RecipeIngredient, parentTitle = ""): ShoppingListIngredient[] {
|
||||||
return {
|
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
|
||||||
checked: !householdsWithFood.includes(userHousehold.value),
|
if (ing.referencedRecipe) {
|
||||||
ingredient: ing,
|
// Recursively flatten all ingredients in the referenced recipe
|
||||||
};
|
return (ing.referencedRecipe.recipeIngredient ?? []).flatMap((subIng) => {
|
||||||
|
const calculatedQty = (ing.quantity || 1) * (subIng.quantity || 1);
|
||||||
|
// Pass the referenced recipe name as the section title
|
||||||
|
return flattenRecipeIngredients(
|
||||||
|
{ ...subIng, quantity: calculatedQty },
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Regular ingredient
|
||||||
|
return [{
|
||||||
|
checked: !householdsWithFood.includes(userHousehold.value),
|
||||||
|
ingredient: {
|
||||||
|
...ing,
|
||||||
|
title: ing.title || parentTitle,
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recipe.recipeIngredient.forEach((ing) => {
|
||||||
|
const flattened = flattenRecipeIngredients(ing, "");
|
||||||
|
shoppingListIngredients.push(...flattened);
|
||||||
});
|
});
|
||||||
|
|
||||||
let currentTitle = "";
|
let currentTitle = "";
|
||||||
@@ -301,6 +324,9 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
|||||||
if (ing.ingredient.title) {
|
if (ing.ingredient.title) {
|
||||||
currentTitle = ing.ingredient.title;
|
currentTitle = ing.ingredient.title;
|
||||||
}
|
}
|
||||||
|
else if (ing.ingredient.referencedRecipe?.name) {
|
||||||
|
currentTitle = ing.ingredient.referencedRecipe.name;
|
||||||
|
}
|
||||||
|
|
||||||
// If this is the first item in the section, create a new section
|
// If this is the first item in the section, create a new section
|
||||||
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
|
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
|
||||||
@@ -316,7 +342,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store the on-hand ingredients for later
|
// Store the on-hand ingredients for later
|
||||||
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
|
const householdsWithFood = (ing.ingredient?.food?.householdsWithIngredientFood || []);
|
||||||
if (householdsWithFood.includes(userHousehold.value)) {
|
if (householdsWithFood.includes(userHousehold.value)) {
|
||||||
onHandIngs.push(ing);
|
onHandIngs.push(ing);
|
||||||
return sections;
|
return sections;
|
||||||
|
|||||||
@@ -141,6 +141,13 @@ function save() {
|
|||||||
dialog.value = false;
|
dialog.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
dialog.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const utilities = [
|
const utilities = [
|
||||||
@@ -160,4 +167,10 @@ const utilities = [
|
|||||||
action: splitByNumberedLine,
|
action: splitByNumberedLine,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Expose functions to parent components
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -69,7 +69,14 @@
|
|||||||
:label="$t('recipe.nutrition')"
|
:label="$t('recipe.nutrition')"
|
||||||
/>
|
/>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row no-gutters />
|
<v-row no-gutters>
|
||||||
|
<v-switch
|
||||||
|
v-model="preferences.expandChildRecipes"
|
||||||
|
hide-details
|
||||||
|
color="primary"
|
||||||
|
:label="$t('recipe.include-linked-recipe-ingredients')"
|
||||||
|
/>
|
||||||
|
</v-row>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-container>
|
</v-container>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
>
|
>
|
||||||
<template #activator="{ props: activatorProps }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="expirationDateString"
|
:model-value="$d(expirationDate)"
|
||||||
:label="$t('recipe-share.expiration-date')"
|
:label="$t('recipe-share.expiration-date')"
|
||||||
:hint="$t('recipe-share.default-30-days')"
|
:hint="$t('recipe-share.default-30-days')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
@@ -59,11 +59,8 @@
|
|||||||
|
|
||||||
<div class="pl-3 flex-grow-1">
|
<div class="pl-3 flex-grow-1">
|
||||||
<v-list-item-title>
|
<v-list-item-title>
|
||||||
{{ $t("recipe-share.expires-at") }}
|
{{ $t("recipe-share.expires-at") + ' ' + $d(new Date(token.expiresAt!), "short") }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<v-list-item-subtitle>
|
|
||||||
{{ $d(new Date(token.expiresAt!), "long") }}
|
|
||||||
</v-list-item-subtitle>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -111,10 +108,6 @@ const datePickerMenu = ref(false);
|
|||||||
const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
|
const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
|
||||||
const tokens = ref<RecipeShareToken[]>([]);
|
const tokens = ref<RecipeShareToken[]>([]);
|
||||||
|
|
||||||
const expirationDateString = computed(() => {
|
|
||||||
return expirationDate.value.toISOString().substring(0, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
whenever(
|
whenever(
|
||||||
() => dialog.value,
|
() => dialog.value,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
v-bind="props"
|
v-bind="props"
|
||||||
>
|
>
|
||||||
<v-icon :start="!$vuetify.display.xs">
|
<v-icon :start="!$vuetify.display.xs">
|
||||||
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
|
{{ state.orderDirection === "asc" ? $globals.icons.sortDescending : $globals.icons.sortAscending }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $vuetify.display.xs ? null : sortText }}
|
{{ $vuetify.display.xs ? null : sortText }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<v-list-item
|
<v-list-item
|
||||||
slim
|
slim
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
|
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortAscending : $globals.icons.sortDescending"
|
||||||
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
||||||
@click="toggleOrderDirection"
|
@click="toggleOrderDirection"
|
||||||
/>
|
/>
|
||||||
@@ -53,10 +53,23 @@
|
|||||||
:active="state.orderBy === v.value"
|
:active="state.orderBy === v.value"
|
||||||
slim
|
slim
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
:prepend-icon="v.icon"
|
@click="v.value === 'random' ? setRandomOrderByWrapper() : setOrderBy(v.value)"
|
||||||
:title="v.name"
|
>
|
||||||
@click="setOrderBy(v.value)"
|
<template #prepend>
|
||||||
/>
|
<v-icon>{{ v.icon }}</v-icon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #title>
|
||||||
|
<span>{{ v.name }}</span>
|
||||||
|
<v-icon
|
||||||
|
v-if="v.value === 'random' && showRandomLoading"
|
||||||
|
size="small"
|
||||||
|
class="ml-3"
|
||||||
|
>
|
||||||
|
{{ $globals.icons.refreshCircle }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
@@ -121,7 +134,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import RecipeExplorerPageSearchFilters from "./RecipeExplorerPageSearchFilters.vue";
|
import RecipeExplorerPageSearchFilters from "./RecipeExplorerPageSearchFilters.vue";
|
||||||
import { useRecipeExplorerSearch } from "~/composables/use-recipe-explorer-search";
|
import { useRecipeExplorerSearch, clearRecipeExplorerSearchState } from "~/composables/use-recipe-explorer-search";
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
ready: [];
|
ready: [];
|
||||||
@@ -131,6 +144,7 @@ const $auth = useMealieAuth();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { $globals } = useNuxtApp();
|
const { $globals } = useNuxtApp();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
const showRandomLoading = ref(false);
|
||||||
|
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
@@ -141,6 +155,7 @@ const {
|
|||||||
reset,
|
reset,
|
||||||
toggleOrderDirection,
|
toggleOrderDirection,
|
||||||
setOrderBy,
|
setOrderBy,
|
||||||
|
setRandomOrderBy,
|
||||||
filterItems,
|
filterItems,
|
||||||
initialize,
|
initialize,
|
||||||
} = useRecipeExplorerSearch(groupSlug);
|
} = useRecipeExplorerSearch(groupSlug);
|
||||||
@@ -155,6 +170,11 @@ onMounted(async () => {
|
|||||||
emit("ready");
|
emit("ready");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// Clear the cache when component unmounts to ensure fresh state on remount
|
||||||
|
clearRecipeExplorerSearchState(groupSlug.value);
|
||||||
|
});
|
||||||
|
|
||||||
const sortText = computed(() => {
|
const sortText = computed(() => {
|
||||||
const sort = sortable.value.find(s => s.value === state.value.orderBy);
|
const sort = sortable.value.find(s => s.value === state.value.orderBy);
|
||||||
if (!sort) return "";
|
if (!sort) return "";
|
||||||
@@ -200,6 +220,14 @@ const input: Ref<any> = ref(null);
|
|||||||
function hideKeyboard() {
|
function hideKeyboard() {
|
||||||
input.value?.blur();
|
input.value?.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// function to show refresh icon
|
||||||
|
async function setRandomOrderByWrapper() {
|
||||||
|
if (!showRandomLoading.value) {
|
||||||
|
showRandomLoading.value = true;
|
||||||
|
}
|
||||||
|
await setRandomOrderBy();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -20,18 +20,36 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<v-card width="400">
|
<v-card width="400">
|
||||||
<v-card-title class="headline flex mb-0">
|
<v-card-title class="headline flex-wrap mb-0">
|
||||||
<div>
|
<div>
|
||||||
{{ $t("recipe.recipe-image") }}
|
{{ $t("recipe.recipe-image") }}
|
||||||
</div>
|
</div>
|
||||||
<AppButtonUpload
|
<div class="d-flex gap-2">
|
||||||
class="ml-auto"
|
<AppButtonUpload
|
||||||
url="none"
|
url="none"
|
||||||
file-name="image"
|
file-name="image"
|
||||||
:text-btn="false"
|
:text-btn="false"
|
||||||
:post="false"
|
:post="false"
|
||||||
@uploaded="uploadImage"
|
@uploaded="uploadImage"
|
||||||
/>
|
/>
|
||||||
|
<BaseButton
|
||||||
|
class="ml-2"
|
||||||
|
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-title>
|
||||||
<v-card-text class="mt-n5">
|
<v-card-text class="mt-n5">
|
||||||
<div>
|
<div>
|
||||||
@@ -62,38 +80,58 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { alert } from "~/composables/use-toast";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
|
||||||
const REFRESH_EVENT = "refresh";
|
|
||||||
const UPLOAD_EVENT = "upload";
|
const UPLOAD_EVENT = "upload";
|
||||||
|
const DELETE_EVENT = "delete";
|
||||||
|
|
||||||
const props = defineProps<{ slug: string }>();
|
const props = defineProps<{ slug: string }>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
refresh: [];
|
refresh: [];
|
||||||
upload: [fileObject: File];
|
upload: [fileObject: File];
|
||||||
|
delete: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const api = useUserApi();
|
||||||
|
|
||||||
const url = ref("");
|
const url = ref("");
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const menu = ref(false);
|
const menu = ref(false);
|
||||||
|
const dialogDeleteImage = ref(false);
|
||||||
|
|
||||||
function uploadImage(fileObject: File) {
|
function uploadImage(fileObject: File) {
|
||||||
emit(UPLOAD_EVENT, fileObject);
|
emit(UPLOAD_EVENT, fileObject);
|
||||||
menu.value = false;
|
menu.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = useUserApi();
|
async function deleteImage() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await api.recipes.deleteImage(props.slug);
|
||||||
|
emit(DELETE_EVENT);
|
||||||
|
menu.value = false;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
console.error("Failed to delete image", e);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getImageFromURL() {
|
async function getImageFromURL() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
|
if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
|
||||||
emit(REFRESH_EVENT);
|
emit(DELETE_EVENT);
|
||||||
}
|
}
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
menu.value = false;
|
menu.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const i18n = useI18n();
|
|
||||||
const messages = computed(() =>
|
const messages = computed(() =>
|
||||||
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
|
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
:placeholder="$t('recipe.quantity')"
|
:placeholder="$t('recipe.quantity')"
|
||||||
@keypress="quantityFilter"
|
@keypress="quantityFilter"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template v-if="enableDragHandle" #prepend>
|
||||||
<v-icon
|
<v-icon
|
||||||
class="mr-n1 handle"
|
class="mr-n1 handle"
|
||||||
>
|
>
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
</v-text-field>
|
</v-text-field>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col
|
<v-col
|
||||||
|
v-if="!state.isRecipe"
|
||||||
sm="12"
|
sm="12"
|
||||||
md="3"
|
md="3"
|
||||||
cols="12"
|
cols="12"
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
class="mx-1"
|
class="mx-1"
|
||||||
:placeholder="$t('recipe.choose-unit')"
|
:placeholder="$t('recipe.choose-unit')"
|
||||||
clearable
|
clearable
|
||||||
|
:menu-props="{ attach: props.menuAttachTarget, maxHeight: '250px' }"
|
||||||
@keyup.enter="handleUnitEnter"
|
@keyup.enter="handleUnitEnter"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
@@ -96,6 +98,7 @@
|
|||||||
|
|
||||||
<!-- Foods Input -->
|
<!-- Foods Input -->
|
||||||
<v-col
|
<v-col
|
||||||
|
v-if="!state.isRecipe"
|
||||||
m="12"
|
m="12"
|
||||||
md="3"
|
md="3"
|
||||||
cols="12"
|
cols="12"
|
||||||
@@ -115,6 +118,7 @@
|
|||||||
class="mx-1 py-0"
|
class="mx-1 py-0"
|
||||||
:placeholder="$t('recipe.choose-food')"
|
:placeholder="$t('recipe.choose-food')"
|
||||||
clearable
|
clearable
|
||||||
|
:menu-props="{ attach: props.menuAttachTarget, maxHeight: '250px' }"
|
||||||
@keyup.enter="handleFoodEnter"
|
@keyup.enter="handleFoodEnter"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
@@ -149,6 +153,35 @@
|
|||||||
</template>
|
</template>
|
||||||
</v-autocomplete>
|
</v-autocomplete>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<!-- Recipe Input -->
|
||||||
|
<v-col
|
||||||
|
v-if="state.isRecipe"
|
||||||
|
m="12"
|
||||||
|
md="6"
|
||||||
|
cols="12"
|
||||||
|
class=""
|
||||||
|
>
|
||||||
|
<v-autocomplete
|
||||||
|
ref="search.query"
|
||||||
|
v-model="model.referencedRecipe"
|
||||||
|
v-model:search="search.query.value"
|
||||||
|
auto-select-first
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
variant="solo"
|
||||||
|
return-object
|
||||||
|
:items="search.data.value || []"
|
||||||
|
item-title="name"
|
||||||
|
class="mx-1 py-0"
|
||||||
|
:placeholder="$t('search.type-to-search')"
|
||||||
|
clearable
|
||||||
|
:label="!model.referencedRecipe ? $t('recipe.choose-recipe') : ''"
|
||||||
|
@click="search.trigger()"
|
||||||
|
@focus="search.trigger()"
|
||||||
|
>
|
||||||
|
<template #prepend />
|
||||||
|
</v-autocomplete>
|
||||||
|
</v-col>
|
||||||
<v-col
|
<v-col
|
||||||
sm="12"
|
sm="12"
|
||||||
md=""
|
md=""
|
||||||
@@ -165,12 +198,13 @@
|
|||||||
@click="$emit('clickIngredientField', 'note')"
|
@click="$emit('clickIngredientField', 'note')"
|
||||||
/>
|
/>
|
||||||
<BaseButtonGroup
|
<BaseButtonGroup
|
||||||
|
v-if="enableContextMenu"
|
||||||
hover
|
hover
|
||||||
:large="false"
|
:large="false"
|
||||||
class="my-auto d-flex"
|
class="my-auto d-flex"
|
||||||
:buttons="btns"
|
:buttons="btns"
|
||||||
@toggle-section="toggleTitle"
|
@toggle-section="toggleTitle"
|
||||||
@toggle-original="toggleOriginalText"
|
@toggle-subrecipe="toggleIsRecipe"
|
||||||
@insert-above="$emit('insert-above')"
|
@insert-above="$emit('insert-above')"
|
||||||
@insert-below="$emit('insert-below')"
|
@insert-below="$emit('insert-below')"
|
||||||
@delete="$emit('delete')"
|
@delete="$emit('delete')"
|
||||||
@@ -178,13 +212,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<p
|
<slot name="before-divider" />
|
||||||
v-if="showOriginalText"
|
|
||||||
class="text-caption"
|
|
||||||
>
|
|
||||||
{{ $t("recipe.original-text-with-value", { originalText: model.originalText }) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<v-divider
|
<v-divider
|
||||||
v-if="!mdAndUp"
|
v-if="!mdAndUp"
|
||||||
class="my-4"
|
class="my-4"
|
||||||
@@ -199,11 +227,21 @@ import { useI18n } from "vue-i18n";
|
|||||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||||
import { useNuxtApp } from "#app";
|
import { useNuxtApp } from "#app";
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
||||||
|
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
|
||||||
|
|
||||||
// defineModel replaces modelValue prop
|
// defineModel replaces modelValue prop
|
||||||
const model = defineModel<RecipeIngredient>({ required: true });
|
const model = defineModel<RecipeIngredient>({ required: true });
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
|
menuAttachTarget: {
|
||||||
|
type: String,
|
||||||
|
default: "body",
|
||||||
|
},
|
||||||
|
isRecipe: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
unitError: {
|
unitError: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -220,6 +258,18 @@ defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
|
enableContextMenu: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
enableDragHandle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
deleteDisabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits([
|
defineEmits([
|
||||||
@@ -235,7 +285,7 @@ const { $globals } = useNuxtApp();
|
|||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
showTitle: false,
|
showTitle: false,
|
||||||
showOriginalText: false,
|
isRecipe: props.isRecipe,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contextMenuOptions = computed(() => {
|
const contextMenuOptions = computed(() => {
|
||||||
@@ -244,6 +294,10 @@ const contextMenuOptions = computed(() => {
|
|||||||
text: i18n.t("recipe.toggle-section"),
|
text: i18n.t("recipe.toggle-section"),
|
||||||
event: "toggle-section",
|
event: "toggle-section",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: i18n.t("recipe.toggle-recipe"),
|
||||||
|
event: "toggle-subrecipe",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: i18n.t("recipe.insert-above"),
|
text: i18n.t("recipe.insert-above"),
|
||||||
event: "insert-above",
|
event: "insert-above",
|
||||||
@@ -254,13 +308,6 @@ const contextMenuOptions = computed(() => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (model.value.originalText) {
|
|
||||||
options.push({
|
|
||||||
text: i18n.t("recipe.see-original-text"),
|
|
||||||
event: "toggle-original",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -281,8 +328,8 @@ const btns = computed(() => {
|
|||||||
text: i18n.t("general.delete"),
|
text: i18n.t("general.delete"),
|
||||||
event: "delete",
|
event: "delete",
|
||||||
children: undefined,
|
children: undefined,
|
||||||
|
disabled: props.deleteDisabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -299,6 +346,25 @@ async function createAssignFood() {
|
|||||||
foodAutocomplete.value?.blur();
|
foodAutocomplete.value?.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recipes
|
||||||
|
const route = useRoute();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||||
|
const search = useRecipeSearch(api);
|
||||||
|
const loading = ref(false);
|
||||||
|
const selectedIndex = ref(-1);
|
||||||
|
// Reset or Grab Recipes on Change
|
||||||
|
watch(loading, (val) => {
|
||||||
|
if (!val) {
|
||||||
|
search.query.value = "";
|
||||||
|
selectedIndex.value = -1;
|
||||||
|
search.data.value = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Units
|
// Units
|
||||||
const unitStore = useUnitStore();
|
const unitStore = useUnitStore();
|
||||||
const unitsData = useUnitData();
|
const unitsData = useUnitData();
|
||||||
@@ -319,8 +385,15 @@ function toggleTitle() {
|
|||||||
state.showTitle = !state.showTitle;
|
state.showTitle = !state.showTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOriginalText() {
|
function toggleIsRecipe() {
|
||||||
state.showOriginalText = !state.showOriginalText;
|
if (state.isRecipe) {
|
||||||
|
model.value.referencedRecipe = undefined;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
model.value.unit = undefined;
|
||||||
|
model.value.food = undefined;
|
||||||
|
}
|
||||||
|
state.isRecipe = !state.isRecipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnitEnter() {
|
function handleUnitEnter() {
|
||||||
@@ -349,7 +422,7 @@ function quantityFilter(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { showTitle, showOriginalText } = toRefs(state);
|
const { showTitle } = toRefs(state);
|
||||||
|
|
||||||
const foods = foodStore.store;
|
const foods = foodStore.store;
|
||||||
const units = unitStore.store;
|
const units = unitStore.store;
|
||||||
|
|||||||
@@ -1,15 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<div class="ingredient-link-label links-disabled">
|
||||||
<div v-html="safeMarkup" />
|
<SafeMarkdown v-if="baseText" :source="baseText" />
|
||||||
|
<SafeMarkdown
|
||||||
|
v-if="ingredient?.note"
|
||||||
|
class="d-inline"
|
||||||
|
:source="` ${ingredient.note}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
|
import { computed } from "vue";
|
||||||
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
import { useParsedIngredientText } from "~/composables/recipes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
markup: string;
|
ingredient?: RecipeIngredient;
|
||||||
|
scale?: number;
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>();
|
|
||||||
|
|
||||||
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
|
const { ingredient, scale = 1 } = defineProps<Props>();
|
||||||
|
|
||||||
|
const baseText = computed(() => {
|
||||||
|
if (!ingredient) return "";
|
||||||
|
const parsed = useParsedIngredientText(ingredient, scale);
|
||||||
|
return [parsed.quantity, parsed.unit, parsed.name].filter(Boolean).join(" ").trim();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ingredient-link-label {
|
||||||
|
display: block;
|
||||||
|
line-height: 1.25;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.links-disabled :deep(a) {
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: default;
|
||||||
|
color: var(--v-theme-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
class="text-bold d-inline"
|
class="text-bold d-inline"
|
||||||
:source="parsedIng.note"
|
:source="parsedIng.note"
|
||||||
/>
|
/>
|
||||||
|
<template v-else-if="parsedIng.recipeLink">
|
||||||
|
<SafeMarkdown v-if="parsedIng.recipeLink" class="text-bold d-inline" :source="parsedIng.recipeLink" />
|
||||||
|
<SafeMarkdown v-if="parsedIng.note" class="note" :source="parsedIng.note" />
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SafeMarkdown
|
<SafeMarkdown
|
||||||
v-if="parsedIng.name"
|
v-if="parsedIng.name"
|
||||||
@@ -39,9 +43,12 @@ interface Props {
|
|||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
scale: 1,
|
scale: 1,
|
||||||
});
|
});
|
||||||
|
const route = useRoute();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||||
|
|
||||||
const parsedIng = computed(() => {
|
const parsedIng = computed(() => {
|
||||||
return useParsedIngredientText(props.ingredient, props.scale);
|
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
:title="$t('recipe.made-this')"
|
:title="$t('recipe.made-this')"
|
||||||
:submit-text="$t('recipe.add-to-timeline')"
|
:submit-text="$t('recipe.add-to-timeline')"
|
||||||
can-submit
|
can-submit
|
||||||
|
disable-submit-on-enter
|
||||||
@submit="createTimelineEvent"
|
@submit="createTimelineEvent"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
@@ -20,6 +21,29 @@
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
rows="4"
|
rows="4"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="childRecipes?.length">
|
||||||
|
<v-card-text class="pt-6 pb-0">
|
||||||
|
{{ $t('recipe.include-linked-recipes') }}
|
||||||
|
</v-card-text>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item
|
||||||
|
v-for="(childRecipe, i) in childRecipes"
|
||||||
|
:key="childRecipe.recipeId + i"
|
||||||
|
density="compact"
|
||||||
|
class="my-0 py-0"
|
||||||
|
@click="childRecipe.checked = !childRecipe.checked"
|
||||||
|
>
|
||||||
|
<v-checkbox
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
:input-value="childRecipe.checked"
|
||||||
|
:label="childRecipe.name"
|
||||||
|
class="my-0 py-0"
|
||||||
|
color="secondary"
|
||||||
|
/>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</div>
|
||||||
<v-container>
|
<v-container>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="6">
|
<v-col cols="6">
|
||||||
@@ -32,7 +56,7 @@
|
|||||||
>
|
>
|
||||||
<template #activator="{ props: activatorProps }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="newTimelineEventTimestampString"
|
:model-value="$d(newTimelineEventTimestamp)"
|
||||||
:prepend-icon="$globals.icons.calendar"
|
:prepend-icon="$globals.icons.calendar"
|
||||||
v-bind="activatorProps"
|
v-bind="activatorProps"
|
||||||
readonly
|
readonly
|
||||||
@@ -102,7 +126,7 @@
|
|||||||
<span class="text-body-1 opacity-80">
|
<span class="text-body-1 opacity-80">
|
||||||
<b>{{ $t("general.last-made") }}</b>
|
<b>{{ $t("general.last-made") }}</b>
|
||||||
<br>
|
<br>
|
||||||
{{ lastMade ? new Date(lastMade).toLocaleDateString($i18n.locale) : $t("general.never") }}
|
{{ lastMade ? $d(new Date(lastMade)) : $t("general.never") }}
|
||||||
</span>
|
</span>
|
||||||
<v-icon end size="large" color="primary">
|
<v-icon end size="large" color="primary">
|
||||||
{{ $globals.icons.createAlt }}
|
{{ $globals.icons.createAlt }}
|
||||||
@@ -166,6 +190,21 @@ onMounted(async () => {
|
|||||||
lastMadeReady.value = true;
|
lastMadeReady.value = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const childRecipes = computed(() => {
|
||||||
|
return props.recipe.recipeIngredient?.map((ingredient) => {
|
||||||
|
if (ingredient.referencedRecipe) {
|
||||||
|
return {
|
||||||
|
checked: false, // Default value for checked
|
||||||
|
recipeId: ingredient.referencedRecipe.id || "", // Non-nullable recipeId
|
||||||
|
...ingredient.referencedRecipe, // Spread the rest of the referencedRecipe properties
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}).filter(recipe => recipe !== undefined); // Filter out undefined values
|
||||||
|
});
|
||||||
|
|
||||||
whenever(
|
whenever(
|
||||||
() => madeThisDialog.value,
|
() => madeThisDialog.value,
|
||||||
() => {
|
() => {
|
||||||
@@ -250,6 +289,37 @@ async function createTimelineEvent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const childRecipe of childRecipes.value || []) {
|
||||||
|
if (!childRecipe.checked) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childTimelineEvent = {
|
||||||
|
...newTimelineEvent.value,
|
||||||
|
recipeId: childRecipe.recipeId,
|
||||||
|
eventMessage: i18n.t("recipe.made-for-recipe", { recipe: childRecipe.name }),
|
||||||
|
image: undefined,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await userApi.recipes.createTimelineEvent(childTimelineEvent);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(`Failed to create timeline event for child recipe ${childRecipe.slug}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
newTimelineEvent.value.timestamp
|
||||||
|
&& (!childRecipe.lastMade || newTimelineEvent.value.timestamp > childRecipe.lastMade)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await userApi.recipes.updateLastMade(childRecipe.slug || "", newTimelineEvent.value.timestamp);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(`Failed to update last made date for child recipe ${childRecipe.slug}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// update the image, if provided
|
// update the image, if provided
|
||||||
let imageError = false;
|
let imageError = false;
|
||||||
if (newTimelineEventImage.value) {
|
if (newTimelineEventImage.value) {
|
||||||
@@ -268,7 +338,6 @@ async function createTimelineEvent() {
|
|||||||
console.error("Failed to upload image for timeline event:", error);
|
console.error("Failed to upload image for timeline event:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imageError) {
|
if (imageError) {
|
||||||
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
|
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,10 +105,9 @@
|
|||||||
<v-icon>
|
<v-icon>
|
||||||
{{ icon }}
|
{{ icon }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-card-title class="py-1">
|
<v-card-title class="py-1 text-truncate flex-shrink-1 flex-grow-1">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-spacer />
|
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
:items="[presets.delete, presets.edit]"
|
:items="[presets.delete, presets.edit]"
|
||||||
@delete="confirmDelete(item)"
|
@delete="confirmDelete(item)"
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<RecipePageParseDialog
|
||||||
|
:model-value="isParsing"
|
||||||
|
:ingredients="recipe.recipeIngredient"
|
||||||
|
:width="$vuetify.display.smAndDown ? '100%' : '80%'"
|
||||||
|
@update:model-value="toggleIsParsing"
|
||||||
|
@save="saveParsedIngredients"
|
||||||
|
/>
|
||||||
<v-container v-show="!isCookMode" key="recipe-page" class="px-0" :class="{ 'pa-0': $vuetify.display.smAndDown }">
|
<v-container v-show="!isCookMode" key="recipe-page" class="px-0" :class="{ 'pa-0': $vuetify.display.smAndDown }">
|
||||||
<v-card :flat="$vuetify.display.smAndDown" class="d-print-none">
|
<v-card :flat="$vuetify.display.smAndDown" class="d-print-none">
|
||||||
<RecipePageHeader
|
<RecipePageHeader
|
||||||
@@ -88,9 +95,12 @@
|
|||||||
<RecipePrintContainer :recipe="recipe" :scale="scale" />
|
<RecipePrintContainer :recipe="recipe" :scale="scale" />
|
||||||
</v-container>
|
</v-container>
|
||||||
<!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same time -->
|
<!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same time -->
|
||||||
|
<!-- The calc is to account for the navabar height (48px) -->
|
||||||
<v-sheet
|
<v-sheet
|
||||||
v-show="isCookMode && !hasLinkedIngredients"
|
v-show="isCookMode && !hasLinkedIngredients"
|
||||||
key="cookmode"
|
key="cookmode"
|
||||||
|
:height="$vuetify.display.smAndUp ? 'calc(100vh - 48px)' : 'auto'"
|
||||||
|
class-name="overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
|
<!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
|
||||||
<v-row style="height: 100%" no-gutters class="overflow-hidden">
|
<v-row style="height: 100%" no-gutters class="overflow-hidden">
|
||||||
@@ -106,9 +116,13 @@
|
|||||||
/>
|
/>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col class="overflow-y-auto"
|
<v-col
|
||||||
:class="$vuetify.display.smAndDown ? 'py-2': 'py-6'"
|
class="overflow-y-auto"
|
||||||
style="height: 100%" cols="12" sm="7">
|
:class="$vuetify.display.smAndDown ? 'py-2': 'py-6'"
|
||||||
|
style="height: 100%"
|
||||||
|
cols="12"
|
||||||
|
sm="7"
|
||||||
|
>
|
||||||
<h2 class="text-h5 px-4 font-weight-medium opacity-80">
|
<h2 class="text-h5 px-4 font-weight-medium opacity-80">
|
||||||
{{ $t('recipe.instructions') }}
|
{{ $t('recipe.instructions') }}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -168,6 +182,7 @@ import RecipePageIngredientEditor from "./RecipePageParts/RecipePageIngredientEd
|
|||||||
import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredientToolsView.vue";
|
import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredientToolsView.vue";
|
||||||
import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue";
|
import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue";
|
||||||
import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
|
import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
|
||||||
|
import RecipePageParseDialog from "./RecipePageParts/RecipePageParseDialog.vue";
|
||||||
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
|
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
|
||||||
import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue";
|
import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue";
|
||||||
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
||||||
@@ -178,12 +193,13 @@ import {
|
|||||||
usePageState,
|
usePageState,
|
||||||
} from "~/composables/recipe-page/shared-state";
|
} from "~/composables/recipe-page/shared-state";
|
||||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import type { Recipe, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
import type { Recipe, RecipeCategory, RecipeIngredient, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||||
import { useRouteQuery } from "~/composables/use-router";
|
import { useRouteQuery } from "~/composables/use-router";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { uuid4, deepCopy } from "~/composables/use-utils";
|
import { uuid4, deepCopy } from "~/composables/use-utils";
|
||||||
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
||||||
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
|
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
|
||||||
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
||||||
|
|
||||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||||
@@ -192,12 +208,13 @@ const display = useDisplay();
|
|||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const $auth = useMealieAuth();
|
const $auth = useMealieAuth();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
|
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode }
|
const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, isParsing, toggleCookMode, toggleIsParsing }
|
||||||
= usePageState(recipe.value.slug);
|
= usePageState(recipe.value.slug);
|
||||||
const { deactivateNavigationWarning } = useNavigationWarning();
|
const { deactivateNavigationWarning } = useNavigationWarning();
|
||||||
const notLinkedIngredients = computed(() => {
|
const notLinkedIngredients = computed(() => {
|
||||||
@@ -246,12 +263,29 @@ const hasLinkedIngredients = computed(() => {
|
|||||||
|
|
||||||
type BooleanString = "true" | "false" | "";
|
type BooleanString = "true" | "false" | "";
|
||||||
|
|
||||||
const edit = useRouteQuery<BooleanString>("edit", "");
|
const paramsEdit = useRouteQuery<BooleanString>("edit", "");
|
||||||
|
const paramsParse = useRouteQuery<BooleanString>("parse", "");
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (edit.value === "true") {
|
if (paramsEdit.value === "true" && isOwnGroup.value) {
|
||||||
setMode(PageMode.EDIT);
|
setMode(PageMode.EDIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (paramsParse.value === "true" && isOwnGroup.value) {
|
||||||
|
toggleIsParsing(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(isEditMode, (newVal) => {
|
||||||
|
if (!newVal) {
|
||||||
|
paramsEdit.value = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(isParsing, () => {
|
||||||
|
if (!isParsing.value) {
|
||||||
|
paramsParse.value = undefined;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** =============================================================
|
/** =============================================================
|
||||||
@@ -259,13 +293,22 @@ onMounted(() => {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
async function saveRecipe() {
|
async function saveRecipe() {
|
||||||
const { data } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
const { data, error } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||||
setMode(PageMode.VIEW);
|
if (!error) {
|
||||||
|
setMode(PageMode.VIEW);
|
||||||
|
}
|
||||||
if (data?.slug) {
|
if (data?.slug) {
|
||||||
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
||||||
|
recipe.value = data as NoUndefinedField<Recipe>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveParsedIngredients(ingredients: NoUndefinedField<RecipeIngredient[]>) {
|
||||||
|
recipe.value.recipeIngredient = ingredients;
|
||||||
|
await saveRecipe();
|
||||||
|
toggleIsParsing(false);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteRecipe() {
|
async function deleteRecipe() {
|
||||||
const { data } = await api.recipes.deleteOne(recipe.value.slug);
|
const { data } = await api.recipes.deleteOne(recipe.value.slug);
|
||||||
if (data?.slug) {
|
if (data?.slug) {
|
||||||
@@ -302,7 +345,7 @@ function addStep(steps: Array<string> | null = null) {
|
|||||||
|
|
||||||
if (steps) {
|
if (steps) {
|
||||||
const cleanedSteps = steps.map((step) => {
|
const cleanedSteps = steps.map((step) => {
|
||||||
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
|
return { id: uuid4(), text: step, title: "", summary: "", ingredientReferences: [] };
|
||||||
});
|
});
|
||||||
|
|
||||||
recipe.value.recipeInstructions.push(...cleanedSteps);
|
recipe.value.recipeInstructions.push(...cleanedSteps);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
:slug="recipe.slug"
|
:slug="recipe.slug"
|
||||||
@upload="uploadImage"
|
@upload="uploadImage"
|
||||||
@refresh="imageKey++"
|
@refresh="imageKey++"
|
||||||
|
@delete="deleteImage"
|
||||||
/>
|
/>
|
||||||
<RecipeSettingsMenu
|
<RecipeSettingsMenu
|
||||||
v-model="recipe.settings"
|
v-model="recipe.settings"
|
||||||
@@ -13,25 +14,25 @@
|
|||||||
@upload="uploadImage"
|
@upload="uploadImage"
|
||||||
/>
|
/>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-select
|
<v-select
|
||||||
v-model="recipe.userId"
|
v-model="recipe.userId"
|
||||||
class="my-2"
|
class="my-2"
|
||||||
max-width="300"
|
max-width="300"
|
||||||
:items="allUsers"
|
:items="allUsers"
|
||||||
:item-props="itemsProps"
|
:item-props="itemsProps"
|
||||||
:label="$t('general.owner')"
|
:label="$t('general.owner')"
|
||||||
:disabled="!canEditOwner"
|
:disabled="!canEditOwner"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="compact"
|
density="compact"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
:user-id="recipe.userId"
|
:user-id="recipe.userId"
|
||||||
:tooltip="false"
|
:tooltip="false"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -78,4 +79,10 @@ async function uploadImage(fileObject: File) {
|
|||||||
}
|
}
|
||||||
imageKey.value++;
|
imageKey.value++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteImage() {
|
||||||
|
// The image is already deleted on the backend, just need to update the UI
|
||||||
|
recipe.value.image = "";
|
||||||
|
imageKey.value++;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -29,8 +29,8 @@
|
|||||||
class="mb-2 mx-n2"
|
class="mb-2 mx-n2"
|
||||||
>
|
>
|
||||||
<v-card-title class="text-h5 font-weight-medium opacity-80">
|
<v-card-title class="text-h5 font-weight-medium opacity-80">
|
||||||
{{ $t('recipe.api-extras') }}
|
{{ $t('recipe.api-extras') }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider class="ml-4" />
|
<v-divider class="ml-4" />
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
{{ $t('recipe.api-extras-description') }}
|
{{ $t('recipe.api-extras-description') }}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
>
|
>
|
||||||
<v-card-text class="w-100">
|
<v-card-text class="w-100">
|
||||||
<div class="d-flex flex-column align-center">
|
<div class="d-flex flex-column align-center">
|
||||||
<v-card-title class="text-h5 font-weight-regular pa-0 text-wrap text-center opacity-80">
|
<v-card-title class="text-h5 font-weight-regular pa-0 text-wrap text-center opacity-80">
|
||||||
{{ recipe.name }}
|
{{ recipe.name }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<RecipeRating
|
<RecipeRating
|
||||||
:key="recipe.slug"
|
:key="recipe.slug"
|
||||||
:value="recipe.rating"
|
:value="recipe.rating"
|
||||||
:recipe-id="recipe.id"
|
:recipe-id="recipe.id"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- eslint-disable vue/no-mutating-props -->
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@@ -31,6 +30,9 @@
|
|||||||
v-for="(ingredient, index) in recipe.recipeIngredient"
|
v-for="(ingredient, index) in recipe.recipeIngredient"
|
||||||
:key="ingredient.referenceId"
|
:key="ingredient.referenceId"
|
||||||
v-model="recipe.recipeIngredient[index]"
|
v-model="recipe.recipeIngredient[index]"
|
||||||
|
:is-recipe="ingredientIsRecipe(ingredient)"
|
||||||
|
enable-drag-handle
|
||||||
|
enable-context-menu
|
||||||
class="list-group-item"
|
class="list-group-item"
|
||||||
@delete="recipe.recipeIngredient.splice(index, 1)"
|
@delete="recipe.recipeIngredient.splice(index, 1)"
|
||||||
@insert-above="insertNewIngredient(index)"
|
@insert-above="insertNewIngredient(index)"
|
||||||
@@ -55,8 +57,8 @@
|
|||||||
class="mb-1"
|
class="mb-1"
|
||||||
:disabled="hasFoodOrUnit"
|
:disabled="hasFoodOrUnit"
|
||||||
color="accent"
|
color="accent"
|
||||||
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
|
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
|
@click="toggleIsParsing(true)"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
{{ $globals.icons.foods }}
|
{{ $globals.icons.foods }}
|
||||||
@@ -68,15 +70,59 @@
|
|||||||
<span>{{ parserToolTip }}</span>
|
<span>{{ parserToolTip }}</span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<RecipeDialogBulkAdd
|
<RecipeDialogBulkAdd
|
||||||
|
ref="domBulkAddDialog"
|
||||||
class="mx-1 mb-1"
|
class="mx-1 mb-1"
|
||||||
|
style="display: none"
|
||||||
@bulk-data="addIngredient"
|
@bulk-data="addIngredient"
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<div class="d-inline-flex">
|
||||||
class="mb-1"
|
<!-- Main button: Add Food -->
|
||||||
@click="addIngredient"
|
<v-btn
|
||||||
>
|
color="success"
|
||||||
{{ $t("general.add") }}
|
class="split-main ml-2"
|
||||||
</BaseButton>
|
@click="addIngredient"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
|
{{ $globals.icons.createAlt }}
|
||||||
|
</v-icon>
|
||||||
|
{{ $t('general.add') || 'Add Food' }}
|
||||||
|
</v-btn>
|
||||||
|
<!-- Dropdown button -->
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
color="success"
|
||||||
|
class="split-dropdown"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon>{{ $globals.icons.chevronDown }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item
|
||||||
|
slim
|
||||||
|
density="comfortable"
|
||||||
|
:prepend-icon="$globals.icons.foods"
|
||||||
|
:title="$t('new-recipe.add-food')"
|
||||||
|
@click="addIngredient"
|
||||||
|
/>
|
||||||
|
<v-list-item
|
||||||
|
slim
|
||||||
|
density="comfortable"
|
||||||
|
:prepend-icon="$globals.icons.silverwareForkKnife"
|
||||||
|
:title="$t('new-recipe.add-recipe')"
|
||||||
|
@click="addRecipe"
|
||||||
|
/>
|
||||||
|
<v-list-item
|
||||||
|
slim
|
||||||
|
density="comfortable"
|
||||||
|
:prepend-icon="$globals.icons.create"
|
||||||
|
:title="$t('new-recipe.bulk-add')"
|
||||||
|
@click="showBulkAdd"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -84,19 +130,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { VueDraggable } from "vue-draggable-plus";
|
import { VueDraggable } from "vue-draggable-plus";
|
||||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import type { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
|
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
|
||||||
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
||||||
|
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||||
import { uuid4 } from "~/composables/use-utils";
|
import { uuid4 } from "~/composables/use-utils";
|
||||||
|
|
||||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||||
|
const ingredientsWithRecipe = new Map<string, boolean>();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const $auth = useMealieAuth();
|
|
||||||
|
|
||||||
const drag = ref(false);
|
const drag = ref(false);
|
||||||
|
const domBulkAddDialog = ref<InstanceType<typeof RecipeDialogBulkAdd> | null>(null);
|
||||||
const route = useRoute();
|
const { toggleIsParsing } = usePageState(recipe.value.slug);
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
|
||||||
|
|
||||||
const hasFoodOrUnit = computed(() => {
|
const hasFoodOrUnit = computed(() => {
|
||||||
if (!recipe.value) {
|
if (!recipe.value) {
|
||||||
@@ -119,6 +165,22 @@ const parserToolTip = computed(() => {
|
|||||||
return i18n.t("recipe.parse-ingredients");
|
return i18n.t("recipe.parse-ingredients");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function showBulkAdd() {
|
||||||
|
domBulkAddDialog.value?.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingredientIsRecipe(ingredient: RecipeIngredient): boolean {
|
||||||
|
if (ingredient.referencedRecipe) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ingredient.referenceId) {
|
||||||
|
return !!ingredientsWithRecipe.get(ingredient.referenceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function addIngredient(ingredients: Array<string> | null = null) {
|
function addIngredient(ingredients: Array<string> | null = null) {
|
||||||
if (ingredients?.length) {
|
if (ingredients?.length) {
|
||||||
const newIngredients = ingredients.map((x) => {
|
const newIngredients = ingredients.map((x) => {
|
||||||
@@ -128,7 +190,7 @@ function addIngredient(ingredients: Array<string> | null = null) {
|
|||||||
note: x,
|
note: x,
|
||||||
unit: undefined,
|
unit: undefined,
|
||||||
food: undefined,
|
food: undefined,
|
||||||
quantity: 1,
|
quantity: 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,6 +208,41 @@ function addIngredient(ingredients: Array<string> | null = null) {
|
|||||||
unit: undefined,
|
unit: undefined,
|
||||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
food: undefined,
|
food: undefined,
|
||||||
|
quantity: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRecipe(recipes: Array<string> | null = null) {
|
||||||
|
const refId = uuid4();
|
||||||
|
ingredientsWithRecipe.set(refId, true);
|
||||||
|
|
||||||
|
if (recipes?.length) {
|
||||||
|
const newRecipes = recipes.map((x) => {
|
||||||
|
return {
|
||||||
|
referenceId: refId,
|
||||||
|
title: "",
|
||||||
|
note: x,
|
||||||
|
unit: undefined,
|
||||||
|
referencedRecipe: undefined,
|
||||||
|
quantity: 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newRecipes) {
|
||||||
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
|
recipe.value.recipeIngredient.push(...newRecipes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
recipe.value.recipeIngredient.push({
|
||||||
|
referenceId: refId,
|
||||||
|
title: "",
|
||||||
|
note: "",
|
||||||
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
|
unit: undefined,
|
||||||
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
|
referencedRecipe: undefined,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -160,7 +257,21 @@ function insertNewIngredient(dest: number) {
|
|||||||
unit: undefined,
|
unit: undefined,
|
||||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
food: undefined,
|
food: undefined,
|
||||||
quantity: 1,
|
quantity: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.split-main {
|
||||||
|
border-top-right-radius: 0 !important;
|
||||||
|
border-bottom-right-radius: 0 !important;
|
||||||
|
}
|
||||||
|
.split-dropdown {
|
||||||
|
border-top-left-radius: 0 !important;
|
||||||
|
border-bottom-left-radius: 0 !important;
|
||||||
|
min-width: 30px;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -34,21 +34,21 @@
|
|||||||
{{ $t("recipe.unlinked") }}
|
{{ $t("recipe.unlinked") }}
|
||||||
</h4>
|
</h4>
|
||||||
<template v-for="(ingredients, title) in groupedUnusedIngredients" :key="title">
|
<template v-for="(ingredients, title) in groupedUnusedIngredients" :key="title">
|
||||||
<h4 v-if="title" class="py-3 ml-1 pl-4">
|
<h4 v-if="title" class="py-3 ml-1 pl-4">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h4>
|
</h4>
|
||||||
<v-checkbox-btn
|
<v-checkbox-btn
|
||||||
v-for="ing in ingredients"
|
v-for="ing in ingredients"
|
||||||
:key="ing.referenceId"
|
:key="ing.referenceId"
|
||||||
v-model="activeRefs"
|
v-model="activeRefs"
|
||||||
:value="ing.referenceId"
|
:value="ing.referenceId"
|
||||||
class="ml-4"
|
class="ml-4"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
<RecipeIngredientHtml :markup="parseIngredientText(ing)" />
|
<RecipeIngredientHtml :ingredient="ing" :scale="scale" />
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox-btn>
|
</v-checkbox-btn>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="Object.keys(groupedUsedIngredients).length > 0">
|
<template v-if="Object.keys(groupedUsedIngredients).length > 0">
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
class="ml-4"
|
class="ml-4"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
<RecipeIngredientHtml :markup="parseIngredientText(ing)" />
|
<RecipeIngredientHtml :ingredient="ing" :scale="scale" />
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox-btn>
|
</v-checkbox-btn>
|
||||||
</template>
|
</template>
|
||||||
@@ -184,17 +184,17 @@
|
|||||||
<v-hover v-slot="{ isHovering }">
|
<v-hover v-slot="{ isHovering }">
|
||||||
<v-card
|
<v-card
|
||||||
class="my-3"
|
class="my-3"
|
||||||
:class="[{ 'on-hover': isHovering }, isChecked(index)]"
|
:class="[{ 'on-hover': isHovering }, { 'cursor-default': isEditForm }, isChecked(index)]"
|
||||||
:elevation="isHovering ? 12 : 2"
|
:elevation="isHovering ? 12 : 2"
|
||||||
:ripple="false"
|
:ripple="false"
|
||||||
@click="toggleDisabled(index)"
|
@click="toggleDisabled(index)"
|
||||||
>
|
>
|
||||||
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
|
<v-card-title class="recipe-step-title pt-3" :class="!isChecked(index) ? 'pb-0' : 'pb-3'">
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center w-100">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-if="isEditForm"
|
v-if="isEditForm"
|
||||||
v-model="step.summary"
|
v-model="step.summary"
|
||||||
class="headline handle"
|
class="headline"
|
||||||
hide-details
|
hide-details
|
||||||
density="compact"
|
density="compact"
|
||||||
variant="solo"
|
variant="solo"
|
||||||
@@ -202,14 +202,27 @@
|
|||||||
:placeholder="$t('recipe.step-index', { step: index + 1 })"
|
:placeholder="$t('recipe.step-index', { step: index + 1 })"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon size="26">
|
<v-icon size="26" class="handle">
|
||||||
{{ $globals.icons.arrowUpDown }}
|
{{ $globals.icons.arrowUpDown }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</template>
|
</template>
|
||||||
</v-text-field>
|
</v-text-field>
|
||||||
<span v-else>
|
<div
|
||||||
{{ step.summary ? step.summary : $t("recipe.step-index", { step: index + 1 }) }}
|
v-else
|
||||||
</span>
|
class="summary-wrapper"
|
||||||
|
>
|
||||||
|
<template v-if="step.summary">
|
||||||
|
<SafeMarkdown
|
||||||
|
class="pr-2"
|
||||||
|
:source="step.summary"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>
|
||||||
|
{{ $t('recipe.step-index', { step: index + 1 }) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<template v-if="isEditForm">
|
<template v-if="isEditForm">
|
||||||
<div class="ml-auto">
|
<div class="ml-auto">
|
||||||
<BaseButtonGroup
|
<BaseButtonGroup
|
||||||
@@ -314,11 +327,22 @@
|
|||||||
persistentHint: true,
|
persistentHint: true,
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<RecipeIngredientHtml
|
<div
|
||||||
v-for="ing in step.ingredientReferences"
|
v-if="step.ingredientReferences && step.ingredientReferences.length"
|
||||||
:key="ing.referenceId!"
|
class="linked-ingredients-editor"
|
||||||
:markup="getIngredientByRefId(ing.referenceId!)"
|
>
|
||||||
/>
|
<div
|
||||||
|
v-for="(linkRef, i) in step.ingredientReferences"
|
||||||
|
:key="linkRef.referenceId ?? i"
|
||||||
|
class="mb-1"
|
||||||
|
>
|
||||||
|
<RecipeIngredientHtml
|
||||||
|
v-if="linkRef.referenceId && ingredientLookup[linkRef.referenceId]"
|
||||||
|
:ingredient="ingredientLookup[linkRef.referenceId]"
|
||||||
|
:scale="scale"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</DropZone>
|
</DropZone>
|
||||||
<v-expand-transition>
|
<v-expand-transition>
|
||||||
@@ -373,9 +397,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { VueDraggable } from "vue-draggable-plus";
|
import { VueDraggable } from "vue-draggable-plus";
|
||||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||||
import RecipeIngredientHtml from "../../RecipeIngredientHtml.vue";
|
|
||||||
import type { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe";
|
import type { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe";
|
||||||
import { parseIngredientText } from "~/composables/recipes";
|
|
||||||
import { uuid4 } from "~/composables/use-utils";
|
import { uuid4 } from "~/composables/use-utils";
|
||||||
import { useUserApi, useStaticRoutes } from "~/composables/api";
|
import { useUserApi, useStaticRoutes } from "~/composables/api";
|
||||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||||
@@ -383,6 +405,7 @@ import { useExtractIngredientReferences } from "~/composables/recipe-page/use-ex
|
|||||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import DropZone from "~/components/global/DropZone.vue";
|
import DropZone from "~/components/global/DropZone.vue";
|
||||||
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
|
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
|
||||||
|
import RecipeIngredientHtml from "~/components/Domain/Recipe/RecipeIngredientHtml.vue";
|
||||||
|
|
||||||
interface MergerHistory {
|
interface MergerHistory {
|
||||||
target: number;
|
target: number;
|
||||||
@@ -500,10 +523,9 @@ function openDialog(idx: number, text: string, refs?: IngredientReferences[]) {
|
|||||||
instructionList.value[idx].ingredientReferences = [];
|
instructionList.value[idx].ingredientReferences = [];
|
||||||
refs = instructionList.value[idx].ingredientReferences as IngredientReferences[];
|
refs = instructionList.value[idx].ingredientReferences as IngredientReferences[];
|
||||||
}
|
}
|
||||||
|
|
||||||
setUsedIngredients();
|
|
||||||
activeText.value = text;
|
|
||||||
activeIndex.value = idx;
|
activeIndex.value = idx;
|
||||||
|
activeText.value = text;
|
||||||
|
setUsedIngredients();
|
||||||
dialog.value = true;
|
dialog.value = true;
|
||||||
activeRefs.value = refs.map(ref => ref.referenceId ?? "");
|
activeRefs.value = refs.map(ref => ref.referenceId ?? "");
|
||||||
}
|
}
|
||||||
@@ -544,29 +566,26 @@ function saveAndOpenNextLinkIngredients() {
|
|||||||
function setUsedIngredients() {
|
function setUsedIngredients() {
|
||||||
const usedRefs: { [key: string]: boolean } = {};
|
const usedRefs: { [key: string]: boolean } = {};
|
||||||
|
|
||||||
instructionList.value.forEach((element) => {
|
instructionList.value.forEach((element, idx) => {
|
||||||
|
if (idx === activeIndex.value) return;
|
||||||
element.ingredientReferences?.forEach((ref) => {
|
element.ingredientReferences?.forEach((ref) => {
|
||||||
if (ref.referenceId !== undefined) {
|
if (ref.referenceId) usedRefs[ref.referenceId] = true;
|
||||||
usedRefs[ref.referenceId!] = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
usedIngredients.value = props.recipe.recipeIngredient.filter((ing) => {
|
usedIngredients.value = props.recipe.recipeIngredient.filter(ing => !!ing.referenceId && ing.referenceId in usedRefs);
|
||||||
return ing.referenceId !== undefined && ing.referenceId in usedRefs;
|
|
||||||
});
|
|
||||||
|
|
||||||
unusedIngredients.value = props.recipe.recipeIngredient.filter((ing) => {
|
unusedIngredients.value = props.recipe.recipeIngredient.filter(ing => !!ing.referenceId && !(ing.referenceId in usedRefs));
|
||||||
return !(ing.referenceId !== undefined && ing.referenceId in usedRefs);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(activeRefs, () => setUsedIngredients());
|
||||||
|
|
||||||
function autoSetReferences() {
|
function autoSetReferences() {
|
||||||
useExtractIngredientReferences(
|
useExtractIngredientReferences(
|
||||||
props.recipe.recipeIngredient,
|
props.recipe.recipeIngredient,
|
||||||
activeRefs.value,
|
activeRefs.value,
|
||||||
activeText.value,
|
activeText.value,
|
||||||
).forEach((ingredient: string) => activeRefs.value.push(ingredient));
|
).forEach(ingredient => activeRefs.value.push(ingredient));
|
||||||
}
|
}
|
||||||
|
|
||||||
const ingredientLookup = computed(() => {
|
const ingredientLookup = computed(() => {
|
||||||
@@ -603,8 +622,8 @@ const ingredientSectionTitles = computed(() => {
|
|||||||
return titleMap;
|
return titleMap;
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupedUnusedIngredients = computed(() => {
|
const groupedUnusedIngredients = computed((): Record<string, RecipeIngredient[]> => {
|
||||||
const groups: { [key: string]: RecipeIngredient[] } = {};
|
const groups: Record<string, RecipeIngredient[]> = {};
|
||||||
|
|
||||||
// Group ingredients by section title
|
// Group ingredients by section title
|
||||||
unusedIngredients.value.forEach((ingredient) => {
|
unusedIngredients.value.forEach((ingredient) => {
|
||||||
@@ -614,20 +633,14 @@ const groupedUnusedIngredients = computed(() => {
|
|||||||
|
|
||||||
// Use the section title from the mapping, or fallback to the ingredient's own title
|
// Use the section title from the mapping, or fallback to the ingredient's own title
|
||||||
const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || "";
|
const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || "";
|
||||||
|
(groups[title] ||= []).push(ingredient);
|
||||||
if (!groups[title]) {
|
|
||||||
groups[title] = [];
|
|
||||||
}
|
|
||||||
groups[title].push(ingredient);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupedUsedIngredients = computed(() => {
|
const groupedUsedIngredients = computed((): Record<string, RecipeIngredient[]> => {
|
||||||
const groups: { [key: string]: RecipeIngredient[] } = {};
|
const groups: Record<string, RecipeIngredient[]> = {};
|
||||||
|
|
||||||
// Group ingredients by section title
|
|
||||||
usedIngredients.value.forEach((ingredient) => {
|
usedIngredients.value.forEach((ingredient) => {
|
||||||
if (ingredient.referenceId === undefined) {
|
if (ingredient.referenceId === undefined) {
|
||||||
return;
|
return;
|
||||||
@@ -635,26 +648,12 @@ const groupedUsedIngredients = computed(() => {
|
|||||||
|
|
||||||
// Use the section title from the mapping, or fallback to the ingredient's own title
|
// Use the section title from the mapping, or fallback to the ingredient's own title
|
||||||
const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || "";
|
const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || "";
|
||||||
|
(groups[title] ||= []).push(ingredient);
|
||||||
if (!groups[title]) {
|
|
||||||
groups[title] = [];
|
|
||||||
}
|
|
||||||
groups[title].push(ingredient);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getIngredientByRefId(refId: string | undefined) {
|
|
||||||
if (refId === undefined) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const ing = ingredientLookup.value[refId];
|
|
||||||
if (!ing) return "";
|
|
||||||
return parseIngredientText(ing, props.scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===============================================================
|
// ===============================================================
|
||||||
// Instruction Merger
|
// Instruction Merger
|
||||||
const mergeHistory = ref<MergerHistory[]>([]);
|
const mergeHistory = ref<MergerHistory[]>([]);
|
||||||
@@ -847,7 +846,21 @@ function openImageUpload(index: number) {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-text-field >>> input {
|
.v-text-field :deep(input) {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recipe-step-title {
|
||||||
|
/* Multiline display */
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.25;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.summary-wrapper {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0; /* wrapping in flex container */
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,552 @@
|
|||||||
|
<template>
|
||||||
|
<BaseDialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
:title="$t('recipe.parse-ingredients')"
|
||||||
|
:icon="$globals.icons.fileSign"
|
||||||
|
disable-submit-on-enter
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<v-container fluid class="pa-2 ma-0" style="background-color: rgb(var(--v-theme-background));">
|
||||||
|
<div v-if="state.loading.parser" class="my-6">
|
||||||
|
<AppLoader waiting-text="" class="my-6" />
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<BaseCardSectionTitle :title="$t('recipe.parser.ingredient-parser')">
|
||||||
|
<div v-if="!state.allReviewed" class="mb-4">
|
||||||
|
<p>{{ $t("recipe.parser.ingredient-parser-description") }}</p>
|
||||||
|
<p>{{ $t("recipe.parser.ingredient-parser-final-review-description") }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap align-center">
|
||||||
|
<div class="text-body-2 mr-2">
|
||||||
|
{{ $t("recipe.parser.select-parser") }}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<BaseOverflowButton
|
||||||
|
v-model="parser"
|
||||||
|
:disabled="state.loading.parser"
|
||||||
|
btn-class="mx-2"
|
||||||
|
:items="availableParsers"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="40"
|
||||||
|
color="info"
|
||||||
|
:disabled="state.loading.parser"
|
||||||
|
@click="parseIngredients"
|
||||||
|
>
|
||||||
|
<v-icon>{{ $globals.icons.refresh }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseCardSectionTitle>
|
||||||
|
<v-card v-if="!state.allReviewed && currentIng">
|
||||||
|
<v-card-text class="pb-0 mb-0">
|
||||||
|
<div class="text-center px-8 py-4 mb-6">
|
||||||
|
<p class="text-h5 font-italic">
|
||||||
|
{{ currentIng.input }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center pa-0 ma-0">
|
||||||
|
<v-icon
|
||||||
|
:color="(currentIng.confidence?.average || 0) < confidenceThreshold ? 'error' : 'success'"
|
||||||
|
>
|
||||||
|
{{ (currentIng.confidence?.average || 0) < confidenceThreshold ? $globals.icons.alert : $globals.icons.check }}
|
||||||
|
</v-icon>
|
||||||
|
<span
|
||||||
|
class="ml-2"
|
||||||
|
:color="currentIngHasError ? 'error-text' : 'success-text'"
|
||||||
|
>
|
||||||
|
{{ $t("recipe.parser.confidence-score") }}: {{ currentIng.confidence ? asPercentage(currentIng.confidence?.average!) : "" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<RecipeIngredientEditor
|
||||||
|
v-model="currentIng.ingredient"
|
||||||
|
:unit-error="!!currentMissingUnit"
|
||||||
|
:unit-error-tooltip="$t('recipe.parser.this-unit-could-not-be-parsed-automatically')"
|
||||||
|
:food-error="!!currentMissingFood"
|
||||||
|
:food-error-tooltip="$t('recipe.parser.this-food-could-not-be-parsed-automatically')"
|
||||||
|
/>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<BaseButton
|
||||||
|
v-if="currentMissingUnit && !currentIng.ingredient.unit?.id"
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
@click="createMissingUnit"
|
||||||
|
>
|
||||||
|
{{ i18n.t("recipe.parser.missing-unit", { unit: currentMissingUnit }) }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-if="
|
||||||
|
currentMissingUnit
|
||||||
|
&& currentIng.ingredient.unit?.id
|
||||||
|
&& currentMissingUnit.toLowerCase() != currentIng.ingredient.unit?.name.toLowerCase()
|
||||||
|
"
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
@click="addMissingUnitAsAlias"
|
||||||
|
>
|
||||||
|
{{ i18n.t("recipe.parser.add-text-as-alias-for-item", { text: currentMissingUnit, item: currentIng.ingredient.unit.name }) }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-if="currentMissingFood && !currentIng.ingredient.food?.id"
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
@click="createMissingFood"
|
||||||
|
>
|
||||||
|
{{ i18n.t("recipe.parser.missing-food", { food: currentMissingFood }) }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-if="
|
||||||
|
currentMissingFood
|
||||||
|
&& currentIng.ingredient.food?.id
|
||||||
|
&& currentMissingFood.toLowerCase() != currentIng.ingredient.food?.name.toLowerCase()
|
||||||
|
"
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
@click="addMissingFoodAsAlias"
|
||||||
|
>
|
||||||
|
{{ i18n.t("recipe.parser.add-text-as-alias-for-item", { text: currentMissingFood, item: currentIng.ingredient.food.name }) }}
|
||||||
|
</BaseButton>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
<div v-else>
|
||||||
|
<v-card-title class="text-center pt-0 pb-8">
|
||||||
|
{{ $t("recipe.parser.review-parsed-ingredients") }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text style="max-height: 60vh; overflow-y: auto;">
|
||||||
|
<VueDraggable
|
||||||
|
v-model="parsedIngs"
|
||||||
|
handle=".handle"
|
||||||
|
:delay="250"
|
||||||
|
:delay-on-touch-only="true"
|
||||||
|
v-bind="{
|
||||||
|
animation: 200,
|
||||||
|
group: 'recipe-ingredients',
|
||||||
|
disabled: false,
|
||||||
|
ghostClass: 'ghost',
|
||||||
|
}"
|
||||||
|
class="px-6"
|
||||||
|
@start="drag = true"
|
||||||
|
@end="drag = false"
|
||||||
|
>
|
||||||
|
<TransitionGroup
|
||||||
|
type="transition"
|
||||||
|
>
|
||||||
|
<v-lazy v-for="(ingredient, index) in parsedIngs" :key="index">
|
||||||
|
<RecipeIngredientEditor
|
||||||
|
v-model="ingredient.ingredient"
|
||||||
|
enable-drag-handle
|
||||||
|
enable-context-menu
|
||||||
|
class="list-group-item pb-8"
|
||||||
|
:delete-disabled="parsedIngs.length <= 1"
|
||||||
|
@delete="parsedIngs.splice(index, 1)"
|
||||||
|
@insert-above="insertNewIngredient(index)"
|
||||||
|
@insert-below="insertNewIngredient(index + 1)"
|
||||||
|
>
|
||||||
|
<template #before-divider>
|
||||||
|
<p v-if="ingredient.input" class="py-0 my-0 text-caption">
|
||||||
|
{{ $t("recipe.original-text-with-value", { originalText: ingredient.input }) }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</RecipeIngredientEditor>
|
||||||
|
</v-lazy>
|
||||||
|
</TransitionGroup>
|
||||||
|
</VueDraggable>
|
||||||
|
</v-card-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-container>
|
||||||
|
<template v-if="!state.loading.parser" #custom-card-action>
|
||||||
|
<!-- Parse -->
|
||||||
|
<div v-if="!state.allReviewed" class="d-flex justify-space-between align-center">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="currentIngShouldDelete"
|
||||||
|
color="error"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
:label="i18n.t('recipe.parser.delete-item')"
|
||||||
|
class="mr-4"
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
:color="currentIngShouldDelete ? 'error' : 'info'"
|
||||||
|
:icon="currentIngShouldDelete ? $globals.icons.delete : $globals.icons.arrowRightBold"
|
||||||
|
:icon-right="!currentIngShouldDelete"
|
||||||
|
:text="$t(currentIngShouldDelete ? 'recipe.parser.delete-item' : 'general.next')"
|
||||||
|
@click="nextIngredient"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Review -->
|
||||||
|
<div v-else>
|
||||||
|
<BaseButton
|
||||||
|
create
|
||||||
|
:text="$t('general.save')"
|
||||||
|
:icon="$globals.icons.save"
|
||||||
|
:loading="state.loading.save"
|
||||||
|
@click="saveIngs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { VueDraggable } from "vue-draggable-plus";
|
||||||
|
import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
import type { Parser } from "~/lib/api/user/recipes/recipe";
|
||||||
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { parseIngredientText } from "~/composables/recipes";
|
||||||
|
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
|
||||||
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
|
import { alert } from "~/composables/use-toast";
|
||||||
|
import { useParsingPreferences } from "~/composables/use-users/preferences";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
ingredients: NoUndefinedField<RecipeIngredient[]>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: boolean): void;
|
||||||
|
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { $appInfo } = useNuxtApp();
|
||||||
|
const i18n = useGlobalI18n();
|
||||||
|
const api = useUserApi();
|
||||||
|
const drag = ref(false);
|
||||||
|
|
||||||
|
const unitStore = useUnitStore();
|
||||||
|
const unitData = useUnitData();
|
||||||
|
const foodStore = useFoodStore();
|
||||||
|
const foodData = useFoodData();
|
||||||
|
|
||||||
|
const parserPreferences = useParsingPreferences();
|
||||||
|
const parser = ref<Parser>(parserPreferences.value.parser || "nlp");
|
||||||
|
const availableParsers = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: i18n.t("recipe.parser.natural-language-processor"),
|
||||||
|
value: "nlp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: i18n.t("recipe.parser.brute-parser"),
|
||||||
|
value: "brute",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: i18n.t("recipe.parser.openai-parser"),
|
||||||
|
value: "openai",
|
||||||
|
hide: !$appInfo.enableOpenai,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If confidence of parsing is below this threshold,
|
||||||
|
* we will prompt the user to review the parsed ingredient.
|
||||||
|
*/
|
||||||
|
const confidenceThreshold = 0.85;
|
||||||
|
const parsedIngs = ref<ParsedIngredient[]>([]);
|
||||||
|
|
||||||
|
const currentIng = ref<ParsedIngredient | null>(null);
|
||||||
|
const currentMissingUnit = ref("");
|
||||||
|
const currentMissingFood = ref("");
|
||||||
|
const currentIngHasError = computed(() => currentMissingUnit.value || currentMissingFood.value);
|
||||||
|
const currentIngShouldDelete = ref(false);
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
currentParsedIndex: -1,
|
||||||
|
allReviewed: false,
|
||||||
|
loading: {
|
||||||
|
parser: false,
|
||||||
|
save: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function shouldReview(ing: ParsedIngredient): boolean {
|
||||||
|
console.debug(`Checking if ingredient needs review (input="${ing.input})":`, ing);
|
||||||
|
|
||||||
|
if (ing.ingredient.referencedRecipe) {
|
||||||
|
console.debug("No review needed for sub-recipe ingredient");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((ing.confidence?.average || 0) < confidenceThreshold) {
|
||||||
|
console.debug("Needs review due to low confidence:", ing.confidence?.average);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const food = ing.ingredient.food;
|
||||||
|
if (food && !food.id) {
|
||||||
|
console.debug("Needs review due to missing food ID:", food);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = ing.ingredient.unit;
|
||||||
|
if (unit && !unit.id) {
|
||||||
|
console.debug("Needs review due to missing unit ID:", unit);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug("No review needed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkUnit(ing: ParsedIngredient) {
|
||||||
|
const unit = ing.ingredient.unit?.name;
|
||||||
|
if (!unit || ing.ingredient.unit?.id) {
|
||||||
|
currentMissingUnit.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const potentialMatch = createdUnits.get(unit.toLowerCase());
|
||||||
|
if (potentialMatch) {
|
||||||
|
ing.ingredient.unit = potentialMatch;
|
||||||
|
currentMissingUnit.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMissingUnit.value = unit;
|
||||||
|
ing.ingredient.unit = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkFood(ing: ParsedIngredient) {
|
||||||
|
const food = ing.ingredient.food?.name;
|
||||||
|
if (!food || ing.ingredient.food?.id) {
|
||||||
|
currentMissingFood.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const potentialMatch = createdFoods.get(food.toLowerCase());
|
||||||
|
if (potentialMatch) {
|
||||||
|
ing.ingredient.food = potentialMatch;
|
||||||
|
currentMissingFood.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMissingFood.value = food;
|
||||||
|
ing.ingredient.food = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextIngredient() {
|
||||||
|
let nextIndex = state.currentParsedIndex;
|
||||||
|
if (currentIngShouldDelete.value) {
|
||||||
|
parsedIngs.value.splice(state.currentParsedIndex, 1);
|
||||||
|
currentIngShouldDelete.value = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
nextIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (nextIndex < parsedIngs.value.length) {
|
||||||
|
const current = parsedIngs.value[nextIndex];
|
||||||
|
if (shouldReview(current)) {
|
||||||
|
state.currentParsedIndex = nextIndex;
|
||||||
|
currentIng.value = current;
|
||||||
|
currentIngShouldDelete.value = false;
|
||||||
|
checkUnit(current);
|
||||||
|
checkFood(current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No more to review
|
||||||
|
state.allReviewed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseIngredients() {
|
||||||
|
if (state.loading.parser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.ingredients || props.ingredients.length === 0) {
|
||||||
|
state.loading.parser = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.loading.parser = true;
|
||||||
|
try {
|
||||||
|
const ingsAsString = props.ingredients
|
||||||
|
.filter(ing => !ing.referencedRecipe)
|
||||||
|
.map(ing => parseIngredientText(ing, 1, false) ?? "");
|
||||||
|
const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString);
|
||||||
|
if (error || !data) {
|
||||||
|
throw new Error("Failed to parse ingredients");
|
||||||
|
}
|
||||||
|
parsedIngs.value = data;
|
||||||
|
const parsed = data ?? [];
|
||||||
|
const recipeRefs = props.ingredients.filter(ing => ing.referencedRecipe).map(ing => ({
|
||||||
|
input: ing.note || "",
|
||||||
|
confidence: {},
|
||||||
|
ingredient: ing,
|
||||||
|
}));
|
||||||
|
parsedIngs.value = [...parsed, ...recipeRefs];
|
||||||
|
state.currentParsedIndex = -1;
|
||||||
|
state.allReviewed = false;
|
||||||
|
createdUnits.clear();
|
||||||
|
createdFoods.clear();
|
||||||
|
currentIngShouldDelete.value = false;
|
||||||
|
nextIngredient();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Error parsing ingredients:", error);
|
||||||
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
state.loading.parser = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache of lowercased created units to avoid duplicate creations */
|
||||||
|
const createdUnits = new Map<string, IngredientUnit>();
|
||||||
|
/** Cache of lowercased created foods to avoid duplicate creations */
|
||||||
|
const createdFoods = new Map<string, IngredientFood>();
|
||||||
|
|
||||||
|
async function createMissingUnit() {
|
||||||
|
if (!currentMissingUnit.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unitData.reset();
|
||||||
|
unitData.data.name = currentMissingUnit.value;
|
||||||
|
|
||||||
|
let newUnit: IngredientUnit | null = null;
|
||||||
|
if (createdUnits.has(unitData.data.name)) {
|
||||||
|
newUnit = createdUnits.get(unitData.data.name)!;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
newUnit = await unitStore.actions.createOne(unitData.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newUnit) {
|
||||||
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIng.value!.ingredient.unit = newUnit;
|
||||||
|
createdUnits.set(newUnit.name.toLowerCase(), newUnit);
|
||||||
|
currentMissingUnit.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMissingFood() {
|
||||||
|
if (!currentMissingFood.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foodData.reset();
|
||||||
|
foodData.data.name = currentMissingFood.value;
|
||||||
|
|
||||||
|
let newFood: IngredientFood | null = null;
|
||||||
|
if (createdFoods.has(foodData.data.name)) {
|
||||||
|
newFood = createdFoods.get(foodData.data.name)!;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
newFood = await foodStore.actions.createOne(foodData.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newFood) {
|
||||||
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIng.value!.ingredient.food = newFood;
|
||||||
|
createdFoods.set(newFood.name.toLowerCase(), newFood);
|
||||||
|
currentMissingFood.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMissingUnitAsAlias() {
|
||||||
|
const unit = currentIng.value?.ingredient.unit as IngredientUnit | undefined;
|
||||||
|
if (!currentMissingUnit.value || !unit?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unit.aliases = unit.aliases || [];
|
||||||
|
if (unit.aliases.map(a => a.name).includes(currentMissingUnit.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unit.aliases.push({ name: currentMissingUnit.value });
|
||||||
|
const updated = await unitStore.actions.updateOne(unit);
|
||||||
|
if (!updated) {
|
||||||
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIng.value!.ingredient.unit = updated;
|
||||||
|
currentMissingUnit.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMissingFoodAsAlias() {
|
||||||
|
const food = currentIng.value?.ingredient.food as IngredientFood | undefined;
|
||||||
|
if (!currentMissingFood.value || !food?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
food.aliases = food.aliases || [];
|
||||||
|
if (food.aliases.map(a => a.name).includes(currentMissingFood.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
food.aliases.push({ name: currentMissingFood.value });
|
||||||
|
const updated = await foodStore.actions.updateOne(food);
|
||||||
|
if (!updated) {
|
||||||
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIng.value!.ingredient.food = updated;
|
||||||
|
currentMissingFood.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.modelValue, () => {
|
||||||
|
if (!props.modelValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseIngredients();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(parser, () => {
|
||||||
|
parserPreferences.value.parser = parser.value;
|
||||||
|
parseIngredients();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([parsedIngs, () => state.allReviewed], () => {
|
||||||
|
if (!state.allReviewed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsedIngs.value.length) {
|
||||||
|
insertNewIngredient(0);
|
||||||
|
}
|
||||||
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
|
function asPercentage(num: number | undefined): string {
|
||||||
|
if (!num) {
|
||||||
|
return "0%";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(num * 100).toFixed(2) + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertNewIngredient(index: number) {
|
||||||
|
const ing = {
|
||||||
|
input: "",
|
||||||
|
confidence: {},
|
||||||
|
ingredient: {
|
||||||
|
quantity: 0,
|
||||||
|
referenceId: uuid4(),
|
||||||
|
},
|
||||||
|
} as ParsedIngredient;
|
||||||
|
|
||||||
|
parsedIngs.value.splice(index, 0, ing);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveIngs() {
|
||||||
|
emit("save", parsedIngs.value.map(x => x.ingredient as NoUndefinedField<RecipeIngredient>));
|
||||||
|
state.loading.save = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -4,20 +4,23 @@
|
|||||||
<section>
|
<section>
|
||||||
<v-container class="ma-0 pa-0">
|
<v-container class="ma-0 pa-0">
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
|
<v-col
|
||||||
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
|
v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
|
||||||
cols="4"
|
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
|
||||||
align-self="center"
|
cols="4"
|
||||||
|
align-self="center"
|
||||||
>
|
>
|
||||||
<img :key="imageKey"
|
<img
|
||||||
:src="recipeImageUrl"
|
:key="imageKey"
|
||||||
style="min-height: 50; max-width: 100%;"
|
:src="recipeImageUrl"
|
||||||
|
style="min-height: 50; max-width: 100%;"
|
||||||
>
|
>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col order="0">
|
<v-col order="0">
|
||||||
<v-card-title class="headline pl-0">
|
<v-card-title class="headline pl-0">
|
||||||
<v-icon start
|
<v-icon
|
||||||
color="primary"
|
start
|
||||||
|
color="primary"
|
||||||
>
|
>
|
||||||
{{ $globals.icons.primary }}
|
{{ $globals.icons.primary }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
@@ -36,17 +39,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<v-row class="d-flex justify-start">
|
<v-row class="d-flex justify-start">
|
||||||
<RecipeTimeCard :prep-time="recipe.prepTime"
|
<RecipeTimeCard
|
||||||
:total-time="recipe.totalTime"
|
:prep-time="recipe.prepTime"
|
||||||
:perform-time="recipe.performTime"
|
:total-time="recipe.totalTime"
|
||||||
small
|
:perform-time="recipe.performTime"
|
||||||
color="white"
|
small
|
||||||
class="ml-4"
|
color="white"
|
||||||
|
class="ml-4"
|
||||||
/>
|
/>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<v-card-text v-if="preferences.showDescription"
|
<v-card-text
|
||||||
class="px-0"
|
v-if="preferences.showDescription"
|
||||||
|
class="px-0"
|
||||||
>
|
>
|
||||||
<SafeMarkdown :source="recipe.description" />
|
<SafeMarkdown :source="recipe.description" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
@@ -60,24 +65,29 @@
|
|||||||
<v-card-title class="headline pl-0">
|
<v-card-title class="headline pl-0">
|
||||||
{{ $t("recipe.ingredients") }}
|
{{ $t("recipe.ingredients") }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<div v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
<div
|
||||||
:key="`ingredient-section-${sectionIndex}`"
|
v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
||||||
class="print-section"
|
:key="`ingredient-section-${sectionIndex}`"
|
||||||
|
class="print-section"
|
||||||
>
|
>
|
||||||
<h4 v-if="ingredientSection.ingredients[0].title"
|
<h4
|
||||||
class="ingredient-title mt-2"
|
v-if="ingredientSection.ingredients[0].title"
|
||||||
|
class="ingredient-title mt-2"
|
||||||
>
|
>
|
||||||
{{ ingredientSection.ingredients[0].title }}
|
{{ ingredientSection.ingredients[0].title }}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="ingredient-grid"
|
<div
|
||||||
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
class="ingredient-grid"
|
||||||
|
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
||||||
>
|
>
|
||||||
<template v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients"
|
<template
|
||||||
:key="`ingredient-${ingredientIndex}`"
|
v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients"
|
||||||
|
:key="`ingredient-${ingredientIndex}`"
|
||||||
>
|
>
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<p class="ingredient-body"
|
<p
|
||||||
v-html="parseText(ingredient)"
|
class="ingredient-body"
|
||||||
|
v-html="parseText(ingredient)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,22 +96,26 @@
|
|||||||
|
|
||||||
<!-- Instructions -->
|
<!-- Instructions -->
|
||||||
<section>
|
<section>
|
||||||
<div v-for="(instructionSection, sectionIndex) in instructionSections"
|
<div
|
||||||
:key="`instruction-section-${sectionIndex}`"
|
v-for="(instructionSection, sectionIndex) in instructionSections"
|
||||||
:class="{ 'print-section': instructionSection.sectionName }"
|
:key="`instruction-section-${sectionIndex}`"
|
||||||
|
:class="{ 'print-section': instructionSection.sectionName }"
|
||||||
>
|
>
|
||||||
<v-card-title v-if="!sectionIndex"
|
<v-card-title
|
||||||
class="headline pl-0"
|
v-if="!sectionIndex"
|
||||||
|
class="headline pl-0"
|
||||||
>
|
>
|
||||||
{{ $t("recipe.instructions") }}
|
{{ $t("recipe.instructions") }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<div v-for="(step, stepIndex) in instructionSection.instructions"
|
<div
|
||||||
:key="`instruction-${stepIndex}`"
|
v-for="(step, stepIndex) in instructionSection.instructions"
|
||||||
|
:key="`instruction-${stepIndex}`"
|
||||||
>
|
>
|
||||||
<div class="print-section">
|
<div class="print-section">
|
||||||
<h4 v-if="step.title"
|
<h4
|
||||||
:key="`instruction-title-${stepIndex}`"
|
v-if="step.title"
|
||||||
class="instruction-title mb-2"
|
:key="`instruction-title-${stepIndex}`"
|
||||||
|
class="instruction-title mb-2"
|
||||||
>
|
>
|
||||||
{{ step.title }}
|
{{ step.title }}
|
||||||
</h4>
|
</h4>
|
||||||
@@ -112,8 +126,9 @@
|
|||||||
+ 1,
|
+ 1,
|
||||||
}) }}
|
}) }}
|
||||||
</h5>
|
</h5>
|
||||||
<SafeMarkdown :source="step.text"
|
<SafeMarkdown
|
||||||
class="recipe-step-body"
|
:source="step.text"
|
||||||
|
class="recipe-step-body"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,18 +137,21 @@
|
|||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
<div v-if="preferences.showNotes">
|
<div v-if="preferences.showNotes">
|
||||||
<v-divider v-if="hasNotes"
|
<v-divider
|
||||||
class="grey my-4"
|
v-if="hasNotes"
|
||||||
|
class="grey my-4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div v-for="(note, index) in recipe.notes"
|
<div
|
||||||
:key="index + 'note'"
|
v-for="(note, index) in recipe.notes"
|
||||||
|
:key="index + 'note'"
|
||||||
>
|
>
|
||||||
<div class="print-section">
|
<div class="print-section">
|
||||||
<h4>{{ note.title }}</h4>
|
<h4>{{ note.title }}</h4>
|
||||||
<SafeMarkdown :source="note.text"
|
<SafeMarkdown
|
||||||
class="note-body"
|
:source="note.text"
|
||||||
|
class="note-body"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,8 +168,9 @@
|
|||||||
<div class="print-section">
|
<div class="print-section">
|
||||||
<table class="nutrition-table">
|
<table class="nutrition-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(value, key) in recipe.nutrition"
|
<tr
|
||||||
:key="key"
|
v-for="(value, key) in recipe.nutrition"
|
||||||
|
:key="key"
|
||||||
>
|
>
|
||||||
<template v-if="value">
|
<template v-if="value">
|
||||||
<td>{{ labels[key].label }}</td>
|
<td>{{ labels[key].label }}</td>
|
||||||
@@ -243,32 +262,55 @@ const ingredientSections = computed<IngredientSection[]>(() => {
|
|||||||
if (!props.recipe.recipeIngredient) {
|
if (!props.recipe.recipeIngredient) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const addIngredientsToSections = (ingredients: RecipeIngredient[], sections: IngredientSection[], title: string | null) => {
|
||||||
return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
|
// If title is set, ensure the section exists before adding ingredients
|
||||||
// if title append new section to the end of the array
|
let section: IngredientSection | undefined;
|
||||||
if (ingredient.title) {
|
if (title) {
|
||||||
sections.push({
|
section = sections.find(sec => sec.sectionName === title);
|
||||||
sectionName: ingredient.title,
|
if (!section) {
|
||||||
ingredients: [ingredient],
|
section = { sectionName: title, ingredients: [] };
|
||||||
});
|
sections.push(section);
|
||||||
|
}
|
||||||
return sections;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// append new section if first
|
ingredients.forEach((ingredient) => {
|
||||||
if (sections.length === 0) {
|
if (preferences.value.expandChildRecipes && ingredient.referencedRecipe?.recipeIngredient?.length) {
|
||||||
sections.push({
|
// Recursively add to the section for this referenced recipe
|
||||||
sectionName: "",
|
addIngredientsToSections(
|
||||||
ingredients: [ingredient],
|
ingredient.referencedRecipe.recipeIngredient,
|
||||||
});
|
sections,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const sectionName = title || ingredient.title || "";
|
||||||
|
if (sectionName) {
|
||||||
|
let sec = sections.find(sec => sec.sectionName === sectionName);
|
||||||
|
if (!sec) {
|
||||||
|
sec = { sectionName, ingredients: [] };
|
||||||
|
sections.push(sec);
|
||||||
|
}
|
||||||
|
ingredient.title = sectionName;
|
||||||
|
sec.ingredients.push(ingredient);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (sections.length === 0) {
|
||||||
|
sections.push({
|
||||||
|
sectionName: "",
|
||||||
|
ingredients: [ingredient],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
sections[sections.length - 1].ingredients.push(ingredient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return sections;
|
const sections: IngredientSection[] = [];
|
||||||
}
|
addIngredientsToSections(props.recipe.recipeIngredient, sections, null);
|
||||||
|
return sections;
|
||||||
// otherwise add ingredient to last section in the array
|
|
||||||
sections[sections.length - 1].ingredients.push(ingredient);
|
|
||||||
return sections;
|
|
||||||
}, [] as IngredientSection[]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group instructions by section so we can style them independently
|
// Group instructions by section so we can style them independently
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<div @click.prevent>
|
<div @click.prevent>
|
||||||
<!-- User Rating -->
|
<!-- User Rating -->
|
||||||
<v-hover v-slot="{ isHovering, props }">
|
<v-hover v-slot="{ isHovering, props }">
|
||||||
<v-rating v-if="isOwnGroup && (userRating || isHovering || !ratingsLoaded)"
|
<v-rating
|
||||||
|
v-if="isOwnGroup && (userRating || isHovering || !ratingsLoaded)"
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
:model-value="userRating"
|
:model-value="userRating"
|
||||||
active-color="secondary"
|
active-color="secondary"
|
||||||
@@ -13,10 +14,10 @@
|
|||||||
hover
|
hover
|
||||||
clearable
|
clearable
|
||||||
@update:model-value="updateRating(+$event)"
|
@update:model-value="updateRating(+$event)"
|
||||||
@click="updateRating"
|
|
||||||
/>
|
/>
|
||||||
<!-- Group Rating -->
|
<!-- Group Rating -->
|
||||||
<v-rating v-else
|
<v-rating
|
||||||
|
v-else
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
:model-value="groupRating"
|
:model-value="groupRating"
|
||||||
:half-increments="true"
|
:half-increments="true"
|
||||||
@@ -83,7 +84,7 @@ export default defineNuxtComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function updateRating(val?: number) {
|
function updateRating(val?: number) {
|
||||||
if (!isOwnGroup.value) {
|
if (!isOwnGroup.value || !val) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
left
|
left
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
{{ $globals.icons.knfife }}
|
{{ $globals.icons.knife }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<p class="my-0">
|
<p class="my-0">
|
||||||
<span class="font-weight-bold opacity-80">{{ validatePrepTime.name }}</span><br>{{ validatePrepTime.value }}
|
<span class="font-weight-bold opacity-80">{{ validatePrepTime.name }}</span><br>{{ validatePrepTime.value }}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<v-icon class="mr-1">
|
<v-icon class="mr-1">
|
||||||
{{ $globals.icons.calendar }}
|
{{ $globals.icons.calendar }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ new Date(event.timestamp).toLocaleDateString($i18n.locale) }}
|
{{ $d(new Date(event.timestamp)) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
<v-card
|
<v-card
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<v-col v-if="useMobileFormat" align-self="center" class="pr-0">
|
<v-col v-if="useMobileFormat" align-self="center" class="pr-0">
|
||||||
<v-chip label>
|
<v-chip label>
|
||||||
<v-icon> {{ $globals.icons.calendar }} </v-icon>
|
<v-icon> {{ $globals.icons.calendar }} </v-icon>
|
||||||
{{ new Date(event.timestamp || "").toLocaleDateString($i18n.locale) }}
|
{{ $d(new Date(event.timestamp || "")) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col v-else cols="9" style="margin: auto; text-align: center">
|
<v-col v-else cols="9" style="margin: auto; text-align: center">
|
||||||
|
|||||||
@@ -130,9 +130,8 @@
|
|||||||
<v-col cols="auto">
|
<v-col cols="auto">
|
||||||
<div class="text-caption font-weight-light font-italic">
|
<div class="text-caption font-weight-light font-italic">
|
||||||
{{ $t("shopping-list.completed-on", {
|
{{ $t("shopping-list.completed-on", {
|
||||||
date: new Date(listItem.updatedAt
|
date: listItem.updatedAt ? $d(new Date(listItem.updatedAt)) : '',
|
||||||
|| "").toLocaleDateString($i18n.locale) })
|
}) }}
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
v-model="listItem.unit"
|
v-model="listItem.unit"
|
||||||
v-model:item-id="listItem.unitId!"
|
v-model:item-id="listItem.unitId!"
|
||||||
:items="units"
|
:items="units"
|
||||||
:label="$t('general.units')"
|
:label="$t('recipe.unit')"
|
||||||
:icon="$globals.icons.units"
|
:icon="$globals.icons.units"
|
||||||
create
|
create
|
||||||
@create="createAssignUnit"
|
@create="createAssignUnit"
|
||||||
@@ -84,12 +84,12 @@
|
|||||||
:buttons="[
|
:buttons="[
|
||||||
...(allowDelete
|
...(allowDelete
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
icon: $globals.icons.delete,
|
icon: $globals.icons.delete,
|
||||||
text: $t('general.delete'),
|
text: $t('general.delete'),
|
||||||
event: 'delete',
|
event: 'delete',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
icon: $globals.icons.close,
|
icon: $globals.icons.close,
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
<v-list-item-subtitle class="font-weight-medium" style="font-size: small;">
|
<v-list-item-subtitle class="font-weight-medium" style="font-size: small;">
|
||||||
{{ item.subtitle }}
|
{{ item.subtitle }}
|
||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-list>
|
</v-list>
|
||||||
@@ -97,17 +97,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import type { SideBarLink } from "~/types/application-types";
|
import type { SideBarLink } from "~/types/application-types";
|
||||||
import { useAppInfo } from "~/composables/api";
|
|
||||||
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
||||||
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
|
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
|
||||||
import { useHouseholdStore, usePublicHouseholdStore } from "~/composables/store/use-household-store";
|
|
||||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
setup() {
|
setup() {
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const { $globals } = useNuxtApp();
|
const { $appInfo, $globals } = useNuxtApp();
|
||||||
const display = useDisplay();
|
const display = useDisplay();
|
||||||
const $auth = useMealieAuth();
|
const $auth = useMealieAuth();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
@@ -116,12 +113,8 @@ export default defineNuxtComponent({
|
|||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
const cookbookPreferences = useCookbookPreferences();
|
const cookbookPreferences = useCookbookPreferences();
|
||||||
|
|
||||||
const ownCookbookStore = useCookbookStore(i18n);
|
const ownCookbookStore = useCookbookStore(i18n);
|
||||||
const ownHouseholdStore = useHouseholdStore(i18n);
|
|
||||||
|
|
||||||
const publicCookbookStoreCache = ref<Record<string, ReturnType<typeof usePublicCookbookStore>>>({});
|
const publicCookbookStoreCache = ref<Record<string, ReturnType<typeof usePublicCookbookStore>>>({});
|
||||||
const publicHouseholdStoreCache = ref<Record<string, ReturnType<typeof usePublicHouseholdStore>>>({});
|
|
||||||
|
|
||||||
function getPublicCookbookStore(slug: string) {
|
function getPublicCookbookStore(slug: string) {
|
||||||
if (!publicCookbookStoreCache.value[slug]) {
|
if (!publicCookbookStoreCache.value[slug]) {
|
||||||
@@ -130,13 +123,6 @@ export default defineNuxtComponent({
|
|||||||
return publicCookbookStoreCache.value[slug];
|
return publicCookbookStoreCache.value[slug];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPublicHouseholdStore(slug: string) {
|
|
||||||
if (!publicHouseholdStoreCache.value[slug]) {
|
|
||||||
publicHouseholdStoreCache.value[slug] = usePublicHouseholdStore(slug, i18n);
|
|
||||||
}
|
|
||||||
return publicHouseholdStoreCache.value[slug];
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookbooks = computed(() => {
|
const cookbooks = computed(() => {
|
||||||
if (isOwnGroup.value) {
|
if (isOwnGroup.value) {
|
||||||
return ownCookbookStore.store.value;
|
return ownCookbookStore.store.value;
|
||||||
@@ -148,27 +134,7 @@ export default defineNuxtComponent({
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const households = computed(() => {
|
const showImageImport = computed(() => $appInfo.enableOpenaiImageServices);
|
||||||
if (isOwnGroup.value) {
|
|
||||||
return ownHouseholdStore.store.value;
|
|
||||||
}
|
|
||||||
else if (groupSlug.value) {
|
|
||||||
const publicStore = getPublicHouseholdStore(groupSlug.value);
|
|
||||||
return unref(publicStore.store);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const householdsById = computed(() => {
|
|
||||||
return households.value.reduce((acc, household) => {
|
|
||||||
acc[household.id] = household;
|
|
||||||
return acc;
|
|
||||||
}, {} as { [key: string]: HouseholdSummary });
|
|
||||||
});
|
|
||||||
|
|
||||||
const appInfo = useAppInfo();
|
|
||||||
const showImageImport = computed(() => appInfo.value?.enableOpenaiImageServices);
|
|
||||||
|
|
||||||
const languageDialog = ref<boolean>(false);
|
const languageDialog = ref<boolean>(false);
|
||||||
|
|
||||||
const sidebar = ref<boolean>(false);
|
const sidebar = ref<boolean>(false);
|
||||||
@@ -197,11 +163,8 @@ export default defineNuxtComponent({
|
|||||||
const ownLinks: SideBarLink[] = [];
|
const ownLinks: SideBarLink[] = [];
|
||||||
const links: SideBarLink[] = [];
|
const links: SideBarLink[] = [];
|
||||||
const cookbooksByHousehold = sortedCookbooks.reduce((acc, cookbook) => {
|
const cookbooksByHousehold = sortedCookbooks.reduce((acc, cookbook) => {
|
||||||
const householdName = householdsById.value[cookbook.householdId]?.name || "";
|
const householdName = cookbook.household?.name || "";
|
||||||
if (!acc[householdName]) {
|
(acc[householdName] ||= []).push(cookbook);
|
||||||
acc[householdName] = [];
|
|
||||||
}
|
|
||||||
acc[householdName].push(cookbook);
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, ReadCookBook[]>);
|
}, {} as Record<string, ReadCookBook[]>);
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export default defineNuxtComponent({
|
|||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await $auth.signOut({ callbackUrl: "/login?direct=1" });
|
await $auth.signOut("/login?direct=1");
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|||||||
@@ -33,20 +33,39 @@
|
|||||||
<template v-for="nav in topLink">
|
<template v-for="nav in topLink">
|
||||||
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
||||||
<!-- Multi Items -->
|
<!-- Multi Items -->
|
||||||
<v-list-group v-if="nav.children" :key="(nav.key || nav.title) + 'multi-item'"
|
<v-list-group
|
||||||
v-model="dropDowns[nav.title]" color="primary" :prepend-icon="nav.icon" :fluid="true">
|
v-if="nav.children"
|
||||||
|
:key="(nav.key || nav.title) + 'multi-item'"
|
||||||
|
v-model="dropDowns[nav.title]"
|
||||||
|
color="primary"
|
||||||
|
:prepend-icon="nav.icon"
|
||||||
|
:fluid="true"
|
||||||
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
|
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to"
|
<v-list-item
|
||||||
:prepend-icon="child.icon" :title="child.title" class="ml-4" />
|
v-for="child in nav.children"
|
||||||
|
:key="child.key || child.title"
|
||||||
|
exact
|
||||||
|
:to="child.to"
|
||||||
|
:prepend-icon="child.icon"
|
||||||
|
:title="child.title"
|
||||||
|
class="ml-4"
|
||||||
|
/>
|
||||||
</v-list-group>
|
</v-list-group>
|
||||||
|
|
||||||
<!-- Single Item -->
|
<!-- Single Item -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<v-list-item :key="(nav.key || nav.title) + 'single-item'" exact link :to="nav.to"
|
<v-list-item
|
||||||
:prepend-icon="nav.icon" :title="nav.title" />
|
:key="(nav.key || nav.title) + 'single-item'"
|
||||||
|
exact
|
||||||
|
link
|
||||||
|
:to="nav.to"
|
||||||
|
:prepend-icon="nav.icon"
|
||||||
|
:title="nav.title"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,14 +79,27 @@
|
|||||||
<template v-for="nav in secondaryLinks">
|
<template v-for="nav in secondaryLinks">
|
||||||
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
||||||
<!-- Multi Items -->
|
<!-- Multi Items -->
|
||||||
<v-list-group v-if="nav.children" :key="(nav.key || nav.title) + 'multi-item'"
|
<v-list-group
|
||||||
v-model="dropDowns[nav.title]" color="primary" :prepend-icon="nav.icon" fluid>
|
v-if="nav.children"
|
||||||
|
:key="(nav.key || nav.title) + 'multi-item'"
|
||||||
|
v-model="dropDowns[nav.title]"
|
||||||
|
color="primary"
|
||||||
|
:prepend-icon="nav.icon"
|
||||||
|
fluid
|
||||||
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
|
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to"
|
<v-list-item
|
||||||
class="ml-2" :prepend-icon="child.icon" :title="child.title" />
|
v-for="child in nav.children"
|
||||||
|
:key="child.key || child.title"
|
||||||
|
exact
|
||||||
|
:to="child.to"
|
||||||
|
class="ml-2"
|
||||||
|
:prepend-icon="child.icon"
|
||||||
|
:title="child.title"
|
||||||
|
/>
|
||||||
</v-list-group>
|
</v-list-group>
|
||||||
|
|
||||||
<!-- Single Item -->
|
<!-- Single Item -->
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { size } = withDefaults(defineProps<{ size?: number }>(), { size: 75 });
|
withDefaults(defineProps<{ size?: number }>(), { size: 75 });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -41,7 +41,8 @@
|
|||||||
:hide-details="!inputField.hint"
|
:hide-details="!inputField.hint"
|
||||||
:persistent-hint="!!inputField.hint"
|
:persistent-hint="!!inputField.hint"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
@change="emitBlur">
|
@change="emitBlur"
|
||||||
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
<span class="ml-4">
|
<span class="ml-4">
|
||||||
{{ inputField.label }}
|
{{ inputField.label }}
|
||||||
|
|||||||
@@ -10,9 +10,7 @@
|
|||||||
:max-width="maxWidth ?? undefined"
|
:max-width="maxWidth ?? undefined"
|
||||||
:content-class="top ? 'top-dialog' : undefined"
|
:content-class="top ? 'top-dialog' : undefined"
|
||||||
:fullscreen="$vuetify.display.xs"
|
:fullscreen="$vuetify.display.xs"
|
||||||
@keydown.enter="() => {
|
@keydown.enter="submitOnEnter"
|
||||||
emit('submit'); dialog = false;
|
|
||||||
}"
|
|
||||||
@click:outside="emit('cancel')"
|
@click:outside="emit('cancel')"
|
||||||
@keydown.esc="emit('cancel')"
|
@keydown.esc="emit('cancel')"
|
||||||
>
|
>
|
||||||
@@ -61,7 +59,6 @@
|
|||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="canDelete"
|
v-if="canDelete"
|
||||||
delete
|
delete
|
||||||
secondary
|
|
||||||
@click="deleteEvent"
|
@click="deleteEvent"
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
@@ -127,6 +124,7 @@ interface DialogProps {
|
|||||||
canDelete?: boolean;
|
canDelete?: boolean;
|
||||||
canConfirm?: boolean;
|
canConfirm?: boolean;
|
||||||
canSubmit?: boolean;
|
canSubmit?: boolean;
|
||||||
|
disableSubmitOnEnter?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DialogEmits {
|
interface DialogEmits {
|
||||||
@@ -150,6 +148,7 @@ const props = withDefaults(defineProps<DialogProps>(), {
|
|||||||
canDelete: false,
|
canDelete: false,
|
||||||
canConfirm: false,
|
canConfirm: false,
|
||||||
canSubmit: false,
|
canSubmit: false,
|
||||||
|
disableSubmitOnEnter: false,
|
||||||
});
|
});
|
||||||
const emit = defineEmits<DialogEmits>();
|
const emit = defineEmits<DialogEmits>();
|
||||||
|
|
||||||
@@ -181,6 +180,14 @@ function submitEvent() {
|
|||||||
submitted.value = true;
|
submitted.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitOnEnter() {
|
||||||
|
if (props.disableSubmitOnEnter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitEvent();
|
||||||
|
}
|
||||||
|
|
||||||
function deleteEvent() {
|
function deleteEvent() {
|
||||||
emit("delete");
|
emit("delete");
|
||||||
submitted.value = true;
|
submitted.value = true;
|
||||||
@@ -192,8 +199,8 @@ function open() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* function close() {
|
/* function close() {
|
||||||
dialog.value = false;
|
dialog.value = false;
|
||||||
logDeprecatedProp("close");
|
logDeprecatedProp("close");
|
||||||
} */
|
} */
|
||||||
|
|
||||||
function logDeprecatedProp(val: string) {
|
function logDeprecatedProp(val: string) {
|
||||||
|
|||||||
17
frontend/components/global/BaseExpansionPanels.vue
Normal file
17
frontend/components/global/BaseExpansionPanels.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<v-expansion-panels v-model="open">
|
||||||
|
<slot />
|
||||||
|
</v-expansion-panels>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
startOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
startOpen: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = ref(props.startOpen ? [0] : []);
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- eslint-disable-next-line vue/no-v-html is safe here because all HTML is sanitized with DOMPurify in setup() -->
|
<!-- eslint-disable-next-line vue/no-v-html is safe here because all HTML is sanitized with DOMPurify in setup() -->
|
||||||
<div v-html="value" />
|
<div v-html="value" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export { useAppInfo } from "./use-app-info";
|
|
||||||
export { useStaticRoutes } from "./static-routes";
|
export { useStaticRoutes } from "./static-routes";
|
||||||
export { useAdminApi, usePublicApi, usePublicExploreApi, useUserApi } from "./api-client";
|
export { useAdminApi, usePublicApi, usePublicExploreApi, useUserApi } from "./api-client";
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { useAsyncKey } from "../use-utils";
|
|
||||||
import type { AppInfo } from "~/lib/api/types/admin";
|
|
||||||
|
|
||||||
export function useAppInfo(): Ref<AppInfo | null> {
|
|
||||||
const appInfo = ref<null | AppInfo>(null);
|
|
||||||
|
|
||||||
const i18n = useI18n();
|
|
||||||
const { $axios } = useNuxtApp();
|
|
||||||
$axios.defaults.headers.common["Accept-Language"] = i18n.locale.value;
|
|
||||||
|
|
||||||
useAsyncData(useAsyncKey(), async () => {
|
|
||||||
const data = await $axios.get<AppInfo>("/api/app/about");
|
|
||||||
appInfo.value = data.data;
|
|
||||||
});
|
|
||||||
|
|
||||||
return appInfo;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import { alert } from "~/composables/use-toast";
|
||||||
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
|
|
||||||
export function useDownloader() {
|
export function useDownloader() {
|
||||||
function download(url: string, filename: string) {
|
function download(url: string, filename: string) {
|
||||||
useFetch(url, {
|
useFetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
onResponse({ response }) {
|
onResponse({ response }) {
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Download failed", response);
|
||||||
|
const i18n = useGlobalI18n();
|
||||||
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response._data]));
|
const url = window.URL.createObjectURL(new Blob([response._data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useAsyncKey } from "../use-utils";
|
import type { AsyncData, NuxtError } from "#app";
|
||||||
import type { BoundT } from "./types";
|
import type { BoundT } from "./types";
|
||||||
import type { BaseCRUDAPI, BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
|
import type { BaseCRUDAPI, BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
|
||||||
import type { QueryValue } from "~/lib/api/base/route";
|
import type { QueryValue } from "~/lib/api/base/route";
|
||||||
|
|
||||||
interface ReadOnlyStoreActions<T extends BoundT> {
|
interface ReadOnlyStoreActions<T extends BoundT> {
|
||||||
getAll(page?: number, perPage?: number, params?: any): Ref<T[] | null>;
|
getAll(page?: number, perPage?: number, params?: any): AsyncData<T[] | null, NuxtError<unknown> | null>;
|
||||||
refresh(page?: number, perPage?: number, params?: any): Promise<void>;
|
refresh(page?: number, perPage?: number, params?: any): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ interface StoreActions<T extends BoundT> extends ReadOnlyStoreActions<T> {
|
|||||||
* a lot of refreshing hooks to be called on operations
|
* a lot of refreshing hooks to be called on operations
|
||||||
*/
|
*/
|
||||||
export function useReadOnlyActions<T extends BoundT>(
|
export function useReadOnlyActions<T extends BoundT>(
|
||||||
|
storeKey: string,
|
||||||
api: BaseCRUDAPIReadOnly<T>,
|
api: BaseCRUDAPIReadOnly<T>,
|
||||||
allRef: Ref<T[] | null> | null,
|
allRef: Ref<T[] | null> | null,
|
||||||
loading: Ref<boolean>,
|
loading: Ref<boolean>,
|
||||||
@@ -29,20 +30,24 @@ export function useReadOnlyActions<T extends BoundT>(
|
|||||||
params.orderBy ??= "name";
|
params.orderBy ??= "name";
|
||||||
params.orderDirection ??= "asc";
|
params.orderDirection ??= "asc";
|
||||||
|
|
||||||
loading.value = true;
|
const allItems = useAsyncData(storeKey, async () => {
|
||||||
const allItems = useAsyncData(useAsyncKey(), async () => {
|
loading.value = true;
|
||||||
const { data } = await api.getAll(page, perPage, params);
|
try {
|
||||||
loading.value = false;
|
const { data } = await api.getAll(page, perPage, params);
|
||||||
|
|
||||||
if (data && allRef) {
|
if (data && allRef) {
|
||||||
allRef.value = data.items;
|
allRef.value = data.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
return data.items ?? [];
|
return data.items ?? [];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
finally {
|
||||||
return [];
|
loading.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,6 +81,7 @@ export function useReadOnlyActions<T extends BoundT>(
|
|||||||
* a lot of refreshing hooks to be called on operations
|
* a lot of refreshing hooks to be called on operations
|
||||||
*/
|
*/
|
||||||
export function useStoreActions<T extends BoundT>(
|
export function useStoreActions<T extends BoundT>(
|
||||||
|
storeKey: string,
|
||||||
api: BaseCRUDAPI<unknown, T, unknown>,
|
api: BaseCRUDAPI<unknown, T, unknown>,
|
||||||
allRef: Ref<T[] | null> | null,
|
allRef: Ref<T[] | null> | null,
|
||||||
loading: Ref<boolean>,
|
loading: Ref<boolean>,
|
||||||
@@ -84,20 +90,24 @@ export function useStoreActions<T extends BoundT>(
|
|||||||
params.orderBy ??= "name";
|
params.orderBy ??= "name";
|
||||||
params.orderDirection ??= "asc";
|
params.orderDirection ??= "asc";
|
||||||
|
|
||||||
loading.value = true;
|
const allItems = useAsyncData(storeKey, async () => {
|
||||||
const allItems = useAsyncData(useAsyncKey(), async () => {
|
loading.value = true;
|
||||||
const { data } = await api.getAll(page, perPage, params);
|
try {
|
||||||
loading.value = false;
|
const { data } = await api.getAll(page, perPage, params);
|
||||||
|
|
||||||
if (data && allRef) {
|
if (data && allRef) {
|
||||||
allRef.value = data.items;
|
allRef.value = data.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
return data.items ?? [];
|
return data.items ?? [];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
finally {
|
||||||
return [];
|
loading.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ export const useData = function <T extends BoundT>(defaultObject: T) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useReadOnlyStore = function <T extends BoundT>(
|
export const useReadOnlyStore = function <T extends BoundT>(
|
||||||
|
storeKey: string,
|
||||||
store: Ref<T[]>,
|
store: Ref<T[]>,
|
||||||
loading: Ref<boolean>,
|
loading: Ref<boolean>,
|
||||||
api: BaseCRUDAPIReadOnly<T>,
|
api: BaseCRUDAPIReadOnly<T>,
|
||||||
params = {} as Record<string, QueryValue>,
|
params = {} as Record<string, QueryValue>,
|
||||||
) {
|
) {
|
||||||
const storeActions = useReadOnlyActions(api, store, loading);
|
const storeActions = useReadOnlyActions(`${storeKey}-store-readonly`, api, store, loading);
|
||||||
const actions = {
|
const actions = {
|
||||||
...storeActions,
|
...storeActions,
|
||||||
async refresh() {
|
async refresh() {
|
||||||
@@ -29,21 +30,22 @@ export const useReadOnlyStore = function <T extends BoundT>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!loading.value && (!store.value || store.value.length === 0)) {
|
// initial hydration
|
||||||
const result = actions.getAll(1, -1, params);
|
if (!loading.value && !store.value.length) {
|
||||||
store.value = result.value || [];
|
actions.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { store, actions };
|
return { store, actions };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useStore = function <T extends BoundT>(
|
export const useStore = function <T extends BoundT>(
|
||||||
|
storeKey: string,
|
||||||
store: Ref<T[]>,
|
store: Ref<T[]>,
|
||||||
loading: Ref<boolean>,
|
loading: Ref<boolean>,
|
||||||
api: BaseCRUDAPI<unknown, T, unknown>,
|
api: BaseCRUDAPI<unknown, T, unknown>,
|
||||||
params = {} as Record<string, QueryValue>,
|
params = {} as Record<string, QueryValue>,
|
||||||
) {
|
) {
|
||||||
const storeActions = useStoreActions(api, store, loading);
|
const storeActions = useStoreActions(`${storeKey}-store`, api, store, loading);
|
||||||
const actions = {
|
const actions = {
|
||||||
...storeActions,
|
...storeActions,
|
||||||
async refresh() {
|
async refresh() {
|
||||||
@@ -54,9 +56,9 @@ export const useStore = function <T extends BoundT>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!loading.value && (!store.value || store.value.length === 0)) {
|
// initial hydration
|
||||||
const result = actions.getAll(1, -1, params);
|
if (!loading.value && !store.value.length) {
|
||||||
store.value = result.value || [];
|
actions.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { store, actions };
|
return { store, actions };
|
||||||
|
|||||||
@@ -29,26 +29,31 @@ interface PageState {
|
|||||||
editMode: ComputedRef<EditorMode>;
|
editMode: ComputedRef<EditorMode>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* true is the page is in edit mode and the edit mode is in form mode.
|
* true is the page is in edit mode and the edit mode is in form mode.
|
||||||
*/
|
*/
|
||||||
isEditForm: ComputedRef<boolean>;
|
isEditForm: ComputedRef<boolean>;
|
||||||
/**
|
/**
|
||||||
* true is the page is in edit mode and the edit mode is in json mode.
|
* true is the page is in edit mode and the edit mode is in json mode.
|
||||||
*/
|
*/
|
||||||
isEditJSON: ComputedRef<boolean>;
|
isEditJSON: ComputedRef<boolean>;
|
||||||
/**
|
/**
|
||||||
* true is the page is in view mode.
|
* true is the page is in view mode.
|
||||||
*/
|
*/
|
||||||
isEditMode: ComputedRef<boolean>;
|
isEditMode: ComputedRef<boolean>;
|
||||||
/**
|
/**
|
||||||
* true is the page is in cook mode.
|
* true is the page is in cook mode.
|
||||||
*/
|
*/
|
||||||
isCookMode: ComputedRef<boolean>;
|
isCookMode: ComputedRef<boolean>;
|
||||||
|
/**
|
||||||
|
* true if the recipe is currently being parsed.
|
||||||
|
*/
|
||||||
|
isParsing: ComputedRef<boolean>;
|
||||||
|
|
||||||
setMode: (v: PageMode) => void;
|
setMode: (v: PageMode) => void;
|
||||||
setEditMode: (v: EditorMode) => void;
|
setEditMode: (v: EditorMode) => void;
|
||||||
toggleEditMode: () => void;
|
toggleEditMode: () => void;
|
||||||
toggleCookMode: () => void;
|
toggleCookMode: () => void;
|
||||||
|
toggleIsParsing: (v?: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PageRefs = ReturnType<typeof pageRefs>;
|
type PageRefs = ReturnType<typeof pageRefs>;
|
||||||
@@ -60,11 +65,12 @@ function pageRefs(slug: string) {
|
|||||||
slugRef: ref(slug),
|
slugRef: ref(slug),
|
||||||
pageModeRef: ref(PageMode.VIEW),
|
pageModeRef: ref(PageMode.VIEW),
|
||||||
editModeRef: ref(EditorMode.FORM),
|
editModeRef: ref(EditorMode.FORM),
|
||||||
|
isParsingRef: ref(false),
|
||||||
imageKey: ref(1),
|
imageKey: ref(1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): PageState {
|
function pageState({ slugRef, pageModeRef, editModeRef, isParsingRef, imageKey }: PageRefs): PageState {
|
||||||
const { activateNavigationWarning, deactivateNavigationWarning } = useNavigationWarning();
|
const { activateNavigationWarning, deactivateNavigationWarning } = useNavigationWarning();
|
||||||
|
|
||||||
const toggleEditMode = () => {
|
const toggleEditMode = () => {
|
||||||
@@ -83,6 +89,14 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P
|
|||||||
pageModeRef.value = PageMode.COOK;
|
pageModeRef.value = PageMode.COOK;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleIsParsing = (v: boolean | null = null) => {
|
||||||
|
if (v === null) {
|
||||||
|
v = !isParsingRef.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
isParsingRef.value = v;
|
||||||
|
};
|
||||||
|
|
||||||
const setEditMode = (v: EditorMode) => {
|
const setEditMode = (v: EditorMode) => {
|
||||||
editModeRef.value = v;
|
editModeRef.value = v;
|
||||||
};
|
};
|
||||||
@@ -113,6 +127,7 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P
|
|||||||
setMode,
|
setMode,
|
||||||
setEditMode,
|
setEditMode,
|
||||||
toggleCookMode,
|
toggleCookMode,
|
||||||
|
toggleIsParsing,
|
||||||
|
|
||||||
isEditForm: computed(() => {
|
isEditForm: computed(() => {
|
||||||
return pageModeRef.value === PageMode.EDIT && editModeRef.value === EditorMode.FORM;
|
return pageModeRef.value === PageMode.EDIT && editModeRef.value === EditorMode.FORM;
|
||||||
@@ -126,6 +141,9 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P
|
|||||||
isCookMode: computed(() => {
|
isCookMode: computed(() => {
|
||||||
return pageModeRef.value === PageMode.COOK;
|
return pageModeRef.value === PageMode.COOK;
|
||||||
}),
|
}),
|
||||||
|
isParsing: computed(() => {
|
||||||
|
return isParsingRef.value;
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import DOMPurify from "isomorphic-dompurify";
|
import DOMPurify from "isomorphic-dompurify";
|
||||||
import { useFraction } from "./use-fraction";
|
import { useFraction } from "./use-fraction";
|
||||||
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
const { frac } = useFraction();
|
const { frac } = useFraction();
|
||||||
|
|
||||||
@@ -36,8 +36,28 @@ function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, us
|
|||||||
return returnVal;
|
return returnVal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true) {
|
function useRecipeLink(recipe: Recipe | undefined, groupSlug: string | undefined): string | undefined {
|
||||||
const { quantity, food, unit, note, title } = ingredient;
|
if (!(recipe && recipe.slug && recipe.name && groupSlug)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<a href="/g/${groupSlug}/r/${recipe.slug}" target="_blank">${recipe.name}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedIngredientText = {
|
||||||
|
quantity?: string;
|
||||||
|
unit?: string;
|
||||||
|
name?: string;
|
||||||
|
note?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the ingredient is a linked recipe, an HTML link to the referenced recipe, otherwise undefined.
|
||||||
|
*/
|
||||||
|
recipeLink?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
|
||||||
|
const { quantity, food, unit, note, referencedRecipe } = ingredient;
|
||||||
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
|
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
|
||||||
const usePluralFood = (!quantity) || quantity * scale > 1;
|
const usePluralFood = (!quantity) || quantity * scale > 1;
|
||||||
|
|
||||||
@@ -63,14 +83,14 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unitName = useUnitName(unit || undefined, usePluralUnit);
|
const unitName = useUnitName(unit || undefined, usePluralUnit);
|
||||||
const foodName = useFoodName(food || undefined, usePluralFood);
|
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: title ? sanitizeIngredientHTML(title) : undefined,
|
|
||||||
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
|
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
|
||||||
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
|
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
|
||||||
name: foodName ? sanitizeIngredientHTML(foodName) : undefined,
|
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
|
||||||
note: note ? sanitizeIngredientHTML(note) : undefined,
|
note: note ? sanitizeIngredientHTML(note) : undefined,
|
||||||
|
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,13 +97,8 @@ export function useShoppingListCrud(
|
|||||||
.sort(sortCheckedItems);
|
.sort(sortCheckedItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the item if it's checked, otherwise updateUncheckedListItems will handle it
|
shoppingListItemActions.updateItem(item);
|
||||||
if (item.checked) {
|
|
||||||
shoppingListItemActions.updateItem(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateListItemOrder();
|
updateListItemOrder();
|
||||||
updateUncheckedListItems();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteListItem(item: ShoppingListItemOut) {
|
function deleteListItem(item: ShoppingListItemOut) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useToggle } from "@vueuse/core";
|
import { useToggle } from "@vueuse/core";
|
||||||
import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household";
|
import type { ShoppingListOut } from "~/lib/api/types/household";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for managing shopping list label state and operations
|
* Composable for managing shopping list label state and operations
|
||||||
@@ -36,14 +36,24 @@ export function useShoppingListLabels(shoppingList: Ref<ShoppingListOut | null>)
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const labelColorByName = computed(() => {
|
||||||
|
const map: Record<string, string | undefined> = {};
|
||||||
|
shoppingList.value?.listItems?.forEach((item) => {
|
||||||
|
if (!item.label) return;
|
||||||
|
const labelName = item.label?.name || t("shopping-list.no-label");
|
||||||
|
map[labelName] = item.label.color;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
watch(labelNames, initializeLabelOpenStates, { immediate: true });
|
watch(labelNames, initializeLabelOpenStates, { immediate: true });
|
||||||
|
|
||||||
function toggleShowLabel(key: string) {
|
function toggleShowLabel(key: string) {
|
||||||
labelOpenState.value[key] = !labelOpenState.value[key];
|
labelOpenState.value[key] = !labelOpenState.value[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLabelColor(item: ShoppingListItemOut | null) {
|
function getLabelColor(label: string) {
|
||||||
return item?.label?.color;
|
return labelColorByName.value[label];
|
||||||
}
|
}
|
||||||
|
|
||||||
const presentLabels = computed(() => {
|
const presentLabels = computed(() => {
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export const useCategoryData = function () {
|
|||||||
|
|
||||||
export const useCategoryStore = function (i18n?: Composer) {
|
export const useCategoryStore = function (i18n?: Composer) {
|
||||||
const api = useUserApi(i18n);
|
const api = useUserApi(i18n);
|
||||||
return useStore<RecipeCategory>(store, loading, api.categories);
|
return useStore<RecipeCategory>("category", store, loading, api.categories);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePublicCategoryStore = function (groupSlug: string, i18n?: Composer) {
|
export const usePublicCategoryStore = function (groupSlug: string, i18n?: Composer) {
|
||||||
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
||||||
return useReadOnlyStore<RecipeCategory>(store, publicLoading, api.categories);
|
return useReadOnlyStore<RecipeCategory>("category", store, publicLoading, api.categories);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const publicLoading = ref(false);
|
|||||||
|
|
||||||
export const useCookbookStore = function (i18n?: Composer) {
|
export const useCookbookStore = function (i18n?: Composer) {
|
||||||
const api = useUserApi(i18n);
|
const api = useUserApi(i18n);
|
||||||
const store = useStore<ReadCookBook>(cookbooks, loading, api.cookbooks);
|
const store = useStore<ReadCookBook>("cookbook", cookbooks, loading, api.cookbooks);
|
||||||
|
|
||||||
const updateAll = async function (updateData: UpdateCookBook[]) {
|
const updateAll = async function (updateData: UpdateCookBook[]) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -25,5 +25,5 @@ export const useCookbookStore = function (i18n?: Composer) {
|
|||||||
|
|
||||||
export const usePublicCookbookStore = function (groupSlug: string, i18n?: Composer) {
|
export const usePublicCookbookStore = function (groupSlug: string, i18n?: Composer) {
|
||||||
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
||||||
return useReadOnlyStore<ReadCookBook>(cookbooks, publicLoading, api.cookbooks);
|
return useReadOnlyStore<ReadCookBook>("cookbook", cookbooks, publicLoading, api.cookbooks);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export const useFoodData = function () {
|
|||||||
|
|
||||||
export const useFoodStore = function (i18n?: Composer) {
|
export const useFoodStore = function (i18n?: Composer) {
|
||||||
const api = useUserApi(i18n);
|
const api = useUserApi(i18n);
|
||||||
return useStore<IngredientFood>(store, loading, api.foods);
|
return useStore<IngredientFood>("food", store, loading, api.foods);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePublicFoodStore = function (groupSlug: string, i18n?: Composer) {
|
export const usePublicFoodStore = function (groupSlug: string, i18n?: Composer) {
|
||||||
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
||||||
return useReadOnlyStore<IngredientFood>(store, publicLoading, api.foods);
|
return useReadOnlyStore<IngredientFood>("food", store, publicLoading, api.foods);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ const publicLoading = ref(false);
|
|||||||
|
|
||||||
export const useHouseholdStore = function (i18n?: Composer) {
|
export const useHouseholdStore = function (i18n?: Composer) {
|
||||||
const api = useUserApi(i18n);
|
const api = useUserApi(i18n);
|
||||||
return useReadOnlyStore<HouseholdSummary>(store, loading, api.households);
|
return useReadOnlyStore<HouseholdSummary>("household", store, loading, api.households);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePublicHouseholdStore = function (groupSlug: string, i18n?: Composer) {
|
export const usePublicHouseholdStore = function (groupSlug: string, i18n?: Composer) {
|
||||||
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
||||||
return useReadOnlyStore<HouseholdSummary>(store, publicLoading, api.households);
|
return useReadOnlyStore<HouseholdSummary>("household-public", store, publicLoading, api.households);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,5 +17,5 @@ export const useLabelData = function () {
|
|||||||
|
|
||||||
export const useLabelStore = function (i18n?: Composer) {
|
export const useLabelStore = function (i18n?: Composer) {
|
||||||
const api = useUserApi(i18n);
|
const api = useUserApi(i18n);
|
||||||
return useStore<MultiPurposeLabelOut>(store, loading, api.multiPurposeLabels);
|
return useStore<MultiPurposeLabelOut>("label", store, loading, api.multiPurposeLabels);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export const useTagData = function () {
|
|||||||
|
|
||||||
export const useTagStore = function (i18n?: Composer) {
|
export const useTagStore = function (i18n?: Composer) {
|
||||||
const api = useUserApi(i18n);
|
const api = useUserApi(i18n);
|
||||||
return useStore<RecipeTag>(store, loading, api.tags);
|
return useStore<RecipeTag>("tag", store, loading, api.tags);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePublicTagStore = function (groupSlug: string, i18n?: Composer) {
|
export const usePublicTagStore = function (groupSlug: string, i18n?: Composer) {
|
||||||
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
||||||
return useReadOnlyStore<RecipeTag>(store, publicLoading, api.tags);
|
return useReadOnlyStore<RecipeTag>("tag", store, publicLoading, api.tags);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export const useToolData = function () {
|
|||||||
|
|
||||||
export const useToolStore = function (i18n?: Composer) {
|
export const useToolStore = function (i18n?: Composer) {
|
||||||
const api = useUserApi(i18n);
|
const api = useUserApi(i18n);
|
||||||
return useStore<RecipeTool>(store, loading, api.tools);
|
return useStore<RecipeTool>("tool", store, loading, api.tools);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePublicToolStore = function (groupSlug: string, i18n?: Composer) {
|
export const usePublicToolStore = function (groupSlug: string, i18n?: Composer) {
|
||||||
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
||||||
return useReadOnlyStore<RecipeTool>(store, publicLoading, api.tools);
|
return useReadOnlyStore<RecipeTool>("tool", store, publicLoading, api.tools);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,5 +18,5 @@ export const useUnitData = function () {
|
|||||||
|
|
||||||
export const useUnitStore = function (i18n?: Composer) {
|
export const useUnitStore = function (i18n?: Composer) {
|
||||||
const api = useUserApi(i18n);
|
const api = useUserApi(i18n);
|
||||||
return useStore<IngredientUnit>(store, loading, api.units);
|
return useStore<IngredientUnit>("unit", store, loading, api.units);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,5 +16,5 @@ export const useUserStore = function (i18n?: Composer) {
|
|||||||
const requests = useRequests(i18n);
|
const requests = useRequests(i18n);
|
||||||
const api = new GroupUserAPIReadOnly(requests);
|
const api = new GroupUserAPIReadOnly(requests);
|
||||||
|
|
||||||
return useReadOnlyStore<UserSummary>(store, loading, api, { orderBy: "full_name" });
|
return useReadOnlyStore<UserSummary>("user", store, loading, api, { orderBy: "full_name" });
|
||||||
};
|
};
|
||||||
|
|||||||
132
frontend/composables/use-auth-backend.ts
Normal file
132
frontend/composables/use-auth-backend.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { ref, computed } from "vue";
|
||||||
|
import type { UserOut } from "~/lib/api/types/user";
|
||||||
|
|
||||||
|
interface AuthData {
|
||||||
|
value: UserOut | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthStatus {
|
||||||
|
value: "loading" | "authenticated" | "unauthenticated";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
data: AuthData;
|
||||||
|
status: AuthStatus;
|
||||||
|
signIn: (credentials: FormData, options?: { redirect?: boolean }) => Promise<void>;
|
||||||
|
signOut: (callbackUrl?: string) => Promise<void>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
getSession: () => Promise<void>;
|
||||||
|
setToken: (token: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUser = ref<UserOut | null>(null);
|
||||||
|
const authStatus = ref<"loading" | "authenticated" | "unauthenticated">("loading");
|
||||||
|
|
||||||
|
export const useAuthBackend = function (): AuthState {
|
||||||
|
const { $appInfo, $axios } = useNuxtApp();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const runtimeConfig = useRuntimeConfig();
|
||||||
|
const tokenName = runtimeConfig.public.AUTH_TOKEN;
|
||||||
|
const tokenCookie = useCookie(tokenName, {
|
||||||
|
maxAge: $appInfo.tokenTime * 60 * 60,
|
||||||
|
secure: $appInfo.production && window?.location?.protocol === "https:",
|
||||||
|
});
|
||||||
|
|
||||||
|
function setToken(token: string | null) {
|
||||||
|
tokenCookie.value = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthError(error: any, redirect = false) {
|
||||||
|
// Only clear token on auth errors, not network errors
|
||||||
|
if (error?.response?.status === 401) {
|
||||||
|
setToken(null);
|
||||||
|
authUser.value = null;
|
||||||
|
authStatus.value = "unauthenticated";
|
||||||
|
if (redirect) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSession(): Promise<void> {
|
||||||
|
if (!tokenCookie.value) {
|
||||||
|
authUser.value = null;
|
||||||
|
authStatus.value = "unauthenticated";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
authStatus.value = "loading";
|
||||||
|
try {
|
||||||
|
const { data } = await $axios.get<UserOut>("/api/users/self");
|
||||||
|
authUser.value = data;
|
||||||
|
authStatus.value = "authenticated";
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
console.error("Failed to fetch user session:", error);
|
||||||
|
handleAuthError(error);
|
||||||
|
authStatus.value = "unauthenticated";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(credentials: FormData): Promise<void> {
|
||||||
|
authStatus.value = "loading";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $axios.post("/api/auth/token", credentials, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { access_token } = response.data;
|
||||||
|
setToken(access_token);
|
||||||
|
await getSession();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
authStatus.value = "unauthenticated";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut(callbackUrl: string = ""): Promise<void> {
|
||||||
|
try {
|
||||||
|
await $axios.post("/api/auth/logout");
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// Continue with logout even if API call fails
|
||||||
|
console.warn("Logout API call failed:", error);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setToken(null);
|
||||||
|
authUser.value = null;
|
||||||
|
authStatus.value = "unauthenticated";
|
||||||
|
await router.push(callbackUrl || "/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
if (!tokenCookie.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $axios.get("/api/auth/refresh");
|
||||||
|
const { access_token } = response.data;
|
||||||
|
setToken(access_token);
|
||||||
|
await getSession();
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
handleAuthError(error, true);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: computed(() => authUser.value),
|
||||||
|
status: computed(() => authStatus.value),
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
refresh,
|
||||||
|
getSession,
|
||||||
|
setToken,
|
||||||
|
};
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user