mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-28 12:40:27 -04:00
Compare commits
415 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
085ecbaae3 | ||
|
|
453d40dab1 | ||
|
|
fc8b1f3719 | ||
|
|
c029a639fb | ||
|
|
63c549ae5c | ||
|
|
b1a846fe62 | ||
|
|
8545cf0c1c | ||
|
|
7c5913b012 | ||
|
|
4dd8d836e1 | ||
|
|
449e3baa07 | ||
|
|
e52a887e30 | ||
|
|
910ac4c81f | ||
|
|
52ad02aad8 | ||
|
|
93d51a2fdb | ||
|
|
41c3f1fced | ||
|
|
9f47f38176 | ||
|
|
a8142a08a1 | ||
|
|
1ede524d90 | ||
|
|
ab3eb6fec2 | ||
|
|
d64dcab9bd | ||
|
|
f9ff29dffc | ||
|
|
47794089da | ||
|
|
b0328ad926 | ||
|
|
18b3c4beab | ||
|
|
27cb585c80 | ||
|
|
f9ddfa94d4 | ||
|
|
5ab6e98f9e | ||
|
|
3ad2d9155d | ||
|
|
6278698ce5 | ||
|
|
3413c23f16 | ||
|
|
2ff2f22060 | ||
|
|
0f8ccdaade | ||
|
|
825c707035 | ||
|
|
9b9a767b00 | ||
|
|
c8793c474a | ||
|
|
b64e27b24b | ||
|
|
94cd6e89cb | ||
|
|
4c02724087 | ||
|
|
33c73feb1c | ||
|
|
002a7e3741 | ||
|
|
be4f71e5df | ||
|
|
78ff4bb875 | ||
|
|
26924ab054 | ||
|
|
c533da1c21 | ||
|
|
1969f50ee6 | ||
|
|
ff7d23d6d4 | ||
|
|
5e239be6fa | ||
|
|
e5520b08e5 | ||
|
|
fce0b47e2c | ||
|
|
e00d2a6e83 | ||
|
|
948914df50 | ||
|
|
0c7d9341bf | ||
|
|
b94b24640b | ||
|
|
c303198857 | ||
|
|
9424858985 | ||
|
|
3711154c44 | ||
|
|
cc12d07576 | ||
|
|
3e96bf7f43 | ||
|
|
ee550f4fe3 | ||
|
|
60d4a62f0a | ||
|
|
69d740e100 | ||
|
|
c4fdab4e05 | ||
|
|
04dd514e6a | ||
|
|
ad5d4b5aba | ||
|
|
b948314c6b | ||
|
|
2568015941 | ||
|
|
235a1f1931 | ||
|
|
8c23fd922a | ||
|
|
537f5ae1dd | ||
|
|
9372f51000 | ||
|
|
1bdb3ce54c | ||
|
|
16e8e8a877 | ||
|
|
6a3b38a31e | ||
|
|
8189416495 | ||
|
|
589c6de053 | ||
|
|
d3d339a4aa | ||
|
|
5c2bbea09b | ||
|
|
f6ca4bb29a | ||
|
|
ddc30fc65f | ||
|
|
a4d1e0a440 | ||
|
|
86b72f4d5b | ||
|
|
1344f1674d | ||
|
|
5a223aa92d | ||
|
|
845637c988 | ||
|
|
b5c089f58c | ||
|
|
96597915ff | ||
|
|
98c555fd20 | ||
|
|
118274ad9d | ||
|
|
50ea601683 | ||
|
|
7cc9010143 | ||
|
|
1f196cf10f | ||
|
|
024e20b4e5 | ||
|
|
5aafebb7a6 | ||
|
|
a89460acdf | ||
|
|
89091678d4 | ||
|
|
562eb89ee7 | ||
|
|
5e40fed623 | ||
|
|
0ddfd9caaf | ||
|
|
56086bdf49 | ||
|
|
a0674dd5d2 | ||
|
|
45c68d160a | ||
|
|
058937e334 | ||
|
|
c91f8e23d7 | ||
|
|
77081d0482 | ||
|
|
bf11729a23 | ||
|
|
b5016857c8 | ||
|
|
26fdf78709 | ||
|
|
455dbb1441 | ||
|
|
f28de01b2e | ||
|
|
ccdf7109e2 | ||
|
|
e574896449 | ||
|
|
15d56c42f7 | ||
|
|
c983e8bd59 | ||
|
|
7584c99591 | ||
|
|
78718dcf26 | ||
|
|
a8ec66f9aa | ||
|
|
3899f85735 | ||
|
|
7cbe17fe09 | ||
|
|
929349d414 | ||
|
|
595a3c66cd | ||
|
|
d4e7dc6e9d | ||
|
|
b719b39c09 | ||
|
|
b208719cb9 | ||
|
|
3ee64c930c | ||
|
|
189e98fb1f | ||
|
|
7da01f7873 | ||
|
|
8144799733 | ||
|
|
669df6bbb4 | ||
|
|
c6171f2cb2 | ||
|
|
e5d930ecb8 | ||
|
|
668246d369 | ||
|
|
b182b50faa | ||
|
|
a3e3fa6f56 | ||
|
|
bd7acabcd1 | ||
|
|
8059cc731f | ||
|
|
3ae455539c | ||
|
|
8fd7995681 | ||
|
|
282eedfe2b | ||
|
|
03f849f20f | ||
|
|
5db3b6ab72 | ||
|
|
353c24ca4b | ||
|
|
216ae8571c | ||
|
|
02d32c8905 | ||
|
|
7e0d083e77 | ||
|
|
b3cea081fe | ||
|
|
d79252752b | ||
|
|
b3c214d102 | ||
|
|
3a01925e48 | ||
|
|
16e2386f5a | ||
|
|
bbfa105e99 | ||
|
|
c94c9940b2 | ||
|
|
29c6176d89 | ||
|
|
0c0d7d11a5 | ||
|
|
e75fc6d391 | ||
|
|
f308869154 | ||
|
|
af30b8bdfa | ||
|
|
de4f22c3f6 | ||
|
|
4c55b282d6 | ||
|
|
8d2b2eb581 | ||
|
|
e9daac5fc4 | ||
|
|
ee1205cfdc | ||
|
|
a165b707af | ||
|
|
564385eb83 | ||
|
|
c23aa61f17 | ||
|
|
cd39d0c4cb | ||
|
|
20e2d4e1a1 | ||
|
|
c09cc5a323 | ||
|
|
6d7b6bccab | ||
|
|
91fea086e5 | ||
|
|
e2fbe118a7 | ||
|
|
904e6b7d82 | ||
|
|
5aafb56c4f | ||
|
|
b4740d291d | ||
|
|
fc6dc34ace | ||
|
|
73d86f6f6b | ||
|
|
8e225ee796 | ||
|
|
ced233d361 | ||
|
|
b173172e6c | ||
|
|
a66db96eb5 | ||
|
|
dfd5abfb5d | ||
|
|
e2ae5cb5b6 | ||
|
|
634aa5cd25 | ||
|
|
23c7bd7e3d | ||
|
|
9c1ee972c9 | ||
|
|
1b9023c8c0 | ||
|
|
3a37cd6959 | ||
|
|
8da0d010a5 | ||
|
|
37f7f770a8 | ||
|
|
1cebbefd88 | ||
|
|
d55149b904 | ||
|
|
fad7acadfc | ||
|
|
a539c6cd2e | ||
|
|
7b5502d019 | ||
|
|
26d9d8fe24 | ||
|
|
b64f14aaae | ||
|
|
9b686ecd2b | ||
|
|
a956a638f4 | ||
|
|
c9d9e6822e | ||
|
|
4a563b76ad | ||
|
|
73f97c2cca | ||
|
|
75e3c99d72 | ||
|
|
217ddd8814 | ||
|
|
f2cc8dc922 | ||
|
|
b8329def91 | ||
|
|
2ae7dc3b82 | ||
|
|
510a63a71f | ||
|
|
14433819c3 | ||
|
|
96a9dbccb6 | ||
|
|
cfe20214e5 | ||
|
|
eef54879fe | ||
|
|
c789ecf0ba | ||
|
|
008f55e725 | ||
|
|
bcbe32f503 | ||
|
|
4101797c0e | ||
|
|
6110200a04 | ||
|
|
49f1e76776 | ||
|
|
24e9417d02 | ||
|
|
69d6985f3b | ||
|
|
84cdeb2398 | ||
|
|
6d439de144 | ||
|
|
1b586f8c67 | ||
|
|
f82f387146 | ||
|
|
d31c07a6c5 | ||
|
|
84372c2f4f | ||
|
|
168ac79daa | ||
|
|
22296277a8 | ||
|
|
6e006458be | ||
|
|
76a2fea076 | ||
|
|
3de4024619 | ||
|
|
194771653d | ||
|
|
24aa8f3525 | ||
|
|
fb8e318739 | ||
|
|
6255c71609 | ||
|
|
f2d1569488 | ||
|
|
987c7209fc | ||
|
|
f6dbd1f1f1 | ||
|
|
d30118899d | ||
|
|
af241dad57 | ||
|
|
b86de79c6f | ||
|
|
86e86f8c81 | ||
|
|
d795f91938 | ||
|
|
a59511cc81 | ||
|
|
a5d4cae6d0 | ||
|
|
2987cf8ba6 | ||
|
|
46b46978ff | ||
|
|
12857883a9 | ||
|
|
60fff3b5b8 | ||
|
|
b42e888929 | ||
|
|
570d6f1433 | ||
|
|
dcf410739e | ||
|
|
1929d630a1 | ||
|
|
c4c7bf2aed | ||
|
|
47034d18c5 | ||
|
|
7ebe491f74 | ||
|
|
719bd89eb1 | ||
|
|
9030c7e6b9 | ||
|
|
0202cc7ef8 | ||
|
|
381ac9bfde | ||
|
|
e9fe71c1b7 | ||
|
|
79bbc20cd6 | ||
|
|
c7be4a452a | ||
|
|
731ee8ae3d | ||
|
|
c7ae67e7cd | ||
|
|
e83891e3ca | ||
|
|
e3e45c534e | ||
|
|
279cf65673 | ||
|
|
cb44ecf394 | ||
|
|
920eeb26d6 | ||
|
|
9738d9f363 | ||
|
|
37e6123f9e | ||
|
|
0a2cabb348 | ||
|
|
447a1fb239 | ||
|
|
b5358896eb | ||
|
|
78fbbf0264 | ||
|
|
a33d8204df | ||
|
|
c8046bbdf0 | ||
|
|
329ad4d8ed | ||
|
|
4ccf649aa1 | ||
|
|
5994328a8b | ||
|
|
15b5917054 | ||
|
|
e48b150f7c | ||
|
|
adbc66316f | ||
|
|
0dc7337972 | ||
|
|
58d4b95a56 | ||
|
|
0e74bc6cd0 | ||
|
|
4866eec62d | ||
|
|
c0d659724a | ||
|
|
5f0996734a | ||
|
|
8cd0286ca1 | ||
|
|
f214e8843a | ||
|
|
66fea60341 | ||
|
|
69b4684bce | ||
|
|
b75d6812a3 | ||
|
|
ed000c2cc6 | ||
|
|
d43a2020b3 | ||
|
|
ff5e65b323 | ||
|
|
e1b07a250b | ||
|
|
e68486a0e1 | ||
|
|
271915ee23 | ||
|
|
a3d64c0761 | ||
|
|
73c664649d | ||
|
|
d887e68228 | ||
|
|
a8d3ed3310 | ||
|
|
00bd45c8f1 | ||
|
|
05003a5c6f | ||
|
|
e2be09b5d3 | ||
|
|
b81e0ac03b | ||
|
|
c5d822cded | ||
|
|
0fc66fee9a | ||
|
|
612c07e6f3 | ||
|
|
a0ac2923d6 | ||
|
|
7107c08021 | ||
|
|
a0e336edcb | ||
|
|
3e306638d0 | ||
|
|
a72641b32e | ||
|
|
f4ed9d92bf | ||
|
|
5ae35c3500 | ||
|
|
08666e6c21 | ||
|
|
5ae530a637 | ||
|
|
2b07497486 | ||
|
|
3b65642325 | ||
|
|
fdd1057e79 | ||
|
|
f1afebcc04 | ||
|
|
e711be7efa | ||
|
|
ec94b8179c | ||
|
|
a7c1d6f486 | ||
|
|
df0b792c52 | ||
|
|
1f5054fcbd | ||
|
|
ca483b9cbe | ||
|
|
03dc459162 | ||
|
|
cf8f5fe2a2 | ||
|
|
760350ef88 | ||
|
|
706d4ee0b5 | ||
|
|
5fd8545cbe | ||
|
|
3397c06db2 | ||
|
|
22df7a1ec7 | ||
|
|
e87b0c75b6 | ||
|
|
b406b7fa16 | ||
|
|
7114ed1122 | ||
|
|
70b5865dce | ||
|
|
3be7056f2c | ||
|
|
1b57310535 | ||
|
|
2b15d9a515 | ||
|
|
adc9c0b970 | ||
|
|
bec1708891 | ||
|
|
66bb545454 | ||
|
|
c1ebf04291 | ||
|
|
3166060644 | ||
|
|
bde7cf6f9d | ||
|
|
8ea9bb19f6 | ||
|
|
6d0f9b0d35 | ||
|
|
df541c1924 | ||
|
|
9af92ff397 | ||
|
|
554d50b079 | ||
|
|
a00e2e8b68 | ||
|
|
4fcfbaff3b | ||
|
|
7792f0504d | ||
|
|
3ca6c67f25 | ||
|
|
2eb0fdc863 | ||
|
|
192d48c4a6 | ||
|
|
e4f38685b3 | ||
|
|
d02023e12c | ||
|
|
64d8786d8f | ||
|
|
0971d59fa6 | ||
|
|
9b799ca441 | ||
|
|
193b823688 | ||
|
|
c64c2d25e7 | ||
|
|
8b4111d68f | ||
|
|
9d601ea4b5 | ||
|
|
95e1bbce2b | ||
|
|
7b32508201 | ||
|
|
6ed85d72d7 | ||
|
|
cd2a522f25 | ||
|
|
6bd6400aba | ||
|
|
8b92d6ee04 | ||
|
|
7cc2ed75e5 | ||
|
|
cb7f46c0ad | ||
|
|
cb12aedf72 | ||
|
|
8c35a26ab0 | ||
|
|
b2d0f46dd2 | ||
|
|
2c4b7bf611 | ||
|
|
38e542bcd3 | ||
|
|
e53452c19c | ||
|
|
13213476d8 | ||
|
|
9925450173 | ||
|
|
efb9dae681 | ||
|
|
cee93d2a87 | ||
|
|
0d4a8654c1 | ||
|
|
95b1be07bb | ||
|
|
a6fc98fc82 | ||
|
|
6f03010f6c | ||
|
|
69397c91b8 | ||
|
|
798792dcdc | ||
|
|
cc32dd9fa6 | ||
|
|
0c64eb29f9 | ||
|
|
8baa5cc315 | ||
|
|
6f3a5c6c8f | ||
|
|
778078590b | ||
|
|
53c82e5491 | ||
|
|
fef114d97f | ||
|
|
e80cbfad7f | ||
|
|
99527ce738 | ||
|
|
08ccced734 | ||
|
|
43c2c9552b | ||
|
|
db5741c7ee | ||
|
|
a1e394cf36 | ||
|
|
bdbef1ab9e | ||
|
|
e5276f6c20 | ||
|
|
20a6e71b31 | ||
|
|
24c111af7b | ||
|
|
ab4559319e | ||
|
|
2f8625ac44 | ||
|
|
dd146afa57 | ||
|
|
91d15f671e | ||
|
|
7008b13246 |
@@ -1,9 +1,10 @@
|
||||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.224.2/containers/python-3/.devcontainer/base.Dockerfile
|
||||
|
||||
# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster
|
||||
ARG VARIANT="3.12-bullseye"
|
||||
FROM mcr.microsoft.com/devcontainers/python:${VARIANT}
|
||||
|
||||
# Remove outdated yarn GPG key, if it exists
|
||||
RUN rm -f /etc/apt/sources.list.d/yarn.list /usr/share/keyrings/yarn-archive-keyring.gpg || true
|
||||
|
||||
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
|
||||
ARG NODE_VERSION="none"
|
||||
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||
@@ -13,12 +14,14 @@ RUN echo "export PROMPT_COMMAND='history -a'" >> /home/vscode/.bashrc \
|
||||
&& chown vscode:vscode -R /home/vscode/
|
||||
|
||||
RUN npm install -g @go-task/cli
|
||||
RUN npm install -g json-schema-to-typescript
|
||||
|
||||
# Install additional OS packages
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
curl \
|
||||
build-essential \
|
||||
ffmpeg \
|
||||
libpq-dev \
|
||||
libwebp-dev \
|
||||
libsasl2-dev libldap2-dev libssl-dev \
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
|
||||
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
|
||||
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
|
||||
"mypy.runUsingActiveInterpreter": true
|
||||
},
|
||||
@@ -34,6 +33,7 @@
|
||||
"ms-python.pylint",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"streetsidesoftware.code-spell-checker-cspell-bundled-dictionaries",
|
||||
"Vue.volar"
|
||||
]
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
8000, // used by mkdocs
|
||||
9000,
|
||||
9091, // used by docker production
|
||||
24678 // used by nuxt when hot-reloading using polling
|
||||
|
||||
17
.github/pull_request_template.md
vendored
17
.github/pull_request_template.md
vendored
@@ -8,11 +8,11 @@
|
||||
- `chore: `
|
||||
- `dev:`
|
||||
|
||||
If a section of the PR template does not apply to this PR, then delete that section.
|
||||
If a section of the PR template does not apply to this PR, and is not marked as "required", then delete that section.
|
||||
|
||||
PLEASE READ:
|
||||
-------------------------
|
||||
Mealie is moving to a regular, automatic release schedule. This means that all PRs should be in a
|
||||
Mealie uses a regular, automatic release schedule. This means that all PRs should be in a
|
||||
stable state, ready for release. This includes:
|
||||
|
||||
- Ensuring new tests have been added to cover new features, or to prevent regressions.
|
||||
@@ -28,8 +28,6 @@ _(REQUIRED)_
|
||||
What goal is this change working towards?
|
||||
Provide a bullet pointed summary of how each file was changed.
|
||||
Briefly explain any decisions you made with respect to the changes.
|
||||
Include anything here that you didn't include in *Release Notes*
|
||||
above, such as changes to CI or changes to internal methods.
|
||||
|
||||
If there is a UI component to the change, please include before/after images.
|
||||
-->
|
||||
@@ -43,6 +41,8 @@ If this PR fixes one of more issues, list them here.
|
||||
One per line, like so:
|
||||
Fixes #123
|
||||
Fixes #39
|
||||
|
||||
Be sure to include the word "fixes" otherwise the associated issue will not be closed.
|
||||
-->
|
||||
|
||||
## Special notes for your reviewer:
|
||||
@@ -61,3 +61,12 @@ _(fill-in or delete this section)_
|
||||
<!--
|
||||
Describe how you tested this change.
|
||||
-->
|
||||
|
||||
## AI / LLM Assistance
|
||||
|
||||
_(REQUIRED)_
|
||||
|
||||
<!--
|
||||
Describe to which degree an LLM was used in creating this pull request. Failure to accurately disclose LLM usage may result in
|
||||
review delays or closure of your PR.
|
||||
-->
|
||||
|
||||
99
.github/workflows/auto-merge-dependencies.yml
vendored
Normal file
99
.github/workflows/auto-merge-dependencies.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
name: Auto-merge dependency PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, labeled]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.labels.*.name, 'dependencies')
|
||||
|
||||
steps:
|
||||
- name: Validate PR author
|
||||
env:
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
if [[ "$AUTHOR" != "renovate[bot]" ]]; then
|
||||
echo "::error::PR author must be renovate[bot] for auto-merge (got: $AUTHOR)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Author validated: $AUTHOR"
|
||||
|
||||
- name: Reject major updates
|
||||
env:
|
||||
TITLE: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
if echo "$TITLE" | grep -qiE '(major|breaking)'; then
|
||||
echo "::error::Major/breaking updates require manual review"
|
||||
exit 1
|
||||
fi
|
||||
echo "PR title does not indicate a major update"
|
||||
|
||||
- name: Validate file paths
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
FILES=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json files --jq '.files[].path')
|
||||
|
||||
for file in $FILES; do
|
||||
if [[ "$file" == "pyproject.toml" ]] || \
|
||||
[[ "$file" == "uv.lock" ]] || \
|
||||
[[ "$file" == "frontend/package.json" ]] || \
|
||||
[[ "$file" == "frontend/yarn.lock" ]] || \
|
||||
[[ "$file" =~ ^docker/ ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "::error::Unexpected file path: $file"
|
||||
echo "Only dependency and lock files are allowed for auto-merge"
|
||||
exit 1
|
||||
done
|
||||
|
||||
echo "All files are in allowed paths"
|
||||
|
||||
- name: Approve PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
APPROVED=$(gh pr view "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--json reviews \
|
||||
--jq '.reviews[] | select(.state == "APPROVED") | .id' \
|
||||
| wc -l)
|
||||
|
||||
if [ "$APPROVED" -gt 0 ]; then
|
||||
echo "PR already approved"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr review "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--approve \
|
||||
--body "Auto-approved: dependency update from Renovate with valid file paths"
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Enable auto-merge
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr merge "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--auto \
|
||||
--squash
|
||||
115
.github/workflows/auto-merge-l10n.yml
vendored
Normal file
115
.github/workflows/auto-merge-l10n.yml
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
name: Auto-merge l10n PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, labeled]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.labels.*.name, 'l10n')
|
||||
|
||||
steps:
|
||||
- name: Validate PR author
|
||||
env:
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
if [[
|
||||
"$AUTHOR" != "hay-kot" &&
|
||||
"$AUTHOR" != "github-actions[bot]" &&
|
||||
"$AUTHOR" != "mealie-actions[bot]"
|
||||
]]; then
|
||||
echo "::error::PR author must be hay-kot, github-actions[bot], or mealie-actions[bot] for auto-merge (got: $AUTHOR)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Author validated: $AUTHOR"
|
||||
|
||||
- name: Validate PR size
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
ADDITIONS=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json additions --jq '.additions')
|
||||
DELETIONS=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json deletions --jq '.deletions')
|
||||
TOTAL=$((ADDITIONS + DELETIONS))
|
||||
|
||||
echo "PR changes: +$ADDITIONS -$DELETIONS (total: $TOTAL lines)"
|
||||
|
||||
if [ "$TOTAL" -gt 6000 ]; then
|
||||
echo "::error::PR exceeds 6000 line change limit ($TOTAL lines)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate file paths
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
FILES=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json files --jq '.files[].path')
|
||||
|
||||
for file in $FILES; do
|
||||
# Check if file matches any allowed path
|
||||
if [[ "$file" == "frontend/composables/use-locales/available-locales.ts" ]] || \
|
||||
[[ "$file" =~ ^frontend/lang/ ]] || \
|
||||
[[ "$file" =~ ^mealie/lang/ ]] || \
|
||||
[[ "$file" =~ ^mealie/repos/seed/resources/[^/]+/locales/ ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# File doesn't match allowed paths
|
||||
echo "::error::Invalid file path: $file"
|
||||
echo "Only the following paths are allowed:"
|
||||
echo " - frontend/composables/use-locales/available-locales.ts"
|
||||
echo " - frontend/lang/"
|
||||
echo " - mealie/lang/"
|
||||
echo " - mealie/repos/seed/resources/*/locales/"
|
||||
exit 1
|
||||
done
|
||||
|
||||
echo "All files are in allowed paths"
|
||||
|
||||
- name: Approve PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
APPROVED=$(gh pr view "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--json reviews \
|
||||
--jq '.reviews[] | select(.state == "APPROVED") | .id' \
|
||||
| wc -l)
|
||||
|
||||
if [ "$APPROVED" -gt 0 ]; then
|
||||
echo "PR already approved"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr review "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--approve \
|
||||
--body "Auto-approved: l10n PR from trusted author with valid file paths"
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Enable auto-merge
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr merge "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--auto \
|
||||
--squash
|
||||
7
.github/workflows/build-package.yml
vendored
7
.github/workflows/build-package.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
tag:
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
@@ -15,6 +18,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout 🛎
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Setup node env 🏗
|
||||
uses: actions/setup-node@v4.0.0
|
||||
@@ -64,6 +69,8 @@ jobs:
|
||||
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
|
||||
50
.github/workflows/docs.yml
vendored
Normal file
50
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [mealie-next]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --only-group docs --no-install-project
|
||||
|
||||
- name: Build docs
|
||||
run: uv run --no-project mkdocs build -d site
|
||||
working-directory: docs
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/site
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
11
.github/workflows/locale-sync.yml
vendored
11
.github/workflows/locale-sync.yml
vendored
@@ -15,10 +15,17 @@ jobs:
|
||||
sync-locales:
|
||||
runs-on: ubuntu-latest
|
||||
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 repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -105,7 +112,7 @@ jobs:
|
||||
- Updated frontend locale files
|
||||
- Generated from latest translation sources" \
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: No changes detected
|
||||
if: steps.changes.outputs.has_changes == 'false'
|
||||
|
||||
19
.github/workflows/publish.yml
vendored
19
.github/workflows/publish.yml
vendored
@@ -9,6 +9,9 @@ on:
|
||||
tags:
|
||||
required: false
|
||||
type: string
|
||||
ref:
|
||||
required: false
|
||||
type: string
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME:
|
||||
required: true
|
||||
@@ -21,6 +24,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Log in to the Container registry (ghcr.io)
|
||||
uses: docker/login-action@v3
|
||||
@@ -37,6 +42,17 @@ jobs:
|
||||
|
||||
- uses: depot/setup-action@v1
|
||||
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
hkotel/mealie
|
||||
ghcr.io/${{ github.repository }}
|
||||
# Overwrite the image.version label with our tag
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ inputs.tag }}
|
||||
|
||||
- name: Retrieve Python package
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -57,5 +73,6 @@ jobs:
|
||||
hkotel/mealie:${{ inputs.tag }}
|
||||
ghcr.io/${{ github.repository }}:${{ inputs.tag }}
|
||||
${{ inputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
COMMIT=${{ github.sha }}
|
||||
COMMIT=${{ inputs.ref || github.sha }}
|
||||
|
||||
13
.github/workflows/pull-requests.yml
vendored
13
.github/workflows/pull-requests.yml
vendored
@@ -4,14 +4,19 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- mealie-next
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
branches:
|
||||
- mealie-next
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.merge_group.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pull-request-lint:
|
||||
name: "Lint PR"
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/pull-request-lint.yml
|
||||
|
||||
backend-tests:
|
||||
@@ -24,6 +29,7 @@ jobs:
|
||||
|
||||
container-scanning:
|
||||
name: "Trivy Container Scanning"
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/partial-trivy-container-scanning.yml
|
||||
|
||||
code-ql:
|
||||
@@ -47,7 +53,10 @@ jobs:
|
||||
|
||||
publish-image:
|
||||
name: "Publish PR Image"
|
||||
if: contains(github.event.pull_request.labels.*.name, 'build-image') && github.repository == 'mealie-recipes/mealie'
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'build-image') &&
|
||||
github.repository == 'mealie-recipes/mealie'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -60,12 +60,16 @@ jobs:
|
||||
uses: ./.github/workflows/test-backend.yml
|
||||
needs:
|
||||
- commit-version-bump
|
||||
with:
|
||||
ref: ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||
|
||||
frontend-tests:
|
||||
name: "Frontend Tests"
|
||||
uses: ./.github/workflows/test-frontend.yml
|
||||
needs:
|
||||
- commit-version-bump
|
||||
with:
|
||||
ref: ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||
|
||||
build-package:
|
||||
name: Build Package
|
||||
@@ -74,6 +78,7 @@ jobs:
|
||||
- commit-version-bump
|
||||
with:
|
||||
tag: ${{ github.event.release.tag_name }}
|
||||
ref: ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||
|
||||
publish:
|
||||
permissions:
|
||||
@@ -90,7 +95,9 @@ jobs:
|
||||
- backend-tests
|
||||
- frontend-tests
|
||||
- build-package
|
||||
- commit-version-bump
|
||||
with:
|
||||
ref: ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||
tag: ${{ github.event.release.tag_name }}
|
||||
tags: |
|
||||
hkotel/mealie:latest
|
||||
|
||||
51
.github/workflows/scheduled-checks.yml
vendored
51
.github/workflows/scheduled-checks.yml
vendored
@@ -40,12 +40,18 @@ jobs:
|
||||
shell: bash
|
||||
run: pre-commit autoupdate --color=always
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
# This doesn't currently work for us because it creates the PR but the workflows don't run.
|
||||
# TODO: Provide a personal access token as a parameter here, that solves that problem.
|
||||
# https://github.com/peter-evans/create-pull-request
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Create Pull Request
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
commit-message: "Update pre-commit hooks"
|
||||
branch: "fix/update-pre-commit-hooks"
|
||||
labels: |
|
||||
@@ -54,3 +60,38 @@ jobs:
|
||||
base: mealie-next
|
||||
title: "chore(auto): Update pre-commit hooks"
|
||||
body: "Auto-generated by `.github/workflows/scheduled-checks.yml`"
|
||||
|
||||
- name: Approve PR
|
||||
if: steps.create-pr.outputs.pull-request-number
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
APPROVED=$(gh pr view "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--json reviews \
|
||||
--jq '.reviews[] | select(.state == "APPROVED") | .id' \
|
||||
| wc -l)
|
||||
|
||||
if [ "$APPROVED" -gt 0 ]; then
|
||||
echo "PR already approved"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr review "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--approve \
|
||||
--body "Auto-approved: Pre-commit hook updates"
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.create-pr.outputs.pull-request-number
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr merge "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--auto \
|
||||
--squash
|
||||
|
||||
6
.github/workflows/test-backend.yml
vendored
6
.github/workflows/test-backend.yml
vendored
@@ -2,6 +2,10 @@ name: Backend Lint and Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
@@ -43,6 +47,8 @@ jobs:
|
||||
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
|
||||
6
.github/workflows/test-frontend.yml
vendored
6
.github/workflows/test-frontend.yml
vendored
@@ -2,6 +2,10 @@ name: Frontend Lint and Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -10,6 +14,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout 🛎
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Setup node env 🏗
|
||||
uses: actions/setup-node@v4.0.0
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -20,6 +20,7 @@ dev/data/backups/*
|
||||
dev/data/debug/*
|
||||
dev/data/img/*
|
||||
dev/data/migration/*
|
||||
dev/data/templates/*
|
||||
dev/data/users/*
|
||||
dev/data/groups/*
|
||||
|
||||
@@ -69,8 +70,11 @@ wheels/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# packaged output - temporarily written here by `uv build`
|
||||
/mealie-*
|
||||
|
||||
# frontend copied into Python module for packaging purposes
|
||||
/mealie/frontend/
|
||||
/mealie/frontend
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
|
||||
@@ -12,7 +12,10 @@ repos:
|
||||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.14.8
|
||||
rev: v0.15.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
# Linter
|
||||
- id: ruff-check
|
||||
args: [ --fix ]
|
||||
# Formatter
|
||||
- id: ruff-format
|
||||
|
||||
104
Taskfile.yml
104
Taskfile.yml
@@ -25,16 +25,9 @@ dotenv:
|
||||
- .env
|
||||
- .dev.env
|
||||
tasks:
|
||||
docs:gen:
|
||||
desc: runs the API documentation generator
|
||||
cmds:
|
||||
- uv run python dev/code-generation/gen_docs_api.py
|
||||
|
||||
docs:
|
||||
desc: runs the documentation server
|
||||
dir: docs
|
||||
deps:
|
||||
- docs:gen
|
||||
cmds:
|
||||
- uv run python -m mkdocs serve
|
||||
|
||||
@@ -47,8 +40,6 @@ tasks:
|
||||
sources:
|
||||
- package.json
|
||||
- yarn.lock
|
||||
generates:
|
||||
- node_modules/**
|
||||
|
||||
setup:py:
|
||||
desc: setup python dependencies
|
||||
@@ -61,6 +52,18 @@ tasks:
|
||||
- pyproject.toml
|
||||
- .pre-commit-config.yaml
|
||||
|
||||
setup:e2e:
|
||||
desc: setup e2e test dependencies
|
||||
dir: tests/e2e
|
||||
run: once
|
||||
cmds:
|
||||
- yarn install
|
||||
- yarn playwright install --with-deps
|
||||
sources:
|
||||
- package.json
|
||||
- playwright.config.ts
|
||||
- yarn.lock
|
||||
|
||||
setup:
|
||||
desc: setup all dependencies
|
||||
deps:
|
||||
@@ -71,7 +74,6 @@ tasks:
|
||||
desc: run code generators
|
||||
cmds:
|
||||
- uv run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
||||
- task: docs:gen
|
||||
- task: py:format
|
||||
|
||||
dev:services:
|
||||
@@ -179,12 +181,21 @@ tasks:
|
||||
status:
|
||||
- '{{ .SKIP_PACKAGE_DEPS | default "false"}}'
|
||||
|
||||
py:package:
|
||||
desc: builds Python packages (sdist and wheel) in top-level dist directory
|
||||
py:package:build:
|
||||
internal: true
|
||||
deps:
|
||||
- py:package:deps
|
||||
cmds:
|
||||
- uv build --out-dir dist
|
||||
sources:
|
||||
- uv.lock
|
||||
- pyproject.toml
|
||||
- mealie/**
|
||||
|
||||
py:package:
|
||||
desc: builds Python packages (sdist and wheel) in top-level dist directory
|
||||
cmds:
|
||||
- task: py:package:build
|
||||
- task: py:package:generate-requirements
|
||||
|
||||
py:
|
||||
@@ -215,6 +226,12 @@ tasks:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn build
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:generate:
|
||||
desc: generates a static version of the frontend in frontend/dist
|
||||
@@ -223,18 +240,36 @@ tasks:
|
||||
- setup:ui
|
||||
cmds:
|
||||
- yarn generate
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:lint:
|
||||
desc: runs the frontend linter
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn lint --max-warnings=0
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:test:
|
||||
desc: runs the frontend tests
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn test
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:check:
|
||||
desc: runs all frontend checks
|
||||
@@ -263,3 +298,48 @@ tasks:
|
||||
dir: docker
|
||||
cmds:
|
||||
- docker compose -f docker-compose.yml -p mealie up -d --build
|
||||
|
||||
e2e:build-image:
|
||||
desc: builds the e2e test docker image
|
||||
deps:
|
||||
- py:package
|
||||
cmds:
|
||||
- docker build --tag mealie:e2e --file docker/Dockerfile --build-context packages=dist .
|
||||
sources:
|
||||
- docker/Dockerfile
|
||||
- dist/**
|
||||
|
||||
e2e:start-server:
|
||||
desc: Builds the image and starts the containers for e2e testing
|
||||
dir: tests/e2e/docker
|
||||
deps:
|
||||
- e2e:build-image
|
||||
vars:
|
||||
WAIT_UNTIL_HEALTHY: '{{if .WAIT_UNTIL_HEALTHY}}--wait{{else}}{{end}}'
|
||||
cmds:
|
||||
- docker compose up -d {{.WAIT_UNTIL_HEALTHY}}
|
||||
|
||||
e2e:stop-server:
|
||||
desc: Shuts down the e2e testing containers
|
||||
dir: tests/e2e/docker
|
||||
cmds:
|
||||
- docker compose down --volumes
|
||||
|
||||
e2e:test:
|
||||
desc: runs the e2e tests
|
||||
dir: tests/e2e
|
||||
deps:
|
||||
- setup:e2e
|
||||
vars:
|
||||
PREVENT_REPORT_OPEN: '{{if .PREVENT_REPORT_OPEN}}PLAYWRIGHT_HTML_OPEN=never{{else}}{{end}}'
|
||||
cmds:
|
||||
- '{{.PREVENT_REPORT_OPEN}} yarn playwright test'
|
||||
|
||||
e2e:
|
||||
desc: runs the full e2e test suite
|
||||
cmds:
|
||||
- task: e2e:start-server
|
||||
vars: { WAIT_UNTIL_HEALTHY: true }
|
||||
- defer: { task: e2e:stop-server }
|
||||
- task: e2e:test
|
||||
vars: { PREVENT_REPORT_OPEN: true }
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from mealie.app import app
|
||||
from mealie.core.config import determine_data_dir
|
||||
|
||||
DATA_DIR = determine_data_dir()
|
||||
|
||||
"""Script to export the ReDoc documentation page into a standalone HTML file."""
|
||||
|
||||
HTML_TEMPLATE = """<!-- Custom HTML site displayed as the Home chapter -->
|
||||
{% extends "main.html" %}
|
||||
{% block tabs %}
|
||||
{{ super() }}
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<div id="redoc-container"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"> </script>
|
||||
<script>
|
||||
var spec = MY_SPECIFIC_TEXT;
|
||||
Redoc.init(spec, {}, document.getElementById("redoc-container"));
|
||||
</script>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
{% block footer %}{% endblock %}
|
||||
"""
|
||||
|
||||
HTML_PATH = DATA_DIR.parent.parent.joinpath("docs/docs/overrides/api.html")
|
||||
CONSTANT_DT = datetime(2025, 10, 24, 15, 53, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def normalize_timestamps(s: dict[str, Any]) -> dict[str, Any]:
|
||||
field_format = s.get("format")
|
||||
is_timestamp = field_format in ["date-time", "date", "time"]
|
||||
has_default = s.get("default")
|
||||
|
||||
if not is_timestamp:
|
||||
for k, v in s.items():
|
||||
if isinstance(v, dict):
|
||||
s[k] = normalize_timestamps(v)
|
||||
elif isinstance(v, list):
|
||||
s[k] = [normalize_timestamps(i) if isinstance(i, dict) else i for i in v]
|
||||
|
||||
return s
|
||||
elif not has_default:
|
||||
return s
|
||||
|
||||
if field_format == "date-time":
|
||||
s["default"] = CONSTANT_DT.isoformat()
|
||||
elif field_format == "date":
|
||||
s["default"] = CONSTANT_DT.date().isoformat()
|
||||
elif field_format == "time":
|
||||
s["default"] = CONSTANT_DT.time().isoformat()
|
||||
|
||||
return s
|
||||
|
||||
|
||||
def generate_api_docs(my_app: FastAPI):
|
||||
openapi_schema = my_app.openapi()
|
||||
openapi_schema = normalize_timestamps(openapi_schema)
|
||||
|
||||
with open(HTML_PATH, "w") as fd:
|
||||
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(openapi_schema))
|
||||
fd.write(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_api_docs(app)
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import dotenv
|
||||
@@ -10,6 +11,7 @@ from pydantic import ConfigDict
|
||||
from requests import Response
|
||||
from utils import CodeDest, CodeKeys, inject_inline, log
|
||||
|
||||
from mealie.lang.locale_config import LOCALE_CONFIG, LocalePluralFoodHandling, LocaleTextDirection
|
||||
from mealie.schema._mealie import MealieModel
|
||||
|
||||
BASE = pathlib.Path(__file__).parent.parent.parent
|
||||
@@ -17,57 +19,6 @@ BASE = pathlib.Path(__file__).parent.parent.parent
|
||||
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") or os.environ.get("CROWDIN_API_KEY", "")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocaleData:
|
||||
name: str
|
||||
dir: str = "ltr"
|
||||
|
||||
|
||||
LOCALE_DATA: dict[str, LocaleData] = {
|
||||
"af-ZA": LocaleData(name="Afrikaans (Afrikaans)"),
|
||||
"ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"),
|
||||
"bg-BG": LocaleData(name="Български (Bulgarian)"),
|
||||
"ca-ES": LocaleData(name="Català (Catalan)"),
|
||||
"cs-CZ": LocaleData(name="Čeština (Czech)"),
|
||||
"da-DK": LocaleData(name="Dansk (Danish)"),
|
||||
"de-DE": LocaleData(name="Deutsch (German)"),
|
||||
"el-GR": LocaleData(name="Ελληνικά (Greek)"),
|
||||
"en-GB": LocaleData(name="British English"),
|
||||
"en-US": LocaleData(name="American English"),
|
||||
"es-ES": LocaleData(name="Español (Spanish)"),
|
||||
"et-EE": LocaleData(name="Eesti (Estonian)"),
|
||||
"fi-FI": LocaleData(name="Suomi (Finnish)"),
|
||||
"fr-BE": LocaleData(name="Belge (Belgian)"),
|
||||
"fr-CA": LocaleData(name="Français canadien (Canadian French)"),
|
||||
"fr-FR": LocaleData(name="Français (French)"),
|
||||
"gl-ES": LocaleData(name="Galego (Galician)"),
|
||||
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
|
||||
"hr-HR": LocaleData(name="Hrvatski (Croatian)"),
|
||||
"hu-HU": LocaleData(name="Magyar (Hungarian)"),
|
||||
"is-IS": LocaleData(name="Íslenska (Icelandic)"),
|
||||
"it-IT": LocaleData(name="Italiano (Italian)"),
|
||||
"ja-JP": LocaleData(name="日本語 (Japanese)"),
|
||||
"ko-KR": LocaleData(name="한국어 (Korean)"),
|
||||
"lt-LT": LocaleData(name="Lietuvių (Lithuanian)"),
|
||||
"lv-LV": LocaleData(name="Latviešu (Latvian)"),
|
||||
"nl-NL": LocaleData(name="Nederlands (Dutch)"),
|
||||
"no-NO": LocaleData(name="Norsk (Norwegian)"),
|
||||
"pl-PL": LocaleData(name="Polski (Polish)"),
|
||||
"pt-BR": LocaleData(name="Português do Brasil (Brazilian Portuguese)"),
|
||||
"pt-PT": LocaleData(name="Português (Portuguese)"),
|
||||
"ro-RO": LocaleData(name="Română (Romanian)"),
|
||||
"ru-RU": LocaleData(name="Pусский (Russian)"),
|
||||
"sk-SK": LocaleData(name="Slovenčina (Slovak)"),
|
||||
"sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
|
||||
"sr-SP": LocaleData(name="српски (Serbian)"),
|
||||
"sv-SE": LocaleData(name="Svenska (Swedish)"),
|
||||
"tr-TR": LocaleData(name="Türkçe (Turkish)"),
|
||||
"uk-UA": LocaleData(name="Українська (Ukrainian)"),
|
||||
"vi-VN": LocaleData(name="Tiếng Việt (Vietnamese)"),
|
||||
"zh-CN": LocaleData(name="简体中文 (Chinese simplified)"),
|
||||
"zh-TW": LocaleData(name="繁體中文 (Chinese traditional)"),
|
||||
}
|
||||
|
||||
LOCALE_TEMPLATE = """// This Code is auto generated by gen_ts_locales.py
|
||||
export const LOCALES = [{% for locale in locales %}
|
||||
{
|
||||
@@ -75,6 +26,7 @@ export const LOCALES = [{% for locale in locales %}
|
||||
value: "{{ locale.locale }}",
|
||||
progress: {{ locale.progress }},
|
||||
dir: "{{ locale.dir }}",
|
||||
pluralFoodHandling: "{{ locale.plural_food_handling }}",
|
||||
},{% endfor %}
|
||||
];
|
||||
|
||||
@@ -87,10 +39,11 @@ class TargetLanguage(MealieModel):
|
||||
id: str
|
||||
name: str
|
||||
locale: str
|
||||
dir: str = "ltr"
|
||||
dir: LocaleTextDirection = LocaleTextDirection.LTR
|
||||
plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
|
||||
threeLettersCode: str
|
||||
twoLettersCode: str
|
||||
progress: float = 0.0
|
||||
progress: int = 0
|
||||
|
||||
|
||||
class CrowdinApi:
|
||||
@@ -117,43 +70,15 @@ class CrowdinApi:
|
||||
def get_languages(self) -> list[TargetLanguage]:
|
||||
response = self.get_project()
|
||||
tls = response.json()["data"]["targetLanguages"]
|
||||
return [TargetLanguage(**t) for t in tls]
|
||||
|
||||
models = [TargetLanguage(**t) for t in tls]
|
||||
|
||||
models.insert(
|
||||
0,
|
||||
TargetLanguage(
|
||||
id="en-US",
|
||||
name="English",
|
||||
locale="en-US",
|
||||
dir="ltr",
|
||||
threeLettersCode="en",
|
||||
twoLettersCode="en",
|
||||
progress=100,
|
||||
),
|
||||
)
|
||||
|
||||
progress: list[dict] = self.get_progress()["data"]
|
||||
|
||||
for model in models:
|
||||
if model.locale in LOCALE_DATA:
|
||||
locale_data = LOCALE_DATA[model.locale]
|
||||
model.name = locale_data.name
|
||||
model.dir = locale_data.dir
|
||||
|
||||
for p in progress:
|
||||
if p["data"]["languageId"] == model.id:
|
||||
model.progress = p["data"]["translationProgress"]
|
||||
|
||||
models.sort(key=lambda x: x.locale, reverse=True)
|
||||
return models
|
||||
|
||||
def get_progress(self) -> dict:
|
||||
def get_progress(self) -> dict[str, int]:
|
||||
response = requests.get(
|
||||
f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500",
|
||||
headers=self.headers,
|
||||
)
|
||||
return response.json()
|
||||
data = response.json()["data"]
|
||||
return {p["data"]["languageId"]: p["data"]["translationProgress"] for p in data}
|
||||
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
@@ -195,8 +120,8 @@ def inject_nuxt_values():
|
||||
|
||||
all_langs = []
|
||||
for match in locales_dir.glob("*.json"):
|
||||
match_data = LOCALE_DATA.get(match.stem)
|
||||
match_dir = match_data.dir if match_data else "ltr"
|
||||
match_data = LOCALE_CONFIG.get(match.stem)
|
||||
match_dir = match_data.dir if match_data else LocaleTextDirection.LTR
|
||||
|
||||
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},'
|
||||
all_langs.append(lang_string)
|
||||
@@ -221,9 +146,82 @@ def inject_registration_validation_values():
|
||||
inject_inline(reg_valid, CodeKeys.nuxt_local_messages, all_langs)
|
||||
|
||||
|
||||
def _get_local_models() -> list[TargetLanguage]:
|
||||
return [
|
||||
TargetLanguage(
|
||||
id=locale,
|
||||
name=data.name,
|
||||
locale=locale,
|
||||
threeLettersCode=locale.split("-")[-1],
|
||||
twoLettersCode=locale.split("-")[-1],
|
||||
)
|
||||
for locale, data in LOCALE_CONFIG.items()
|
||||
if locale != "en-US" # Crowdin doesn't include this, so we manually inject it later
|
||||
]
|
||||
|
||||
|
||||
def _get_local_progress() -> dict[str, int]:
|
||||
with open(CodeDest.use_locales) as f:
|
||||
content = f.read()
|
||||
|
||||
# Extract the array content between [ and ]
|
||||
match = re.search(r"export const LOCALES = (\[.*?\]);", content, re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError("Could not find LOCALES array in file")
|
||||
|
||||
# Convert JS to JSON
|
||||
array_content = match.group(1)
|
||||
|
||||
# Replace unquoted keys with quoted keys for valid JSON
|
||||
# This converts: { name: "value" } to { "name": "value" }
|
||||
json_str = re.sub(r"([,\{\s])([a-zA-Z_][a-zA-Z0-9_]*)\s*:", r'\1"\2":', array_content)
|
||||
|
||||
# Remove trailing commas before } and ]
|
||||
json_str = re.sub(r",(\s*[}\]])", r"\1", json_str)
|
||||
|
||||
locales = json.loads(json_str)
|
||||
return {locale["value"]: locale["progress"] for locale in locales}
|
||||
|
||||
|
||||
def get_languages() -> list[TargetLanguage]:
|
||||
if API_KEY:
|
||||
api = CrowdinApi(None)
|
||||
models = api.get_languages()
|
||||
progress = api.get_progress()
|
||||
else:
|
||||
log.warning("CROWDIN_API_KEY is not set, using local lanugages instead")
|
||||
log.warning("DOUBLE CHECK the output!!! Do not overwrite with bad local locale data!")
|
||||
models = _get_local_models()
|
||||
progress = _get_local_progress()
|
||||
|
||||
models.insert(
|
||||
0,
|
||||
TargetLanguage(
|
||||
id="en-US",
|
||||
name="English",
|
||||
locale="en-US",
|
||||
dir=LocaleTextDirection.LTR,
|
||||
plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT,
|
||||
threeLettersCode="en",
|
||||
twoLettersCode="en",
|
||||
progress=100,
|
||||
),
|
||||
)
|
||||
|
||||
for model in models:
|
||||
if model.locale in LOCALE_CONFIG:
|
||||
locale_data = LOCALE_CONFIG[model.locale]
|
||||
model.name = locale_data.name
|
||||
model.dir = locale_data.dir
|
||||
model.plural_food_handling = locale_data.plural_food_handling
|
||||
model.progress = progress.get(model.id, model.progress)
|
||||
|
||||
models.sort(key=lambda x: x.locale, reverse=True)
|
||||
return models
|
||||
|
||||
|
||||
def generate_locales_ts_file():
|
||||
api = CrowdinApi(None)
|
||||
models = api.get_languages()
|
||||
models = get_languages()
|
||||
tmpl = Template(LOCALE_TEMPLATE)
|
||||
rendered = tmpl.render(locales=models)
|
||||
|
||||
@@ -233,10 +231,6 @@ def generate_locales_ts_file():
|
||||
|
||||
|
||||
def main():
|
||||
if API_KEY is None or API_KEY == "":
|
||||
log.error("CROWDIN_API_KEY is not set")
|
||||
return
|
||||
|
||||
generate_locales_ts_file()
|
||||
inject_nuxt_values()
|
||||
inject_registration_validation_values()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###############################################
|
||||
# Frontend Build
|
||||
###############################################
|
||||
FROM node:24@sha256:20988bcdc6dc76690023eb2505dd273bdeefddcd0bde4bfd1efe4ebf8707f747 \
|
||||
FROM node:24@sha256:bb20cf73b3ad7212834ec48e2174cdcb5775f6550510a5336b842ae32741ce6c \
|
||||
AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
@@ -21,7 +21,7 @@ RUN yarn generate
|
||||
###############################################
|
||||
# Base Image - Python
|
||||
###############################################
|
||||
FROM python:3.12-slim@sha256:2267adc248a477c1f1a852a07a5a224d42abe54c28aafa572efa157dfb001bba \
|
||||
FROM python:3.12-slim@sha256:7026274c107626d7e940e0e5d6730481a4600ae95d5ca7eb532dd4180313fea9 \
|
||||
AS python-base
|
||||
|
||||
ENV MEALIE_HOME="/app"
|
||||
@@ -91,6 +91,7 @@ RUN apt-get update \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
libwebp-dev \
|
||||
ffmpeg \
|
||||
# LDAP Dependencies
|
||||
libsasl2-dev libldap2-dev libssl-dev \
|
||||
gnupg gnupg2 gnupg1 \
|
||||
@@ -111,7 +112,6 @@ RUN . $VENV_PATH/bin/activate \
|
||||
# Production Image
|
||||
###############################################
|
||||
FROM python-base AS production
|
||||
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
|
||||
ENV PRODUCTION=true
|
||||
ENV TESTING=false
|
||||
|
||||
@@ -120,6 +120,8 @@ ENV GIT_COMMIT_HASH=$COMMIT
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
curl \
|
||||
ffmpeg \
|
||||
gosu \
|
||||
iproute2 \
|
||||
libldap-common \
|
||||
@@ -142,7 +144,9 @@ ENV APP_PORT=9000
|
||||
|
||||
EXPOSE ${APP_PORT}
|
||||
|
||||
HEALTHCHECK CMD python -m mealie.scripts.healthcheck || exit 1
|
||||
COPY ./docker/healthcheck.sh $MEALIE_HOME/healthcheck.sh
|
||||
RUN chmod +x $MEALIE_HOME/healthcheck.sh
|
||||
HEALTHCHECK CMD $MEALIE_HOME/healthcheck.sh
|
||||
|
||||
ENV HOST 0.0.0.0
|
||||
|
||||
|
||||
12
docker/healthcheck.sh
Executable file
12
docker/healthcheck.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
PORT="${API_PORT:-9000}"
|
||||
|
||||
if [ -n "$TLS_CERTIFICATE_PATH" ] && [ -n "$TLS_PRIVATE_KEY_PATH" ]; then
|
||||
PROTO="https"
|
||||
else
|
||||
PROTO="http"
|
||||
fi
|
||||
|
||||
# -k: TLS certificate is likely not issued for 127.0.0.1, so don't verify
|
||||
curl -fsk "${PROTO}://127.0.0.1:${PORT}/api/app/about" > /dev/null
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
title: API
|
||||
template: api.html
|
||||
---
|
||||
@@ -6,7 +6,7 @@ While this guide aims to simplify the migration process for developers, it's not
|
||||
|
||||
## V1 → V2
|
||||
|
||||
The biggest change between V1 and V2 is the introduction of Households. For more information on how households work in relation to groups/users, check out the [Groups and Households](./features.md#groups-and-households) section in the Features guide.
|
||||
The biggest change between V1 and V2 is the introduction of Households. For more information on how households work in relation to groups/users, check out the [Groups and Households](../../documentation/getting-started/features.md#groups-and-households) section in the Features guide.
|
||||
|
||||
### `updateAt` is now `updatedAt`
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Recipes extras are a key feature of the Mealie API. They allow you to create cus
|
||||
For example you could add `{"message": "Remember to thaw the chicken"}` to a recipe and use the webhooks built into mealie to send that message payload to a destination to be processed.
|
||||
|
||||
#### Shopping List and Food Extras
|
||||
Similarly to recipes, extras are supported on shopping lists, shopping list items, and foods. At this time they are only accessible through the API. Extras for these objects allow for rich integrations between the Mealie shopping list and your favorite list manager, such as Alexa, ToDoist, Trello, or any other list manager with an API.
|
||||
Similarly to recipes, extras are supported on shopping lists, shopping list items, and foods. At this time they are only accessible through the API. Extras for these objects allow for rich integrations between the Mealie shopping list and your favorite list manager, such as Todoist, Trello, or any other list manager with an API.
|
||||
|
||||
To keep shopping lists in sync, for instance, you can store your Trello list id on your Mealie shopping list: <br />
|
||||
`{"trello_list_id": "5abbe4b7ddc1b351ef961414"}`
|
||||
@@ -52,6 +52,7 @@ Many applications will keep track of the query and adjust the page parameter app
|
||||
Notice that the route does not contain the baseurl (e.g. `https://mymealieapplication.com/api`).
|
||||
|
||||
There are a few shorthands available to reduce the number of calls for certain common requests:
|
||||
|
||||
- if you want to return _all_ results, effectively disabling pagination, set `perPage = -1` (and fetch the first page)
|
||||
- if you want to fetch the _last_ page, set `page = -1`
|
||||
|
||||
@@ -78,8 +79,8 @@ This filter will find all foods that are not named "carrot": <br>
|
||||
##### Keyword Filters
|
||||
The API supports many SQL keywords, such as `IS NULL` and `IN`, as well as their negations (e.g. `IS NOT NULL` and `NOT IN`).
|
||||
|
||||
Here is an example of a filter that returns all recipes where the "last made" value is not null: <br>
|
||||
`lastMade IS NOT NULL`
|
||||
Here is an example of a filter that returns all shopping list items without a food: <br>
|
||||
`foodId IS NULL`
|
||||
|
||||
This filter will find all recipes that don't start with the word "Test": <br>
|
||||
`name NOT LIKE "Test%"`
|
||||
@@ -89,6 +90,28 @@ This filter will find all recipes that don't start with the word "Test": <br>
|
||||
This filter will find all recipes that have particular slugs: <br>
|
||||
`slug IN ["pasta-fagioli", "delicious-ramen"]`
|
||||
|
||||
##### Placeholder Keywords
|
||||
You can use placeholders to insert dynamic values as opposed to static values. Currently the only supported placeholder keyword is `$NOW`, to insert the current date/time.
|
||||
|
||||
`$NOW` can optionally be paired with basic offsets. Here is an example of a filter which gives you recipes not made within the past 30 days: <br>
|
||||
`lastMade <= "$NOW-30d"`
|
||||
|
||||
Supported offset operations include:
|
||||
|
||||
- `-` for subtracting a time (i.e. in the past)
|
||||
- `+` for adding a time (i.e. in the future)
|
||||
|
||||
Supported offset intervals include:
|
||||
|
||||
- `y` for years
|
||||
- `m` for months
|
||||
- `d` for days
|
||||
- `H` for hours
|
||||
- `M` for minutes
|
||||
- `S` for seconds
|
||||
|
||||
Note that intervals are _case sensitive_ (e.g. `s` is an invalid interval).
|
||||
|
||||
##### Nested Property filters
|
||||
When querying tables with relationships, you can filter properties on related tables. For instance, if you want to query all recipes owned by a particular user: <br>
|
||||
`user.username = "SousChef20220320"`
|
||||
@@ -96,7 +119,7 @@ When querying tables with relationships, you can filter properties on related ta
|
||||
This timeline event filter will return all timeline events for recipes that were created after a particular date: <br>
|
||||
`recipe.createdAt >= "2023-02-25"`
|
||||
|
||||
This recipe filter will return all recipes that contains a particular set of tags: <br>
|
||||
This recipe filter will return all recipes that contain a particular set of tags: <br>
|
||||
`tags.name CONTAINS ALL ["Easy", "Cajun"]`
|
||||
|
||||
##### Compound Filters
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many Identity Providers (IdP), including:
|
||||
|
||||
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
||||
- [Authentik](https://integrations.goauthentik.io/documentation/mealie/)
|
||||
- [Authelia](https://www.authelia.com/integration/openid-connect/mealie/)
|
||||
- [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc)
|
||||
- [Okta](https://www.okta.com/openid-connect/)
|
||||
@@ -68,7 +68,6 @@ Example configurations for several Identity Providers have been provided by the
|
||||
|
||||
If you don't see your provider and have successfully set it up, please consider [creating your own example](https://github.com/mealie-recipes/mealie/discussions/new?category=oauth-provider-example) so that others can have a smoother setup.
|
||||
|
||||
|
||||
## Migration from Mealie v1.x
|
||||
|
||||
**High level changes**
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
## Recipes
|
||||
|
||||
### Creating Recipes
|
||||
|
||||
Mealie offers two main ways to create recipes. You can use the integrated recipe-scraper to create recipes from hundreds of websites, or you can create recipes manually using the recipe editor.
|
||||
|
||||
Mealie offers several ways to create recipes:
|
||||
- **Recipe Scraper:** Create recipes from hundreds of websites by simply providing a URL.
|
||||
- **Image Import:** Upload an image of a written or typed recipe and Mealie will use OCR to import it.
|
||||
- **Video URL Import:** Provide a video URL (e.g., YouTube) and Mealie will transcribe the audio and parse the recipe.
|
||||
- **Manual Editor:** Create recipes from scratch using the integrated editor.
|
||||
[Creation Demo](https://demo.mealie.io/g/home/r/create/url){ .md-button .md-button--primary .align-right }
|
||||
|
||||
### Importing Recipes
|
||||
@@ -85,13 +87,13 @@ The meal planner has the concept of plan rules. These offer a flexible way to us
|
||||
|
||||
The shopping lists feature is a great way to keep track of what you need to buy for your next meal. You can add items directly to the shopping list or link a recipe and all of it's ingredients to track meals during the week.
|
||||
|
||||
Managing shopping lists can be done from the Sidebar > Shopping Lists.
|
||||
Managing shopping lists can be done from the Sidebar > Shopping Lists.
|
||||
|
||||
Here you will be able to:
|
||||
|
||||
- See items already on the Shopping List
|
||||
- See linked recipes with ingredients
|
||||
- Toggling via the 'Pot' icon will show you the linked recipe, allowing you to click to access it.
|
||||
- Toggling via the 'Pot' icon will show you the linked recipe, allowing you to click to access it.
|
||||
- Check off an item
|
||||
- Add / Change / Remove / Sort Items via the grid icon
|
||||
- Be sure if you are modifying an ingredient to click the 'Save' icon.
|
||||
@@ -103,13 +105,10 @@ Here you will be able to:
|
||||
|
||||
!!! tip
|
||||
You can use Labels to categorize your ingredients. You may want to Label by Food Type (Frozen, Fresh, etc), by Store, Tool, Recipe, or more. Play around with this to see what works best for you.
|
||||
|
||||
!!! tip
|
||||
You can toggle 'Food' on items so that if you add multiple of the same food / ingredient, Mealie will automatically combine them together. Do this by editing an item in the Shopping List and clicking the 'Apple' icon. If you then have recipes that contain "1 | cup | cheese" and "2 | cup | cheese" this would be combined to show "3 cups of cheese."
|
||||
|
||||
[See FAQ for more information](../getting-started/faq.md)
|
||||
|
||||
|
||||
|
||||
[Shopping List Demo](https://demo.mealie.io/shopping-lists){ .md-button .md-button--primary }
|
||||
|
||||
## Integrations
|
||||
@@ -198,7 +197,7 @@ Mealie lets you fully customize how you organize your users. You can use Groups
|
||||
|
||||
### Groups
|
||||
|
||||
Groups are fully isolated instances of Mealie. Think of a goup as a completely separate, fully self-contained site. There is no data shared between groups. Each group has its own users, recipes, tags, categories, etc. A user logged-in to one group cannot make any changes to another.
|
||||
Groups are fully isolated instances of Mealie. Think of a group as a completely separate, fully self-contained site. There is no data shared between groups. Each group has its own users, recipes, tags, categories, etc. A user logged-in to one group cannot make any changes to another.
|
||||
|
||||
Common use cases for groups include:
|
||||
|
||||
|
||||
@@ -122,17 +122,20 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
|
||||
Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./open-ai.md).
|
||||
For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values as JSON encoded strings (e.g. `OPENAI_CUSTOM_PARAMS='{"k1": "v1", "k2": "v2"}'`)
|
||||
|
||||
| Variables | Default | Description |
|
||||
| ------------------------------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| OPENAI_BASE_URL<super>[†][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
|
||||
| OPENAI_API_KEY<super>[†][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features |
|
||||
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
|
||||
| OPENAI_CUSTOM_HEADERS | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
||||
| OPENAI_CUSTOM_PARAMS | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
||||
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
| Variables | Default | Description |
|
||||
|-------------------------------------------------------------------------|:-----------:|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| OPENAI_BASE_URL<super>[†][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
|
||||
| OPENAI_API_KEY<super>[†][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features |
|
||||
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
|
||||
| OPENAI_AUDIO_MODEL <br/> :octicons-tag-24: v3.13.0 | whisper-1 | Which OpenAI model to use for audio transcriptions, if enabled. If you're not sure, leave this empty |
|
||||
| OPENAI_CUSTOM_HEADERS <br/> :octicons-tag-24: v2.0.0 | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
||||
| OPENAI_CUSTOM_PARAMS <br/> :octicons-tag-24: v2.0.0 | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
||||
| OPENAI_ENABLE_IMAGE_SERVICES <br/> :octicons-tag-24: v1.12.0 | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
||||
| OPENAI_ENABLE_TRANSCRIPTION_SERVICES <br/> :octicons-tag-24: v3.13.0 | True | Whether to enable OpenAI transcription services, such as creating recipes via video URL. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
| OPENAI_CUSTOM_PROMPT_DIR <br/> :octicons-tag-24: v3.10.0 | None. | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. |
|
||||
|
||||
### Theming
|
||||
|
||||
@@ -235,6 +238,10 @@ The examples below provide copy-ready Docker Compose environment configurations
|
||||
THEME_DARK_ERROR: '#E57373'
|
||||
```
|
||||
|
||||
!!! info
|
||||
Browser cookies may cause the client to keep outdated settings.
|
||||
Clearing the cookies can be required for the change to take effect.
|
||||
|
||||
### Docker Secrets
|
||||
|
||||
> <super>†</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger
|
||||
|
||||
@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
||||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||
|
||||
1. Take a backup just in case!
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.7.0`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.14.0`
|
||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||
4. Restart the container
|
||||
|
||||
|
||||
@@ -10,9 +10,15 @@ For most users, supplying the OpenAI API key is all you need to do; you will use
|
||||
|
||||
Alternatively, if you have another service you'd like to use in-place of OpenAI, you can configure Mealie to use that instead, as long as it has an OpenAI-compatible API. For instance, a common self-hosted alternative to OpenAI is [Ollama](https://ollama.com/). To use Ollama with Mealie, change your `OPENAI_BASE_URL` to `http://localhost:11434/v1` (where `http://localhost:11434` is wherever you're hosting Ollama, and `/v1` enables the OpenAI-compatible endpoints). Note that you *must* provide an API key, even though it is ultimately ignored by Ollama.
|
||||
|
||||
If you wish to disable image recognition features (to save costs, or because your custom model doesn't support them) you can set `OPENAI_ENABLE_IMAGE_SERVICES` to `False`. For more information on what configuration options are available, check out the [backend configuration](./backend-config.md#openai).
|
||||
If you wish to disable image recognition features (to save costs, or because your custom model doesn't support them) you can set `OPENAI_ENABLE_IMAGE_SERVICES` to `False`.
|
||||
If you wish to disable transcription features (to save costs, or because your custom model doesn't support them) you can set `OPENAI_ENABLE_TRANSCRIPTION_SERVICES` to `False`.
|
||||
|
||||
For more information on what configuration options are available, check out the [backend configuration](./backend-config.md#openai).
|
||||
|
||||
|
||||
|
||||
## OpenAI Features
|
||||
- The OpenAI Ingredient Parser can be used as an alternative to the NLP and Brute Force parsers. Simply choose the OpenAI parser while parsing ingredients (:octicons-tag-24: v1.7.0)
|
||||
- When importing a recipe via URL, if the default recipe scraper is unable to read the recipe data from a webpage, the webpage contents will be parsed by OpenAI (:octicons-tag-24: v1.9.0)
|
||||
- You can import an image of a written recipe, which is sent to OpenAI and imported into Mealie. The recipe can be hand-written or typed, as long as the text is in the picture. You can also optionally have OpenAI translate the recipe into your own language (:octicons-tag-24: v1.12.0)
|
||||
- You can import a recipe via a video URL (e.g., a YouTube link). The video is transcribed using OpenAI's Whisper model, and the transcription is parsed into a recipe (:octicons-tag-24: v3.13.0)
|
||||
|
||||
@@ -10,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.7.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.14.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.7.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.14.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -32,8 +32,8 @@ theme:
|
||||
|
||||
markdown_extensions:
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||
- def_list
|
||||
- pymdownx.highlight
|
||||
- pymdownx.superfences
|
||||
@@ -93,7 +93,7 @@ nav:
|
||||
- iOS Shortcut: "documentation/community-guide/ios-shortcut.md"
|
||||
- Reverse Proxy (SWAG): "documentation/community-guide/swag.md"
|
||||
|
||||
- API Reference: "api/redoc.md"
|
||||
- API Reference: "https://demo.mealie.io/docs"
|
||||
|
||||
- Contributors Guide:
|
||||
- Non-Code: "contributors/non-coders.md"
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
max-width: 950px !important;
|
||||
}
|
||||
|
||||
.lg-container {
|
||||
max-width: 1100px !important;
|
||||
}
|
||||
|
||||
.theme--dark.v-application {
|
||||
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
|
||||
}
|
||||
|
||||
@@ -37,73 +37,68 @@
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineNuxtComponent({
|
||||
setup() {
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => $auth.user.value?.groupSlug);
|
||||
const { $globals } = useNuxtApp();
|
||||
<script setup lang="ts">
|
||||
const i18n = useI18n();
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = computed(() => auth.user.value?.groupSlug);
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const sections = ref([
|
||||
const sections = ref([
|
||||
{
|
||||
title: i18n.t("profile.data-migrations"),
|
||||
color: "info",
|
||||
links: [
|
||||
{
|
||||
title: i18n.t("profile.data-migrations"),
|
||||
color: "info",
|
||||
links: [
|
||||
{
|
||||
icon: $globals.icons.backupRestore,
|
||||
to: "/admin/backups",
|
||||
text: i18n.t("settings.backup.backup-restore"),
|
||||
description: i18n.t("admin.setup.restore-from-v1-backup"),
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.import,
|
||||
to: "/group/migrations",
|
||||
text: i18n.t("migration.recipe-migration"),
|
||||
description: i18n.t("migration.coming-from-another-application-or-an-even-older-version-of-mealie"),
|
||||
},
|
||||
],
|
||||
icon: $globals.icons.backupRestore,
|
||||
to: "/admin/backups",
|
||||
text: i18n.t("settings.backup.backup-restore"),
|
||||
description: i18n.t("admin.setup.restore-from-v1-backup"),
|
||||
},
|
||||
{
|
||||
title: i18n.t("recipe.create-recipes"),
|
||||
color: "success",
|
||||
links: [
|
||||
{
|
||||
icon: $globals.icons.createAlt,
|
||||
to: computed(() => `/g/${groupSlug.value || ""}/r/create/new`),
|
||||
text: i18n.t("recipe.create-recipe"),
|
||||
description: i18n.t("recipe.create-recipe-description"),
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.link,
|
||||
to: computed(() => `/g/${groupSlug.value || ""}/r/create/url`),
|
||||
text: i18n.t("recipe.import-with-url"),
|
||||
description: i18n.t("recipe.scrape-recipe-description"),
|
||||
},
|
||||
],
|
||||
icon: $globals.icons.import,
|
||||
to: "/group/migrations",
|
||||
text: i18n.t("migration.recipe-migration"),
|
||||
description: i18n.t("migration.coming-from-another-application-or-an-even-older-version-of-mealie"),
|
||||
},
|
||||
{
|
||||
title: i18n.t("user.manage-users"),
|
||||
color: "primary",
|
||||
links: [
|
||||
{
|
||||
icon: $globals.icons.group,
|
||||
to: "/admin/manage/users",
|
||||
text: i18n.t("user.manage-users"),
|
||||
description: i18n.t("user.manage-users-description"),
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.user,
|
||||
to: "/user/profile",
|
||||
text: i18n.t("profile.manage-user-profile"),
|
||||
description: i18n.t("admin.setup.manage-profile-or-get-invite-link"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
return { sections };
|
||||
],
|
||||
},
|
||||
});
|
||||
{
|
||||
title: i18n.t("recipe.create-recipes"),
|
||||
color: "success",
|
||||
links: [
|
||||
{
|
||||
icon: $globals.icons.createAlt,
|
||||
to: computed(() => `/g/${groupSlug.value || ""}/r/create/new`),
|
||||
text: i18n.t("recipe.create-recipe"),
|
||||
description: i18n.t("recipe.create-recipe-description"),
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.link,
|
||||
to: computed(() => `/g/${groupSlug.value || ""}/r/create/url`),
|
||||
text: i18n.t("recipe.import-with-url"),
|
||||
description: i18n.t("recipe.scrape-recipe-description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: i18n.t("user.manage-users"),
|
||||
color: "primary",
|
||||
links: [
|
||||
{
|
||||
icon: $globals.icons.group,
|
||||
to: "/admin/manage/users",
|
||||
text: i18n.t("user.manage-users"),
|
||||
description: i18n.t("user.manage-users-description"),
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.user,
|
||||
to: "/user/profile",
|
||||
text: i18n.t("profile.manage-user-profile"),
|
||||
description: i18n.t("admin.setup.manage-profile-or-get-invite-link"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -73,11 +73,11 @@ import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const slug = route.params.slug as string;
|
||||
@@ -88,11 +88,11 @@ const router = useRouter();
|
||||
const book = getOne(slug);
|
||||
|
||||
const isOwnHousehold = computed(() => {
|
||||
if (!($auth.user.value && book.value?.householdId)) {
|
||||
if (!(auth.user.value && book.value?.householdId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $auth.user.value.householdId === book.value.householdId;
|
||||
return auth.user.value.householdId === book.value.householdId;
|
||||
});
|
||||
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
||||
|
||||
|
||||
230
frontend/components/Domain/Group/GroupDataPage.vue
Normal file
230
frontend/components/Domain/Group/GroupDataPage.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<!-- Create Dialog -->
|
||||
<BaseDialog
|
||||
v-model="createDialog"
|
||||
:title="createTitle || $t('general.create')"
|
||||
:icon="icon"
|
||||
color="primary"
|
||||
max-width="600px"
|
||||
width="100%"
|
||||
:submit-disabled="!createFormValid"
|
||||
can-confirm
|
||||
@confirm="emit('create-one', createForm.data)"
|
||||
>
|
||||
<div class="mx-2 mt-2">
|
||||
<slot name="create-dialog-top" />
|
||||
<AutoForm
|
||||
v-model="createForm.data"
|
||||
v-model:is-valid="createFormValid"
|
||||
:items="createForm.items"
|
||||
class="py-2"
|
||||
/>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<BaseDialog
|
||||
v-model="editDialog"
|
||||
:title="editTitle || $t('general.edit')"
|
||||
:icon="icon"
|
||||
color="primary"
|
||||
max-width="600px"
|
||||
width="100%"
|
||||
:submit-disabled="!editFormValid"
|
||||
can-confirm
|
||||
@confirm="emit('edit-one', editForm.data)"
|
||||
>
|
||||
<div class="mx-2 mt-2">
|
||||
<slot name="edit-dialog-top" />
|
||||
<AutoForm
|
||||
v-model="editForm.data"
|
||||
v-model:is-valid="editFormValid"
|
||||
:items="editForm.items"
|
||||
class="py-2"
|
||||
/>
|
||||
</div>
|
||||
<template #custom-card-action>
|
||||
<slot name="edit-dialog-custom-action" />
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Dialog -->
|
||||
<BaseDialog
|
||||
v-model="deleteDialog"
|
||||
:title="$t('general.confirm')"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
color="error"
|
||||
can-confirm
|
||||
@confirm="$emit('deleteOne', deleteTarget.id)"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("general.confirm-delete-generic") }}
|
||||
<p v-if="deleteTarget" class="mt-4 ml-4">
|
||||
{{ deleteTarget.name || deleteTarget.title || deleteTarget.id }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Bulk Delete Dialog -->
|
||||
<BaseDialog
|
||||
v-model="bulkDeleteDialog"
|
||||
width="650px"
|
||||
:title="$t('general.confirm')"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
color="error"
|
||||
can-confirm
|
||||
@confirm="$emit('bulk-action', 'delete-selected', bulkDeleteTarget)"
|
||||
>
|
||||
<v-card-text>
|
||||
<p class="h4">
|
||||
{{ $t('general.confirm-delete-generic-items') }}
|
||||
</p>
|
||||
<v-card variant="outlined">
|
||||
<v-virtual-scroll height="400" item-height="25" :items="bulkDeleteTarget">
|
||||
<template #default="{ item }">
|
||||
<v-list-item class="pb-2">
|
||||
<v-list-item-title>{{ item.name || item.title || item.id }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseCardSectionTitle
|
||||
:icon="icon"
|
||||
section
|
||||
:title="title"
|
||||
/>
|
||||
|
||||
<CrudTable
|
||||
:headers="tableHeaders"
|
||||
:table-config="tableConfig"
|
||||
:data="data || []"
|
||||
:bulk-actions="bulkActions"
|
||||
:initial-sort="initialSort"
|
||||
@edit-one="editEventHandler"
|
||||
@delete-one="deleteEventHandler"
|
||||
@bulk-action="handleBulkAction"
|
||||
>
|
||||
<template
|
||||
v-for="slotName in itemSlotNames"
|
||||
#[slotName]="slotProps"
|
||||
>
|
||||
<slot
|
||||
:name="slotName"
|
||||
v-bind="slotProps"
|
||||
/>
|
||||
</template>
|
||||
<template #button-row>
|
||||
<BaseButton
|
||||
create
|
||||
@click="createDialog = true"
|
||||
>
|
||||
{{ $t("general.create") }}
|
||||
</BaseButton>
|
||||
<slot name="table-button-row" />
|
||||
</template>
|
||||
<template #button-bottom>
|
||||
<slot name="table-button-bottom" />
|
||||
</template>
|
||||
</CrudTable>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TableHeaders, TableConfig, BulkAction } from "~/components/global/CrudTable.vue";
|
||||
import type { AutoFormItems } from "~/types/auto-forms";
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "deleteOne", id: string): void;
|
||||
(e: "deleteMany", ids: string[]): void;
|
||||
(e: "create-one" | "edit-one", data: any): void;
|
||||
(e: "bulk-action", event: string, items: any[]): void;
|
||||
}>();
|
||||
|
||||
const tableHeaders = defineModel<TableHeaders[]>("tableHeaders", { required: true });
|
||||
const createForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("createForm", { required: true });
|
||||
const createDialog = defineModel("createDialog", { type: Boolean, default: false });
|
||||
|
||||
const editForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("editForm", { required: true });
|
||||
const editDialog = defineModel("editDialog", { type: Boolean, default: false });
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
createTitle: {
|
||||
type: String,
|
||||
},
|
||||
editTitle: {
|
||||
type: String,
|
||||
},
|
||||
tableConfig: {
|
||||
type: Object as PropType<TableConfig>,
|
||||
default: () => ({
|
||||
hideColumns: false,
|
||||
canExport: true,
|
||||
}),
|
||||
},
|
||||
data: {
|
||||
type: Array as PropType<Array<any>>,
|
||||
required: true,
|
||||
},
|
||||
bulkActions: {
|
||||
type: Array as PropType<BulkAction[]>,
|
||||
required: true,
|
||||
},
|
||||
initialSort: {
|
||||
type: String,
|
||||
default: "name",
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Bulk Action Handler
|
||||
function handleBulkAction(event: string, items: any[]) {
|
||||
if (event === "delete-selected") {
|
||||
bulkDeleteEventHandler(items);
|
||||
return;
|
||||
}
|
||||
emit("bulk-action", event, items);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Create & Edit
|
||||
const createFormValid = ref(false);
|
||||
const editFormValid = ref(false);
|
||||
const itemSlotNames = computed(() => Object.keys(slots).filter(slotName => slotName.startsWith("item.")));
|
||||
const editEventHandler = (item: any) => {
|
||||
editForm.value.data = { ...item };
|
||||
editDialog.value = true;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Delete Logic
|
||||
const deleteTarget = ref<any>(null);
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
function deleteEventHandler(item: any) {
|
||||
deleteTarget.value = item;
|
||||
deleteDialog.value = true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Bulk Delete Logic
|
||||
const bulkDeleteTarget = ref<Array<any>>([]);
|
||||
const bulkDeleteDialog = ref(false);
|
||||
|
||||
function bulkDeleteEventHandler(items: Array<any>) {
|
||||
bulkDeleteTarget.value = items;
|
||||
bulkDeleteDialog.value = true;
|
||||
console.log("Bulk Delete Event Handler", items);
|
||||
}
|
||||
</script>
|
||||
@@ -14,7 +14,7 @@
|
||||
<BaseButton
|
||||
download
|
||||
size="small"
|
||||
:download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"
|
||||
:download-url="`/api/recipes/bulk-actions/export/${item.id}/download`"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="mdAndUp"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
@@ -83,8 +82,6 @@ const emit = defineEmits<{
|
||||
[key: string]: [];
|
||||
}>();
|
||||
|
||||
const { mdAndUp } = useDisplay();
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const api = useUserApi();
|
||||
@@ -94,7 +91,7 @@ const state = reactive({
|
||||
shoppingListDialog: false,
|
||||
menuItems: [
|
||||
{
|
||||
title: i18n.t("recipe.add-to-list"),
|
||||
title: i18n.t("meal-plan.add-day-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
@@ -126,8 +123,8 @@ async function getShoppingLists() {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||
shoppingList: () => {
|
||||
getShoppingLists();
|
||||
shoppingList: async () => {
|
||||
await getShoppingLists();
|
||||
state.shoppingListDialog = true;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
|
||||
|
||||
interface Props {
|
||||
queryFilter?: QueryFilterJSON | null;
|
||||
@@ -76,7 +76,6 @@ const MEAL_DAY_OPTIONS = [
|
||||
];
|
||||
|
||||
function handleQueryFilterInput(value: string | undefined) {
|
||||
console.warn("handleQueryFilterInput called with value:", value);
|
||||
queryFilterString.value = value || "";
|
||||
}
|
||||
|
||||
@@ -114,7 +113,7 @@ const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "last_made",
|
||||
label: i18n.t("general.last-made"),
|
||||
type: "date",
|
||||
type: "relativeDate",
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
flat
|
||||
/>
|
||||
|
||||
<BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')" />
|
||||
<BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')">
|
||||
{{ $t("household.default-recipe-preferences-description") }}
|
||||
</BaseCardSectionTitle>
|
||||
<div class="preference-container">
|
||||
<div v-for="p in recipePreferences" :key="p.key">
|
||||
<v-checkbox v-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
<v-select
|
||||
v-if="field.type !== 'boolean'"
|
||||
:model-value="field.relationalOperatorValue"
|
||||
:items="field.relationalOperatorOptions"
|
||||
:items="field.relationalOperatorChoices"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
variant="underlined"
|
||||
@@ -129,9 +129,9 @@
|
||||
:class="config.col.class"
|
||||
>
|
||||
<v-select
|
||||
v-if="field.fieldOptions"
|
||||
v-if="field.fieldChoices"
|
||||
:model-value="field.values"
|
||||
:items="field.fieldOptions"
|
||||
:items="field.fieldChoices"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
multiple
|
||||
@@ -144,11 +144,13 @@
|
||||
variant="underlined"
|
||||
@update:model-value="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<v-text-field
|
||||
<v-number-input
|
||||
v-else-if="field.type === 'number'"
|
||||
:model-value="field.value"
|
||||
type="number"
|
||||
variant="underlined"
|
||||
control-variant="stacked"
|
||||
inset
|
||||
:precision="null"
|
||||
@update:model-value="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<v-checkbox
|
||||
@@ -167,23 +169,39 @@
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
:model-value="$d(safeNewDate(field.value + 'T00:00:00'))"
|
||||
variant="underlined"
|
||||
color="primary"
|
||||
class="date-input"
|
||||
v-bind="activatorProps"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
:model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
|
||||
:model-value="safeNewDate(field.value + 'T00:00:00')"
|
||||
hide-header
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
|
||||
/>
|
||||
</v-menu>
|
||||
<!--
|
||||
Relative dates are assumed to be negative intervals with a unit of days.
|
||||
The input is a *positive*, interpreted internally as a *negative* offset.
|
||||
-->
|
||||
<v-number-input
|
||||
v-else-if="field.type === 'relativeDate'"
|
||||
:model-value="parseRelativeDateOffset(field.value)"
|
||||
:suffix="$t('query-filter.dates.days-ago', parseRelativeDateOffset(field.value))"
|
||||
variant="underlined"
|
||||
control-variant="stacked"
|
||||
density="compact"
|
||||
inset
|
||||
:min="0"
|
||||
:precision="0"
|
||||
class="date-input"
|
||||
@update:model-value="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Category"
|
||||
v-model="field.organizers"
|
||||
@@ -317,7 +335,13 @@ import { useDebounceFn } from "@vueuse/core";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||
import type {
|
||||
LogicalOperator,
|
||||
QueryFilterJSON,
|
||||
QueryFilterJSONPart,
|
||||
RelationalKeyword,
|
||||
RelationalOperator,
|
||||
} from "~/lib/api/types/non-generated";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { useUserStore } from "~/composables/store/use-user-store";
|
||||
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||
@@ -339,7 +363,14 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { household } = useHouseholdSelf();
|
||||
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
||||
const {
|
||||
logOps,
|
||||
placeholderKeywords,
|
||||
getRelOps,
|
||||
buildQueryFilterString,
|
||||
getFieldFromFieldDef,
|
||||
isOrganizerType,
|
||||
} = useQueryFilterBuilder();
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
@@ -394,16 +425,29 @@ function setField(index: number, fieldLabel: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
|
||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldChoices !== fields.value[index].fieldChoices);
|
||||
const updatedField = { ...fields.value[index], ...fieldDef };
|
||||
|
||||
// we have to set this explicitly since it might be undefined
|
||||
updatedField.fieldOptions = fieldDef.fieldOptions;
|
||||
updatedField.fieldChoices = fieldDef.fieldChoices;
|
||||
|
||||
fields.value[index] = {
|
||||
...getFieldFromFieldDef(updatedField, resetValue),
|
||||
id: fields.value[index].id, // keep the id
|
||||
};
|
||||
|
||||
// Defaults
|
||||
switch (fields.value[index].type) {
|
||||
case "date":
|
||||
fields.value[index].value = safeNewDate("");
|
||||
break;
|
||||
case "relativeDate":
|
||||
fields.value[index].value = "$NOW-30d";
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||
@@ -423,12 +467,21 @@ function setLogicalOperatorValue(field: FieldWithId, index: number, value: Logic
|
||||
}
|
||||
|
||||
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
|
||||
const relOps = getRelOps(field.type);
|
||||
fields.value[index].relationalOperatorValue = relOps.value[value];
|
||||
}
|
||||
|
||||
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
|
||||
state.datePickers[index] = false;
|
||||
fields.value[index].value = value;
|
||||
|
||||
if (field.type === "relativeDate") {
|
||||
// Value is set to an int representing the offset from $NOW
|
||||
// Values are assumed to be negative offsets ('-') with a unit of days ('d')
|
||||
fields.value[index].value = `$NOW-${Math.abs(value)}d`;
|
||||
}
|
||||
else {
|
||||
fields.value[index].value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
|
||||
@@ -446,12 +499,7 @@ function removeField(index: number) {
|
||||
state.datePickers.splice(index, 1);
|
||||
}
|
||||
|
||||
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
||||
/* newFields.forEach((field, index) => {
|
||||
const updatedField = getFieldFromFieldDef(field);
|
||||
fields.value[index] = updatedField; // recursive!!!
|
||||
}); */
|
||||
|
||||
const fieldsUpdater = useDebounceFn(() => {
|
||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||
if (qf) {
|
||||
console.debug(`Set query filter: ${qf}`);
|
||||
@@ -517,6 +565,9 @@ async function initializeFields() {
|
||||
...getFieldFromFieldDef(fieldDef),
|
||||
id: useUid(),
|
||||
};
|
||||
|
||||
const relOps = getRelOps(field.type);
|
||||
|
||||
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
|
||||
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
|
||||
field.logicalOperator = part.logicalOperator
|
||||
@@ -525,12 +576,15 @@ async function initializeFields() {
|
||||
field.relationalOperatorValue = part.relationalOperator
|
||||
? relOps.value[part.relationalOperator]
|
||||
: field.relationalOperatorValue;
|
||||
field.relationalOperatorValue = part.relationalOperator
|
||||
? relOps.value[part.relationalOperator]
|
||||
: field.relationalOperatorValue;
|
||||
|
||||
if (field.leftParenthesis || field.rightParenthesis) {
|
||||
state.showAdvanced = true;
|
||||
}
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
|
||||
if (typeof part.value === "string") {
|
||||
field.values = part.value ? [part.value] : [];
|
||||
}
|
||||
@@ -599,7 +653,7 @@ function buildQueryFilterJSON(): QueryFilterJSON {
|
||||
relationalOperator: field.relationalOperatorValue?.value,
|
||||
};
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
|
||||
part.value = field.values.map(value => value.toString());
|
||||
}
|
||||
else if (field.type === "boolean") {
|
||||
@@ -617,6 +671,50 @@ function buildQueryFilterJSON(): QueryFilterJSON {
|
||||
return qfJSON;
|
||||
}
|
||||
|
||||
function safeNewDate(input: string): Date {
|
||||
const date = new Date(input);
|
||||
if (isNaN(date.getTime())) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return today;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a relative date string offset (e.g. $NOW-30d --> 30)
|
||||
*
|
||||
* Currently only values with a negative offset ('-') and a unit of days ('d') are supported
|
||||
*/
|
||||
function parseRelativeDateOffset(value: string): number {
|
||||
const defaultVal = 30;
|
||||
if (!value) {
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!value.startsWith(placeholderKeywords.value["$NOW"].value)) {
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
const remainder = value.slice(placeholderKeywords.value["$NOW"].value.length);
|
||||
if (!remainder.startsWith("-")) {
|
||||
throw new Error("Invalid operator (not '-')");
|
||||
}
|
||||
|
||||
if (remainder.slice(-1) !== "d") {
|
||||
throw new Error("Invalid unit (not 'd')");
|
||||
}
|
||||
|
||||
// Slice off sign and unit
|
||||
return parseInt(remainder.slice(1, -1));
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Unable to parse relative date offset from '${value}': ${error}`);
|
||||
return defaultVal;
|
||||
}
|
||||
}
|
||||
|
||||
const config = computed(() => {
|
||||
const multiple = fields.value.length > 1;
|
||||
const adv = state.showAdvanced;
|
||||
@@ -687,4 +785,13 @@ const config = computed(() => {
|
||||
.bg-light {
|
||||
background-color: rgba(255, 255, 255, var(--bg-opactity));
|
||||
}
|
||||
|
||||
:deep(.date-input input) {
|
||||
text-align: end;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
:deep(.date-input .v-field__field) {
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
@print="$emit('print')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="open" class="custom-btn-group gapped">
|
||||
<div v-if="open" class="custom-btn-group gapped ma-1">
|
||||
<v-btn
|
||||
v-for="(btn, index) in editorButtons"
|
||||
:key="index"
|
||||
@@ -126,7 +126,7 @@ withDefaults(defineProps<Props>(), {
|
||||
canEdit: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["print", "input", "delete", "close", "edit"]);
|
||||
const emit = defineEmits(["print", "input", "save", "delete", "close", "json", "edit"]);
|
||||
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
|
||||
@@ -1,60 +1,97 @@
|
||||
<template>
|
||||
<div v-if="model.length > 0 || edit">
|
||||
<v-card class="mt-4">
|
||||
<v-card-title class="py-2">
|
||||
{{ $t("asset.assets") }}
|
||||
</v-card-title>
|
||||
<v-list-item class="pr-2 pl-0">
|
||||
<v-card-title>
|
||||
{{ $t("asset.assets") }}
|
||||
</v-card-title>
|
||||
<template #append>
|
||||
<v-btn
|
||||
v-if="edit"
|
||||
variant="plain"
|
||||
:icon="$globals.icons.create"
|
||||
@click="state.newAssetDialog = true"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-divider class="mx-2" />
|
||||
<v-list
|
||||
v-if="model.length > 0"
|
||||
lines="two"
|
||||
:flat="!edit"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(item, i) in model"
|
||||
:key="i"
|
||||
:href="!edit ? assetURL(item.fileName ?? '') : ''"
|
||||
target="_blank"
|
||||
class="pr-2"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="ma-auto">
|
||||
<v-tooltip location="bottom">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-icon v-bind="tooltipProps">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ getIconDefinition(item.icon).title }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<v-avatar size="48" rounded="lg" class="elevation-1">
|
||||
<v-img
|
||||
v-if="isImage(item.fileName)"
|
||||
:src="assetURL(item.fileName ?? '')"
|
||||
:alt="item.name"
|
||||
loading="lazy"
|
||||
cover
|
||||
/>
|
||||
<v-icon v-else size="large">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="pl-2">
|
||||
|
||||
<v-list-item-title>
|
||||
{{ item.name }}
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<v-menu v-if="edit" location="bottom end">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon
|
||||
variant="plain"
|
||||
>
|
||||
<v-icon :icon="$globals.icons.dotsVertical" />
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" min-width="220">
|
||||
<v-list-item
|
||||
:href="assetURL(item.fileName ?? '')"
|
||||
:prepend-icon="$globals.icons.eye"
|
||||
:title="$t('general.view')"
|
||||
target="_blank"
|
||||
/>
|
||||
<v-list-item
|
||||
:href="assetURL(item.fileName ?? '')"
|
||||
:prepend-icon="$globals.icons.download"
|
||||
:title="$t('general.download')"
|
||||
download
|
||||
/>
|
||||
<v-list-item
|
||||
v-if="edit"
|
||||
:prepend-icon="$globals.icons.contentCopy"
|
||||
:title="$t('general.copy')"
|
||||
@click="copyText(assetEmbed(item.fileName ?? ''))"
|
||||
/>
|
||||
<v-list-item
|
||||
v-if="edit"
|
||||
:prepend-icon="$globals.icons.delete"
|
||||
:title="$t('general.delete')"
|
||||
@click="model.splice(i, 1)"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
v-if="!edit"
|
||||
color="primary"
|
||||
icon
|
||||
size="small"
|
||||
variant="plain"
|
||||
:href="assetURL(item.fileName ?? '')"
|
||||
target="_blank"
|
||||
top
|
||||
download
|
||||
>
|
||||
<v-icon> {{ $globals.icons.download }} </v-icon>
|
||||
</v-btn>
|
||||
<div v-else>
|
||||
<v-btn
|
||||
color="error"
|
||||
icon
|
||||
size="small"
|
||||
top
|
||||
@click="model.splice(i, 1)"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
<AppButtonCopy
|
||||
color=""
|
||||
:copy-text="assetEmbed(item.fileName ?? '')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
@@ -68,18 +105,9 @@
|
||||
can-submit
|
||||
@submit="addAsset"
|
||||
>
|
||||
<template #activator>
|
||||
<BaseButton
|
||||
v-if="edit"
|
||||
size="small"
|
||||
create
|
||||
@click="state.newAssetDialog = true"
|
||||
/>
|
||||
</template>
|
||||
<v-card-text class="pt-4">
|
||||
<v-text-field
|
||||
v-model="state.newAsset.name"
|
||||
density="compact"
|
||||
:label="$t('general.name')"
|
||||
/>
|
||||
<div class="d-flex justify-space-between">
|
||||
@@ -92,10 +120,14 @@
|
||||
item-value="name"
|
||||
class="mr-2"
|
||||
>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #prepend>
|
||||
<v-icon>{{ item.raw.icon }}</v-icon>
|
||||
<v-avatar>
|
||||
<v-icon>
|
||||
{{ item.raw.icon }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
@@ -107,7 +139,6 @@
|
||||
@uploaded="setFileObject"
|
||||
/>
|
||||
</div>
|
||||
{{ state.fileObject.name }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
@@ -118,6 +149,7 @@
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import type { RecipeAsset } from "~/lib/api/types/recipe";
|
||||
import { useCopy } from "~/composables/use-copy";
|
||||
|
||||
const props = defineProps({
|
||||
slug: {
|
||||
@@ -149,6 +181,7 @@ const state = reactive({
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { copyText } = useCopy();
|
||||
|
||||
const iconOptions = [
|
||||
{
|
||||
@@ -184,21 +217,31 @@ function getIconDefinition(icon: string) {
|
||||
return iconOptions.find(item => item.name === icon) || iconOptions[0];
|
||||
}
|
||||
|
||||
function isImage(fileName?: string | null) {
|
||||
if (!fileName) return false;
|
||||
return /\.(png|jpe?g|gif|webp|bmp|avif)$/i.test(fileName);
|
||||
}
|
||||
|
||||
const { recipeAssetPath } = useStaticRoutes();
|
||||
function assetURL(assetName: string) {
|
||||
return recipeAssetPath(props.recipeId, assetName);
|
||||
}
|
||||
|
||||
function assetEmbed(name: string) {
|
||||
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%"> </img>`;
|
||||
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%" />`;
|
||||
}
|
||||
|
||||
function setFileObject(fileObject: File) {
|
||||
state.fileObject = fileObject;
|
||||
// If the user didn't provide a name, default to the file base name
|
||||
if (!state.newAsset.name?.trim()) {
|
||||
state.newAsset.name = fileObject.name.substring(0, fileObject.name.lastIndexOf("."));
|
||||
}
|
||||
}
|
||||
|
||||
function validFields() {
|
||||
return state.newAsset.name.length > 0 && state.fileObject.name.length > 0;
|
||||
// Only require a file; name will fall back to the file name if empty
|
||||
return Boolean(state.fileObject?.name);
|
||||
}
|
||||
|
||||
async function addAsset() {
|
||||
@@ -207,8 +250,10 @@ async function addAsset() {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameToUse = state.newAsset.name?.trim() || state.fileObject.name;
|
||||
|
||||
const { data } = await api.recipes.createAsset(props.slug, {
|
||||
name: state.newAsset.name,
|
||||
name: nameToUse,
|
||||
icon: state.newAsset.icon,
|
||||
file: state.fileObject,
|
||||
extension: state.fileObject.name.split(".").pop() || "",
|
||||
|
||||
@@ -130,11 +130,11 @@ defineEmits<{
|
||||
delete: [slug: string];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
|
||||
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||
const recipeRoute = computed<string>(() => {
|
||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||
|
||||
@@ -160,11 +160,11 @@ defineEmits<{
|
||||
delete: [slug: string];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
|
||||
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||
const recipeRoute = computed<string>(() => {
|
||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||
|
||||
@@ -219,7 +219,7 @@ const EVENTS = {
|
||||
shuffle: "shuffle",
|
||||
};
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const useMobileCards = computed(() => {
|
||||
@@ -234,7 +234,7 @@ const sortLoading = ref(false);
|
||||
const randomSeed = ref(Date.now().toString());
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const page = ref(1);
|
||||
const perPage = 32;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="$vuetify.display.mdAndUp"
|
||||
content-class="d-print-none"
|
||||
@update:model-value="onMenuToggle"
|
||||
>
|
||||
@@ -24,7 +23,6 @@
|
||||
:fab="fab"
|
||||
v-bind="activatorProps"
|
||||
@click.prevent
|
||||
@mouseenter="onHover"
|
||||
>
|
||||
<v-icon
|
||||
:size="!fab ? undefined : 'x-large'"
|
||||
@@ -127,12 +125,6 @@ const contentProps = computed(() => {
|
||||
return rest;
|
||||
});
|
||||
|
||||
function onHover() {
|
||||
if (!isMenuContentLoaded.value) {
|
||||
isMenuContentLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuToggle(isOpen: boolean) {
|
||||
if (isOpen && !isMenuContentLoaded.value) {
|
||||
isMenuContentLoaded.value = true;
|
||||
|
||||
@@ -176,6 +176,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<{
|
||||
[key: string]: any;
|
||||
deleted: [slug: string];
|
||||
print: [];
|
||||
}>();
|
||||
|
||||
const api = useUserApi();
|
||||
@@ -201,13 +202,13 @@ const newMealdateString = computed(() => {
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { household } = useHouseholdSelf();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
@@ -295,12 +296,12 @@ const recipeRefWithScale = computed(() =>
|
||||
);
|
||||
const isAdminAndNotOwner = computed(() => {
|
||||
return (
|
||||
$auth.user.value?.admin
|
||||
&& $auth.user.value?.id !== recipeRef.value?.userId
|
||||
auth.user.value?.admin
|
||||
&& auth.user.value?.id !== recipeRef.value?.userId
|
||||
);
|
||||
});
|
||||
const canDelete = computed(() => {
|
||||
const user = $auth.user.value;
|
||||
const user = auth.user.value;
|
||||
const recipe = recipeRef.value;
|
||||
return user && recipe && (user.admin || user.id === recipe.userId);
|
||||
});
|
||||
|
||||
@@ -110,8 +110,8 @@ defineEmits<{
|
||||
const selected = defineModel<Recipe[]>({ default: () => [] });
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = $auth.user.value?.groupSlug;
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = auth.user.value?.groupSlug;
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize sort state with default sorting by dateAdded descending
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:title="$t('recipe.add-to-list')"
|
||||
:icon="$globals.icons.cartCheck"
|
||||
>
|
||||
<v-container v-if="!shoppingListChoices.length">
|
||||
<v-container v-if="!filteredShoppingLists.length">
|
||||
<BasePageTitle>
|
||||
<template #title>
|
||||
{{ $t('shopping-list.no-shopping-lists-found') }}
|
||||
@@ -15,7 +15,7 @@
|
||||
</v-container>
|
||||
<v-card-text>
|
||||
<v-card
|
||||
v-for="list in shoppingListChoices"
|
||||
v-for="list in filteredShoppingLists"
|
||||
:key="list.id"
|
||||
hover
|
||||
class="my-2 left-border"
|
||||
@@ -86,6 +86,19 @@
|
||||
class="text-center"
|
||||
>
|
||||
{{ recipeSection.recipeName }}
|
||||
<v-tooltip v-if="recipeSection.parentRecipe?.name" location="top">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
size="tiny"
|
||||
class="mb-2 ml-2"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ $t("shopping-list.ingredient-of-recipe", { recipe: recipeSection.parentRecipe.name }) }}</span>
|
||||
</v-tooltip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row
|
||||
@@ -203,6 +216,7 @@ export interface ShoppingListRecipeIngredientSection {
|
||||
recipeName: string;
|
||||
recipeScale: number;
|
||||
ingredientSections: ShoppingListIngredientSection[];
|
||||
parentRecipe?: Recipe;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -217,50 +231,112 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const api = useUserApi();
|
||||
const preferences = useShoppingListPreferences();
|
||||
const ready = ref(false);
|
||||
|
||||
// Capture values at initialization to avoid reactive updates
|
||||
const currentHouseholdSlug = ref("");
|
||||
const filteredShoppingLists = ref<ShoppingListSummary[]>([]);
|
||||
|
||||
const state = reactive({
|
||||
shoppingListDialog: true,
|
||||
shoppingListDialog: false,
|
||||
shoppingListIngredientDialog: false,
|
||||
shoppingListShowAllToggled: false,
|
||||
});
|
||||
|
||||
const { shoppingListDialog, shoppingListIngredientDialog, shoppingListShowAllToggled: _shoppingListShowAllToggled } = toRefs(state);
|
||||
|
||||
const userHousehold = computed(() => {
|
||||
return $auth.user.value?.householdSlug || "";
|
||||
});
|
||||
|
||||
const shoppingListChoices = computed(() => {
|
||||
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
|
||||
});
|
||||
|
||||
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
||||
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
selectedShoppingList.value = shoppingListChoices.value[0];
|
||||
watch([dialog, () => preferences.value.viewAllLists], () => {
|
||||
if (dialog.value) {
|
||||
currentHouseholdSlug.value = auth.user.value?.householdSlug || "";
|
||||
filteredShoppingLists.value = props.shoppingLists.filter(
|
||||
list => preferences.value.viewAllLists || list.userId === auth.user.value?.id,
|
||||
);
|
||||
|
||||
if (filteredShoppingLists.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
selectedShoppingList.value = filteredShoppingLists.value[0];
|
||||
openShoppingListIngredientDialog(selectedShoppingList.value);
|
||||
}
|
||||
else {
|
||||
state.shoppingListDialog = true;
|
||||
ready.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
}
|
||||
else if (!dialog.value) {
|
||||
initState();
|
||||
}
|
||||
});
|
||||
|
||||
function buildIngredientSections(ingredients: ShoppingListIngredient[]): ShoppingListIngredientSection[] {
|
||||
let currentTitle = "";
|
||||
const onHandIngs: ShoppingListIngredient[] = [];
|
||||
const sections = ingredients.reduce((acc, ing) => {
|
||||
if (ing.ingredient.title) {
|
||||
currentTitle = ing.ingredient.title;
|
||||
}
|
||||
|
||||
if (!acc.length || currentTitle !== acc[acc.length - 1].sectionName) {
|
||||
if (acc.length) {
|
||||
acc[acc.length - 1].ingredients.push(...onHandIngs);
|
||||
onHandIngs.length = 0;
|
||||
}
|
||||
acc.push({ sectionName: currentTitle, ingredients: [] });
|
||||
}
|
||||
|
||||
const householdsWithFood = ing.ingredient?.food?.householdsWithIngredientFood || [];
|
||||
if (householdsWithFood.includes(currentHouseholdSlug.value)) {
|
||||
onHandIngs.push(ing);
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[acc.length - 1].ingredients.push(ing);
|
||||
return acc;
|
||||
}, [] as ShoppingListIngredientSection[]);
|
||||
|
||||
if (sections.length) {
|
||||
sections[sections.length - 1].ingredients.push(...onHandIngs);
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
|
||||
|
||||
function addSubRecipeToMap(ing: RecipeIngredient, parentQuantity: number, parentScale: number, parentRecipe: Recipe) {
|
||||
const ref = ing.referencedRecipe!;
|
||||
const key = ref.id || ref.slug || "";
|
||||
const ownIngs: ShoppingListIngredient[] = [];
|
||||
const subRefIngs: RecipeIngredient[] = [];
|
||||
|
||||
for (const subIng of ref.recipeIngredient ?? []) {
|
||||
if (subIng.referencedRecipe) {
|
||||
subRefIngs.push(subIng);
|
||||
}
|
||||
else {
|
||||
const householdsWithFood = subIng.food?.householdsWithIngredientFood || [];
|
||||
ownIngs.push({
|
||||
checked: !householdsWithFood.includes(currentHouseholdSlug.value),
|
||||
ingredient: { ...subIng, quantity: (ing.quantity || 1) * (subIng.quantity || 1) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
recipeSectionMap.set(key, {
|
||||
recipeId: ref.id || "",
|
||||
recipeName: ref.name || "",
|
||||
recipeScale: parentQuantity * parentScale,
|
||||
ingredientSections: buildIngredientSections(ownIngs),
|
||||
parentRecipe,
|
||||
});
|
||||
|
||||
subRefIngs.forEach(subIng => addSubRecipeToMap(subIng, (ing.quantity || 1) * (subIng.quantity || 1), parentScale, ref));
|
||||
}
|
||||
|
||||
for (const recipe of recipes) {
|
||||
if (!recipe.slug) {
|
||||
continue;
|
||||
@@ -274,101 +350,54 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
|
||||
const { data } = await api.recipes.getOne(recipe.slug);
|
||||
// Create a local copy to avoid mutating props
|
||||
let recipeData = { ...recipe };
|
||||
if (!(recipeData.id && recipeData.name && recipeData.recipeIngredient)) {
|
||||
const { data } = await api.recipes.getOne(recipeData.slug);
|
||||
if (!data?.recipeIngredient?.length) {
|
||||
continue;
|
||||
}
|
||||
recipe.id = data.id || "";
|
||||
recipe.name = data.name || "";
|
||||
recipe.recipeIngredient = data.recipeIngredient;
|
||||
recipeData = {
|
||||
...recipeData,
|
||||
id: data.id || "",
|
||||
name: data.name || "",
|
||||
recipeIngredient: data.recipeIngredient,
|
||||
};
|
||||
}
|
||||
else if (!recipe.recipeIngredient.length) {
|
||||
else if (!recipeData.recipeIngredient.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shoppingListIngredients: ShoppingListIngredient[] = [];
|
||||
function flattenRecipeIngredients(ing: RecipeIngredient, parentTitle = ""): ShoppingListIngredient[] {
|
||||
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
|
||||
const ownIngs: ShoppingListIngredient[] = [];
|
||||
const subRefIngs: RecipeIngredient[] = [];
|
||||
recipeData.recipeIngredient.forEach((ing) => {
|
||||
if (ing.referencedRecipe) {
|
||||
// Recursively flatten all ingredients in the referenced recipe
|
||||
return (ing.referencedRecipe.recipeIngredient ?? []).flatMap((subIng) => {
|
||||
const calculatedQty = (ing.quantity || 1) * (subIng.quantity || 1);
|
||||
// Pass the referenced recipe name as the section title
|
||||
return flattenRecipeIngredients(
|
||||
{ ...subIng, quantity: calculatedQty },
|
||||
"",
|
||||
);
|
||||
});
|
||||
subRefIngs.push(ing);
|
||||
}
|
||||
else {
|
||||
// Regular ingredient
|
||||
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 = "";
|
||||
const onHandIngs: ShoppingListIngredient[] = [];
|
||||
const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
|
||||
if (ing.ingredient.title) {
|
||||
currentTitle = ing.ingredient.title;
|
||||
}
|
||||
else if (ing.ingredient.referencedRecipe?.name) {
|
||||
currentTitle = ing.ingredient.referencedRecipe.name;
|
||||
}
|
||||
|
||||
// If this is the first item in the section, create a new section
|
||||
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
|
||||
if (sections.length) {
|
||||
// Add the on-hand ingredients to the previous section
|
||||
sections[sections.length - 1].ingredients.push(...onHandIngs);
|
||||
onHandIngs.length = 0;
|
||||
}
|
||||
sections.push({
|
||||
sectionName: currentTitle,
|
||||
ingredients: [],
|
||||
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
|
||||
ownIngs.push({
|
||||
checked: !householdsWithFood.includes(currentHouseholdSlug.value),
|
||||
ingredient: ing,
|
||||
});
|
||||
}
|
||||
|
||||
// Store the on-hand ingredients for later
|
||||
const householdsWithFood = (ing.ingredient?.food?.householdsWithIngredientFood || []);
|
||||
if (householdsWithFood.includes(userHousehold.value)) {
|
||||
onHandIngs.push(ing);
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Add the ingredient to previous section
|
||||
sections[sections.length - 1].ingredients.push(ing);
|
||||
return sections;
|
||||
}, [] as ShoppingListIngredientSection[]);
|
||||
|
||||
// Add remaining on-hand ingredients to the previous section
|
||||
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
|
||||
});
|
||||
|
||||
recipeSectionMap.set(recipe.slug, {
|
||||
recipeId: recipe.id,
|
||||
recipeName: recipe.name,
|
||||
recipeScale: recipe.scale,
|
||||
ingredientSections: shoppingListIngredientSections,
|
||||
recipeId: recipeData.id,
|
||||
recipeName: recipeData.name,
|
||||
recipeScale: recipeData.scale,
|
||||
ingredientSections: buildIngredientSections(ownIngs),
|
||||
});
|
||||
|
||||
subRefIngs.forEach(ing => addSubRecipeToMap(ing, ing.quantity || 1, recipeData.scale, recipeData));
|
||||
}
|
||||
|
||||
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
|
||||
}
|
||||
|
||||
function initState() {
|
||||
state.shoppingListDialog = true;
|
||||
state.shoppingListDialog = false;
|
||||
state.shoppingListIngredientDialog = false;
|
||||
state.shoppingListShowAllToggled = false;
|
||||
recipeIngredientSections.value = [];
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
dark
|
||||
color="primary-lighten-1 top-0 position-relative left-0"
|
||||
:rounded="!$vuetify.display.xs"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<v-text-field
|
||||
id="arrow-search"
|
||||
@@ -32,9 +33,8 @@
|
||||
|
||||
<v-btn
|
||||
v-if="$vuetify.display.xs"
|
||||
icon
|
||||
size="x-small"
|
||||
class="rounded-circle"
|
||||
light
|
||||
@click="dialog = false"
|
||||
>
|
||||
<v-icon>
|
||||
@@ -87,7 +87,7 @@ const emit = defineEmits<{
|
||||
selected: [recipe: RecipeSummary];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const loading = ref(false);
|
||||
const selectedIndex = ref(-1);
|
||||
|
||||
@@ -153,7 +153,7 @@ watch(dialog, (val) => {
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
watch(route, close);
|
||||
|
||||
function open() {
|
||||
|
||||
@@ -119,10 +119,10 @@ whenever(
|
||||
);
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { household } = useHouseholdSelf();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
|
||||
@@ -25,48 +25,32 @@
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeExplorerPageSearch from "./RecipeExplorerPageParts/RecipeExplorerPageSearch.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
||||
import { useLazyRecipes } from "~/composables/recipes";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeCardSection, RecipeExplorerPageSearch },
|
||||
setup() {
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const { recipes, appendRecipes, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const { recipes, appendRecipes, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
|
||||
const ready = ref(false);
|
||||
const searchComponent = ref<InstanceType<typeof RecipeExplorerPageSearch>>();
|
||||
const ready = ref(false);
|
||||
const searchComponent = ref<InstanceType<typeof RecipeExplorerPageSearch>>();
|
||||
|
||||
const searchQuery = computed(() => {
|
||||
return searchComponent.value?.passedQueryWithSeed || {};
|
||||
});
|
||||
|
||||
function onSearchReady() {
|
||||
ready.value = true;
|
||||
}
|
||||
|
||||
function onItemSelected(item: any, urlPrefix: string) {
|
||||
searchComponent.value?.filterItems(item, urlPrefix);
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
searchComponent,
|
||||
searchQuery,
|
||||
recipes,
|
||||
appendRecipes,
|
||||
replaceRecipes,
|
||||
onSearchReady,
|
||||
onItemSelected,
|
||||
};
|
||||
},
|
||||
const searchQuery = computed(() => {
|
||||
return searchComponent.value?.passedQueryWithSeed || {};
|
||||
});
|
||||
|
||||
function onSearchReady() {
|
||||
ready.value = true;
|
||||
}
|
||||
|
||||
function onItemSelected(item: any, urlPrefix: string) {
|
||||
searchComponent.value?.filterItems(item, urlPrefix);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
v-model="state.auto"
|
||||
:label="$t('search.auto-search')"
|
||||
single-line
|
||||
color="primary"
|
||||
/>
|
||||
<v-btn
|
||||
block
|
||||
@@ -140,13 +141,13 @@ const emit = defineEmits<{
|
||||
ready: [];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const { $globals } = useNuxtApp();
|
||||
const i18n = useI18n();
|
||||
const showRandomLoading = ref(false);
|
||||
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const {
|
||||
state,
|
||||
|
||||
@@ -81,11 +81,11 @@ import {
|
||||
usePublicToolStore,
|
||||
} from "~/composables/store";
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const {
|
||||
state,
|
||||
@@ -101,4 +101,14 @@ const { store: tags } = isOwnGroup.value ? useTagStore() : usePublicTagStore(gro
|
||||
const { store: tools } = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
|
||||
const { store: foods } = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
|
||||
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
|
||||
|
||||
watch(
|
||||
households,
|
||||
() => {
|
||||
// if exactly one household exists, then we shouldn't be filtering by household
|
||||
if (households.value.length == 1) {
|
||||
selectedHouseholds.value = [];
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -52,14 +52,14 @@ const isFavorite = computed(() => {
|
||||
|
||||
async function toggleFavorite() {
|
||||
const api = useUserApi();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
|
||||
if (!$auth.user.value) return;
|
||||
if (!auth.user.value) return;
|
||||
if (!isFavorite.value) {
|
||||
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
||||
await api.users.addFavorite(auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
else {
|
||||
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
||||
await api.users.removeFavorite(auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
await refreshUserRatings();
|
||||
}
|
||||
|
||||
@@ -22,12 +22,15 @@
|
||||
cols="12"
|
||||
class="flex-grow-0 flex-shrink-0"
|
||||
>
|
||||
<v-text-field
|
||||
<v-number-input
|
||||
v-model="model.quantity"
|
||||
variant="solo"
|
||||
:precision="null"
|
||||
:min="0"
|
||||
hide-details
|
||||
control-variant="stacked"
|
||||
inset
|
||||
density="compact"
|
||||
type="number"
|
||||
:placeholder="$t('recipe.quantity')"
|
||||
@keypress="quantityFilter"
|
||||
>
|
||||
@@ -38,12 +41,11 @@
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-number-input>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="!state.isRecipe"
|
||||
sm="12"
|
||||
md="3"
|
||||
md="2"
|
||||
cols="12"
|
||||
>
|
||||
<v-autocomplete
|
||||
@@ -55,8 +57,8 @@
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="units || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
:items="filteredUnits"
|
||||
:custom-filter="() => true"
|
||||
item-title="name"
|
||||
class="mx-1"
|
||||
:placeholder="$t('recipe.choose-unit')"
|
||||
@@ -101,7 +103,7 @@
|
||||
<v-col
|
||||
v-if="!state.isRecipe"
|
||||
m="12"
|
||||
md="3"
|
||||
md="4"
|
||||
cols="12"
|
||||
class=""
|
||||
>
|
||||
@@ -114,8 +116,8 @@
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="foods || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
:items="filteredFoods"
|
||||
:custom-filter="() => true"
|
||||
item-title="name"
|
||||
class="mx-1 py-0"
|
||||
:placeholder="$t('recipe.choose-food')"
|
||||
@@ -159,7 +161,7 @@
|
||||
<v-col
|
||||
v-if="state.isRecipe"
|
||||
m="12"
|
||||
md="6"
|
||||
md="4"
|
||||
cols="12"
|
||||
class=""
|
||||
>
|
||||
@@ -173,7 +175,6 @@
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="search.data.value || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
item-title="name"
|
||||
class="mx-1 py-0"
|
||||
:placeholder="$t('search.type-to-search')"
|
||||
@@ -224,11 +225,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, toRefs } from "vue";
|
||||
import { ref, computed, reactive, toRefs, watch } from "vue";
|
||||
import { useDisplay } from "vuetify";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||
import { normalizeFilter } from "~/composables/use-utils";
|
||||
import { useSearch } from "~/composables/use-search";
|
||||
import { useNuxtApp } from "#app";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
||||
@@ -340,8 +341,8 @@ const btns = computed(() => {
|
||||
// Foods
|
||||
const foodStore = useFoodStore();
|
||||
const foodData = useFoodData();
|
||||
const foodSearch = ref("");
|
||||
const foodAutocomplete = ref<HTMLInputElement>();
|
||||
const { search: foodSearch, filtered: filteredFoods } = useSearch(foodStore.store);
|
||||
|
||||
async function createAssignFood() {
|
||||
foodData.data.name = foodSearch.value;
|
||||
@@ -352,8 +353,8 @@ async function createAssignFood() {
|
||||
|
||||
// Recipes
|
||||
const route = useRoute();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||
@@ -372,8 +373,8 @@ watch(loading, (val) => {
|
||||
// Units
|
||||
const unitStore = useUnitStore();
|
||||
const unitsData = useUnitData();
|
||||
const unitSearch = ref("");
|
||||
const unitAutocomplete = ref<HTMLInputElement>();
|
||||
const { search: unitSearch, filtered: filteredUnits } = useSearch(unitStore.store);
|
||||
|
||||
async function createAssignUnit() {
|
||||
unitsData.data.name = unitSearch.value;
|
||||
@@ -427,9 +428,6 @@ function quantityFilter(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
const { showTitle } = toRefs(state);
|
||||
|
||||
const foods = foodStore.store;
|
||||
const units = unitStore.store;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
|
||||
interface Props {
|
||||
ingredient?: RecipeIngredient;
|
||||
@@ -20,6 +20,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { ingredient, scale = 1 } = defineProps<Props>();
|
||||
const { useParsedIngredientText } = useIngredientTextParser();
|
||||
|
||||
const baseText = computed(() => {
|
||||
if (!ingredient) return "";
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RecipeIngredient } from "~/lib/api/types/household";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
|
||||
interface Props {
|
||||
ingredient: RecipeIngredient;
|
||||
@@ -44,8 +44,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
});
|
||||
const route = useRoute();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
|
||||
const { useParsedIngredientText } = useIngredientTextParser();
|
||||
|
||||
const parsedIng = computed(() => {
|
||||
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
|
||||
|
||||
@@ -17,15 +17,13 @@
|
||||
v-for="(ingredient, index) in value"
|
||||
:key="'ingredient' + index"
|
||||
>
|
||||
<template v-if="!isCookMode">
|
||||
<h3
|
||||
v-if="showTitleEditor[index]"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ ingredient.title }}
|
||||
</h3>
|
||||
<v-divider v-if="showTitleEditor[index]" />
|
||||
</template>
|
||||
<h3
|
||||
v-if="showTitleEditor[index]"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ ingredient.title }}
|
||||
</h3>
|
||||
<v-divider v-if="showTitleEditor[index]" />
|
||||
<v-list-item
|
||||
density="compact"
|
||||
class="pa-0"
|
||||
@@ -54,7 +52,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
|
||||
interface Props {
|
||||
@@ -68,6 +66,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isCookMode: false,
|
||||
});
|
||||
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
function validateTitle(title?: string | null) {
|
||||
return !(title === undefined || title === "" || title === null);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ const madeThisDialog = ref(false);
|
||||
const userApi = useUserApi();
|
||||
const { household } = useHouseholdSelf();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const domMadeThisForm = ref<VForm>();
|
||||
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
||||
subject: "",
|
||||
@@ -179,7 +179,7 @@ const newTimelineEventTimestampString = computed(() => {
|
||||
const lastMade = ref(props.recipe.lastMade);
|
||||
const lastMadeReady = ref(false);
|
||||
onMounted(async () => {
|
||||
if (!$auth.user?.value?.householdSlug) {
|
||||
if (!auth.user?.value?.householdSlug) {
|
||||
lastMade.value = props.recipe.lastMade;
|
||||
}
|
||||
else {
|
||||
@@ -255,8 +255,8 @@ async function createTimelineEvent() {
|
||||
madeThisFormLoading.value = true;
|
||||
|
||||
newTimelineEvent.value.recipeId = props.recipe.id;
|
||||
// Note: $auth.user is now a ref
|
||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
|
||||
// Note: auth.user is now a ref
|
||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: auth.user.value?.fullName });
|
||||
|
||||
// the user only selects the date, so we set the time to end of day local time
|
||||
// we choose the end of day so it always comes after "new recipe" events
|
||||
|
||||
@@ -73,10 +73,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const { frac } = useFraction();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
|
||||
|
||||
const attrs = computed(() => {
|
||||
return props.small
|
||||
|
||||
@@ -10,14 +10,17 @@
|
||||
v-for="(item, key, index) in modelValue"
|
||||
:key="index"
|
||||
>
|
||||
<v-text-field
|
||||
density="compact"
|
||||
<v-number-input
|
||||
:model-value="modelValue[key]"
|
||||
:label="labels[key].label"
|
||||
:suffix="labels[key].suffix"
|
||||
type="number"
|
||||
density="compact"
|
||||
autocomplete="off"
|
||||
variant="underlined"
|
||||
control-variant="stacked"
|
||||
inset
|
||||
:precision="null"
|
||||
:min="0"
|
||||
@update:model-value="updateValue(key, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -162,9 +162,9 @@ const state = reactive({
|
||||
},
|
||||
});
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user?.value?.groupSlug || "");
|
||||
|
||||
// =================================================================
|
||||
// Context Menu
|
||||
|
||||
@@ -48,8 +48,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
||||
import type { RecipeTool } from "~/lib/api/types/admin";
|
||||
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
@@ -166,6 +165,15 @@ const items = computed<any[]>(() => {
|
||||
return list;
|
||||
});
|
||||
|
||||
function removeByIndex(index: number) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSelected = selected.value.filter((_, i) => i !== index);
|
||||
selected.value = [...newSelected];
|
||||
}
|
||||
|
||||
function appendCreated(item: any) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
<template>
|
||||
<div>
|
||||
<BaseDialog
|
||||
v-model="discardDialog"
|
||||
:title="$t('general.discard-changes')"
|
||||
color="warning"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
can-confirm
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("general.discard-changes-description") }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<RecipePageParseDialog
|
||||
:model-value="isParsing"
|
||||
:ingredients="recipe.recipeIngredient"
|
||||
@@ -15,6 +28,7 @@
|
||||
:landscape="landscape"
|
||||
@save="saveRecipe"
|
||||
@delete="deleteRecipe"
|
||||
@close="closeEditor"
|
||||
/>
|
||||
<RecipeJsonEditor
|
||||
v-if="isEditJSON"
|
||||
@@ -174,6 +188,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { invoke, until } from "@vueuse/core";
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import RecipeIngredients from "../RecipeIngredients.vue";
|
||||
import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue";
|
||||
import RecipePageFooter from "./RecipePageParts/RecipePageFooter.vue";
|
||||
@@ -205,12 +220,11 @@ import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
const display = useDisplay();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || auth.user?.value?.groupSlug || "");
|
||||
|
||||
const router = useRouter();
|
||||
const api = useUserApi();
|
||||
@@ -231,26 +245,68 @@ const notLinkedIngredients = computed(() => {
|
||||
* and prompts the user to save if they have unsaved changes.
|
||||
*/
|
||||
const originalRecipe = ref<Recipe | null>(null);
|
||||
const discardDialog = ref(false);
|
||||
const pendingRoute = ref<RouteLocationNormalized | null>(null);
|
||||
|
||||
invoke(async () => {
|
||||
await until(recipe.value).not.toBeNull();
|
||||
originalRecipe.value = deepCopy(recipe.value);
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
const isSame = JSON.stringify(recipe.value) === JSON.stringify(originalRecipe.value);
|
||||
if (isEditMode.value && !isSame && recipe.value?.slug !== undefined) {
|
||||
const save = window.confirm(i18n.t("general.unsaved-changes"));
|
||||
|
||||
if (save) {
|
||||
await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||
}
|
||||
function hasUnsavedChanges(): boolean {
|
||||
if (originalRecipe.value === null) {
|
||||
return false;
|
||||
}
|
||||
return JSON.stringify(recipe.value) !== JSON.stringify(originalRecipe.value);
|
||||
}
|
||||
|
||||
function restoreOriginalRecipe() {
|
||||
if (originalRecipe.value) {
|
||||
recipe.value = deepCopy(originalRecipe.value) as NoUndefinedField<Recipe>;
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
if (hasUnsavedChanges()) {
|
||||
pendingRoute.value = null;
|
||||
discardDialog.value = true;
|
||||
}
|
||||
else {
|
||||
setMode(PageMode.VIEW);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDiscard() {
|
||||
restoreOriginalRecipe();
|
||||
discardDialog.value = false;
|
||||
|
||||
if (pendingRoute.value) {
|
||||
const destination = pendingRoute.value;
|
||||
pendingRoute.value = null;
|
||||
router.push(destination);
|
||||
}
|
||||
else {
|
||||
setMode(PageMode.VIEW);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDiscard() {
|
||||
discardDialog.value = false;
|
||||
pendingRoute.value = null;
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (isEditMode.value && hasUnsavedChanges()) {
|
||||
pendingRoute.value = to;
|
||||
discardDialog.value = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
deactivateNavigationWarning();
|
||||
toggleCookMode();
|
||||
|
||||
clearPageState(recipe.value.slug || "");
|
||||
console.debug("reset RecipePage state during unmount");
|
||||
});
|
||||
const hasLinkedIngredients = computed(() => {
|
||||
return recipe.value.recipeInstructions.some(
|
||||
@@ -300,6 +356,8 @@ async function saveRecipe() {
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
||||
recipe.value = data as NoUndefinedField<Recipe>;
|
||||
// Update the snapshot after successful save
|
||||
originalRecipe.value = deepCopy(recipe.value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
:open="isEditMode"
|
||||
:recipe-id="recipe.id"
|
||||
class="ml-auto mt-n7 pb-4"
|
||||
@close="setMode(PageMode.VIEW)"
|
||||
@close="$emit('close')"
|
||||
@json="toggleEditMode()"
|
||||
@edit="setMode(PageMode.EDIT)"
|
||||
@save="$emit('save')"
|
||||
@@ -47,7 +47,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
landscape: false,
|
||||
});
|
||||
|
||||
defineEmits(["save", "delete"]);
|
||||
defineEmits(["save", "delete", "print", "close"]);
|
||||
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</v-card-title>
|
||||
<RecipeRating
|
||||
:key="recipe.slug"
|
||||
:value="recipe.rating"
|
||||
:model-value="recipe.rating"
|
||||
:recipe-id="recipe.id"
|
||||
:slug="recipe.slug"
|
||||
/>
|
||||
|
||||
@@ -11,27 +11,27 @@
|
||||
<v-container class="ma-0 pa-0">
|
||||
<v-row>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
:model-value="recipeServings"
|
||||
type="number"
|
||||
<v-number-input
|
||||
:model-value="recipe.recipeServings"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
:precision="null"
|
||||
density="compact"
|
||||
:label="$t('recipe.servings')"
|
||||
variant="underlined"
|
||||
@update:model-value="validateInput($event, 'recipeServings')"
|
||||
control-variant="hidden"
|
||||
@update:model-value="recipe.recipeServings = $event"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
:model-value="recipeYieldQuantity"
|
||||
type="number"
|
||||
<v-number-input
|
||||
:model-value="recipe.recipeYieldQuantity"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
:precision="null"
|
||||
density="compact"
|
||||
:label="$t('recipe.yield')"
|
||||
variant="underlined"
|
||||
@update:model-value="validateInput($event, 'recipeYieldQuantity')"
|
||||
control-variant="hidden"
|
||||
@update:model-value="recipe.recipeYieldQuantity = $event"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
@@ -85,37 +85,4 @@ import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
const recipeServings = computed<number>({
|
||||
get() {
|
||||
return recipe.value.recipeServings;
|
||||
},
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeServings");
|
||||
},
|
||||
});
|
||||
|
||||
const recipeYieldQuantity = computed<number>({
|
||||
get() {
|
||||
return recipe.value.recipeYieldQuantity;
|
||||
},
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeYieldQuantity");
|
||||
},
|
||||
});
|
||||
|
||||
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
|
||||
if (!value) {
|
||||
recipe.value[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const number = parseFloat(value.replace(/[^0-9.]/g, ""));
|
||||
if (isNaN(number) || number <= 0) {
|
||||
recipe.value[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
recipe.value[property] = number;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -431,6 +431,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(["click-instruction-field", "update:assets"]);
|
||||
|
||||
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
|
||||
const dialog = ref(false);
|
||||
const disabledSteps = ref<number[]>([]);
|
||||
@@ -581,7 +582,7 @@ function setUsedIngredients() {
|
||||
watch(activeRefs, () => setUsedIngredients());
|
||||
|
||||
function autoSetReferences() {
|
||||
useExtractIngredientReferences(
|
||||
extractIngredientReferences(
|
||||
props.recipe.recipeIngredient,
|
||||
activeRefs.value,
|
||||
activeText.value,
|
||||
|
||||
@@ -197,7 +197,7 @@ import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient
|
||||
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 { useIngredientTextParser } from "~/composables/recipes";
|
||||
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
|
||||
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
@@ -208,6 +208,8 @@ const props = defineProps<{
|
||||
ingredients: NoUndefinedField<RecipeIngredient[]>;
|
||||
}>();
|
||||
|
||||
const { ingredientToParserString } = useIngredientTextParser();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
|
||||
@@ -371,7 +373,7 @@ async function parseIngredients() {
|
||||
try {
|
||||
const ingsAsString = props.ingredients
|
||||
.filter(ing => !ing.referencedRecipe)
|
||||
.map(ing => parseIngredientText(ing, 1, false) ?? "");
|
||||
.map(ing => ingredientToParserString(ing));
|
||||
const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString);
|
||||
if (error || !data) {
|
||||
throw new Error("Failed to parse ingredients");
|
||||
|
||||
@@ -192,7 +192,7 @@ import { useStaticRoutes } from "~/composables/api";
|
||||
import type { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
|
||||
import { useIngredientTextParser, useNutritionLabels } from "~/composables/recipes";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
@@ -362,6 +362,8 @@ const hasNotes = computed(() => {
|
||||
return props.recipe.notes && props.recipe.notes.length > 0;
|
||||
});
|
||||
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
function parseText(ingredient: RecipeIngredient) {
|
||||
return parseIngredientText(ingredient, props.scale);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div @click.prevent>
|
||||
<!-- User Rating -->
|
||||
<v-hover v-slot="{ isHovering, props }">
|
||||
<v-hover v-slot="{ isHovering, props: hoverProps }">
|
||||
<v-rating
|
||||
v-if="isOwnGroup && (userRating || isHovering || !ratingsLoaded)"
|
||||
v-bind="props"
|
||||
v-bind="hoverProps"
|
||||
:model-value="userRating"
|
||||
active-color="secondary"
|
||||
color="secondary-lighten-3"
|
||||
@@ -18,7 +18,7 @@
|
||||
<!-- Group Rating -->
|
||||
<v-rating
|
||||
v-else
|
||||
v-bind="props"
|
||||
v-bind="hoverProps"
|
||||
:model-value="groupRating"
|
||||
:half-increments="true"
|
||||
active-color="grey-darken-1"
|
||||
@@ -32,77 +32,62 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useUserSelfRatings } from "~/composables/use-users";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
emitOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
recipeId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const { userRatings, setRating, ready: ratingsLoaded } = useUserSelfRatings();
|
||||
interface Props {
|
||||
emitOnly?: boolean;
|
||||
recipeId?: string;
|
||||
slug?: string;
|
||||
small?: boolean;
|
||||
}
|
||||
|
||||
const userRating = computed(() => {
|
||||
return userRatings.value.find(r => r.recipeId === props.recipeId)?.rating ?? undefined;
|
||||
});
|
||||
|
||||
// if a user unsets their rating, we don't want to fall back to the group rating since it's out of sync
|
||||
const hideGroupRating = ref(!!userRating.value);
|
||||
watch(
|
||||
() => userRating.value,
|
||||
() => {
|
||||
if (userRating.value) {
|
||||
hideGroupRating.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const groupRating = computed(() => {
|
||||
return hideGroupRating.value ? 0 : props.modelValue;
|
||||
});
|
||||
|
||||
function updateRating(val?: number) {
|
||||
if (!isOwnGroup.value || !val) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.emitOnly) {
|
||||
setRating(props.slug, val || 0, null);
|
||||
}
|
||||
context.emit("update:modelValue", val);
|
||||
}
|
||||
|
||||
return {
|
||||
isOwnGroup,
|
||||
ratingsLoaded,
|
||||
groupRating,
|
||||
userRating,
|
||||
updateRating,
|
||||
};
|
||||
},
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
emitOnly: false,
|
||||
recipeId: "",
|
||||
slug: "",
|
||||
small: false,
|
||||
});
|
||||
|
||||
const modelValue = defineModel<number>({ default: 0 });
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const { userRatings, setRating, ready: ratingsLoaded } = useUserSelfRatings();
|
||||
|
||||
const userRating = computed(() => {
|
||||
return userRatings.value.find(r => r.recipeId === props.recipeId)?.rating ?? undefined;
|
||||
});
|
||||
|
||||
// if a user unsets their rating, we don't want to fall back to the group rating since it's out of sync
|
||||
const hideGroupRating = ref(!!userRating.value);
|
||||
watch(
|
||||
() => userRating.value,
|
||||
() => {
|
||||
if (userRating.value) {
|
||||
hideGroupRating.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const groupRating = computed(() => {
|
||||
return hideGroupRating.value ? 0 : modelValue.value;
|
||||
});
|
||||
|
||||
function updateRating(val?: number) {
|
||||
if (!isOwnGroup.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (val === userRating.value) {
|
||||
val = 0;
|
||||
}
|
||||
|
||||
if (!props.emitOnly) {
|
||||
setRating(props.slug, val || 0, null);
|
||||
}
|
||||
modelValue.value = val ?? 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -65,13 +65,13 @@
|
||||
</v-card-title>
|
||||
<v-card-text class="mt-n5">
|
||||
<div class="mt-4 d-flex align-center">
|
||||
<v-text-field
|
||||
<v-number-input
|
||||
:model-value="yieldQuantity"
|
||||
type="number"
|
||||
:precision="null"
|
||||
:min="0"
|
||||
variant="underlined"
|
||||
hide-spin-buttons
|
||||
@update:model-value="recalculateScale(parseFloat($event) || 0)"
|
||||
control-variant="hidden"
|
||||
@update:model-value="recalculateScale($event || 0)"
|
||||
/>
|
||||
<v-tooltip
|
||||
location="end"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:title="$t('recipe.edit-timeline-event')"
|
||||
:icon="$globals.icons.edit"
|
||||
can-submit
|
||||
disable-submit-on-enter
|
||||
:submit-text="$t('general.save')"
|
||||
@submit="submitEdit"
|
||||
>
|
||||
@@ -38,7 +39,6 @@
|
||||
:nudge-top="props.menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="!props.useMobileFormat"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props: btnProps }">
|
||||
@@ -98,7 +98,6 @@ const props = defineProps<{
|
||||
color?: string;
|
||||
event: RecipeTimelineEventOut;
|
||||
menuIcon?: string | null;
|
||||
useMobileFormat?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["delete", "update"]);
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
:menu-top="false"
|
||||
:event="event"
|
||||
:menu-icon="$globals.icons.dotsVertical"
|
||||
:use-mobile-format="useMobileFormat"
|
||||
color="transparent"
|
||||
:elevation="0"
|
||||
:card-menu="false"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
nudge-bottom="3"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-badge
|
||||
v-memo="[selectedCount]"
|
||||
:model-value="selectedCount > 0"
|
||||
@@ -19,7 +19,7 @@
|
||||
size="small"
|
||||
color="accent"
|
||||
dark
|
||||
v-bind="props"
|
||||
v-bind="menuProps"
|
||||
>
|
||||
<slot />
|
||||
</v-btn>
|
||||
@@ -28,8 +28,8 @@
|
||||
<v-card width="400">
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="state.search"
|
||||
v-memo="[state.search]"
|
||||
v-model="searchInput"
|
||||
v-memo="[searchInput]"
|
||||
class="mb-2"
|
||||
hide-details
|
||||
density="comfortable"
|
||||
@@ -37,21 +37,29 @@
|
||||
:label="$t('search.search')"
|
||||
clearable
|
||||
/>
|
||||
<div class="d-flex py-4 px-1">
|
||||
<v-switch
|
||||
<div />
|
||||
<div class="d-flex flex-wrap py-4 px-1 align-center">
|
||||
<v-btn-toggle
|
||||
v-if="requireAll != undefined"
|
||||
v-model="requireAllValue"
|
||||
v-model="combinator"
|
||||
mandatory
|
||||
density="compact"
|
||||
hide-details
|
||||
class="my-auto"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
:label="requireAllValue ? $t('search.has-all') : $t('search.has-any')"
|
||||
/>
|
||||
class="my-1"
|
||||
>
|
||||
<v-btn value="hasAll">
|
||||
{{ $t('search.has-all') }}
|
||||
</v-btn>
|
||||
<v-btn value="hasAny">
|
||||
{{ $t('search.has-any') }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
size="small"
|
||||
color="accent"
|
||||
class="mr-2 my-auto"
|
||||
class="my-1"
|
||||
@click="clearSelection"
|
||||
>
|
||||
{{ $t("search.clear-selection") }}
|
||||
@@ -137,126 +145,72 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
<script setup lang="ts">
|
||||
import type { ISearchableItem } from "~/composables/use-search";
|
||||
import { useSearch } from "~/composables/use-search";
|
||||
|
||||
export interface SelectableItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as () => SelectableItem[],
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: Array as () => any[],
|
||||
required: true,
|
||||
},
|
||||
requireAll: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
radio: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array as () => ISearchableItem[],
|
||||
required: true,
|
||||
},
|
||||
emits: ["update:requireAll", "update:modelValue"],
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
search: "",
|
||||
menu: false,
|
||||
});
|
||||
|
||||
// Use shallowRef for better performance with arrays
|
||||
const debouncedSearch = shallowRef("");
|
||||
|
||||
const requireAllValue = computed({
|
||||
get: () => props.requireAll,
|
||||
set: (value) => {
|
||||
context.emit("update:requireAll", value);
|
||||
},
|
||||
});
|
||||
|
||||
// Use shallowRef to prevent deep reactivity on large arrays
|
||||
const selected = computed({
|
||||
get: () => props.modelValue as SelectableItem[],
|
||||
set: (value) => {
|
||||
context.emit("update:modelValue", value);
|
||||
},
|
||||
});
|
||||
|
||||
const selectedRadio = computed({
|
||||
get: () => (selected.value.length > 0 ? selected.value[0] : null),
|
||||
set: (value) => {
|
||||
context.emit("update:modelValue", value ? [value] : []);
|
||||
},
|
||||
});
|
||||
|
||||
watchDebounced(
|
||||
() => state.search,
|
||||
(newSearch) => {
|
||||
debouncedSearch.value = newSearch;
|
||||
},
|
||||
{ debounce: 500, maxWait: 1500, immediate: false }, // Increased debounce time
|
||||
);
|
||||
|
||||
const filtered = computed(() => {
|
||||
const items = props.items;
|
||||
const search = debouncedSearch.value;
|
||||
|
||||
if (!search || search.length < 2) { // Only filter after 2 characters
|
||||
return items;
|
||||
}
|
||||
|
||||
const searchLower = search.toLowerCase();
|
||||
return items.filter(item => item.name.toLowerCase().includes(searchLower));
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => selected.value.length);
|
||||
const selectedIds = computed(() => {
|
||||
return new Set(selected.value.map(item => item.id));
|
||||
});
|
||||
|
||||
const handleCheckboxClick = (item: SelectableItem) => {
|
||||
const currentSelection = selected.value;
|
||||
const isSelected = selectedIds.value.has(item.id);
|
||||
|
||||
if (isSelected) {
|
||||
selected.value = currentSelection.filter(i => i.id !== item.id);
|
||||
}
|
||||
else {
|
||||
selected.value = [...currentSelection, item];
|
||||
}
|
||||
};
|
||||
|
||||
const handleRadioClick = (item: SelectableItem) => {
|
||||
if (selectedRadio.value === item) {
|
||||
selectedRadio.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
function clearSelection() {
|
||||
selected.value = [];
|
||||
selectedRadio.value = null;
|
||||
state.search = "";
|
||||
}
|
||||
|
||||
return {
|
||||
requireAllValue,
|
||||
state,
|
||||
selected,
|
||||
selectedRadio,
|
||||
selectedCount,
|
||||
selectedIds,
|
||||
filtered,
|
||||
handleCheckboxClick,
|
||||
handleRadioClick,
|
||||
clearSelection,
|
||||
};
|
||||
requireAll: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
radio: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = defineModel<ISearchableItem[]>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:requireAll", value: boolean | undefined): void;
|
||||
}>();
|
||||
|
||||
const state = reactive({
|
||||
menu: false,
|
||||
});
|
||||
|
||||
// Use the search composable
|
||||
const { search: searchInput, filtered } = useSearch(computed(() => props.items));
|
||||
|
||||
const combinator = computed({
|
||||
get: () => (props.requireAll ? "hasAll" : "hasAny"),
|
||||
set: (value: string) => {
|
||||
emit("update:requireAll", value === "hasAll");
|
||||
},
|
||||
});
|
||||
|
||||
const selected = computed<ISearchableItem[]>({
|
||||
get: () => modelValue.value ?? [],
|
||||
set: (value: ISearchableItem[]) => {
|
||||
modelValue.value = value;
|
||||
},
|
||||
});
|
||||
|
||||
const selectedRadio = computed<null | ISearchableItem>({
|
||||
get: () => (selected.value.length > 0 ? selected.value[0] : null),
|
||||
set: (value: ISearchableItem | null) => {
|
||||
const next = value ? [value] : [];
|
||||
selected.value = next;
|
||||
},
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => selected.value.length);
|
||||
const selectedIds = computed(() => new Set(selected.value.map(item => item.id)));
|
||||
|
||||
const handleRadioClick = (item: ISearchableItem) => {
|
||||
if (selectedRadio.value === item) {
|
||||
selectedRadio.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
function clearSelection() {
|
||||
selected.value = [];
|
||||
selectedRadio.value = null;
|
||||
searchInput.value = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,23 +12,13 @@
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { getTextColor } from "~/composables/use-text-color";
|
||||
import type { MultiPurposeLabelSummary } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
label: {
|
||||
type: Object as () => MultiPurposeLabelSummary,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const textColor = computed(() => getTextColor(props.label.color));
|
||||
const props = defineProps<{
|
||||
label: MultiPurposeLabelSummary;
|
||||
}>();
|
||||
|
||||
return {
|
||||
textColor,
|
||||
};
|
||||
},
|
||||
});
|
||||
const textColor = computed(() => getTextColor(props.label.color));
|
||||
</script>
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
start
|
||||
min-width="125px"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: hoverProps }">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="ml-2 handle"
|
||||
icon
|
||||
v-bind="props"
|
||||
v-bind="hoverProps"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
@@ -35,31 +35,13 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/household";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object as () => ShoppingListMultiPurposeLabelOut,
|
||||
required: true,
|
||||
},
|
||||
useColor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const labelColor = ref<string | undefined>(props.useColor ? props.modelValue.label.color : undefined);
|
||||
const props = defineProps<{
|
||||
useColor?: boolean;
|
||||
}>();
|
||||
const modelValue = defineModel<ShoppingListMultiPurposeLabelOut>({ required: true });
|
||||
|
||||
function contextHandler(event: string) {
|
||||
context.emit(event);
|
||||
}
|
||||
|
||||
return {
|
||||
contextHandler,
|
||||
labelColor,
|
||||
};
|
||||
},
|
||||
});
|
||||
const labelColor = ref<string | undefined>(props.useColor ? modelValue.value.label.color : undefined);
|
||||
</script>
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
<v-col :cols="itemLabelCols">
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-checkbox
|
||||
v-model="listItem.checked"
|
||||
:model-value="listItem.checked"
|
||||
hide-details
|
||||
density="compact"
|
||||
class="mt-0 flex-shrink-0"
|
||||
color="null"
|
||||
@change="$emit('checked', listItem)"
|
||||
@click="toggleChecked"
|
||||
/>
|
||||
<div
|
||||
class="ml-2 text-truncate"
|
||||
@@ -40,7 +40,7 @@
|
||||
start
|
||||
min-width="125px"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: hoverProps }">
|
||||
<v-tooltip
|
||||
v-if="recipeList && recipeList.length"
|
||||
open-delay="200"
|
||||
@@ -81,7 +81,7 @@
|
||||
variant="text"
|
||||
class="handle"
|
||||
icon
|
||||
v-bind="props"
|
||||
v-bind="hoverProps"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
@@ -152,158 +152,99 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import { useOnline } from "@vueuse/core";
|
||||
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
|
||||
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
|
||||
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
|
||||
import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe";
|
||||
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
|
||||
|
||||
interface actions {
|
||||
text: string;
|
||||
event: string;
|
||||
const model = defineModel<ShoppingListItemOut>({ type: Object as () => ShoppingListItemOut, required: true });
|
||||
|
||||
const props = defineProps({
|
||||
labels: {
|
||||
type: Array as () => MultiPurposeLabelOut[],
|
||||
required: true,
|
||||
},
|
||||
units: {
|
||||
type: Array as () => IngredientUnit[],
|
||||
required: true,
|
||||
},
|
||||
foods: {
|
||||
type: Array as () => IngredientFood[],
|
||||
required: true,
|
||||
},
|
||||
recipes: {
|
||||
type: Map as unknown as () => Map<string, RecipeSummary>,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "checked" | "save", item: ShoppingListItemOut): void;
|
||||
(e: "delete"): void;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const displayRecipeRefs = ref(false);
|
||||
const itemLabelCols = computed<string>(() => (model.value?.checked ? "auto" : "6"));
|
||||
const online = useOnline();
|
||||
const isOffline = computed(() => online.value === false);
|
||||
|
||||
type actions = { text: string; event: string };
|
||||
const contextMenu = ref<actions[]>([
|
||||
{ text: i18n.t("general.edit") as string, event: "edit" },
|
||||
{ text: i18n.t("general.delete") as string, event: "delete" },
|
||||
]);
|
||||
|
||||
// copy prop value so a refresh doesn't interrupt the user
|
||||
const localListItem = ref(Object.assign({}, model.value));
|
||||
|
||||
const listItem = computed<ShoppingListItemOut>({
|
||||
get: () => model.value,
|
||||
set: (val: ShoppingListItemOut) => {
|
||||
localListItem.value = val;
|
||||
model.value = val;
|
||||
},
|
||||
});
|
||||
|
||||
const edit = ref(false);
|
||||
function toggleEdit(val = !edit.value) {
|
||||
if (edit.value === val) return;
|
||||
if (val) localListItem.value = model.value;
|
||||
edit.value = val;
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { ShoppingListItemEditor, RecipeList, RecipeIngredientListItem },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object as () => ShoppingListItemOut,
|
||||
required: true,
|
||||
},
|
||||
labels: {
|
||||
type: Array as () => MultiPurposeLabelOut[],
|
||||
required: true,
|
||||
},
|
||||
units: {
|
||||
type: Array as () => IngredientUnit[],
|
||||
required: true,
|
||||
},
|
||||
foods: {
|
||||
type: Array as () => IngredientFood[],
|
||||
required: true,
|
||||
},
|
||||
recipes: {
|
||||
type: Map<string, RecipeSummary>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ["checked", "update:modelValue", "save", "delete"],
|
||||
setup(props, context) {
|
||||
const i18n = useI18n();
|
||||
const displayRecipeRefs = ref(false);
|
||||
const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : "6");
|
||||
const isOffline = computed(() => useOnline().value === false);
|
||||
function toggleChecked() {
|
||||
const updated = { ...model.value, checked: !model.value.checked } as ShoppingListItemOut;
|
||||
model.value = updated;
|
||||
emit("checked", updated);
|
||||
}
|
||||
|
||||
const contextMenu: actions[] = [
|
||||
{
|
||||
text: i18n.t("general.edit") as string,
|
||||
event: "edit",
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.delete") as string,
|
||||
event: "delete",
|
||||
},
|
||||
];
|
||||
function contextHandler(event: string) {
|
||||
if (event === "edit") {
|
||||
toggleEdit(true);
|
||||
}
|
||||
else {
|
||||
emit(event as any);
|
||||
}
|
||||
}
|
||||
|
||||
// copy prop value so a refresh doesn't interrupt the user
|
||||
const localListItem = ref(Object.assign({}, props.modelValue));
|
||||
const listItem = computed({
|
||||
get: () => {
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
// keep local copy in sync
|
||||
localListItem.value = val;
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
const edit = ref(false);
|
||||
function toggleEdit(val = !edit.value) {
|
||||
if (edit.value === val) {
|
||||
return;
|
||||
}
|
||||
function save() {
|
||||
emit("save", localListItem.value);
|
||||
edit.value = false;
|
||||
}
|
||||
|
||||
if (val) {
|
||||
// update local copy of item with the current value
|
||||
localListItem.value = props.modelValue;
|
||||
}
|
||||
|
||||
edit.value = val;
|
||||
}
|
||||
|
||||
function contextHandler(event: string) {
|
||||
if (event === "edit") {
|
||||
toggleEdit(true);
|
||||
}
|
||||
else {
|
||||
context.emit(event);
|
||||
}
|
||||
}
|
||||
function save() {
|
||||
context.emit("save", localListItem.value);
|
||||
edit.value = false;
|
||||
}
|
||||
|
||||
const updatedLabels = computed(() => {
|
||||
return props.labels.map((label) => {
|
||||
return {
|
||||
id: label.id,
|
||||
text: label.name,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets the label for the shopping list item. Either the label assign to the item
|
||||
* or the label of the food applied.
|
||||
*/
|
||||
const label = computed<MultiPurposeLabelSummary | undefined>(() => {
|
||||
if (listItem.value.label) {
|
||||
return listItem.value.label as MultiPurposeLabelSummary;
|
||||
}
|
||||
|
||||
if (listItem.value.food?.label) {
|
||||
return listItem.value.food.label;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const recipeList = computed<RecipeSummary[]>(() => {
|
||||
const recipeList: RecipeSummary[] = [];
|
||||
if (!listItem.value.recipeReferences) {
|
||||
return recipeList;
|
||||
}
|
||||
|
||||
listItem.value.recipeReferences.forEach((ref) => {
|
||||
const recipe = props.recipes?.get(ref.recipeId);
|
||||
if (recipe) {
|
||||
recipeList.push(recipe);
|
||||
}
|
||||
});
|
||||
|
||||
return recipeList;
|
||||
});
|
||||
|
||||
return {
|
||||
updatedLabels,
|
||||
save,
|
||||
contextHandler,
|
||||
displayRecipeRefs,
|
||||
edit,
|
||||
contextMenu,
|
||||
itemLabelCols,
|
||||
listItem,
|
||||
localListItem,
|
||||
label,
|
||||
recipeList,
|
||||
toggleEdit,
|
||||
isOffline,
|
||||
};
|
||||
},
|
||||
const recipeList = computed<RecipeSummary[]>(() => {
|
||||
const ret: RecipeSummary[] = [];
|
||||
if (!listItem.value.recipeReferences) return ret;
|
||||
listItem.value.recipeReferences.forEach((ref) => {
|
||||
const recipe = props.recipes?.get(ref.recipeId);
|
||||
if (recipe) ret.push(recipe);
|
||||
});
|
||||
return ret;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
<v-card-text class="pb-3 pt-1">
|
||||
<div class="d-md-flex align-center mb-2" style="gap: 20px">
|
||||
<div>
|
||||
<InputQuantity v-model="listItem.quantity" />
|
||||
<v-number-input
|
||||
v-model="listItem.quantity"
|
||||
hide-details
|
||||
:label="$t('form.quantity-label-abbreviated')"
|
||||
:min="0"
|
||||
:precision="null"
|
||||
control-variant="stacked"
|
||||
inset
|
||||
style="width: 100px;"
|
||||
/>
|
||||
</div>
|
||||
<InputLabelType
|
||||
v-model="listItem.unit"
|
||||
@@ -21,6 +30,7 @@
|
||||
:items="foods"
|
||||
:label="$t('shopping-list.food')"
|
||||
:icon="$globals.icons.foods"
|
||||
:autofocus="autoFocus === 'food'"
|
||||
create
|
||||
@create="createAssignFood"
|
||||
/>
|
||||
@@ -32,7 +42,7 @@
|
||||
:label="$t('shopping-list.note')"
|
||||
rows="1"
|
||||
auto-grow
|
||||
autofocus
|
||||
:autofocus="autoFocus === 'note'"
|
||||
@keypress="handleNoteKeyPress"
|
||||
/>
|
||||
</div>
|
||||
@@ -47,25 +57,6 @@
|
||||
width="250"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-menu
|
||||
v-if="listItem.recipeReferences && listItem.recipeReferences.length > 0"
|
||||
open-on-hover
|
||||
offset-y
|
||||
start
|
||||
top
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-icon class="mt-auto" :icon="$globals.icons.alert" v-bind="props" color="warning">
|
||||
{{ $globals.icons.alert }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-card max-width="350px" class="left-warning-border">
|
||||
<v-card-text>
|
||||
{{ $t("shopping-list.linked-item-warning") }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
<BaseButton
|
||||
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
|
||||
@@ -111,113 +102,108 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
|
||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object as () => ShoppingListItemCreate | ShoppingListItemOut,
|
||||
required: true,
|
||||
},
|
||||
labels: {
|
||||
type: Array as () => MultiPurposeLabelOut[],
|
||||
required: true,
|
||||
},
|
||||
units: {
|
||||
type: Array as () => IngredientUnit[],
|
||||
required: true,
|
||||
},
|
||||
foods: {
|
||||
type: Array as () => IngredientFood[],
|
||||
required: true,
|
||||
},
|
||||
allowDelete: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
// modelValue as reactive v-model
|
||||
const listItem = defineModel<ShoppingListItemCreate | ShoppingListItemOut>({ required: true });
|
||||
|
||||
defineProps({
|
||||
labels: {
|
||||
type: Array as () => MultiPurposeLabelOut[],
|
||||
required: true,
|
||||
},
|
||||
emits: ["update:modelValue", "save", "cancel", "delete"],
|
||||
setup(props, context) {
|
||||
const foodStore = useFoodStore();
|
||||
const foodData = useFoodData();
|
||||
|
||||
const unitStore = useUnitStore();
|
||||
const unitData = useUnitData();
|
||||
|
||||
const listItem = computed({
|
||||
get: () => {
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue.food,
|
||||
(newFood) => {
|
||||
listItem.value.label = newFood?.label || null;
|
||||
listItem.value.labelId = listItem.value.label?.id || null;
|
||||
},
|
||||
);
|
||||
|
||||
async function createAssignFood(val: string) {
|
||||
// keep UI reactive
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
listItem.value.food ? (listItem.value.food.name = val) : (listItem.value.food = { name: val });
|
||||
|
||||
foodData.data.name = val;
|
||||
const newFood = await foodStore.actions.createOne(foodData.data);
|
||||
if (newFood) {
|
||||
listItem.value.food = newFood;
|
||||
listItem.value.foodId = newFood.id;
|
||||
}
|
||||
foodData.reset();
|
||||
}
|
||||
|
||||
async function createAssignUnit(val: string) {
|
||||
// keep UI reactive
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
listItem.value.unit ? (listItem.value.unit.name = val) : (listItem.value.unit = { name: val });
|
||||
|
||||
unitData.data.name = val;
|
||||
const newUnit = await unitStore.actions.createOne(unitData.data);
|
||||
if (newUnit) {
|
||||
listItem.value.unit = newUnit;
|
||||
listItem.value.unitId = newUnit.id;
|
||||
}
|
||||
unitData.reset();
|
||||
}
|
||||
|
||||
async function assignLabelToFood() {
|
||||
if (!(listItem.value.food && listItem.value.foodId && listItem.value.labelId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
listItem.value.food.labelId = listItem.value.labelId;
|
||||
await foodStore.actions.updateOne(listItem.value.food);
|
||||
}
|
||||
|
||||
return {
|
||||
listItem,
|
||||
createAssignFood,
|
||||
createAssignUnit,
|
||||
assignLabelToFood,
|
||||
};
|
||||
units: {
|
||||
type: Array as () => IngredientUnit[],
|
||||
required: true,
|
||||
},
|
||||
methods: {
|
||||
handleNoteKeyPress(event) {
|
||||
// Save on Enter
|
||||
if (!event.shiftKey && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
this.$emit("save");
|
||||
}
|
||||
},
|
||||
foods: {
|
||||
type: Array as () => IngredientFood[],
|
||||
required: true,
|
||||
},
|
||||
allowDelete: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
// const emit = defineEmits<["save", "cancel", "delete"]>();
|
||||
const emit = defineEmits<{
|
||||
(e: "save", item: ShoppingListItemOut): void;
|
||||
(e: "cancel" | "delete"): void;
|
||||
}>();
|
||||
|
||||
const foodStore = useFoodStore();
|
||||
const foodData = useFoodData();
|
||||
|
||||
const unitStore = useUnitStore();
|
||||
const unitData = useUnitData();
|
||||
|
||||
watch(
|
||||
() => listItem.value.quantity,
|
||||
(newQty) => {
|
||||
if (!newQty) {
|
||||
listItem.value.quantity = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => listItem.value.food,
|
||||
(newFood) => {
|
||||
listItem.value.label = newFood?.label || null;
|
||||
listItem.value.labelId = listItem.value.label?.id || null;
|
||||
},
|
||||
);
|
||||
|
||||
const autoFocus = computed(() => (!listItem.value.food && listItem.value.note ? "note" : "food"));
|
||||
|
||||
async function createAssignFood(val: string) {
|
||||
// keep UI reactive
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
listItem.value.food ? (listItem.value.food.name = val) : (listItem.value.food = { name: val } as any);
|
||||
|
||||
foodData.data.name = val;
|
||||
const newFood = await foodStore.actions.createOne(foodData.data);
|
||||
if (newFood) {
|
||||
listItem.value.food = newFood;
|
||||
listItem.value.foodId = newFood.id;
|
||||
}
|
||||
foodData.reset();
|
||||
}
|
||||
|
||||
async function createAssignUnit(val: string) {
|
||||
// keep UI reactive
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
listItem.value.unit ? (listItem.value.unit.name = val) : (listItem.value.unit = { name: val } as any);
|
||||
|
||||
unitData.data.name = val;
|
||||
const newUnit = await unitStore.actions.createOne(unitData.data);
|
||||
if (newUnit) {
|
||||
listItem.value.unit = newUnit;
|
||||
listItem.value.unitId = newUnit.id;
|
||||
}
|
||||
unitData.reset();
|
||||
}
|
||||
|
||||
async function assignLabelToFood() {
|
||||
if (!(listItem.value.food && listItem.value.foodId && listItem.value.labelId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
listItem.value.food.labelId = listItem.value.labelId;
|
||||
await foodStore.actions.updateOne(listItem.value.food);
|
||||
}
|
||||
|
||||
function handleNoteKeyPress(event: KeyboardEvent) {
|
||||
const e = event as KeyboardEvent & { key: string; shiftKey: boolean };
|
||||
if (!e.shiftKey && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
emit("save");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
:disabled="!user || !tooltip"
|
||||
location="end"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-avatar
|
||||
v-if="list"
|
||||
v-bind="props"
|
||||
v-bind="tooltipProps"
|
||||
>
|
||||
<v-img
|
||||
:src="imageURL"
|
||||
@@ -19,7 +19,7 @@
|
||||
<v-avatar
|
||||
v-else
|
||||
:size="size"
|
||||
v-bind="props"
|
||||
v-bind="tooltipProps"
|
||||
>
|
||||
<v-img
|
||||
:src="imageURL"
|
||||
@@ -35,51 +35,40 @@
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "~/composables/store/use-user-store";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
userId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
list: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "42",
|
||||
},
|
||||
tooltip: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
const props = defineProps({
|
||||
userId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
setup(props) {
|
||||
const state = reactive({
|
||||
error: false,
|
||||
});
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const { store: users } = useUserStore();
|
||||
const user = computed(() => {
|
||||
return users.value.find(user => user.id === props.userId);
|
||||
});
|
||||
|
||||
const imageURL = computed(() => {
|
||||
// Note: $auth.user is a ref now
|
||||
const authUser = $auth.user.value;
|
||||
const key = authUser?.cacheKey ?? "";
|
||||
return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`;
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
imageURL,
|
||||
...toRefs(state),
|
||||
};
|
||||
list: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "42",
|
||||
},
|
||||
tooltip: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const error = ref(false);
|
||||
|
||||
const auth = useMealieAuth();
|
||||
const { store: users } = useUserStore();
|
||||
const user = computed(() => {
|
||||
return users.value.find(user => user.id === props.userId);
|
||||
});
|
||||
|
||||
const imageURL = computed(() => {
|
||||
// Note: auth.user is a ref now
|
||||
const authUser = auth.user.value;
|
||||
const key = authUser?.cacheKey ?? "";
|
||||
return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -73,8 +73,7 @@
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { watchEffect } from "vue";
|
||||
<script setup lang="ts">
|
||||
import { useUserApi } from "@/composables/api";
|
||||
import BaseDialog from "~/components/global/BaseDialog.vue";
|
||||
import AppButtonCopy from "~/components/global/AppButtonCopy.vue";
|
||||
@@ -86,147 +85,95 @@ import type { HouseholdInDB } from "~/lib/api/types/household";
|
||||
import { useGroups } from "~/composables/use-groups";
|
||||
import { useAdminHouseholds } from "~/composables/use-households";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
name: "UserInviteDialog",
|
||||
components: {
|
||||
BaseDialog,
|
||||
AppButtonCopy,
|
||||
BaseButton,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const inviteDialog = defineModel<boolean>("modelValue", { type: Boolean, default: false });
|
||||
|
||||
const isAdmin = computed(() => $auth.user.value?.admin);
|
||||
const token = ref("");
|
||||
const selectedGroup = ref<string | null>(null);
|
||||
const selectedHousehold = ref<string | null>(null);
|
||||
const groups = ref<GroupInDB[]>([]);
|
||||
const households = ref<HouseholdInDB[]>([]);
|
||||
const api = useUserApi();
|
||||
const i18n = useI18n();
|
||||
const auth = useMealieAuth();
|
||||
|
||||
const fetchGroupsAndHouseholds = () => {
|
||||
if (isAdmin.value) {
|
||||
const groupsResponse = useGroups();
|
||||
const householdsResponse = useAdminHouseholds();
|
||||
watchEffect(() => {
|
||||
groups.value = groupsResponse.groups.value || [];
|
||||
households.value = householdsResponse.households.value || [];
|
||||
});
|
||||
}
|
||||
};
|
||||
const isAdmin = computed(() => auth.user.value?.admin);
|
||||
const token = ref("");
|
||||
const selectedGroup = ref<string | null>(null);
|
||||
const selectedHousehold = ref<string | null>(null);
|
||||
const groups = ref<GroupInDB[]>([]);
|
||||
const households = ref<HouseholdInDB[]>([]);
|
||||
const api = useUserApi();
|
||||
|
||||
const inviteDialog = computed<boolean>({
|
||||
get() {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(val) {
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
const fetchGroupsAndHouseholds = () => {
|
||||
if (isAdmin.value) {
|
||||
const groupsResponse = useGroups();
|
||||
const householdsResponse = useAdminHouseholds();
|
||||
watchEffect(() => {
|
||||
groups.value = groupsResponse.groups.value || [];
|
||||
households.value = householdsResponse.households.value || [];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
async function getSignupLink(group: string | null = null, household: string | null = null) {
|
||||
const payload = (group && household) ? { uses: 1, group_id: group, household_id: household } : { uses: 1 };
|
||||
const { data } = await api.households.createInvitation(payload);
|
||||
if (data) {
|
||||
token.value = data.token;
|
||||
}
|
||||
}
|
||||
async function getSignupLink(group: string | null = null, household: string | null = null) {
|
||||
const payload = (group && household) ? { uses: 1, group_id: group, household_id: household } : { uses: 1 };
|
||||
const { data } = await api.households.createInvitation(payload);
|
||||
if (data) {
|
||||
token.value = data.token;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredHouseholds = computed(() => {
|
||||
if (!selectedGroup.value) return [];
|
||||
return households.value?.filter(household => household.groupId === selectedGroup.value);
|
||||
});
|
||||
|
||||
function constructLink(token: string) {
|
||||
return token ? `${window.location.origin}/register?token=${token}` : "";
|
||||
}
|
||||
|
||||
const generatedSignupLink = computed(() => {
|
||||
return constructLink(token.value);
|
||||
});
|
||||
|
||||
// =================================================
|
||||
// Email Invitation
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
sendTo: "",
|
||||
});
|
||||
|
||||
async function sendInvite() {
|
||||
state.loading = true;
|
||||
if (!token.value) {
|
||||
getSignupLink(selectedGroup.value, selectedHousehold.value);
|
||||
}
|
||||
const { data } = await api.email.sendInvitation({
|
||||
email: state.sendTo,
|
||||
token: token.value,
|
||||
});
|
||||
|
||||
if (data && data.success) {
|
||||
alert.success(i18n.t("profile.email-sent"));
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("profile.error-sending-email"));
|
||||
}
|
||||
state.loading = false;
|
||||
inviteDialog.value = false;
|
||||
}
|
||||
|
||||
const validEmail = computed(() => {
|
||||
if (state.sendTo === "") {
|
||||
return false;
|
||||
}
|
||||
const valid = validators.email(state.sendTo);
|
||||
|
||||
// Explicit bool check because validators.email sometimes returns a string
|
||||
if (valid === true) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return {
|
||||
sendInvite,
|
||||
validators,
|
||||
validEmail,
|
||||
inviteDialog,
|
||||
getSignupLink,
|
||||
generatedSignupLink,
|
||||
selectedGroup,
|
||||
selectedHousehold,
|
||||
filteredHouseholds,
|
||||
groups,
|
||||
households,
|
||||
fetchGroupsAndHouseholds,
|
||||
...toRefs(state),
|
||||
isAdmin,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
modelValue: {
|
||||
immediate: false,
|
||||
handler(val) {
|
||||
if (val && !this.isAdmin) {
|
||||
this.getSignupLink();
|
||||
}
|
||||
},
|
||||
},
|
||||
selectedHousehold(newVal) {
|
||||
if (newVal && this.selectedGroup) {
|
||||
this.getSignupLink(this.selectedGroup, this.selectedHousehold);
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchGroupsAndHouseholds();
|
||||
},
|
||||
const filteredHouseholds = computed(() => {
|
||||
if (!selectedGroup.value) return [];
|
||||
return households.value?.filter(household => household.groupId === selectedGroup.value);
|
||||
});
|
||||
|
||||
function constructLink(tokenVal: string) {
|
||||
return tokenVal ? `${window.location.origin}/register?token=${tokenVal}` : "";
|
||||
}
|
||||
|
||||
const generatedSignupLink = computed(() => constructLink(token.value));
|
||||
|
||||
// Email Invitation
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
sendTo: "",
|
||||
});
|
||||
const { loading, sendTo } = toRefs(state);
|
||||
|
||||
async function sendInvite() {
|
||||
state.loading = true;
|
||||
if (!token.value) {
|
||||
getSignupLink(selectedGroup.value, selectedHousehold.value);
|
||||
}
|
||||
const { data } = await api.email.sendInvitation({
|
||||
email: state.sendTo,
|
||||
token: token.value,
|
||||
});
|
||||
|
||||
if (data && data.success) {
|
||||
alert.success(i18n.t("profile.email-sent"));
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("profile.error-sending-email"));
|
||||
}
|
||||
state.loading = false;
|
||||
inviteDialog.value = false;
|
||||
}
|
||||
|
||||
const validEmail = computed(() => {
|
||||
if (sendTo.value === "") return false;
|
||||
const valid = validators.email(sendTo.value);
|
||||
return valid === true;
|
||||
});
|
||||
|
||||
// Watchers (replacing options API watchers)
|
||||
watch(inviteDialog, (val) => {
|
||||
if (val && !isAdmin.value) {
|
||||
getSignupLink();
|
||||
}
|
||||
});
|
||||
|
||||
watch(selectedHousehold, (newVal) => {
|
||||
if (newVal && selectedGroup.value) {
|
||||
getSignupLink(selectedGroup.value, selectedHousehold.value);
|
||||
}
|
||||
});
|
||||
|
||||
// initial fetch
|
||||
fetchGroupsAndHouseholds();
|
||||
</script>
|
||||
|
||||
@@ -52,14 +52,14 @@
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
<script setup lang="ts">
|
||||
interface LinkProp {
|
||||
text: string;
|
||||
url?: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
link: {
|
||||
type: Object as () => LinkProp,
|
||||
required: true,
|
||||
@@ -70,6 +70,4 @@ const props = defineProps({
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Props", props);
|
||||
</script>
|
||||
|
||||
@@ -78,57 +78,30 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useDark } from "@vueuse/core";
|
||||
<script setup lang="ts">
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
|
||||
import { usePasswordField } from "~/composables/use-passwords";
|
||||
import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue";
|
||||
|
||||
definePageMeta({ layout: "blank" });
|
||||
|
||||
const inputAttrs = {
|
||||
validateOnBlur: true,
|
||||
class: "pb-1",
|
||||
variant: "solo-filled" as any,
|
||||
};
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { UserPasswordStrength },
|
||||
setup() {
|
||||
definePageMeta({
|
||||
layout: "blank",
|
||||
});
|
||||
|
||||
const isDark = useDark();
|
||||
const langDialog = ref(false);
|
||||
|
||||
const pwFields = usePasswordField();
|
||||
const {
|
||||
accountDetails,
|
||||
credentials,
|
||||
emailErrorMessages,
|
||||
usernameErrorMessages,
|
||||
validateUsername,
|
||||
validateEmail,
|
||||
domAccountForm,
|
||||
} = useUserRegistrationForm();
|
||||
return {
|
||||
accountDetails,
|
||||
credentials,
|
||||
emailErrorMessages,
|
||||
inputAttrs,
|
||||
isDark,
|
||||
langDialog,
|
||||
pwFields,
|
||||
usernameErrorMessages,
|
||||
validators,
|
||||
// Validators
|
||||
validateUsername,
|
||||
validateEmail,
|
||||
// Dom Refs
|
||||
domAccountForm,
|
||||
};
|
||||
},
|
||||
});
|
||||
const pwFields = usePasswordField();
|
||||
const {
|
||||
accountDetails,
|
||||
credentials,
|
||||
emailErrorMessages,
|
||||
usernameErrorMessages,
|
||||
validateUsername,
|
||||
validateEmail,
|
||||
domAccountForm,
|
||||
} = useUserRegistrationForm();
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
|
||||
@@ -94,210 +94,195 @@
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import type { SideBarLink } from "~/types/application-types";
|
||||
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
||||
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
setup() {
|
||||
const i18n = useI18n();
|
||||
const { $appInfo, $globals } = useNuxtApp();
|
||||
const display = useDisplay();
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const i18n = useI18n();
|
||||
const { $appInfo, $globals } = useNuxtApp();
|
||||
const display = useDisplay();
|
||||
const auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
|
||||
const cookbookPreferences = useCookbookPreferences();
|
||||
const ownCookbookStore = useCookbookStore(i18n);
|
||||
const publicCookbookStoreCache = ref<Record<string, ReturnType<typeof usePublicCookbookStore>>>({});
|
||||
const cookbookPreferences = useCookbookPreferences();
|
||||
const ownCookbookStore = useCookbookStore(i18n);
|
||||
const publicCookbookStoreCache = ref<Record<string, ReturnType<typeof usePublicCookbookStore>>>({});
|
||||
|
||||
function getPublicCookbookStore(slug: string) {
|
||||
if (!publicCookbookStoreCache.value[slug]) {
|
||||
publicCookbookStoreCache.value[slug] = usePublicCookbookStore(slug, i18n);
|
||||
}
|
||||
return publicCookbookStoreCache.value[slug];
|
||||
}
|
||||
function getPublicCookbookStore(slug: string) {
|
||||
if (!publicCookbookStoreCache.value[slug]) {
|
||||
publicCookbookStoreCache.value[slug] = usePublicCookbookStore(slug, i18n);
|
||||
}
|
||||
return publicCookbookStoreCache.value[slug];
|
||||
}
|
||||
|
||||
const cookbooks = computed(() => {
|
||||
if (isOwnGroup.value) {
|
||||
return ownCookbookStore.store.value;
|
||||
}
|
||||
else if (groupSlug.value) {
|
||||
const publicStore = getPublicCookbookStore(groupSlug.value);
|
||||
return unref(publicStore.store);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const showImageImport = computed(() => $appInfo.enableOpenaiImageServices);
|
||||
const languageDialog = ref<boolean>(false);
|
||||
|
||||
const sidebar = ref<boolean>(false);
|
||||
onMounted(() => {
|
||||
sidebar.value = display.lgAndUp.value;
|
||||
});
|
||||
|
||||
function cookbookAsLink(cookbook: ReadCookBook): SideBarLink {
|
||||
return {
|
||||
key: cookbook.slug || "",
|
||||
icon: $globals.icons.pages,
|
||||
title: cookbook.name,
|
||||
to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug || ""}`,
|
||||
restricted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const currentUserHouseholdId = computed(() => $auth.user.value?.householdId);
|
||||
const cookbookLinks = computed<SideBarLink[]>(() => {
|
||||
if (!cookbooks.value?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sortedCookbooks = [...cookbooks.value].sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
|
||||
const ownLinks: SideBarLink[] = [];
|
||||
const links: SideBarLink[] = [];
|
||||
const cookbooksByHousehold = sortedCookbooks.reduce((acc, cookbook) => {
|
||||
const householdName = cookbook.household?.name || "";
|
||||
(acc[householdName] ||= []).push(cookbook);
|
||||
return acc;
|
||||
}, {} as Record<string, ReadCookBook[]>);
|
||||
|
||||
Object.entries(cookbooksByHousehold).forEach(([householdName, cookbooks]) => {
|
||||
if (!cookbooks.length) {
|
||||
return;
|
||||
}
|
||||
if (cookbooks[0].householdId === currentUserHouseholdId.value) {
|
||||
ownLinks.push(...cookbooks.map(cookbookAsLink));
|
||||
}
|
||||
else {
|
||||
links.push({
|
||||
key: householdName,
|
||||
icon: $globals.icons.book,
|
||||
title: householdName,
|
||||
children: cookbooks.map(cookbookAsLink),
|
||||
restricted: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
links.sort((a, b) => a.title.localeCompare(b.title));
|
||||
if ($auth.user.value && cookbookPreferences.value.hideOtherHouseholds) {
|
||||
return ownLinks;
|
||||
}
|
||||
else {
|
||||
return [...ownLinks, ...links];
|
||||
}
|
||||
});
|
||||
|
||||
const createLinks = computed(() => [
|
||||
{
|
||||
insertDivider: false,
|
||||
icon: $globals.icons.link,
|
||||
title: i18n.t("general.import"),
|
||||
subtitle: i18n.t("new-recipe.import-by-url"),
|
||||
to: `/g/${groupSlug.value}/r/create/url`,
|
||||
restricted: true,
|
||||
hide: false,
|
||||
},
|
||||
{
|
||||
insertDivider: false,
|
||||
icon: $globals.icons.fileImage,
|
||||
title: i18n.t("recipe.create-from-images"),
|
||||
subtitle: i18n.t("recipe.create-recipe-from-an-image"),
|
||||
to: `/g/${groupSlug.value}/r/create/image`,
|
||||
restricted: true,
|
||||
hide: !showImageImport.value,
|
||||
},
|
||||
{
|
||||
insertDivider: true,
|
||||
icon: $globals.icons.edit,
|
||||
title: i18n.t("general.create"),
|
||||
subtitle: i18n.t("new-recipe.create-manually"),
|
||||
to: `/g/${groupSlug.value}/r/create/new`,
|
||||
restricted: true,
|
||||
hide: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const topLinks = computed<SideBarLink[]>(() => [
|
||||
{
|
||||
icon: $globals.icons.silverwareForkKnife,
|
||||
to: `/g/${groupSlug.value}`,
|
||||
title: i18n.t("general.recipes"),
|
||||
restricted: false,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.search,
|
||||
to: `/g/${groupSlug.value}/recipes/finder`,
|
||||
title: i18n.t("recipe-finder.recipe-finder"),
|
||||
restricted: false,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.calendarMultiselect,
|
||||
title: i18n.t("meal-plan.meal-planner"),
|
||||
to: "/household/mealplan/planner/view",
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.formatListCheck,
|
||||
title: i18n.t("shopping-list.shopping-lists"),
|
||||
to: "/shopping-lists",
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.timelineText,
|
||||
title: i18n.t("recipe.timeline"),
|
||||
to: `/g/${groupSlug.value}/recipes/timeline`,
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.book,
|
||||
to: `/g/${groupSlug.value}/cookbooks`,
|
||||
title: i18n.t("cookbook.cookbooks"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.organizers,
|
||||
title: i18n.t("general.organizers"),
|
||||
restricted: true,
|
||||
children: [
|
||||
{
|
||||
icon: $globals.icons.categories,
|
||||
to: `/g/${groupSlug.value}/recipes/categories`,
|
||||
title: i18n.t("sidebar.categories"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.tags,
|
||||
to: `/g/${groupSlug.value}/recipes/tags`,
|
||||
title: i18n.t("sidebar.tags"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.potSteam,
|
||||
to: `/g/${groupSlug.value}/recipes/tools`,
|
||||
title: i18n.t("tool.tools"),
|
||||
restricted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
groupSlug,
|
||||
cookbookLinks,
|
||||
createLinks,
|
||||
topLinks,
|
||||
isOwnGroup,
|
||||
languageDialog,
|
||||
sidebar,
|
||||
};
|
||||
},
|
||||
const cookbooks = computed(() => {
|
||||
if (isOwnGroup.value) {
|
||||
return ownCookbookStore.store.value;
|
||||
}
|
||||
else if (groupSlug.value) {
|
||||
const publicStore = getPublicCookbookStore(groupSlug.value);
|
||||
return unref(publicStore.store);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const showImageImport = computed(() => $appInfo.enableOpenaiImageServices);
|
||||
|
||||
const sidebar = ref<boolean>(false);
|
||||
onMounted(() => {
|
||||
sidebar.value = display.lgAndUp.value;
|
||||
});
|
||||
|
||||
function cookbookAsLink(cookbook: ReadCookBook): SideBarLink {
|
||||
return {
|
||||
key: cookbook.slug || "",
|
||||
icon: $globals.icons.pages,
|
||||
title: cookbook.name,
|
||||
to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug || ""}`,
|
||||
restricted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const currentUserHouseholdId = computed(() => auth.user.value?.householdId);
|
||||
const cookbookLinks = computed<SideBarLink[]>(() => {
|
||||
if (!cookbooks.value?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sortedCookbooks = [...cookbooks.value].sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
|
||||
const ownLinks: SideBarLink[] = [];
|
||||
const links: SideBarLink[] = [];
|
||||
const cookbooksByHousehold = sortedCookbooks.reduce((acc, cookbook) => {
|
||||
const householdName = cookbook.household?.name || "";
|
||||
(acc[householdName] ||= []).push(cookbook);
|
||||
return acc;
|
||||
}, {} as Record<string, ReadCookBook[]>);
|
||||
|
||||
Object.entries(cookbooksByHousehold).forEach(([householdName, cookbooks]) => {
|
||||
if (!cookbooks.length) {
|
||||
return;
|
||||
}
|
||||
if (cookbooks[0].householdId === currentUserHouseholdId.value) {
|
||||
ownLinks.push(...cookbooks.map(cookbookAsLink));
|
||||
}
|
||||
else {
|
||||
links.push({
|
||||
key: householdName,
|
||||
icon: $globals.icons.book,
|
||||
title: householdName,
|
||||
children: cookbooks.map(cookbookAsLink),
|
||||
restricted: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
links.sort((a, b) => a.title.localeCompare(b.title));
|
||||
if (auth.user.value && cookbookPreferences.value.hideOtherHouseholds) {
|
||||
return ownLinks;
|
||||
}
|
||||
else {
|
||||
return [...ownLinks, ...links];
|
||||
}
|
||||
});
|
||||
|
||||
const createLinks = computed(() => [
|
||||
{
|
||||
insertDivider: false,
|
||||
icon: $globals.icons.link,
|
||||
title: i18n.t("general.import"),
|
||||
subtitle: i18n.t("new-recipe.import-by-url"),
|
||||
to: `/g/${groupSlug.value}/r/create/url`,
|
||||
restricted: true,
|
||||
hide: false,
|
||||
},
|
||||
{
|
||||
insertDivider: false,
|
||||
icon: $globals.icons.fileImage,
|
||||
title: i18n.t("recipe.create-from-images"),
|
||||
subtitle: i18n.t("recipe.create-recipe-from-an-image"),
|
||||
to: `/g/${groupSlug.value}/r/create/image`,
|
||||
restricted: true,
|
||||
hide: !showImageImport.value,
|
||||
},
|
||||
{
|
||||
insertDivider: true,
|
||||
icon: $globals.icons.edit,
|
||||
title: i18n.t("general.create"),
|
||||
subtitle: i18n.t("new-recipe.create-manually"),
|
||||
to: `/g/${groupSlug.value}/r/create/new`,
|
||||
restricted: true,
|
||||
hide: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const topLinks = computed<SideBarLink[]>(() => [
|
||||
{
|
||||
icon: $globals.icons.silverwareForkKnife,
|
||||
to: `/g/${groupSlug.value}`,
|
||||
title: i18n.t("general.recipes"),
|
||||
restricted: false,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.search,
|
||||
to: `/g/${groupSlug.value}/recipes/finder`,
|
||||
title: i18n.t("recipe-finder.recipe-finder"),
|
||||
restricted: false,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.calendarMultiselect,
|
||||
title: i18n.t("meal-plan.meal-planner"),
|
||||
to: "/household/mealplan/planner/view",
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.formatListCheck,
|
||||
title: i18n.t("shopping-list.shopping-lists"),
|
||||
to: "/shopping-lists",
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.timelineText,
|
||||
title: i18n.t("recipe.timeline"),
|
||||
to: `/g/${groupSlug.value}/recipes/timeline`,
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.book,
|
||||
to: `/g/${groupSlug.value}/cookbooks`,
|
||||
title: i18n.t("cookbook.cookbooks"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.organizers,
|
||||
title: i18n.t("general.organizers"),
|
||||
restricted: true,
|
||||
children: [
|
||||
{
|
||||
icon: $globals.icons.categories,
|
||||
to: `/g/${groupSlug.value}/recipes/categories`,
|
||||
title: i18n.t("sidebar.categories"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.tags,
|
||||
to: `/g/${groupSlug.value}/recipes/tags`,
|
||||
title: i18n.t("sidebar.tags"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.potSteam,
|
||||
to: `/g/${groupSlug.value}/recipes/tools`,
|
||||
title: i18n.t("tool.tools"),
|
||||
restricted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -29,11 +29,3 @@
|
||||
</v-row>
|
||||
</v-footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineNuxtComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
class="d-print-none"
|
||||
>
|
||||
<slot />
|
||||
<router-link :to="routerLink">
|
||||
<RouterLink :to="routerLink">
|
||||
<v-btn
|
||||
icon
|
||||
color="white"
|
||||
>
|
||||
<v-icon size="40"> {{ $globals.icons.primary }} </v-icon>
|
||||
</v-btn>
|
||||
</router-link>
|
||||
</RouterLink>
|
||||
|
||||
<div
|
||||
btn
|
||||
@@ -84,71 +84,57 @@
|
||||
</v-app-bar>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue";
|
||||
import type RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeDialogSearch },
|
||||
props: {
|
||||
menu: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const $auth = useMealieAuth();
|
||||
const { loggedIn } = useLoggedInState();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const { xs, smAndUp } = useDisplay();
|
||||
|
||||
const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/");
|
||||
const domSearchDialog = ref<InstanceType<typeof RecipeDialogSearch> | null>(null);
|
||||
|
||||
function activateSearch() {
|
||||
domSearchDialog.value?.open();
|
||||
}
|
||||
|
||||
function handleKeyEvent(e: KeyboardEvent) {
|
||||
const activeTag = document.activeElement?.tagName;
|
||||
if (e.key === "/" && activeTag !== "INPUT" && activeTag !== "TEXTAREA") {
|
||||
e.preventDefault();
|
||||
activateSearch();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("keydown", handleKeyEvent);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", handleKeyEvent);
|
||||
});
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await $auth.signOut("/login?direct=1");
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activateSearch,
|
||||
domSearchDialog,
|
||||
routerLink,
|
||||
loggedIn,
|
||||
logout,
|
||||
xs, smAndUp,
|
||||
};
|
||||
defineProps({
|
||||
menu: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
const auth = useMealieAuth();
|
||||
const { loggedIn } = useLoggedInState();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
const { xs, smAndUp } = useDisplay();
|
||||
|
||||
const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/");
|
||||
const domSearchDialog = ref<InstanceType<typeof RecipeDialogSearch> | null>(null);
|
||||
|
||||
function activateSearch() {
|
||||
domSearchDialog.value?.open();
|
||||
}
|
||||
|
||||
function handleKeyEvent(e: KeyboardEvent) {
|
||||
const activeTag = document.activeElement?.tagName;
|
||||
if (e.key === "/" && activeTag !== "INPUT" && activeTag !== "TEXTAREA") {
|
||||
e.preventDefault();
|
||||
activateSearch();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("keydown", handleKeyEvent);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", handleKeyEvent);
|
||||
});
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await auth.signOut("/login?direct=1");
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-toolbar {
|
||||
z-index: 1010 !important;
|
||||
z-index: 2010 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<v-navigation-drawer v-model="showDrawer" class="d-flex flex-column d-print-none position-fixed" touchless>
|
||||
<LanguageDialog v-model="languageDialog" />
|
||||
<v-navigation-drawer v-model="modelValue" class="d-flex flex-column d-print-none position-fixed" touchless>
|
||||
<LanguageDialog v-model="state.languageDialog" />
|
||||
<!-- User Profile -->
|
||||
<template v-if="loggedIn">
|
||||
<template v-if="loggedIn && sessionUser">
|
||||
<v-list-item lines="two" :to="userProfileLink" exact>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<UserAvatar list :user-id="sessionUser.id" :tooltip="false" />
|
||||
@@ -29,20 +29,20 @@
|
||||
|
||||
<!-- Primary Links -->
|
||||
<template v-if="topLink">
|
||||
<v-list v-model:selected="secondarySelected" nav density="comfortable" color="primary">
|
||||
<v-list v-model:selected="state.secondarySelected" nav density="comfortable" color="primary">
|
||||
<template v-for="nav in topLink">
|
||||
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
||||
<!-- Multi Items -->
|
||||
<v-list-group
|
||||
v-if="nav.children"
|
||||
:key="(nav.key || nav.title) + 'multi-item'"
|
||||
v-model="dropDowns[nav.title]"
|
||||
v-model="state.dropDowns[nav.title]"
|
||||
color="primary"
|
||||
:prepend-icon="nav.icon"
|
||||
:fluid="true"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
|
||||
<template #activator="{ props: hoverProps }">
|
||||
<v-list-item v-bind="hoverProps" :prepend-icon="nav.icon" :title="nav.title" />
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
@@ -75,20 +75,20 @@
|
||||
<!-- Secondary Links -->
|
||||
<template v-if="secondaryLinks.length > 0">
|
||||
<v-divider class="mt-2" />
|
||||
<v-list v-model:selected="secondarySelected" nav density="compact" exact>
|
||||
<v-list v-model:selected="state.secondarySelected" nav density="compact" exact>
|
||||
<template v-for="nav in secondaryLinks">
|
||||
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
||||
<!-- Multi Items -->
|
||||
<v-list-group
|
||||
v-if="nav.children"
|
||||
:key="(nav.key || nav.title) + 'multi-item'"
|
||||
v-model="dropDowns[nav.title]"
|
||||
v-model="state.dropDowns[nav.title]"
|
||||
color="primary"
|
||||
:prepend-icon="nav.icon"
|
||||
fluid
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
|
||||
<template #activator="{ props: hoverProps }">
|
||||
<v-list-item v-bind="hoverProps" :prepend-icon="nav.icon" :title="nav.title" />
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
@@ -116,13 +116,13 @@
|
||||
|
||||
<!-- Bottom Navigation Links -->
|
||||
<template #append>
|
||||
<v-list v-model:selected="bottomSelected" nav density="comfortable">
|
||||
<v-list v-model:selected="state.bottomSelected" nav density="comfortable">
|
||||
<v-menu location="end bottom" :offset="15">
|
||||
<template #activator="{ props }">
|
||||
<v-list-item v-bind="props" :prepend-icon="$globals.icons.cog" :title="$t('general.settings')" />
|
||||
<template #activator="{ props: hoverProps }">
|
||||
<v-list-item v-bind="hoverProps" :prepend-icon="$globals.icons.cog" :title="$t('general.settings')" />
|
||||
</template>
|
||||
<v-list density="comfortable" color="primary">
|
||||
<v-list-item :prepend-icon="$globals.icons.translate" :title="$t('sidebar.language')" @click="languageDialog=true" />
|
||||
<v-list-item :prepend-icon="$globals.icons.translate" :title="$t('sidebar.language')" @click="state.languageDialog=true" />
|
||||
<v-list-item :prepend-icon="$vuetify.theme.current.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight" :title="$vuetify.theme.current.dark ? $t('settings.theme.light-mode') : $t('settings.theme.dark-mode')" @click="toggleDark" />
|
||||
<v-divider v-if="loggedIn" class="my-2" />
|
||||
<v-list-item v-if="loggedIn" :prepend-icon="$globals.icons.cog" :title="$t('profile.user-settings')" to="/user/profile" />
|
||||
@@ -136,92 +136,63 @@
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import type { SidebarLinks } from "~/types/application-types";
|
||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||
import { useToggleDarkMode } from "~/composables/use-utils";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
UserAvatar,
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
topLink: {
|
||||
type: Array as () => SidebarLinks,
|
||||
required: true,
|
||||
},
|
||||
secondaryLinks: {
|
||||
type: Array as () => SidebarLinks,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
topLink: {
|
||||
type: Array as () => SidebarLinks,
|
||||
required: true,
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const $auth = useMealieAuth();
|
||||
const { loggedIn, isOwnGroup } = useLoggedInState();
|
||||
const isAdmin = computed(() => $auth.user.value?.admin);
|
||||
const canManage = computed(() => $auth.user.value?.canManage);
|
||||
|
||||
const userFavoritesLink = computed(() => $auth.user.value ? `/user/${$auth.user.value.id}/favorites` : undefined);
|
||||
const userProfileLink = computed(() => $auth.user.value ? "/user/profile" : undefined);
|
||||
|
||||
const toggleDark = useToggleDarkMode();
|
||||
|
||||
const state = reactive({
|
||||
dropDowns: {} as Record<string, boolean>,
|
||||
topSelected: null as string[] | null,
|
||||
secondarySelected: null as string[] | null,
|
||||
bottomSelected: null as string[] | null,
|
||||
hasOpenedBefore: false as boolean,
|
||||
languageDialog: false as boolean,
|
||||
});
|
||||
// model to control the drawer
|
||||
const showDrawer = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => context.emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || [])]);
|
||||
function initDropdowns() {
|
||||
allLinks.value.forEach((link) => {
|
||||
state.dropDowns[link.title] = link.childrenStartExpanded || false;
|
||||
});
|
||||
}
|
||||
watch(
|
||||
() => allLinks,
|
||||
() => {
|
||||
initDropdowns();
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
userFavoritesLink,
|
||||
userProfileLink,
|
||||
showDrawer,
|
||||
loggedIn,
|
||||
isAdmin,
|
||||
canManage,
|
||||
isOwnGroup,
|
||||
sessionUser: $auth.user,
|
||||
toggleDark,
|
||||
};
|
||||
secondaryLinks: {
|
||||
type: Array as () => SidebarLinks,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = defineModel<boolean>({ default: false });
|
||||
|
||||
const auth = useMealieAuth();
|
||||
const sessionUser = computed(() => auth.user.value);
|
||||
const { loggedIn, isOwnGroup } = useLoggedInState();
|
||||
const isAdmin = computed(() => auth.user.value?.admin);
|
||||
const canManage = computed(() => auth.user.value?.canManage);
|
||||
|
||||
const userFavoritesLink = computed(() => auth.user.value ? `/user/${auth.user.value.id}/favorites` : undefined);
|
||||
const userProfileLink = computed(() => auth.user.value ? "/user/profile" : undefined);
|
||||
|
||||
const toggleDark = useToggleDarkMode();
|
||||
|
||||
const state = reactive({
|
||||
dropDowns: {} as Record<string, boolean>,
|
||||
secondarySelected: null as string[] | null,
|
||||
bottomSelected: null as string[] | null,
|
||||
languageDialog: false as boolean,
|
||||
});
|
||||
|
||||
const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || [])]);
|
||||
function initDropdowns() {
|
||||
allLinks.value.forEach((link) => {
|
||||
state.dropDowns[link.title] = link.childrenStartExpanded || false;
|
||||
});
|
||||
}
|
||||
watch(
|
||||
() => allLinks,
|
||||
() => {
|
||||
initDropdowns();
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -49,27 +49,21 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useNuxtApp } from "#app";
|
||||
import { toastAlert, toastLoading } from "~/composables/use-toast";
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const { $globals } = useNuxtApp();
|
||||
const icon = computed(() => {
|
||||
switch (toastAlert.color) {
|
||||
case "error":
|
||||
return $globals.icons.alertOutline;
|
||||
case "success":
|
||||
return $globals.icons.checkBold;
|
||||
case "info":
|
||||
return $globals.icons.informationOutline;
|
||||
default:
|
||||
return $globals.icons.alertOutline;
|
||||
}
|
||||
});
|
||||
|
||||
return { icon, toastAlert, toastLoading };
|
||||
},
|
||||
};
|
||||
const { $globals } = useNuxtApp();
|
||||
const icon = computed(() => {
|
||||
switch (toastAlert.color) {
|
||||
case "error":
|
||||
return $globals.icons.alertOutline;
|
||||
case "success":
|
||||
return $globals.icons.checkBold;
|
||||
case "info":
|
||||
return $globals.icons.informationOutline;
|
||||
default:
|
||||
return $globals.icons.alertOutline;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
<template>
|
||||
<div scoped-slot />
|
||||
<slot v-if="advanced" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Renderless component that only renders if the user is logged in.
|
||||
* and has advanced options toggled.
|
||||
*/
|
||||
export default defineNuxtComponent({
|
||||
setup(_, ctx) {
|
||||
const $auth = useMealieAuth();
|
||||
const auth = useMealieAuth();
|
||||
|
||||
const r = $auth.user.value?.advanced || false;
|
||||
|
||||
return () => {
|
||||
return r ? ctx.slots.default?.() : null;
|
||||
};
|
||||
},
|
||||
});
|
||||
const advanced = auth.user.value?.advanced || false;
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
close-delay="500"
|
||||
transition="slide-y-transition"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: hoverProps }">
|
||||
<v-btn
|
||||
variant="flat"
|
||||
:icon="icon"
|
||||
@@ -16,7 +16,7 @@
|
||||
retain-focus-on-click
|
||||
:class="btnClass"
|
||||
:disabled="copyText !== '' ? false : true"
|
||||
v-bind="props"
|
||||
v-bind="hoverProps"
|
||||
@click="textToClipboard()"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.contentCopy }}</v-icon>
|
||||
@@ -33,66 +33,53 @@
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
copyText: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
icon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
btnClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
const props = defineProps({
|
||||
copyText: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
setup(props) {
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
const show = ref(false);
|
||||
const copyToolTip = ref<VTooltip | null>(null);
|
||||
const copiedSuccess = ref<boolean | null>(null);
|
||||
|
||||
async function textToClipboard() {
|
||||
if (isSupported.value) {
|
||||
await copy(props.copyText);
|
||||
if (copied.value) {
|
||||
copiedSuccess.value = true;
|
||||
console.info(`Copied\n${props.copyText}`);
|
||||
}
|
||||
else {
|
||||
copiedSuccess.value = false;
|
||||
console.error("Copy failed: ", copied.value);
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.warn("Clipboard is currently not supported by your browser. Ensure you're on a secure (https) site.");
|
||||
}
|
||||
|
||||
show.value = true;
|
||||
setTimeout(() => {
|
||||
show.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
copyToolTip,
|
||||
textToClipboard,
|
||||
copied,
|
||||
isSupported,
|
||||
copiedSuccess,
|
||||
};
|
||||
color: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
icon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
btnClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
const show = ref(false);
|
||||
const copiedSuccess = ref<boolean | null>(null);
|
||||
|
||||
async function textToClipboard() {
|
||||
if (isSupported.value) {
|
||||
await copy(props.copyText);
|
||||
if (copied.value) {
|
||||
copiedSuccess.value = true;
|
||||
console.info(`Copied\n${props.copyText}`);
|
||||
}
|
||||
else {
|
||||
copiedSuccess.value = false;
|
||||
console.error("Copy failed: ", copied.value);
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.warn("Clipboard is currently not supported by your browser. Ensure you're on a secure (https) site.");
|
||||
}
|
||||
|
||||
show.value = true;
|
||||
setTimeout(() => {
|
||||
show.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-form ref="files">
|
||||
<v-form ref="form">
|
||||
<input
|
||||
ref="uploader"
|
||||
class="d-none"
|
||||
@@ -26,144 +26,129 @@
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
const UPLOAD_EVENT = "uploaded";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
post: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
fileName: {
|
||||
type: String,
|
||||
default: "archive",
|
||||
},
|
||||
textBtn: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "info",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
const props = defineProps({
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
setup(props, context) {
|
||||
const files = ref<File[]>([]);
|
||||
const uploader = ref<HTMLInputElement | null>(null);
|
||||
const isSelecting = ref(false);
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const effIcon = props.icon ? props.icon : $globals.icons.upload;
|
||||
|
||||
const defaultText = i18n.t("general.upload");
|
||||
|
||||
const api = useUserApi();
|
||||
async function upload() {
|
||||
if (files.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSelecting.value = true;
|
||||
|
||||
if (!props.post) {
|
||||
// NOTE: To preserve behaviour for other parents of this component,
|
||||
// we emit a single File if !props.multiple.
|
||||
context.emit(UPLOAD_EVENT, props.multiple ? files.value : files.value[0]);
|
||||
isSelecting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// WARN: My change is only for !props.post.
|
||||
// I have not added support for multiple files in the API.
|
||||
// Existing call-sites never passed the `multiple` prop,
|
||||
// so this case will only be hit if the prop is set to true.
|
||||
if (props.multiple && files.value.length > 1) {
|
||||
console.warn("Multiple file uploads are not supported by the API.");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files.value[0];
|
||||
const formData = new FormData();
|
||||
formData.append(props.fileName, file);
|
||||
|
||||
try {
|
||||
const response = await api.upload.file(props.url, formData);
|
||||
if (response) {
|
||||
context.emit(UPLOAD_EVENT, response);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
context.emit(UPLOAD_EVENT, null);
|
||||
}
|
||||
|
||||
isSelecting.value = false;
|
||||
}
|
||||
|
||||
function onFileChanged(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
||||
if (target.files !== null && target.files.length > 0) {
|
||||
files.value = Array.from(target.files);
|
||||
upload();
|
||||
}
|
||||
}
|
||||
|
||||
function onButtonClick() {
|
||||
isSelecting.value = true;
|
||||
window.addEventListener(
|
||||
"focus",
|
||||
() => {
|
||||
isSelecting.value = false;
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
uploader.value?.click();
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
uploader,
|
||||
isSelecting,
|
||||
effIcon,
|
||||
defaultText,
|
||||
onFileChanged,
|
||||
onButtonClick,
|
||||
};
|
||||
post: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
fileName: {
|
||||
type: String,
|
||||
default: "archive",
|
||||
},
|
||||
textBtn: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "info",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "uploaded", payload: File | File[] | unknown | null): void;
|
||||
}>();
|
||||
|
||||
const selectedFiles = ref<File[]>([]);
|
||||
const uploader = ref<HTMLInputElement | null>(null);
|
||||
const isSelecting = ref(false);
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const effIcon = props.icon ? props.icon : $globals.icons.upload;
|
||||
|
||||
const defaultText = i18n.t("general.upload");
|
||||
|
||||
const api = useUserApi();
|
||||
async function upload() {
|
||||
if (selectedFiles.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSelecting.value = true;
|
||||
|
||||
if (!props.post) {
|
||||
emit(UPLOAD_EVENT, props.multiple ? selectedFiles.value : selectedFiles.value[0]);
|
||||
isSelecting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.multiple && selectedFiles.value.length > 1) {
|
||||
console.warn("Multiple file uploads are not supported by the API.");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = selectedFiles.value[0];
|
||||
const formData = new FormData();
|
||||
formData.append(props.fileName, file);
|
||||
|
||||
try {
|
||||
const response = await api.upload.file(props.url, formData);
|
||||
if (response) {
|
||||
emit(UPLOAD_EVENT, response);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
emit(UPLOAD_EVENT, null);
|
||||
}
|
||||
|
||||
isSelecting.value = false;
|
||||
}
|
||||
|
||||
function onFileChanged(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
||||
if (target.files !== null && target.files.length > 0) {
|
||||
selectedFiles.value = Array.from(target.files);
|
||||
upload();
|
||||
}
|
||||
}
|
||||
|
||||
function onButtonClick() {
|
||||
isSelecting.value = true;
|
||||
window.addEventListener(
|
||||
"focus",
|
||||
() => {
|
||||
isSelecting.value = false;
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
uploader.value?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
|
||||
@@ -39,71 +39,63 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
tiny: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
medium: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
large: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
waitingText: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
setup(props) {
|
||||
const size = computed(() => {
|
||||
if (props.tiny) {
|
||||
return {
|
||||
width: 2,
|
||||
icon: 0,
|
||||
size: 25,
|
||||
};
|
||||
}
|
||||
if (props.small) {
|
||||
return {
|
||||
width: 2,
|
||||
icon: 30,
|
||||
size: 50,
|
||||
};
|
||||
}
|
||||
else if (props.large) {
|
||||
return {
|
||||
width: 4,
|
||||
icon: 120,
|
||||
size: 200,
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: 3,
|
||||
icon: 75,
|
||||
size: 125,
|
||||
};
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const waitingTextCalculated = props.waitingText == null ? i18n.t("general.loading-recipes") : props.waitingText;
|
||||
|
||||
return {
|
||||
size,
|
||||
waitingTextCalculated,
|
||||
};
|
||||
tiny: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
medium: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
large: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
waitingText: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const size = computed(() => {
|
||||
if (props.tiny) {
|
||||
return {
|
||||
width: 2,
|
||||
icon: 0,
|
||||
size: 25,
|
||||
};
|
||||
}
|
||||
if (props.small) {
|
||||
return {
|
||||
width: 2,
|
||||
icon: 30,
|
||||
size: 50,
|
||||
};
|
||||
}
|
||||
else if (props.large) {
|
||||
return {
|
||||
width: 4,
|
||||
icon: 120,
|
||||
size: 200,
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: 3,
|
||||
icon: 75,
|
||||
size: 125,
|
||||
};
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const waitingTextCalculated = props.waitingText == null ? i18n.t("general.loading-recipes") : props.waitingText;
|
||||
</script>
|
||||
|
||||
@@ -18,13 +18,11 @@
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
back: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
back: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,211 +1,158 @@
|
||||
<template>
|
||||
<v-card
|
||||
:color="color"
|
||||
:dark="dark"
|
||||
flat
|
||||
:width="width"
|
||||
class="my-2"
|
||||
>
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="(inputField, index) in items"
|
||||
:key="index"
|
||||
cols="12"
|
||||
sm="12"
|
||||
>
|
||||
<v-divider
|
||||
v-if="inputField.section"
|
||||
class="my-2"
|
||||
/>
|
||||
<v-card-title
|
||||
v-if="inputField.section"
|
||||
class="pl-0"
|
||||
>
|
||||
{{ inputField.section }}
|
||||
</v-card-title>
|
||||
<v-card-text
|
||||
v-if="inputField.sectionDetails"
|
||||
class="pl-0 mt-0 pt-0"
|
||||
>
|
||||
{{ inputField.sectionDetails }}
|
||||
</v-card-text>
|
||||
|
||||
<!-- Check Box -->
|
||||
<v-checkbox
|
||||
v-if="inputField.type === fieldTypes.BOOLEAN"
|
||||
v-model="model[inputField.varName]"
|
||||
:name="inputField.varName"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
:hint="inputField.hint"
|
||||
:hide-details="!inputField.hint"
|
||||
:persistent-hint="!!inputField.hint"
|
||||
density="comfortable"
|
||||
@change="emitBlur"
|
||||
>
|
||||
<template #label>
|
||||
<span class="ml-4">
|
||||
{{ inputField.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
|
||||
<!-- Text Field -->
|
||||
<v-text-field
|
||||
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:autofocus="index === 0"
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules as any), ...defaultRules] : []"
|
||||
lazy-validation
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
|
||||
<!-- Text Area -->
|
||||
<v-textarea
|
||||
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
rows="3"
|
||||
auto-grow
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
:rules="[...rulesByKey(inputField.rules as any), ...defaultRules]"
|
||||
lazy-validation
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
|
||||
<!-- Option Select -->
|
||||
<v-select
|
||||
v-else-if="inputField.type === fieldTypes.SELECT"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:items="inputField.options"
|
||||
item-title="text"
|
||||
item-value="text"
|
||||
:return-object="false"
|
||||
:hint="inputField.hint"
|
||||
density="comfortable"
|
||||
persistent-hint
|
||||
lazy-validation
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div
|
||||
v-else-if="inputField.type === fieldTypes.COLOR"
|
||||
class="d-flex"
|
||||
style="width: 100%"
|
||||
>
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ props: templateProps }">
|
||||
<v-btn
|
||||
class="my-2 ml-auto"
|
||||
style="min-width: 200px"
|
||||
:color="model[inputField.varName]"
|
||||
dark
|
||||
v-bind="templateProps"
|
||||
>
|
||||
{{ inputField.label }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-color-picker
|
||||
v-model="model[inputField.varName]"
|
||||
value="#7417BE"
|
||||
hide-canvas
|
||||
hide-inputs
|
||||
show-swatches
|
||||
class="mx-auto"
|
||||
@input="emitBlur"
|
||||
/>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<!-- Object Type -->
|
||||
<div v-else-if="inputField.type === fieldTypes.OBJECT">
|
||||
<auto-form
|
||||
v-model="model[inputField.varName]"
|
||||
:color="color"
|
||||
:items="(inputField as any).items"
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- List Type -->
|
||||
<div v-else-if="inputField.type === fieldTypes.LIST">
|
||||
<div
|
||||
v-for="(item, idx) in model[inputField.varName]"
|
||||
:key="idx"
|
||||
<v-form v-model="isValid" validate-on="input">
|
||||
<v-card
|
||||
:color="color"
|
||||
:dark="dark"
|
||||
flat
|
||||
:width="width"
|
||||
class="my-2"
|
||||
>
|
||||
<v-row no-gutters>
|
||||
<template v-for="(inputField, index) in items" :key="index">
|
||||
<v-col
|
||||
v-if="inputField.section"
|
||||
:cols="12"
|
||||
class="px-2"
|
||||
>
|
||||
<p>
|
||||
{{ inputField.label }} {{ idx + 1 }}
|
||||
<span>
|
||||
<BaseButton
|
||||
class="ml-5"
|
||||
x-small
|
||||
delete
|
||||
@click="removeByIndex(model[inputField.varName], idx)"
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
<v-divider class="mb-5 mx-2" />
|
||||
<auto-form
|
||||
v-model="model[inputField.varName][idx]"
|
||||
:color="color"
|
||||
:items="(inputField as any).items"
|
||||
@blur="emitBlur"
|
||||
<v-divider
|
||||
class="my-2"
|
||||
/>
|
||||
</div>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<BaseButton
|
||||
small
|
||||
@click="model[inputField.varName].push(getTemplate((inputField as any).items))"
|
||||
<v-card-title
|
||||
class="pl-0"
|
||||
>
|
||||
{{ $t("general.new") }}
|
||||
</BaseButton>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
{{ inputField.section }}
|
||||
</v-card-title>
|
||||
<v-card-text
|
||||
v-if="inputField.sectionDetails"
|
||||
class="pl-0 mt-0 pt-0"
|
||||
>
|
||||
{{ inputField.sectionDetails }}
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
<v-col
|
||||
:cols="inputField.cols || 12"
|
||||
class="px-2"
|
||||
>
|
||||
<!-- Check Box -->
|
||||
<v-checkbox
|
||||
v-if="inputField.type === fieldTypes.BOOLEAN"
|
||||
v-model="model[inputField.varName]"
|
||||
:name="inputField.varName"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
:hint="inputField.hint"
|
||||
:hide-details="!inputField.hint"
|
||||
:persistent-hint="!!inputField.hint"
|
||||
density="comfortable"
|
||||
validate-on="input"
|
||||
>
|
||||
<template #label>
|
||||
<span class="ml-4">
|
||||
{{ inputField.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
|
||||
<!-- Text Field -->
|
||||
<v-text-field
|
||||
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
|
||||
validate-on="input"
|
||||
/>
|
||||
|
||||
<!-- Text Area -->
|
||||
<v-textarea
|
||||
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
rows="3"
|
||||
auto-grow
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
|
||||
validate-on="input"
|
||||
/>
|
||||
|
||||
<!-- Number Input -->
|
||||
<v-number-input
|
||||
v-else-if="inputField.type === fieldTypes.NUMBER"
|
||||
v-model="model[inputField.varName]"
|
||||
variant="underlined"
|
||||
:control-variant="inputField.numberInputConfig?.controlVariant"
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:min="inputField.numberInputConfig?.min"
|
||||
:max="inputField.numberInputConfig?.max"
|
||||
:precision="inputField.numberInputConfig?.precision"
|
||||
:hint="inputField.hint"
|
||||
:hide-details="!inputField.hint"
|
||||
:persistent-hint="!!inputField.hint"
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
|
||||
validate-on="input"
|
||||
/>
|
||||
|
||||
<!-- Option Select -->
|
||||
<v-select
|
||||
v-else-if="inputField.type === fieldTypes.SELECT"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:items="inputField.options"
|
||||
item-title="text"
|
||||
:item-value="inputField.selectReturnValue || 'text'"
|
||||
:return-object="false"
|
||||
:hint="inputField.hint"
|
||||
density="comfortable"
|
||||
persistent-hint
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
|
||||
validate-on="input"
|
||||
/>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div
|
||||
v-else-if="inputField.type === fieldTypes.COLOR"
|
||||
class="d-flex"
|
||||
style="width: 100%"
|
||||
>
|
||||
<InputColor v-model="model[inputField.varName]" />
|
||||
</div>
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { validators } from "@/composables/use-validators";
|
||||
import { fieldTypes } from "@/composables/forms";
|
||||
import type { AutoFormItems } from "~/types/auto-forms";
|
||||
|
||||
const BLUR_EVENT = "blur";
|
||||
|
||||
type ValidatorKey = keyof typeof validators;
|
||||
|
||||
// Use defineModel for v-model
|
||||
const modelValue = defineModel<Record<string, any> | any[]>({
|
||||
const model = defineModel<Record<string, any> | any[]>({
|
||||
type: [Object, Array],
|
||||
required: true,
|
||||
});
|
||||
|
||||
// alias to avoid template TS complaining about possible undefined
|
||||
const model = modelValue as any;
|
||||
const isValid = defineModel("isValid", { type: Boolean, default: false });
|
||||
|
||||
const props = defineProps({
|
||||
updateMode: {
|
||||
@@ -220,10 +167,6 @@ const props = defineProps({
|
||||
type: [Number, String],
|
||||
default: "max",
|
||||
},
|
||||
globalRules: {
|
||||
default: null,
|
||||
type: Array as () => string[],
|
||||
},
|
||||
color: {
|
||||
default: null,
|
||||
type: String,
|
||||
@@ -242,31 +185,6 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["blur", "update:modelValue"]);
|
||||
|
||||
function rulesByKey(keys?: ValidatorKey[] | null) {
|
||||
if (keys === undefined || keys === null) {
|
||||
return [] as any[];
|
||||
}
|
||||
|
||||
const list: any[] = [];
|
||||
keys.forEach((key) => {
|
||||
const split = key.split(":");
|
||||
const validatorKey = split[0] as ValidatorKey;
|
||||
if (validatorKey in validators) {
|
||||
if (split.length === 1) {
|
||||
list.push((validators as any)[validatorKey]);
|
||||
}
|
||||
else {
|
||||
list.push((validators as any)[validatorKey](split[1] as any));
|
||||
}
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
const defaultRules = computed<any[]>(() => rulesByKey(props.globalRules as any));
|
||||
|
||||
// Combined state map for readonly and disabled fields
|
||||
const fieldState = computed<Record<string, { readonly: boolean; disabled: boolean }>>(() => {
|
||||
const map: Record<string, { readonly: boolean; disabled: boolean }> = {};
|
||||
@@ -279,25 +197,6 @@ const fieldState = computed<Record<string, { readonly: boolean; disabled: boolea
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
function removeByIndex(list: never[], index: number) {
|
||||
// Removes the item at the index
|
||||
list.splice(index, 1);
|
||||
}
|
||||
|
||||
function getTemplate(item: AutoFormItems) {
|
||||
const obj = {} as { [key: string]: string };
|
||||
|
||||
item.forEach((field) => {
|
||||
obj[field.varName] = "";
|
||||
});
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
function emitBlur() {
|
||||
emit(BLUR_EVENT, modelValue.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -15,14 +15,12 @@
|
||||
</BannerWarning>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: {
|
||||
issue: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
issue: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<v-alert
|
||||
border="start"
|
||||
border-color
|
||||
variant="tonal"
|
||||
type="warning"
|
||||
elevation="2"
|
||||
@@ -20,19 +19,17 @@
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
};
|
||||
description: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -32,191 +32,168 @@
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
name: "BaseButton",
|
||||
props: {
|
||||
// Types
|
||||
cancel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
create: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
update: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
save: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
delete: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Download
|
||||
download: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
downloadUrl: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
// Property
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Styles
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
xSmall: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
secondary: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
minor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
iconRight: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
const props = defineProps({
|
||||
cancel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
setup(props) {
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const buttonOptions = {
|
||||
create: {
|
||||
text: i18n.t("general.create"),
|
||||
icon: $globals.icons.createAlt,
|
||||
color: "success",
|
||||
},
|
||||
update: {
|
||||
text: i18n.t("general.update"),
|
||||
icon: $globals.icons.edit,
|
||||
color: "success",
|
||||
},
|
||||
save: {
|
||||
text: i18n.t("general.save"),
|
||||
icon: $globals.icons.save,
|
||||
color: "success",
|
||||
},
|
||||
edit: {
|
||||
text: i18n.t("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
color: "info",
|
||||
},
|
||||
delete: {
|
||||
text: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
color: "error",
|
||||
},
|
||||
cancel: {
|
||||
text: i18n.t("general.cancel"),
|
||||
icon: $globals.icons.close,
|
||||
color: "grey",
|
||||
},
|
||||
download: {
|
||||
text: i18n.t("general.download"),
|
||||
icon: $globals.icons.download,
|
||||
color: "info",
|
||||
},
|
||||
};
|
||||
|
||||
const btnAttrs = computed(() => {
|
||||
if (props.delete) {
|
||||
return buttonOptions.delete;
|
||||
}
|
||||
else if (props.update) {
|
||||
return buttonOptions.update;
|
||||
}
|
||||
else if (props.edit) {
|
||||
return buttonOptions.edit;
|
||||
}
|
||||
else if (props.cancel) {
|
||||
return buttonOptions.cancel;
|
||||
}
|
||||
else if (props.save) {
|
||||
return buttonOptions.save;
|
||||
}
|
||||
else if (props.download) {
|
||||
return buttonOptions.download;
|
||||
}
|
||||
return buttonOptions.create;
|
||||
});
|
||||
|
||||
const buttonStyles = {
|
||||
defaults: {
|
||||
text: false,
|
||||
outlined: false,
|
||||
},
|
||||
secondary: {
|
||||
text: false,
|
||||
outlined: true,
|
||||
},
|
||||
minor: {
|
||||
text: true,
|
||||
outlined: false,
|
||||
},
|
||||
};
|
||||
|
||||
const btnStyle = computed(() => {
|
||||
if (props.secondary) {
|
||||
return buttonStyles.secondary;
|
||||
}
|
||||
else if (props.minor || props.cancel) {
|
||||
return buttonStyles.minor;
|
||||
}
|
||||
return buttonStyles.defaults;
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
function downloadFile() {
|
||||
api.utils.download(props.downloadUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
btnAttrs,
|
||||
btnStyle,
|
||||
downloadFile,
|
||||
};
|
||||
create: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
update: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
save: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
delete: {
|
||||
type: Boolean,
|
||||
default: false },
|
||||
download: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
downloadUrl: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
xSmall: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
secondary: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
minor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
iconRight: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const buttonOptions = {
|
||||
create: {
|
||||
text: i18n.t("general.create"),
|
||||
icon: $globals.icons.createAlt,
|
||||
color: "success",
|
||||
},
|
||||
update: {
|
||||
text: i18n.t("general.update"),
|
||||
icon: $globals.icons.edit,
|
||||
color: "success",
|
||||
},
|
||||
save: {
|
||||
text: i18n.t("general.save"),
|
||||
icon: $globals.icons.save,
|
||||
color: "success",
|
||||
},
|
||||
edit: {
|
||||
text: i18n.t("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
color: "info",
|
||||
},
|
||||
delete: {
|
||||
text: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
color: "error",
|
||||
},
|
||||
cancel: {
|
||||
text: i18n.t("general.cancel"),
|
||||
icon: $globals.icons.close,
|
||||
color: "grey",
|
||||
},
|
||||
download: {
|
||||
text: i18n.t("general.download"),
|
||||
icon: $globals.icons.download,
|
||||
color: "info",
|
||||
},
|
||||
};
|
||||
|
||||
const btnAttrs = computed(() => {
|
||||
if (props.delete) {
|
||||
return buttonOptions.delete;
|
||||
}
|
||||
if (props.update) {
|
||||
return buttonOptions.update;
|
||||
}
|
||||
if (props.edit) {
|
||||
return buttonOptions.edit;
|
||||
}
|
||||
if (props.cancel) {
|
||||
return buttonOptions.cancel;
|
||||
}
|
||||
if (props.save) {
|
||||
return buttonOptions.save;
|
||||
}
|
||||
if (props.download) {
|
||||
return buttonOptions.download;
|
||||
}
|
||||
return buttonOptions.create;
|
||||
});
|
||||
|
||||
const buttonStyles = {
|
||||
defaults: { text: false, outlined: false },
|
||||
secondary: { text: false, outlined: true },
|
||||
minor: { text: true, outlined: false },
|
||||
};
|
||||
|
||||
const btnStyle = computed(() => {
|
||||
if (props.secondary) {
|
||||
return buttonStyles.secondary;
|
||||
}
|
||||
if (props.minor || props.cancel) {
|
||||
return buttonStyles.minor;
|
||||
}
|
||||
return buttonStyles.defaults;
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
function downloadFile() {
|
||||
api.utils.download(props.downloadUrl);
|
||||
}
|
||||
</script>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user