mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-03-04 04:33:12 -05:00
Compare commits
194 Commits
Kuchenpira
...
v3.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ec1599427 | ||
|
|
9cfc54b1f5 | ||
|
|
40d2ac9a6b | ||
|
|
44db525049 | ||
|
|
d737cb3e14 | ||
|
|
1034d87a99 | ||
|
|
1243e6804c | ||
|
|
8b9e80358b | ||
|
|
2bae6e9d02 | ||
|
|
6b98a7cd74 | ||
|
|
e0238eb3a2 | ||
|
|
5adb7662c4 | ||
|
|
4e6a7a09ff | ||
|
|
719c7c9f6b | ||
|
|
7331007f30 | ||
|
|
ea329a6b71 | ||
|
|
e1a04ba673 | ||
|
|
63a4d4c801 | ||
|
|
5cf3e2565a | ||
|
|
9e1fe618ba | ||
|
|
691300e481 | ||
|
|
939588f54c | ||
|
|
2d8f491666 | ||
|
|
50754ad012 | ||
|
|
04eca1b992 | ||
|
|
aad7dc1abd | ||
|
|
2f19d31d1b | ||
|
|
095b92c29a | ||
|
|
49c704a4b1 | ||
|
|
c15a4f786b | ||
|
|
6e33878e4f | ||
|
|
5ca004802d | ||
|
|
68115cbf2f | ||
|
|
2b4bc8a662 | ||
|
|
fc801c9da4 | ||
|
|
f99b305dc3 | ||
|
|
b0b3d7e5e5 | ||
|
|
eedd2204a6 | ||
|
|
1ccc67774a | ||
|
|
6d98041ec8 | ||
|
|
c24cfb8096 | ||
|
|
ca41bc8d5c | ||
|
|
da3271f33f | ||
|
|
50a986f331 | ||
|
|
f72ebed0dc | ||
|
|
0c534ad9d4 | ||
|
|
9cce0f65aa | ||
|
|
c9e22892a6 | ||
|
|
e794c6b525 | ||
|
|
abc37f258d | ||
|
|
c2fda0d85a | ||
|
|
437a6ae526 | ||
|
|
9f5de0bd5d | ||
|
|
4bf963b14c | ||
|
|
7092d85a53 | ||
|
|
83fd320920 | ||
|
|
28e2666c17 | ||
|
|
62c7e2d2fb | ||
|
|
6540bfacfe | ||
|
|
47eb1ebbb1 | ||
|
|
31f90c79c0 | ||
|
|
3b1edf67fc | ||
|
|
781bbecc7b | ||
|
|
15f06b5378 | ||
|
|
95fa0af28a | ||
|
|
084f99b0de | ||
|
|
2fb5dac966 | ||
|
|
51ec02bdb2 | ||
|
|
1a1fe0a442 | ||
|
|
b0b88d361f | ||
|
|
b4a9c472e5 | ||
|
|
bcc038091a | ||
|
|
9e0db03f8c | ||
|
|
af274bf476 | ||
|
|
ca9d5677b8 | ||
|
|
07483a13ff | ||
|
|
d412271b0b | ||
|
|
cea3ddc883 | ||
|
|
c965d12bf1 | ||
|
|
181aebf424 | ||
|
|
b77ff9c341 | ||
|
|
93cec24f26 | ||
|
|
a2a0ad1af0 | ||
|
|
969a3c9005 | ||
|
|
a09601f051 | ||
|
|
d6110f1a94 | ||
|
|
1562437b98 | ||
|
|
e2eb754cf2 | ||
|
|
3a4222c6c1 | ||
|
|
2673834a9f | ||
|
|
c24d532608 | ||
|
|
89ab7fac25 | ||
|
|
78b55c0b98 | ||
|
|
ac984a2d04 | ||
|
|
079cfe7fe0 | ||
|
|
4a9095fcbb | ||
|
|
384bb7480f | ||
|
|
69488bd6df | ||
|
|
038fbd38ef | ||
|
|
1697d6299e | ||
|
|
b87edc823a | ||
|
|
cacb197aa8 | ||
|
|
5d58c93331 | ||
|
|
104c9b36a5 | ||
|
|
b68c96c348 | ||
|
|
b577cf5520 | ||
|
|
431638c1ed | ||
|
|
a4871b65eb | ||
|
|
582974b265 | ||
|
|
22fdb32f61 | ||
|
|
649013a028 | ||
|
|
14de1410ae | ||
|
|
03bc87d3a8 | ||
|
|
bb7885543e | ||
|
|
404a4cfa9d | ||
|
|
63a5c0076a | ||
|
|
a4ea5ba10d | ||
|
|
fc6b239343 | ||
|
|
9185cd8df1 | ||
|
|
f0a9d5333d | ||
|
|
7bb84d504a | ||
|
|
dad2712fe9 | ||
|
|
8e7e3e21ed | ||
|
|
af3057951d | ||
|
|
2f3ef738c4 | ||
|
|
44ee1440e2 | ||
|
|
c4aaf1a8c3 | ||
|
|
e093a93189 | ||
|
|
51c92a1e35 | ||
|
|
84629c540e | ||
|
|
28b3ba6506 | ||
|
|
a6ce140e60 | ||
|
|
4784672113 | ||
|
|
9db31ca125 | ||
|
|
972b588250 | ||
|
|
57ae31d231 | ||
|
|
7398b2784a | ||
|
|
c13c0868ae | ||
|
|
a652830a26 | ||
|
|
1f34571820 | ||
|
|
4e16273f00 | ||
|
|
d110f21d37 | ||
|
|
6caa74254f | ||
|
|
66bc4c25ec | ||
|
|
89bed4d675 | ||
|
|
25fbdd6523 | ||
|
|
7e64ce2767 | ||
|
|
62dabe2c18 | ||
|
|
3742c4e86c | ||
|
|
98da2cadc6 | ||
|
|
8360829f61 | ||
|
|
aec38e367b | ||
|
|
6ad7009509 | ||
|
|
46505ba8a5 | ||
|
|
4011d6e29b | ||
|
|
7ee7b753d6 | ||
|
|
c77f41d08e | ||
|
|
ab7fa150fe | ||
|
|
22fa5d27e3 | ||
|
|
5f05002c20 | ||
|
|
0cd33de2f6 | ||
|
|
e46d19edfe | ||
|
|
18ff3c3c48 | ||
|
|
da1c9a448e | ||
|
|
58e1f71711 | ||
|
|
918899d346 | ||
|
|
7f57e1d9a2 | ||
|
|
df6dc6c8ac | ||
|
|
840bd32ee3 | ||
|
|
da3d056d81 | ||
|
|
b3ea48333c | ||
|
|
f37b39aad2 | ||
|
|
d4c987e48a | ||
|
|
955e38ea0b | ||
|
|
7d87182b1a | ||
|
|
5e80002297 | ||
|
|
1364cd0d6b | ||
|
|
5d21af0e02 | ||
|
|
64afccb36c | ||
|
|
5b0497e14e | ||
|
|
5010bb5665 | ||
|
|
c7789da1ad | ||
|
|
b853ce221d | ||
|
|
3522f81025 | ||
|
|
a22c0c4787 | ||
|
|
4dfc5ead54 | ||
|
|
c667bda427 | ||
|
|
188b129da4 | ||
|
|
6845b51def | ||
|
|
c8ec19e371 | ||
|
|
c9002d2391 | ||
|
|
0ba4cc4d4c | ||
|
|
5baade58fb | ||
|
|
e667fe8a5e |
@@ -11,7 +11,7 @@
|
|||||||
// Use -bullseye variants on local on arm64/Apple Silicon.
|
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||||
"VARIANT": "3.12-bullseye",
|
"VARIANT": "3.12-bullseye",
|
||||||
// Options
|
// Options
|
||||||
"NODE_VERSION": "16"
|
"NODE_VERSION": "20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mounts": [
|
"mounts": [
|
||||||
@@ -55,5 +55,6 @@
|
|||||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||||
"dockerDashComposeVersion": "v2"
|
"dockerDashComposeVersion": "v2"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"appPort": 3000
|
||||||
}
|
}
|
||||||
|
|||||||
2
.github/workflows/build-package.yml
vendored
2
.github/workflows/build-package.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- name: Setup node env 🏗
|
- name: Setup node env 🏗
|
||||||
uses: actions/setup-node@v4.0.0
|
uses: actions/setup-node@v4.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 20
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Get yarn cache directory path 🛠
|
- name: Get yarn cache directory path 🛠
|
||||||
|
|||||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 20
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
cache-dependency-path: ./tests/e2e/yarn.lock
|
cache-dependency-path: ./tests/e2e/yarn.lock
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
|||||||
114
.github/workflows/locale-sync.yml
vendored
Normal file
114
.github/workflows/locale-sync.yml
vendored
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
name: Automatic Locale Sync
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run every Sunday at 2 AM UTC
|
||||||
|
- cron: "0 2 * * 0"
|
||||||
|
workflow_dispatch:
|
||||||
|
# Allow manual triggering from the GitHub UI
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # To checkout, commit, and push changes
|
||||||
|
pull-requests: write # To create pull requests
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-locales:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install Poetry
|
||||||
|
uses: snok/install-poetry@v1
|
||||||
|
with:
|
||||||
|
virtualenvs-create: true
|
||||||
|
virtualenvs-in-project: true
|
||||||
|
|
||||||
|
- name: Load cached venv
|
||||||
|
id: cached-poetry-dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .venv
|
||||||
|
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
|
||||||
|
|
||||||
|
- name: Check venv cache
|
||||||
|
id: cache-validate
|
||||||
|
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
|
||||||
|
run: |
|
||||||
|
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
||||||
|
rm test.py
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
||||||
|
poetry install
|
||||||
|
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||||
|
|
||||||
|
- name: Run locale generation
|
||||||
|
run: |
|
||||||
|
cd dev/code-generation
|
||||||
|
poetry run python main.py locales
|
||||||
|
env:
|
||||||
|
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
|
||||||
|
|
||||||
|
- name: Check for changes
|
||||||
|
id: changes
|
||||||
|
run: |
|
||||||
|
if git diff --quiet; then
|
||||||
|
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Commit and create PR
|
||||||
|
if: steps.changes.outputs.has_changes == 'true'
|
||||||
|
run: |
|
||||||
|
# Configure git
|
||||||
|
git config --local user.email "action@github.com"
|
||||||
|
git config --local user.name "GitHub Action"
|
||||||
|
|
||||||
|
# Use the current branch as the base
|
||||||
|
BASE_BRANCH="${{ github.ref_name }}"
|
||||||
|
echo "Using base branch: $BASE_BRANCH"
|
||||||
|
|
||||||
|
# Create a new branch from the base branch
|
||||||
|
BRANCH_NAME="auto-locale-sync-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
git checkout -b "$BRANCH_NAME"
|
||||||
|
|
||||||
|
# Add and commit changes
|
||||||
|
git add .
|
||||||
|
git commit -m "chore: automatic locale sync"
|
||||||
|
|
||||||
|
# Push the branch
|
||||||
|
git push origin "$BRANCH_NAME"
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Create PR using GitHub CLI with explicit repository
|
||||||
|
gh pr create \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--title "chore: automatic locale sync" \
|
||||||
|
--base "$BASE_BRANCH" \
|
||||||
|
--head "$BRANCH_NAME" \
|
||||||
|
--body "## Summary
|
||||||
|
|
||||||
|
Automatically generated locale updates from the weekly sync job.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- Updated frontend locale files
|
||||||
|
- Generated from latest translation sources" \
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: No changes detected
|
||||||
|
if: steps.changes.outputs.has_changes == 'false'
|
||||||
|
run: echo "No locale changes detected, skipping PR creation"
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
name: Build Package
|
name: Build Package
|
||||||
uses: ./.github/workflows/build-package.yml
|
uses: ./.github/workflows/build-package.yml
|
||||||
with:
|
with:
|
||||||
tag: release
|
tag: ${{ github.event.release.tag_name }}
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
11
.github/workflows/stale.yml
vendored
11
.github/workflows/stale.yml
vendored
@@ -16,12 +16,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
stale-issue-label: 'stale'
|
stale-issue-label: 'stale'
|
||||||
exempt-issue-labels: 'pinned,security,early-stages,bug: confirmed,feedback,task'
|
exempt-issue-labels: 'pinned,security,early-stages,bug: confirmed,feedback,task'
|
||||||
stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.'
|
stale-issue-message: 'This issue has been automatically marked as stale because it has been open 90 days with no activity.'
|
||||||
days-before-issue-stale: 30
|
days-before-issue-stale: 90
|
||||||
days-before-issue-close: 5
|
# This stops an issue from ever getting closed automatically.
|
||||||
|
days-before-issue-close: -1
|
||||||
stale-pr-label: 'stale'
|
stale-pr-label: 'stale'
|
||||||
stale-pr-message: 'This PR is stale because it has been open 45 days with no activity.'
|
stale-pr-message: 'This PR has been automatically marked as stale because it has been open 90 days with no activity.'
|
||||||
days-before-pr-stale: 45
|
days-before-pr-stale: 90
|
||||||
# This stops a PR from ever getting closed automatically.
|
# This stops a PR from ever getting closed automatically.
|
||||||
days-before-pr-close: -1
|
days-before-pr-close: -1
|
||||||
# If an issue/PR has a milestone, it's exempt from being marked as stale.
|
# If an issue/PR has a milestone, it's exempt from being marked as stale.
|
||||||
|
|||||||
6
.github/workflows/test-frontend.yml
vendored
6
.github/workflows/test-frontend.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
- name: Setup node env 🏗
|
- name: Setup node env 🏗
|
||||||
uses: actions/setup-node@v4.0.0
|
uses: actions/setup-node@v4.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 20
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Get yarn cache directory path 🛠
|
- name: Get yarn cache directory path 🛠
|
||||||
@@ -34,6 +34,10 @@ jobs:
|
|||||||
run: yarn
|
run: yarn
|
||||||
working-directory: "frontend"
|
working-directory: "frontend"
|
||||||
|
|
||||||
|
- name: Prepare nuxt 🚀
|
||||||
|
run: yarn nuxt prepare
|
||||||
|
working-directory: "frontend"
|
||||||
|
|
||||||
- name: Run linter 👀
|
- name: Run linter 👀
|
||||||
run: yarn lint
|
run: yarn lint
|
||||||
working-directory: "frontend"
|
working-directory: "frontend"
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,6 +10,9 @@ docs/site/
|
|||||||
*temp/*
|
*temp/*
|
||||||
.secret
|
.secret
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
frontend/.output/*
|
||||||
|
frontend/.yarn/*
|
||||||
|
frontend/.yarnrc.yml
|
||||||
|
|
||||||
dev/code-generation/generated/*
|
dev/code-generation/generated/*
|
||||||
dev/data/mealie.db-journal
|
dev/data/mealie.db-journal
|
||||||
@@ -164,3 +167,5 @@ dev/code-generation/openapi.json
|
|||||||
|
|
||||||
.run/
|
.run/
|
||||||
.task/*
|
.task/*
|
||||||
|
.dev.env
|
||||||
|
frontend/eslint.config.deprecated.js
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ repos:
|
|||||||
exclude: ^tests/data/
|
exclude: ^tests/data/
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.11.4
|
rev: v0.12.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|||||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -18,6 +18,7 @@
|
|||||||
"source.organizeImports": "never"
|
"source.organizeImports": "never"
|
||||||
},
|
},
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
|
"eslint.useFlatConfig": true,
|
||||||
"eslint.workingDirectories": [
|
"eslint.workingDirectories": [
|
||||||
"./frontend"
|
"./frontend"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ tasks:
|
|||||||
dev:generate:
|
dev:generate:
|
||||||
desc: run code generators
|
desc: run code generators
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python dev/code-generation/main.py
|
- poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
||||||
- task: py:format
|
- task: py:format
|
||||||
|
|
||||||
dev:services:
|
dev:services:
|
||||||
@@ -243,7 +243,7 @@ tasks:
|
|||||||
desc: runs the frontend server
|
desc: runs the frontend server
|
||||||
dir: frontend
|
dir: frontend
|
||||||
cmds:
|
cmds:
|
||||||
- yarn run dev
|
- yarn run dev --no-fork
|
||||||
|
|
||||||
docker:build-from-package:
|
docker:build-from-package:
|
||||||
desc: Builds the Docker image from the existing Python package in dist/
|
desc: Builds the Docker image from the existing Python package in dist/
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -13,7 +14,7 @@ from mealie.schema._mealie import MealieModel
|
|||||||
|
|
||||||
BASE = pathlib.Path(__file__).parent.parent.parent
|
BASE = pathlib.Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY")
|
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") or os.environ.get("CROWDIN_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -23,19 +24,22 @@ class LocaleData:
|
|||||||
|
|
||||||
|
|
||||||
LOCALE_DATA: dict[str, LocaleData] = {
|
LOCALE_DATA: dict[str, LocaleData] = {
|
||||||
"en-US": LocaleData(name="American English"),
|
|
||||||
"en-GB": LocaleData(name="British English"),
|
|
||||||
"af-ZA": LocaleData(name="Afrikaans (Afrikaans)"),
|
"af-ZA": LocaleData(name="Afrikaans (Afrikaans)"),
|
||||||
"ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"),
|
"ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"),
|
||||||
|
"bg-BG": LocaleData(name="Български (Bulgarian)"),
|
||||||
"ca-ES": LocaleData(name="Català (Catalan)"),
|
"ca-ES": LocaleData(name="Català (Catalan)"),
|
||||||
"cs-CZ": LocaleData(name="Čeština (Czech)"),
|
"cs-CZ": LocaleData(name="Čeština (Czech)"),
|
||||||
"da-DK": LocaleData(name="Dansk (Danish)"),
|
"da-DK": LocaleData(name="Dansk (Danish)"),
|
||||||
"de-DE": LocaleData(name="Deutsch (German)"),
|
"de-DE": LocaleData(name="Deutsch (German)"),
|
||||||
"el-GR": LocaleData(name="Ελληνικά (Greek)"),
|
"el-GR": LocaleData(name="Ελληνικά (Greek)"),
|
||||||
|
"en-GB": LocaleData(name="British English"),
|
||||||
|
"en-US": LocaleData(name="American English"),
|
||||||
"es-ES": LocaleData(name="Español (Spanish)"),
|
"es-ES": LocaleData(name="Español (Spanish)"),
|
||||||
|
"et-EE": LocaleData(name="Eesti (Estonian)"),
|
||||||
"fi-FI": LocaleData(name="Suomi (Finnish)"),
|
"fi-FI": LocaleData(name="Suomi (Finnish)"),
|
||||||
"fr-FR": LocaleData(name="Français (French)"),
|
|
||||||
"fr-BE": LocaleData(name="Belge (Belgian)"),
|
"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)"),
|
"gl-ES": LocaleData(name="Galego (Galician)"),
|
||||||
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
|
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
|
||||||
"hr-HR": LocaleData(name="Hrvatski (Croatian)"),
|
"hr-HR": LocaleData(name="Hrvatski (Croatian)"),
|
||||||
@@ -53,6 +57,7 @@ LOCALE_DATA: dict[str, LocaleData] = {
|
|||||||
"pt-PT": LocaleData(name="Português (Portuguese)"),
|
"pt-PT": LocaleData(name="Português (Portuguese)"),
|
||||||
"ro-RO": LocaleData(name="Română (Romanian)"),
|
"ro-RO": LocaleData(name="Română (Romanian)"),
|
||||||
"ru-RU": LocaleData(name="Pусский (Russian)"),
|
"ru-RU": LocaleData(name="Pусский (Russian)"),
|
||||||
|
"sk-SK": LocaleData(name="Slovenčina (Slovak)"),
|
||||||
"sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
|
"sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
|
||||||
"sr-SP": LocaleData(name="српски (Serbian)"),
|
"sr-SP": LocaleData(name="српски (Serbian)"),
|
||||||
"sv-SE": LocaleData(name="Svenska (Swedish)"),
|
"sv-SE": LocaleData(name="Svenska (Swedish)"),
|
||||||
@@ -71,7 +76,7 @@ export const LOCALES = [{% for locale in locales %}
|
|||||||
progress: {{ locale.progress }},
|
progress: {{ locale.progress }},
|
||||||
dir: "{{ locale.dir }}",
|
dir: "{{ locale.dir }}",
|
||||||
},{% endfor %}
|
},{% endfor %}
|
||||||
]
|
];
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -93,8 +98,8 @@ class CrowdinApi:
|
|||||||
project_id = "451976"
|
project_id = "451976"
|
||||||
api_key = API_KEY
|
api_key = API_KEY
|
||||||
|
|
||||||
def __init__(self, api_key: str):
|
def __init__(self, api_key: str | None):
|
||||||
api_key = api_key
|
self.api_key = api_key or API_KEY
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def headers(self) -> dict:
|
def headers(self) -> dict:
|
||||||
@@ -156,12 +161,13 @@ PROJECT_DIR = Path(__file__).parent.parent.parent
|
|||||||
|
|
||||||
datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats"
|
datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats"
|
||||||
locales_dir = PROJECT_DIR / "frontend" / "lang" / "messages"
|
locales_dir = PROJECT_DIR / "frontend" / "lang" / "messages"
|
||||||
nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.js"
|
nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.ts"
|
||||||
|
i18n_config = PROJECT_DIR / "frontend" / "i18n.config.ts"
|
||||||
reg_valid = PROJECT_DIR / "mealie" / "schema" / "_mealie" / "validators.py"
|
reg_valid = PROJECT_DIR / "mealie" / "schema" / "_mealie" / "validators.py"
|
||||||
|
|
||||||
"""
|
"""
|
||||||
This snippet walks the message and dat locales directories and generates the import information
|
This snippet walks the message and dat locales directories and generates the import information
|
||||||
for the nuxt.config.js file and automatically injects it into the nuxt.config.js file. Note that
|
for the nuxt.config.ts file and automatically injects it into the nuxt.config.ts file. Note that
|
||||||
the code generation ID is hardcoded into the script and required in the nuxt config.
|
the code generation ID is hardcoded into the script and required in the nuxt config.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -173,12 +179,12 @@ def inject_nuxt_values():
|
|||||||
|
|
||||||
all_langs = []
|
all_langs = []
|
||||||
for match in locales_dir.glob("*.json"):
|
for match in locales_dir.glob("*.json"):
|
||||||
lang_string = f'{{ code: "{match.stem}", file: "{match.name}" }},'
|
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}" }},'
|
||||||
all_langs.append(lang_string)
|
all_langs.append(lang_string)
|
||||||
|
|
||||||
log.debug(f"injecting locales into nuxt config -> {nuxt_config}")
|
log.debug(f"injecting locales into nuxt config -> {nuxt_config}")
|
||||||
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)
|
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)
|
||||||
inject_inline(nuxt_config, CodeKeys.nuxt_local_dates, all_date_locales)
|
inject_inline(i18n_config, CodeKeys.nuxt_local_dates, all_date_locales)
|
||||||
|
|
||||||
|
|
||||||
def inject_registration_validation_values():
|
def inject_registration_validation_values():
|
||||||
@@ -195,7 +201,7 @@ def inject_registration_validation_values():
|
|||||||
|
|
||||||
|
|
||||||
def generate_locales_ts_file():
|
def generate_locales_ts_file():
|
||||||
api = CrowdinApi("")
|
api = CrowdinApi(None)
|
||||||
models = api.get_languages()
|
models = api.get_languages()
|
||||||
tmpl = Template(LOCALE_TEMPLATE)
|
tmpl = Template(LOCALE_TEMPLATE)
|
||||||
rendered = tmpl.render(locales=models)
|
rendered = tmpl.render(locales=models)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import gen_py_pytest_data_paths
|
import gen_py_pytest_data_paths
|
||||||
@@ -11,15 +12,39 @@ CWD = Path(__file__).parent
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
items = [
|
parser = argparse.ArgumentParser(description="Run code generators")
|
||||||
(gen_py_schema_exports.main, "schema exports"),
|
parser.add_argument(
|
||||||
(gen_ts_types.main, "frontend types"),
|
"generators",
|
||||||
(gen_ts_locales.main, "locales"),
|
nargs="*",
|
||||||
(gen_py_pytest_data_paths.main, "test data paths"),
|
help="Specific generators to run (schema, types, locales, data-paths, routes). If none specified, all will run.", # noqa: E501 - long line
|
||||||
(gen_py_pytest_routes.main, "pytest routes"),
|
)
|
||||||
]
|
args = parser.parse_args()
|
||||||
|
|
||||||
for func, name in items:
|
# Define all available generators
|
||||||
|
all_generators = {
|
||||||
|
"schema": (gen_py_schema_exports.main, "schema exports"),
|
||||||
|
"types": (gen_ts_types.main, "frontend types"),
|
||||||
|
"locales": (gen_ts_locales.main, "locales"),
|
||||||
|
"data-paths": (gen_py_pytest_data_paths.main, "test data paths"),
|
||||||
|
"routes": (gen_py_pytest_routes.main, "pytest routes"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine which generators to run
|
||||||
|
if args.generators:
|
||||||
|
# Validate requested generators
|
||||||
|
invalid_generators = [g for g in args.generators if g not in all_generators]
|
||||||
|
if invalid_generators:
|
||||||
|
log.error(f"Invalid generator(s): {', '.join(invalid_generators)}")
|
||||||
|
log.info(f"Available generators: {', '.join(all_generators.keys())}")
|
||||||
|
return
|
||||||
|
|
||||||
|
generators_to_run = [(all_generators[g][0], all_generators[g][1]) for g in args.generators]
|
||||||
|
else:
|
||||||
|
# Run all generators (default behavior)
|
||||||
|
generators_to_run = list(all_generators.values())
|
||||||
|
|
||||||
|
# Run the selected generators
|
||||||
|
for func, name in generators_to_run:
|
||||||
log.info(f"Generating {name}...")
|
log.info(f"Generating {name}...")
|
||||||
func()
|
func()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -35,7 +34,7 @@ class CodeSlicer:
|
|||||||
start: int
|
start: int
|
||||||
end: int
|
end: int
|
||||||
|
|
||||||
indentation: str
|
indentation: str | None
|
||||||
text: list[str]
|
text: list[str]
|
||||||
|
|
||||||
_next_line = None
|
_next_line = None
|
||||||
@@ -47,15 +46,24 @@ class CodeSlicer:
|
|||||||
|
|
||||||
def push_line(self, string: str) -> None:
|
def push_line(self, string: str) -> None:
|
||||||
self._next_line = self._next_line or self.start + 1
|
self._next_line = self._next_line or self.start + 1
|
||||||
self.text.insert(self._next_line, self.indentation + string + "\n")
|
self.text.insert(self._next_line, (self.indentation or "") + string + "\n")
|
||||||
self._next_line += 1
|
self._next_line += 1
|
||||||
|
|
||||||
|
|
||||||
def get_indentation_of_string(line: str, comment_char: str = "//|#") -> str:
|
def get_indentation_of_string(line: str) -> str:
|
||||||
return re.sub(rf"{comment_char}.*", "", line).removesuffix("\n")
|
# Extract everything before the comment
|
||||||
|
if "//" in line:
|
||||||
|
indentation = line.split("//")[0]
|
||||||
|
elif "#" in line:
|
||||||
|
indentation = line.split("#")[0]
|
||||||
|
else:
|
||||||
|
indentation = line
|
||||||
|
|
||||||
|
# Keep only the whitespace, remove any non-whitespace characters
|
||||||
|
return "".join(c for c in indentation if c.isspace())
|
||||||
|
|
||||||
|
|
||||||
def find_start_end(file_text: list[str], gen_id: str) -> tuple[int, int, str]:
|
def find_start_end(file_text: list[str], gen_id: str) -> tuple[int, int, str | None]:
|
||||||
start = None
|
start = None
|
||||||
end = None
|
end = None
|
||||||
indentation = None
|
indentation = None
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
# {{ recipe.name }}
|
|
||||||
{{ recipe.description }}
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
{% for ingredient in recipe.recipeIngredient %}
|
|
||||||
- [ ] {{ ingredient }} {% endfor %}
|
|
||||||
|
|
||||||
## Instructions
|
|
||||||
{% for step in recipe.recipeInstructions %}
|
|
||||||
- [ ] {{ step.text }} {% endfor %}
|
|
||||||
|
|
||||||
{% for note in recipe.notes %}
|
|
||||||
**{{ note.title }}:** {{ note.text }}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Tags: {{ recipe.tags }}
|
|
||||||
Categories: {{ recipe.categories }}
|
|
||||||
Original URL: {{ recipe.orgURL }}
|
|
||||||
75
dev/scripts/convert_seed_files_to_new_format.py
Normal file
75
dev/scripts/convert_seed_files_to_new_format.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
|
||||||
|
def get_seed_locale_names() -> set[str]:
|
||||||
|
"""Find all locales in the seed/resources/ folder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A set of every file name where there's both a seed label and seed food file
|
||||||
|
"""
|
||||||
|
|
||||||
|
LABELS_PATH = "/workspaces/mealie/mealie/repos/seed/resources/labels/locales/"
|
||||||
|
FOODS_PATH = "/workspaces/mealie/mealie/repos/seed/resources/foods/locales/"
|
||||||
|
label_locales = glob.glob("*.json", root_dir=LABELS_PATH)
|
||||||
|
foods_locales = glob.glob("*.json", root_dir=FOODS_PATH)
|
||||||
|
|
||||||
|
# ensure that a locale has both a label and a food seed file
|
||||||
|
return set(label_locales).intersection(foods_locales)
|
||||||
|
|
||||||
|
|
||||||
|
def get_labels_from_file(locale: str) -> list[str]:
|
||||||
|
"""Query a locale to get all of the labels so that they can be added to the new foods seed format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
All of the labels found within the seed file for a given locale
|
||||||
|
"""
|
||||||
|
|
||||||
|
locale_path = pathlib.Path("/workspaces/mealie/mealie/repos/seed/resources/labels/locales/" + locale)
|
||||||
|
label_names = [label["name"] for label in json.loads(locale_path.read_text(encoding="utf-8"))]
|
||||||
|
return label_names
|
||||||
|
|
||||||
|
|
||||||
|
def transform_foods(locale: str):
|
||||||
|
"""
|
||||||
|
Convert the current food seed file for a locale into a new format which maps each food to a label
|
||||||
|
|
||||||
|
Existing format of foods seed file is a dictionary where each key is a food name and the values are a dictionary
|
||||||
|
of attributes such as name and plural_name
|
||||||
|
|
||||||
|
New format maps each food to a label. The top-level dictionary has each key as a label e.g. "Fruits".
|
||||||
|
Each label key as a value that is a dictionary with an element called "foods"
|
||||||
|
"Foods" is a dictionary of each food for that label, with a key of the english food name e.g. "baking-soda"
|
||||||
|
and a value of attributes, including the translated name of the item e.g. "bicarbonate of soda" for en-GB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
locale_path = pathlib.Path("/workspaces/mealie/mealie/repos/seed/resources/foods/locales/" + locale)
|
||||||
|
|
||||||
|
with open(locale_path, encoding="utf-8") as infile:
|
||||||
|
data = json.load(infile)
|
||||||
|
|
||||||
|
first_value = next(iter(data.values()))
|
||||||
|
if isinstance(first_value, dict) and "foods" in first_value:
|
||||||
|
# Locale is already in the new format, skipping transformation
|
||||||
|
return
|
||||||
|
|
||||||
|
transformed_data = {"": {"foods": dict(data.items())}}
|
||||||
|
|
||||||
|
# Seeding for labels now pulls from the foods file and parses the labels from there (as top-level keys),
|
||||||
|
# thus we need to add all of the existing labels to the new food seed file and give them an empty foods dictionary
|
||||||
|
label_names = get_labels_from_file(locale)
|
||||||
|
for label in label_names:
|
||||||
|
transformed_data[label] = {"foods": {}}
|
||||||
|
|
||||||
|
with open(locale_path, "w", encoding="utf-8") as outfile:
|
||||||
|
json.dump(transformed_data, outfile, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
for locale in get_seed_locale_names():
|
||||||
|
transform_foods(locale)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
###############################################
|
###############################################
|
||||||
# Frontend Build
|
# Frontend Build
|
||||||
###############################################
|
###############################################
|
||||||
FROM node:16 AS frontend-builder
|
FROM node:20 AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ RUN yarn generate
|
|||||||
###############################################
|
###############################################
|
||||||
# Base Image - Python
|
# Base Image - Python
|
||||||
###############################################
|
###############################################
|
||||||
FROM python:3.12-slim as python-base
|
FROM python:3.12-slim AS python-base
|
||||||
|
|
||||||
ENV MEALIE_HOME="/app"
|
ENV MEALIE_HOME="/app"
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ RUN . $VENV_PATH/bin/activate \
|
|||||||
###############################################
|
###############################################
|
||||||
# Production Image
|
# Production Image
|
||||||
###############################################
|
###############################################
|
||||||
FROM python-base as production
|
FROM python-base AS production
|
||||||
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
|
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
|
||||||
ENV PRODUCTION=true
|
ENV PRODUCTION=true
|
||||||
ENV TESTING=false
|
ENV TESTING=false
|
||||||
|
|||||||
27
docs/docs/documentation/community-guide/ios-shortcut.md
Normal file
27
docs/docs/documentation/community-guide/ios-shortcut.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
!!! info
|
||||||
|
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||||
|
|
||||||
|
An easy way to add recipes to Mealie from an Apple device is via an Apple Shortcut. This is a short guide to install an configure a shortcut able to add recipes via a link or image(s).
|
||||||
|
|
||||||
|
*Note: if adding via images make sure to enable [Mealie's openai integration](https://docs.mealie.io/documentation/getting-started/installation/open-ai/)*
|
||||||
|
|
||||||
|
## Javascript can only be run via Shortcuts on the Safari browser on MacOS and iOS. If you do not use Safari you may skip this section
|
||||||
|
Some sites have begun blocking AI scraping bots, inadvertently blocking the recipe scraping library Mealie uses as well. To circumvent this, the shortcut uses javascript to capture the raw html loaded in the browser and sends that to mealie when possible.
|
||||||
|
|
||||||
|
**iOS**
|
||||||
|
|
||||||
|
Settings app -> apps -> Shortcuts -> Advanced -> Allow Running Scripts
|
||||||
|
|
||||||
|
**MacOS**
|
||||||
|
|
||||||
|
Shortcuts app -> Settings (CMD ,) -> Advanced -> Allow Running Scripts
|
||||||
|
|
||||||
|
## Initial setup
|
||||||
|
An API key is needed to authenticate with mealie. To create an api key for a user, navigate to http://YOUR_MEALIE_URL/user/profile/api-tokens. Alternatively you can create a key via the mealie home page by clicking the user's profile pic in the top left -> Api Tokens
|
||||||
|
|
||||||
|
The shortcut can be installed via **[This link](https://www.icloud.com/shortcuts/52834724050b42aebe0f2efd8d067360)**. Upon install, replace "MEALIE_API_KEY" with the API key generated previously and "MEALIE_URI" with the full URL used to access your mealie instance e.g. "http://10.0.0.5:9000" or "https://mealie.domain.com".
|
||||||
|
|
||||||
|
## Using the shortcut
|
||||||
|
Once installed, the shortcut will automatically appear as an option when sharing an image or webpage. It can also be useful to add the shortcut to the home screen of your device. If selected from the home screen or shortcuts app, a menu will appear with prompts to import via **taking photo(s)**, **selecting photo(s)**, **scanning a URL**, or **pasting a URL**.
|
||||||
|
|
||||||
|
*Note: despite the mealie API being able to accept multiple recipe images for import it is currently impossible to send multiple files in 1 web request via Shortcuts. Instead, the shortcut combines the images into a singular, vertically-concatenated image to send to mealie. This can result in slightly less-accurate text recognition.*
|
||||||
@@ -52,6 +52,8 @@ Before you can start using OIDC Authentication, you must first configure a new c
|
|||||||
|
|
||||||
Take the client id and your discovery URL and update your environment variables to include the required OIDC variables described in [Installation - Backend Configuration](../installation/backend-config.md#openid-connect-oidc).
|
Take the client id and your discovery URL and update your environment variables to include the required OIDC variables described in [Installation - Backend Configuration](../installation/backend-config.md#openid-connect-oidc).
|
||||||
|
|
||||||
|
You might also want to set ALLOW_PASSWORD_LOGIN to false, to hide the username+password inputs, if you want to allow logins only via OIDC.
|
||||||
|
|
||||||
### Groups
|
### Groups
|
||||||
|
|
||||||
There are two (optional) [environment variables](../installation/backend-config.md#openid-connect-oidc) that can control which of the users in your IdP can log in to Mealie and what permissions they will have. Keep in mind that these groups **do not necessarily correspond to groups in Mealie**. The groups claim is configurable via the `OIDC_GROUPS_CLAIM` environment variable. The groups should be **defined in your IdP** and be returned in the configured claim value.
|
There are two (optional) [environment variables](../installation/backend-config.md#openid-connect-oidc) that can control which of the users in your IdP can log in to Mealie and what permissions they will have. Keep in mind that these groups **do not necessarily correspond to groups in Mealie**. The groups claim is configurable via the `OIDC_GROUPS_CLAIM` environment variable. The groups should be **defined in your IdP** and be returned in the configured claim value.
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ Before you can start using OIDC Authentication, you must first configure a new c
|
|||||||
http://localhost:9091/login
|
http://localhost:9091/login
|
||||||
https://mealie.example.com/login
|
https://mealie.example.com/login
|
||||||
|
|
||||||
|
If you are hosting Mealie behind a reverse proxy (nginx, Caddy, ...) to terminate TLS, make sure to start Mealie's Gunicorn server
|
||||||
|
with `--forwarded-allow-ips=<ip-of-proxy>`, otherwise the `X-Forwarded-*` headers will be ignored and the generated OIDC redirect
|
||||||
|
URI will use the wrong scheme (http instead of https). This will lead to authentication errors with strict OIDC providers.
|
||||||
|
|
||||||
3. Configure origins
|
3. Configure origins
|
||||||
|
|
||||||
If your identity provider enforces CORS on any endpoints, you will need to specify your Mealie URL as an Allowed Origin.
|
If your identity provider enforces CORS on any endpoints, you will need to specify your Mealie URL as an Allowed Origin.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
| API_DOCS | True | Turns on/off access to the API documentation locally |
|
| API_DOCS | True | Turns on/off access to the API documentation locally |
|
||||||
| TZ | UTC | Must be set to get correct date/time on the server |
|
| TZ | UTC | Must be set to get correct date/time on the server |
|
||||||
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
|
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
|
||||||
|
| ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication |
|
||||||
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
|
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
|
||||||
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
|
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
|
||||||
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
|
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
|
||||||
@@ -155,8 +156,6 @@ Setting the following environmental variables will change the theme of the front
|
|||||||
|
|
||||||
### Docker Secrets
|
### Docker Secrets
|
||||||
|
|
||||||
### Docker Secrets
|
|
||||||
|
|
||||||
> <super>†</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger
|
> <super>†</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger
|
||||||
> symbol next to them support the Docker Compose secrets pattern, below.
|
> symbol next to them support the Docker Compose secrets pattern, below.
|
||||||
[Docker Compose secrets][docker-secrets] can be used to secure sensitive information regarding the Mealie implementation
|
[Docker Compose secrets][docker-secrets] can be used to secure sensitive information regarding the Mealie implementation
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
|||||||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||||
|
|
||||||
1. Take a backup just in case!
|
1. Take a backup just in case!
|
||||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.8.0`
|
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.0.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.
|
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||||
4. Restart the container
|
4. Restart the container
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mealie:
|
mealie:
|
||||||
image: ghcr.io/mealie-recipes/mealie:v2.8.0 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v3.0.0 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mealie:
|
mealie:
|
||||||
image: ghcr.io/mealie-recipes/mealie:v2.8.0 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v3.0.0 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ nav:
|
|||||||
- Bulk Url Import: "documentation/community-guide/bulk-url-import.md"
|
- Bulk Url Import: "documentation/community-guide/bulk-url-import.md"
|
||||||
- Home Assistant: "documentation/community-guide/home-assistant.md"
|
- Home Assistant: "documentation/community-guide/home-assistant.md"
|
||||||
- Import Bookmarklet: "documentation/community-guide/import-recipe-bookmarklet.md"
|
- Import Bookmarklet: "documentation/community-guide/import-recipe-bookmarklet.md"
|
||||||
- iOS Shortcuts: "documentation/community-guide/ios.md"
|
- iOS Shortcut: "documentation/community-guide/ios-shortcut.md"
|
||||||
- Reverse Proxy (SWAG): "documentation/community-guide/swag.md"
|
- Reverse Proxy (SWAG): "documentation/community-guide/swag.md"
|
||||||
|
|
||||||
- API Reference: "api/redoc.md"
|
- API Reference: "api/redoc.md"
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
browser: true,
|
|
||||||
node: true,
|
|
||||||
},
|
|
||||||
parser: "vue-eslint-parser",
|
|
||||||
parserOptions: {
|
|
||||||
parser: "@typescript-eslint/parser",
|
|
||||||
requireConfigFile: false,
|
|
||||||
tsConfigRootDir: __dirname,
|
|
||||||
project: ["./tsconfig.json"],
|
|
||||||
extraFileExtensions: [".vue"],
|
|
||||||
},
|
|
||||||
extends: [
|
|
||||||
"@nuxtjs/eslint-config-typescript",
|
|
||||||
"plugin:nuxt/recommended",
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
|
||||||
// "plugin:prettier/recommended",
|
|
||||||
"prettier",
|
|
||||||
],
|
|
||||||
// Re-add once we use nuxt bridge
|
|
||||||
// See https://v3.nuxtjs.org/getting-started/bridge#update-nuxtconfig
|
|
||||||
ignorePatterns: ["nuxt.config.js", "lib/api/types/**/*.ts"],
|
|
||||||
plugins: ["prettier"],
|
|
||||||
// add your custom rules here
|
|
||||||
rules: {
|
|
||||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
|
||||||
quotes: ["error", "double"],
|
|
||||||
"vue/component-name-in-template-casing": ["error", "PascalCase"],
|
|
||||||
camelcase: 0,
|
|
||||||
"vue/singleline-html-element-content-newline": "off",
|
|
||||||
"vue/multiline-html-element-content-newline": "off",
|
|
||||||
"vue/no-mutating-props": "off",
|
|
||||||
"vue/no-v-text-v-html-on-component": "warn",
|
|
||||||
"vue/no-v-for-template-key-on-child": "off",
|
|
||||||
"vue/valid-v-slot": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
allowModifiers: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"@typescript-eslint/ban-ts-comment": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"ts-ignore": "allow-with-description",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{ paths: ["@vue/reactivity", "@vue/runtime-dom", "@vue/composition-api", "vue-demi"] },
|
|
||||||
],
|
|
||||||
|
|
||||||
// TODO Gradually activate all rules
|
|
||||||
// Allow Promise in onMounted
|
|
||||||
"@typescript-eslint/no-misused-promises": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
checksVoidReturn: {
|
|
||||||
arguments: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
|
||||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
|
||||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
|
||||||
"@typescript-eslint/no-unsafe-call": "off",
|
|
||||||
"@typescript-eslint/no-floating-promises": "off",
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
/* cyrillic-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-100-cyrillic-ext1.woff2') format('woff2');
|
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
|
||||||
}
|
|
||||||
/* cyrillic */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-100-cyrillic2.woff2') format('woff2');
|
|
||||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
||||||
}
|
|
||||||
/* greek-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-100-greek-ext3.woff2') format('woff2');
|
|
||||||
unicode-range: U+1F00-1FFF;
|
|
||||||
}
|
|
||||||
/* greek */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-100-greek4.woff2') format('woff2');
|
|
||||||
unicode-range: U+0370-03FF;
|
|
||||||
}
|
|
||||||
/* vietnamese */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-100-vietnamese5.woff2') format('woff2');
|
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
|
||||||
}
|
|
||||||
/* latin-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-100-latin-ext6.woff2') format('woff2');
|
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
/* latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-100-latin7.woff2') format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
/* cyrillic-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-300-cyrillic-ext8.woff2') format('woff2');
|
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
|
||||||
}
|
|
||||||
/* cyrillic */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-300-cyrillic9.woff2') format('woff2');
|
|
||||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
||||||
}
|
|
||||||
/* greek-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-300-greek-ext10.woff2') format('woff2');
|
|
||||||
unicode-range: U+1F00-1FFF;
|
|
||||||
}
|
|
||||||
/* greek */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-300-greek11.woff2') format('woff2');
|
|
||||||
unicode-range: U+0370-03FF;
|
|
||||||
}
|
|
||||||
/* vietnamese */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-300-vietnamese12.woff2') format('woff2');
|
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
|
||||||
}
|
|
||||||
/* latin-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-300-latin-ext13.woff2') format('woff2');
|
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
/* latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-300-latin14.woff2') format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
/* cyrillic-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-400-cyrillic-ext15.woff2') format('woff2');
|
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
|
||||||
}
|
|
||||||
/* cyrillic */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-400-cyrillic16.woff2') format('woff2');
|
|
||||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
||||||
}
|
|
||||||
/* greek-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-400-greek-ext17.woff2') format('woff2');
|
|
||||||
unicode-range: U+1F00-1FFF;
|
|
||||||
}
|
|
||||||
/* greek */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-400-greek18.woff2') format('woff2');
|
|
||||||
unicode-range: U+0370-03FF;
|
|
||||||
}
|
|
||||||
/* vietnamese */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-400-vietnamese19.woff2') format('woff2');
|
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
|
||||||
}
|
|
||||||
/* latin-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-400-latin-ext20.woff2') format('woff2');
|
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
/* latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-400-latin21.woff2') format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
/* cyrillic-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-500-cyrillic-ext22.woff2') format('woff2');
|
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
|
||||||
}
|
|
||||||
/* cyrillic */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-500-cyrillic23.woff2') format('woff2');
|
|
||||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
||||||
}
|
|
||||||
/* greek-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-500-greek-ext24.woff2') format('woff2');
|
|
||||||
unicode-range: U+1F00-1FFF;
|
|
||||||
}
|
|
||||||
/* greek */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-500-greek25.woff2') format('woff2');
|
|
||||||
unicode-range: U+0370-03FF;
|
|
||||||
}
|
|
||||||
/* vietnamese */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-500-vietnamese26.woff2') format('woff2');
|
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
|
||||||
}
|
|
||||||
/* latin-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-500-latin-ext27.woff2') format('woff2');
|
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
/* latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-500-latin28.woff2') format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
/* cyrillic-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-700-cyrillic-ext29.woff2') format('woff2');
|
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
|
||||||
}
|
|
||||||
/* cyrillic */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-700-cyrillic30.woff2') format('woff2');
|
|
||||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
||||||
}
|
|
||||||
/* greek-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-700-greek-ext31.woff2') format('woff2');
|
|
||||||
unicode-range: U+1F00-1FFF;
|
|
||||||
}
|
|
||||||
/* greek */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-700-greek32.woff2') format('woff2');
|
|
||||||
unicode-range: U+0370-03FF;
|
|
||||||
}
|
|
||||||
/* vietnamese */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-700-vietnamese33.woff2') format('woff2');
|
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
|
||||||
}
|
|
||||||
/* latin-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-700-latin-ext34.woff2') format('woff2');
|
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
/* latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-700-latin35.woff2') format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
/* cyrillic-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-900-cyrillic-ext36.woff2') format('woff2');
|
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
|
||||||
}
|
|
||||||
/* cyrillic */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-900-cyrillic37.woff2') format('woff2');
|
|
||||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
||||||
}
|
|
||||||
/* greek-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-900-greek-ext38.woff2') format('woff2');
|
|
||||||
unicode-range: U+1F00-1FFF;
|
|
||||||
}
|
|
||||||
/* greek */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-900-greek39.woff2') format('woff2');
|
|
||||||
unicode-range: U+0370-03FF;
|
|
||||||
}
|
|
||||||
/* vietnamese */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-900-vietnamese40.woff2') format('woff2');
|
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
|
||||||
}
|
|
||||||
/* latin-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-900-latin-ext41.woff2') format('woff2');
|
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
/* latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('~assets/fonts/Roboto-900-latin42.woff2') format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,11 +17,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme--dark.v-application {
|
.theme--dark.v-application {
|
||||||
background-color: var(--v-background-base, #1e1e1e) !important;
|
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme--dark.v-navigation-drawer {
|
.theme--dark.v-navigation-drawer {
|
||||||
background-color: var(--v-background-base, #1e1e1e) !important;
|
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme--dark.v-card {
|
.theme--dark.v-card {
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.left-border {
|
.left-border {
|
||||||
border-left: 5px solid var(--v-primary-base) !important;
|
border-left: 5px solid rgb(var(--v-theme-primary)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-warning-border {
|
.left-warning-border {
|
||||||
border-left: 5px solid var(--v-warning-base) !important;
|
border-left: 5px solid rgb(var(--v-theme-warning)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handle {
|
.handle {
|
||||||
@@ -56,3 +56,15 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill-height {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-simple-handler {
|
||||||
|
background-color: rgb(var(--v-theme-primary)) !important;
|
||||||
|
}
|
||||||
@@ -1,17 +1,41 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-card-text v-if="cookbook" class="px-1">
|
<v-card-text
|
||||||
<v-text-field v-model="cookbook.name" :label="$t('cookbook.cookbook-name')"></v-text-field>
|
v-if="cookbook"
|
||||||
<v-textarea v-model="cookbook.description" auto-grow :rows="2" :label="$t('recipe.description')"></v-textarea>
|
class="px-1"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="cookbook.name"
|
||||||
|
:label="$t('cookbook.cookbook-name')"
|
||||||
|
variant="underlined"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<v-textarea
|
||||||
|
v-model="cookbook.description"
|
||||||
|
auto-grow
|
||||||
|
:rows="2"
|
||||||
|
:label="$t('recipe.description')"
|
||||||
|
variant="underlined"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
<QueryFilterBuilder
|
<QueryFilterBuilder
|
||||||
:field-defs="fieldDefs"
|
:field-defs="fieldDefs"
|
||||||
:initial-query-filter="cookbook.queryFilter"
|
:initial-query-filter="cookbook.queryFilter"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
/>
|
/>
|
||||||
<v-switch v-model="cookbook.public" hide-details single-line>
|
<v-switch
|
||||||
|
v-model="cookbook.public"
|
||||||
|
hide-details
|
||||||
|
single-line
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
{{ $t('cookbook.public-cookbook') }}
|
{{ $t('cookbook.public-cookbook') }}
|
||||||
<HelpIcon small right class="ml-2">
|
<HelpIcon
|
||||||
|
size="small"
|
||||||
|
right
|
||||||
|
class="ml-2"
|
||||||
|
>
|
||||||
{{ $t('cookbook.public-cookbook-description') }}
|
{{ $t('cookbook.public-cookbook-description') }}
|
||||||
</HelpIcon>
|
</HelpIcon>
|
||||||
</template>
|
</template>
|
||||||
@@ -21,16 +45,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||||
import { ReadCookBook } from "~/lib/api/types/cookbook";
|
|
||||||
import { Organizer } from "~/lib/api/types/non-generated";
|
import { Organizer } from "~/lib/api/types/non-generated";
|
||||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||||
import { FieldDefinition } from "~/composables/use-query-filter-builder";
|
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: { QueryFilterBuilder },
|
components: { QueryFilterBuilder },
|
||||||
props: {
|
props: {
|
||||||
cookbook: {
|
modelValue: {
|
||||||
type: Object as () => ReadCookBook,
|
type: Object as () => ReadCookBook,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
@@ -39,52 +62,57 @@ export default defineComponent({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
emits: ["update:modelValue"],
|
||||||
const { i18n } = useContext();
|
setup(props, { emit }) {
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const cookbook = toRef(() => props.modelValue);
|
||||||
|
|
||||||
function handleInput(value: string | undefined) {
|
function handleInput(value: string | undefined) {
|
||||||
props.cookbook.queryFilterString = value || "";
|
cookbook.value.queryFilterString = value || "";
|
||||||
|
emit("update:modelValue", cookbook.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldDefs: FieldDefinition[] = [
|
const fieldDefs: FieldDefinition[] = [
|
||||||
{
|
{
|
||||||
name: "recipe_category.id",
|
name: "recipe_category.id",
|
||||||
label: i18n.tc("category.categories"),
|
label: i18n.t("category.categories"),
|
||||||
type: Organizer.Category,
|
type: Organizer.Category,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tags.id",
|
name: "tags.id",
|
||||||
label: i18n.tc("tag.tags"),
|
label: i18n.t("tag.tags"),
|
||||||
type: Organizer.Tag,
|
type: Organizer.Tag,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "recipe_ingredient.food.id",
|
name: "recipe_ingredient.food.id",
|
||||||
label: i18n.tc("recipe.ingredients"),
|
label: i18n.t("recipe.ingredients"),
|
||||||
type: Organizer.Food,
|
type: Organizer.Food,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tools.id",
|
name: "tools.id",
|
||||||
label: i18n.tc("tool.tools"),
|
label: i18n.t("tool.tools"),
|
||||||
type: Organizer.Tool,
|
type: Organizer.Tool,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "household_id",
|
name: "household_id",
|
||||||
label: i18n.tc("household.households"),
|
label: i18n.t("household.households"),
|
||||||
type: Organizer.Household,
|
type: Organizer.Household,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "created_at",
|
name: "created_at",
|
||||||
label: i18n.tc("general.date-created"),
|
label: i18n.t("general.date-created"),
|
||||||
type: "date",
|
type: "date",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "updated_at",
|
name: "updated_at",
|
||||||
label: i18n.tc("general.date-updated"),
|
label: i18n.t("general.date-updated"),
|
||||||
type: "date",
|
type: "date",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
cookbook,
|
||||||
handleInput,
|
handleInput,
|
||||||
fieldDefs,
|
fieldDefs,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,44 +7,57 @@
|
|||||||
width="100%"
|
width="100%"
|
||||||
max-width="1100px"
|
max-width="1100px"
|
||||||
:icon="$globals.icons.pages"
|
:icon="$globals.icons.pages"
|
||||||
:title="$tc('general.edit')"
|
:title="$t('general.edit')"
|
||||||
:submit-icon="$globals.icons.save"
|
:submit-icon="$globals.icons.save"
|
||||||
:submit-text="$tc('general.save')"
|
:submit-text="$t('general.save')"
|
||||||
:submit-disabled="!editTarget.queryFilterString"
|
:submit-disabled="!editTarget.queryFilterString"
|
||||||
|
can-submit
|
||||||
@submit="editCookbook"
|
@submit="editCookbook"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<CookbookEditor :cookbook="editTarget" :actions="actions" />
|
<CookbookEditor
|
||||||
|
v-model="editTarget"
|
||||||
|
:actions="actions"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
<!-- Page -->
|
<v-container
|
||||||
<v-container v-if="book" fluid>
|
v-if="book"
|
||||||
<v-app-bar color="transparent" flat class="mt-n1">
|
class="my-0"
|
||||||
<v-icon large left> {{ $globals.icons.pages }} </v-icon>
|
>
|
||||||
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title>
|
<v-sheet
|
||||||
<v-spacer></v-spacer>
|
color="transparent"
|
||||||
|
class="d-flex flex-column w-100 pa-0 ma-0"
|
||||||
|
elevation="0"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center w-100 mb-2">
|
||||||
|
<v-toolbar-title class="headline mb-0">
|
||||||
|
<v-icon size="large" class="mr-3">
|
||||||
|
{{ $globals.icons.pages }}
|
||||||
|
</v-icon>
|
||||||
|
{{ book.name }}
|
||||||
|
</v-toolbar-title>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="canEdit"
|
v-if="canEdit"
|
||||||
class="mx-1"
|
class="mx-1"
|
||||||
:edit="true"
|
:edit="true"
|
||||||
@click="handleEditCookbook"
|
@click="handleEditCookbook"
|
||||||
/>
|
/>
|
||||||
</v-app-bar>
|
</div>
|
||||||
<v-card flat>
|
<div v-if="book.description" class="subtitle-1 text-grey-lighten-1 mb-2">
|
||||||
<v-card-text class="py-0">
|
|
||||||
{{ book.description }}
|
{{ book.description }}
|
||||||
</v-card-text>
|
</div>
|
||||||
</v-card>
|
</v-sheet>
|
||||||
|
|
||||||
<v-container class="pa-0">
|
<v-container class="pa-0">
|
||||||
<RecipeCardSection
|
<RecipeCardSection
|
||||||
class="mb-5 mx-1"
|
class="mb-5 mx-1"
|
||||||
:recipes="recipes"
|
:recipes="recipes"
|
||||||
:query="{ cookbook: slug }"
|
:query="{ cookbook: slug }"
|
||||||
@sortRecipes="assignSorted"
|
@sort-recipes="assignSorted"
|
||||||
@replaceRecipes="replaceRecipes"
|
@replace-recipes="replaceRecipes"
|
||||||
@appendRecipes="appendRecipes"
|
@append-recipes="appendRecipes"
|
||||||
@delete="removeRecipe"
|
@delete="removeRecipe"
|
||||||
/>
|
/>
|
||||||
</v-container>
|
</v-container>
|
||||||
@@ -53,39 +66,39 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useRoute, ref, useContext, useMeta, reactive, useRouter } from "@nuxtjs/composition-api";
|
|
||||||
import { useLazyRecipes } from "~/composables/recipes";
|
import { useLazyRecipes } from "~/composables/recipes";
|
||||||
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
|
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
|
||||||
import { useCookbook, useCookbooks } from "~/composables/use-group-cookbooks";
|
import { useCookbookStore } from "~/composables/store/use-cookbook-store";
|
||||||
|
import { useCookbook } from "~/composables/use-group-cookbooks";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { RecipeCookBook } from "~/lib/api/types/cookbook";
|
import type { RecipeCookBook } from "~/lib/api/types/cookbook";
|
||||||
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: { RecipeCardSection, CookbookEditor },
|
components: { RecipeCardSection, CookbookEditor },
|
||||||
setup() {
|
setup() {
|
||||||
const { $auth } = useContext();
|
const $auth = useMealieAuth();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.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 { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||||
const slug = route.value.params.slug;
|
const slug = route.params.slug as string;
|
||||||
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
|
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
|
||||||
const { actions } = useCookbooks();
|
const { actions } = useCookbookStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const tab = ref(null);
|
const tab = ref(null);
|
||||||
const book = getOne(slug);
|
const book = getOne(slug);
|
||||||
|
|
||||||
const isOwnHousehold = computed(() => {
|
const isOwnHousehold = computed(() => {
|
||||||
if (!($auth.user && book.value?.householdId)) {
|
if (!($auth.user.value && book.value?.householdId)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $auth.user.householdId === book.value.householdId;
|
return $auth.user.value.householdId === book.value.householdId;
|
||||||
})
|
});
|
||||||
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
||||||
|
|
||||||
const dialogStates = reactive({
|
const dialogStates = reactive({
|
||||||
@@ -106,8 +119,9 @@
|
|||||||
|
|
||||||
if (response?.slug && book.value?.slug !== response?.slug) {
|
if (response?.slug && book.value?.slug !== response?.slug) {
|
||||||
// if name changed, redirect to new slug
|
// if name changed, redirect to new slug
|
||||||
router.push(`/g/${route.value.params.groupSlug}/cookbooks/${response?.slug}`);
|
router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
// otherwise reload the page, since the recipe criteria changed
|
// otherwise reload the page, since the recipe criteria changed
|
||||||
router.go(0);
|
router.go(0);
|
||||||
}
|
}
|
||||||
@@ -115,10 +129,8 @@
|
|||||||
editTarget.value = null;
|
editTarget.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
useMeta(() => {
|
useSeoMeta({
|
||||||
return {
|
|
||||||
title: book?.value?.name || "Cookbook",
|
title: book?.value?.name || "Cookbook",
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -138,6 +150,5 @@
|
|||||||
actions,
|
actions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
head: {}, // Must include for useMeta
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,21 +7,24 @@
|
|||||||
class="elevation-0"
|
class="elevation-0"
|
||||||
@click:row="downloadData"
|
@click:row="downloadData"
|
||||||
>
|
>
|
||||||
<template #item.expires="{ item }">
|
<template #[`item.expires`]="{ item }">
|
||||||
{{ getTimeToExpire(item.expires) }}
|
{{ getTimeToExpire(item.expires) }}
|
||||||
</template>
|
</template>
|
||||||
<template #item.actions="{ item }">
|
<template #[`item.actions`]="{ item }">
|
||||||
<BaseButton download small :download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`">
|
<BaseButton
|
||||||
</BaseButton>
|
download
|
||||||
|
size="small"
|
||||||
|
:download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-data-table>
|
</v-data-table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import { parseISO, formatDistanceToNow } from "date-fns";
|
import { parseISO, formatDistanceToNow } from "date-fns";
|
||||||
import { GroupDataExport } from "~/lib/api/types/group";
|
import type { GroupDataExport } from "~/lib/api/types/group";
|
||||||
export default defineComponent({
|
|
||||||
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
exports: {
|
exports: {
|
||||||
type: Array as () => GroupDataExport[],
|
type: Array as () => GroupDataExport[],
|
||||||
@@ -29,14 +32,14 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
{ text: i18n.t("export.export"), value: "name" },
|
{ title: i18n.t("export.export"), value: "name" },
|
||||||
{ text: i18n.t("export.file-name"), value: "filename" },
|
{ title: i18n.t("export.file-name"), value: "filename" },
|
||||||
{ text: i18n.t("export.size"), value: "size" },
|
{ title: i18n.t("export.size"), value: "size" },
|
||||||
{ text: i18n.t("export.link-expires"), value: "expires" },
|
{ title: i18n.t("export.link-expires"), value: "expires" },
|
||||||
{ text: "", value: "actions" },
|
{ title: "", value: "actions" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getTimeToExpire(timeString: string) {
|
function getTimeToExpire(timeString: string) {
|
||||||
|
|||||||
@@ -1,27 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="preferences">
|
<div v-if="preferences">
|
||||||
<BaseCardSectionTitle :title="$tc('group.general-preferences')"></BaseCardSectionTitle>
|
<BaseCardSectionTitle :title="$t('group.general-preferences')" />
|
||||||
<v-checkbox v-model="preferences.privateGroup" class="mt-n4" :label="$t('group.private-group')"></v-checkbox>
|
<v-checkbox
|
||||||
|
v-model="preferences.privateGroup"
|
||||||
|
class="mt-n4"
|
||||||
|
:label="$t('group.private-group')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed } from "@nuxtjs/composition-api";
|
export default defineNuxtComponent({
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const preferences = computed({
|
const preferences = computed({
|
||||||
get() {
|
get() {
|
||||||
return props.value;
|
return props.modelValue;
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
context.emit("input", val);
|
context.emit("update:modelValue", val);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,5 +35,4 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped></style>
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -5,31 +5,30 @@
|
|||||||
:label="label"
|
:label="label"
|
||||||
:hint="description"
|
:hint="description"
|
||||||
:persistent-hint="!!description"
|
:persistent-hint="!!description"
|
||||||
item-text="name"
|
item-title="name"
|
||||||
:multiple="multiselect"
|
:multiple="multiselect"
|
||||||
:prepend-inner-icon="$globals.icons.household"
|
:prepend-inner-icon="$globals.icons.household"
|
||||||
return-object
|
return-object
|
||||||
>
|
>
|
||||||
<template #selection="data">
|
<template #chip="data">
|
||||||
<v-chip
|
<v-chip
|
||||||
:key="data.index"
|
:key="data.index"
|
||||||
class="ma-1"
|
class="ma-1"
|
||||||
:input-value="data.selected"
|
:input-value="data.item"
|
||||||
small
|
size="small"
|
||||||
close
|
closable
|
||||||
label
|
label
|
||||||
color="accent"
|
color="accent"
|
||||||
dark
|
dark
|
||||||
@click:close="removeByIndex(data.index)"
|
@click:close="removeByIndex(data.index)"
|
||||||
>
|
>
|
||||||
{{ data.item.name || data.item }}
|
{{ data.item.raw.name || data.item }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, onMounted, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import { useHouseholdStore } from "~/composables/store/use-household-store";
|
import { useHouseholdStore } from "~/composables/store/use-household-store";
|
||||||
|
|
||||||
interface HouseholdLike {
|
interface HouseholdLike {
|
||||||
@@ -37,9 +36,9 @@ interface HouseholdLike {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Array as () => HouseholdLike[],
|
type: Array as () => HouseholdLike[],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
@@ -52,11 +51,12 @@ export default defineComponent({
|
|||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const selected = computed({
|
const selected = computed({
|
||||||
get: () => props.value,
|
get: () => props.modelValue,
|
||||||
set: (val) => {
|
set: (val) => {
|
||||||
context.emit("input", val);
|
context.emit("update:modelValue", val);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,9 +66,9 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
const label = computed(
|
const label = computed(
|
||||||
() => props.multiselect ? i18n.tc("household.households") : i18n.tc("household.household")
|
() => props.multiselect ? i18n.t("household.households") : i18n.t("household.household"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { store: households } = useHouseholdStore();
|
const { store: households } = useHouseholdStore();
|
||||||
|
|||||||
@@ -8,26 +8,41 @@
|
|||||||
/>
|
/>
|
||||||
<v-menu
|
<v-menu
|
||||||
offset-y
|
offset-y
|
||||||
left
|
start
|
||||||
:bottom="!menuTop"
|
:bottom="!menuTop"
|
||||||
:nudge-bottom="!menuTop ? '5' : '0'"
|
:nudge-bottom="!menuTop ? '5' : '0'"
|
||||||
:top="menuTop"
|
:top="menuTop"
|
||||||
:nudge-top="menuTop ? '5' : '0'"
|
:nudge-top="menuTop ? '5' : '0'"
|
||||||
allow-overflow
|
allow-overflow
|
||||||
close-delay="125"
|
close-delay="125"
|
||||||
:open-on-hover="$vuetify.breakpoint.mdAndUp"
|
:open-on-hover="mdAndUp"
|
||||||
content-class="d-print-none"
|
content-class="d-print-none"
|
||||||
>
|
>
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ props }">
|
||||||
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent>
|
<v-btn
|
||||||
|
:class="{ 'rounded-circle': fab }"
|
||||||
|
:size="fab ? 'small' : undefined"
|
||||||
|
:color="color"
|
||||||
|
:icon="!fab"
|
||||||
|
variant="text"
|
||||||
|
dark
|
||||||
|
v-bind="props"
|
||||||
|
@click.prevent
|
||||||
|
>
|
||||||
<v-icon>{{ icon }}</v-icon>
|
<v-icon>{{ icon }}</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<v-list dense>
|
<v-list density="compact">
|
||||||
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
|
<v-list-item
|
||||||
<v-list-item-icon>
|
v-for="(item, index) in menuItems"
|
||||||
<v-icon :color="item.color"> {{ item.icon }} </v-icon>
|
:key="index"
|
||||||
</v-list-item-icon>
|
@click="contextMenuEventHandler(item.event)"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon :color="item.color">
|
||||||
|
{{ item.icon }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
@@ -36,10 +51,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
|
||||||
import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
|
import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
|
||||||
import { ShoppingListSummary } from "~/lib/api/types/household";
|
import type { ShoppingListSummary } from "~/lib/api/types/household";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
|
||||||
export interface ContextMenuItem {
|
export interface ContextMenuItem {
|
||||||
@@ -50,7 +64,7 @@ export interface ContextMenuItem {
|
|||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeDialogAddToShoppingList,
|
RecipeDialogAddToShoppingList,
|
||||||
},
|
},
|
||||||
@@ -77,7 +91,10 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { $globals, i18n } = useContext();
|
const { mdAndUp } = useDisplay();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
@@ -85,7 +102,7 @@ export default defineComponent({
|
|||||||
shoppingListDialog: false,
|
shoppingListDialog: false,
|
||||||
menuItems: [
|
menuItems: [
|
||||||
{
|
{
|
||||||
title: i18n.tc("recipe.add-to-list"),
|
title: i18n.t("recipe.add-to-list"),
|
||||||
icon: $globals.icons.cartCheck,
|
icon: $globals.icons.cartCheck,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "shoppingList",
|
event: "shoppingList",
|
||||||
@@ -103,16 +120,17 @@ export default defineComponent({
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
...recipe,
|
...recipe,
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
async function getShoppingLists() {
|
async function getShoppingLists() {
|
||||||
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
||||||
if (data) {
|
if (data) {
|
||||||
shoppingLists.value = data.items ?? [];
|
shoppingLists.value = data.items as ShoppingListSummary[] ?? [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||||
shoppingList: () => {
|
shoppingList: () => {
|
||||||
getShoppingLists();
|
getShoppingLists();
|
||||||
@@ -139,7 +157,8 @@ export default defineComponent({
|
|||||||
icon,
|
icon,
|
||||||
recipesWithScales,
|
recipesWithScales,
|
||||||
shoppingLists,
|
shoppingLists,
|
||||||
}
|
mdAndUp,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="d-md-flex" style="gap: 10px">
|
<div
|
||||||
<v-select v-model="inputDay" :items="MEAL_DAY_OPTIONS" :label="$t('meal-plan.rule-day')"></v-select>
|
class="d-md-flex"
|
||||||
<v-select v-model="inputEntryType" :items="MEAL_TYPE_OPTIONS" :label="$t('meal-plan.meal-type')"></v-select>
|
style="gap: 10px"
|
||||||
|
>
|
||||||
|
<v-select
|
||||||
|
v-model="inputDay"
|
||||||
|
:items="MEAL_DAY_OPTIONS"
|
||||||
|
:label="$t('meal-plan.rule-day')"
|
||||||
|
/>
|
||||||
|
<v-select
|
||||||
|
v-model="inputEntryType"
|
||||||
|
:items="MEAL_TYPE_OPTIONS"
|
||||||
|
:label="$t('meal-plan.meal-type')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-5">
|
<div class="mb-5">
|
||||||
@@ -16,19 +27,18 @@
|
|||||||
<!-- TODO: proper pluralization of inputDay -->
|
<!-- TODO: proper pluralization of inputDay -->
|
||||||
{{ $t('meal-plan.this-rule-will-apply', {
|
{{ $t('meal-plan.this-rule-will-apply', {
|
||||||
dayCriteria: inputDay === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [inputDay]),
|
dayCriteria: inputDay === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [inputDay]),
|
||||||
mealTypeCriteria: inputEntryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [inputEntryType])
|
mealTypeCriteria: inputEntryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [inputEntryType]),
|
||||||
}) }}
|
}) }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||||
import { FieldDefinition } from "~/composables/use-query-filter-builder";
|
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||||
import { Organizer } from "~/lib/api/types/non-generated";
|
import { Organizer } from "~/lib/api/types/non-generated";
|
||||||
import { QueryFilterJSON } from "~/lib/api/types/response";
|
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
QueryFilterBuilder,
|
QueryFilterBuilder,
|
||||||
},
|
},
|
||||||
@@ -54,26 +64,27 @@ export default defineComponent({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:day", "update:entry-type", "update:query-filter-string"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const MEAL_TYPE_OPTIONS = [
|
const MEAL_TYPE_OPTIONS = [
|
||||||
{ text: i18n.t("meal-plan.breakfast"), value: "breakfast" },
|
{ title: i18n.t("meal-plan.breakfast"), value: "breakfast" },
|
||||||
{ text: i18n.t("meal-plan.lunch"), value: "lunch" },
|
{ title: i18n.t("meal-plan.lunch"), value: "lunch" },
|
||||||
{ text: i18n.t("meal-plan.dinner"), value: "dinner" },
|
{ title: i18n.t("meal-plan.dinner"), value: "dinner" },
|
||||||
{ text: i18n.t("meal-plan.side"), value: "side" },
|
{ title: i18n.t("meal-plan.side"), value: "side" },
|
||||||
{ text: i18n.t("meal-plan.type-any"), value: "unset" },
|
{ title: i18n.t("meal-plan.type-any"), value: "unset" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MEAL_DAY_OPTIONS = [
|
const MEAL_DAY_OPTIONS = [
|
||||||
{ text: i18n.t("general.monday"), value: "monday" },
|
{ title: i18n.t("general.monday"), value: "monday" },
|
||||||
{ text: i18n.t("general.tuesday"), value: "tuesday" },
|
{ title: i18n.t("general.tuesday"), value: "tuesday" },
|
||||||
{ text: i18n.t("general.wednesday"), value: "wednesday" },
|
{ title: i18n.t("general.wednesday"), value: "wednesday" },
|
||||||
{ text: i18n.t("general.thursday"), value: "thursday" },
|
{ title: i18n.t("general.thursday"), value: "thursday" },
|
||||||
{ text: i18n.t("general.friday"), value: "friday" },
|
{ title: i18n.t("general.friday"), value: "friday" },
|
||||||
{ text: i18n.t("general.saturday"), value: "saturday" },
|
{ title: i18n.t("general.saturday"), value: "saturday" },
|
||||||
{ text: i18n.t("general.sunday"), value: "sunday" },
|
{ title: i18n.t("general.sunday"), value: "sunday" },
|
||||||
{ text: i18n.t("meal-plan.day-any"), value: "unset" },
|
{ title: i18n.t("meal-plan.day-any"), value: "unset" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const inputDay = computed({
|
const inputDay = computed({
|
||||||
@@ -110,42 +121,42 @@ export default defineComponent({
|
|||||||
const fieldDefs: FieldDefinition[] = [
|
const fieldDefs: FieldDefinition[] = [
|
||||||
{
|
{
|
||||||
name: "recipe_category.id",
|
name: "recipe_category.id",
|
||||||
label: i18n.tc("category.categories"),
|
label: i18n.t("category.categories"),
|
||||||
type: Organizer.Category,
|
type: Organizer.Category,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tags.id",
|
name: "tags.id",
|
||||||
label: i18n.tc("tag.tags"),
|
label: i18n.t("tag.tags"),
|
||||||
type: Organizer.Tag,
|
type: Organizer.Tag,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "recipe_ingredient.food.id",
|
name: "recipe_ingredient.food.id",
|
||||||
label: i18n.tc("recipe.ingredients"),
|
label: i18n.t("recipe.ingredients"),
|
||||||
type: Organizer.Food,
|
type: Organizer.Food,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tools.id",
|
name: "tools.id",
|
||||||
label: i18n.tc("tool.tools"),
|
label: i18n.t("tool.tools"),
|
||||||
type: Organizer.Tool,
|
type: Organizer.Tool,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "household_id",
|
name: "household_id",
|
||||||
label: i18n.tc("household.households"),
|
label: i18n.t("household.households"),
|
||||||
type: Organizer.Household,
|
type: Organizer.Household,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "last_made",
|
name: "last_made",
|
||||||
label: i18n.tc("general.last-made"),
|
label: i18n.t("general.last-made"),
|
||||||
type: "date",
|
type: "date",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "created_at",
|
name: "created_at",
|
||||||
label: i18n.tc("general.date-created"),
|
label: i18n.t("general.date-created"),
|
||||||
type: "date",
|
type: "date",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "updated_at",
|
name: "updated_at",
|
||||||
label: i18n.tc("general.date-updated"),
|
label: i18n.t("general.date-updated"),
|
||||||
type: "date",
|
type: "date",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,27 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-switch v-model="webhookCopy.enabled" :label="$t('general.enabled')"></v-switch>
|
<v-switch
|
||||||
<v-text-field v-model="webhookCopy.name" :label="$t('settings.webhooks.webhook-name')"></v-text-field>
|
v-model="webhookCopy.enabled"
|
||||||
<v-text-field v-model="webhookCopy.url" :label="$t('settings.webhooks.webhook-url')"></v-text-field>
|
color="primary"
|
||||||
<v-time-picker v-model="scheduledTime" class="elevation-2" ampm-in-title format="ampm"></v-time-picker>
|
:label="$t('general.enabled')"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="webhookCopy.name"
|
||||||
|
:label="$t('settings.webhooks.webhook-name')"
|
||||||
|
variant="underlined"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="webhookCopy.url"
|
||||||
|
:label="$t('settings.webhooks.webhook-url')"
|
||||||
|
variant="underlined"
|
||||||
|
/>
|
||||||
|
<v-time-picker
|
||||||
|
v-model="scheduledTime"
|
||||||
|
class="elevation-2"
|
||||||
|
ampm-in-title
|
||||||
|
format="ampm"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions class="py-0 justify-end">
|
<v-card-actions class="py-0 justify-end">
|
||||||
<BaseButtonGroup
|
<BaseButtonGroup
|
||||||
:buttons="[
|
:buttons="[
|
||||||
{
|
{
|
||||||
icon: $globals.icons.delete,
|
icon: $globals.icons.delete,
|
||||||
text: $tc('general.delete'),
|
text: $t('general.delete'),
|
||||||
event: 'delete',
|
event: 'delete',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.testTube,
|
icon: $globals.icons.testTube,
|
||||||
text: $tc('general.test'),
|
text: $t('general.test'),
|
||||||
event: 'test',
|
event: 'test',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.save,
|
icon: $globals.icons.save,
|
||||||
text: $tc('general.save'),
|
text: $t('general.save'),
|
||||||
event: 'save',
|
event: 'save',
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
@@ -34,11 +51,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
|
import type { ReadWebhook } from "~/lib/api/types/household";
|
||||||
import { ReadWebhook } from "~/lib/api/types/household";
|
|
||||||
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
|
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
webhook: {
|
webhook: {
|
||||||
type: Object as () => ReadWebhook,
|
type: Object as () => ReadWebhook,
|
||||||
@@ -47,6 +63,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
emits: ["delete", "save", "test"],
|
emits: ["delete", "save", "test"],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
|
const i18n = useI18n();
|
||||||
const itemUTC = ref<string>(props.webhook.scheduledTime);
|
const itemUTC = ref<string>(props.webhook.scheduledTime);
|
||||||
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
|
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
|
||||||
|
|
||||||
@@ -67,6 +84,11 @@ export default defineComponent({
|
|||||||
emit("save", webhookCopy.value);
|
emit("save", webhookCopy.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set page title using useSeoMeta
|
||||||
|
useSeoMeta({
|
||||||
|
title: i18n.t("settings.webhooks.webhooks"),
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
webhookCopy,
|
webhookCopy,
|
||||||
scheduledTime,
|
scheduledTime,
|
||||||
@@ -75,10 +97,5 @@ export default defineComponent({
|
|||||||
itemLocal,
|
itemLocal,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
head() {
|
|
||||||
return {
|
|
||||||
title: this.$t("settings.webhooks.webhooks") as string,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="preferences">
|
<div v-if="preferences">
|
||||||
<BaseCardSectionTitle class="mt-10" :title="$tc('household.household-preferences')"></BaseCardSectionTitle>
|
<BaseCardSectionTitle :title="$t('household.household-preferences')" />
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<v-checkbox
|
<v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
|
||||||
v-model="preferences.privateHousehold"
|
|
||||||
hide-details
|
|
||||||
dense
|
|
||||||
:label="$t('household.private-household')"
|
|
||||||
/>
|
|
||||||
<div class="ml-8">
|
<div class="ml-8">
|
||||||
<p class="text-subtitle-2 my-0 py-0">
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
{{ $t("household.private-household-description") }}
|
{{ $t("household.private-household-description") }}
|
||||||
@@ -16,12 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<v-checkbox
|
<v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
|
||||||
v-model="preferences.lockRecipeEditsFromOtherHouseholds"
|
|
||||||
hide-details
|
|
||||||
dense
|
|
||||||
:label="$t('household.lock-recipe-edits-from-other-households')"
|
|
||||||
/>
|
|
||||||
<div class="ml-8">
|
<div class="ml-8">
|
||||||
<p class="text-subtitle-2 my-0 py-0">
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
||||||
@@ -32,20 +22,17 @@
|
|||||||
v-model="preferences.firstDayOfWeek"
|
v-model="preferences.firstDayOfWeek"
|
||||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||||
:items="allDays"
|
:items="allDays"
|
||||||
item-text="name"
|
item-title="name"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
:label="$t('settings.first-day-of-week')"
|
:label="$t('settings.first-day-of-week')"
|
||||||
|
variant="underlined"
|
||||||
|
flat
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BaseCardSectionTitle class="mt-5" :title="$tc('household.household-recipe-preferences')"></BaseCardSectionTitle>
|
<BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')" />
|
||||||
<div class="preference-container">
|
<div class="preference-container">
|
||||||
<div v-for="p in recipePreferences" :key="p.key">
|
<div v-for="p in recipePreferences" :key="p.key">
|
||||||
<v-checkbox
|
<v-checkbox v-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
|
||||||
v-model="preferences[p.key]"
|
|
||||||
hide-details
|
|
||||||
dense
|
|
||||||
:label="p.label"
|
|
||||||
/>
|
|
||||||
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
||||||
{{ p.description }}
|
{{ p.description }}
|
||||||
</p>
|
</p>
|
||||||
@@ -55,55 +42,55 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
||||||
import { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
|
||||||
type Preference = {
|
type Preference = {
|
||||||
key: keyof ReadHouseholdPreferences;
|
key: keyof ReadHouseholdPreferences;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const recipePreferences: Preference[] = [
|
const recipePreferences: Preference[] = [
|
||||||
{
|
{
|
||||||
key: "recipePublic",
|
key: "recipePublic",
|
||||||
label: i18n.tc("group.allow-users-outside-of-your-group-to-see-your-recipes"),
|
label: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes"),
|
||||||
description: i18n.tc("group.allow-users-outside-of-your-group-to-see-your-recipes-description"),
|
description: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes-description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "recipeShowNutrition",
|
key: "recipeShowNutrition",
|
||||||
label: i18n.tc("group.show-nutrition-information"),
|
label: i18n.t("group.show-nutrition-information"),
|
||||||
description: i18n.tc("group.show-nutrition-information-description"),
|
description: i18n.t("group.show-nutrition-information-description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "recipeShowAssets",
|
key: "recipeShowAssets",
|
||||||
label: i18n.tc("group.show-recipe-assets"),
|
label: i18n.t("group.show-recipe-assets"),
|
||||||
description: i18n.tc("group.show-recipe-assets-description"),
|
description: i18n.t("group.show-recipe-assets-description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "recipeLandscapeView",
|
key: "recipeLandscapeView",
|
||||||
label: i18n.tc("group.default-to-landscape-view"),
|
label: i18n.t("group.default-to-landscape-view"),
|
||||||
description: i18n.tc("group.default-to-landscape-view-description"),
|
description: i18n.t("group.default-to-landscape-view-description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "recipeDisableComments",
|
key: "recipeDisableComments",
|
||||||
label: i18n.tc("group.disable-users-from-commenting-on-recipes"),
|
label: i18n.t("group.disable-users-from-commenting-on-recipes"),
|
||||||
description: i18n.tc("group.disable-users-from-commenting-on-recipes-description"),
|
description: i18n.t("group.disable-users-from-commenting-on-recipes-description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "recipeDisableAmount",
|
key: "recipeDisableAmount",
|
||||||
label: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food"),
|
label: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food"),
|
||||||
description: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food-description"),
|
description: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food-description"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -140,10 +127,10 @@ export default defineComponent({
|
|||||||
|
|
||||||
const preferences = computed({
|
const preferences = computed({
|
||||||
get() {
|
get() {
|
||||||
return props.value;
|
return props.modelValue;
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
context.emit("input", val);
|
context.emit("update:modelValue", val);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
<v-card class="ma-0" style="overflow-x: auto;">
|
<v-card class="ma-0" style="overflow-x: auto;">
|
||||||
<v-card-text class="ma-0 pa-0">
|
<v-card-text class="ma-0 pa-0">
|
||||||
<v-container fluid class="ma-0 pa-0">
|
<v-container fluid class="ma-0 pa-0">
|
||||||
<draggable
|
<VueDraggable
|
||||||
:value="fields"
|
v-model="fields"
|
||||||
handle=".handle"
|
handle=".handle"
|
||||||
delay="250"
|
:delay="250"
|
||||||
:delay-on-touch-only="true"
|
:delay-on-touch-only="true"
|
||||||
v-bind="{
|
v-bind="{
|
||||||
animation: 200,
|
animation: 200,
|
||||||
@@ -17,127 +17,142 @@
|
|||||||
>
|
>
|
||||||
<v-row
|
<v-row
|
||||||
v-for="(field, index) in fields"
|
v-for="(field, index) in fields"
|
||||||
:key="index"
|
:key="field.id"
|
||||||
class="d-flex flex-nowrap"
|
class="d-flex flex-nowrap"
|
||||||
style="max-width: 100%;"
|
style="max-width: 100%;"
|
||||||
>
|
>
|
||||||
|
<!-- drag handle -->
|
||||||
<v-col
|
<v-col
|
||||||
:cols="attrs.fields.icon.cols"
|
:cols="config.items.icon.cols"
|
||||||
:class="attrs.col.class"
|
:class="config.col.class"
|
||||||
:style="attrs.fields.icon.style"
|
:style="config.items.icon.style"
|
||||||
>
|
>
|
||||||
<v-icon
|
<v-icon
|
||||||
class="handle"
|
class="handle"
|
||||||
style="width: 100%; height: 100%;"
|
:size="24"
|
||||||
|
style="cursor: move;margin: auto;"
|
||||||
>
|
>
|
||||||
{{ $globals.icons.arrowUpDown }}
|
{{ $globals.icons.arrowUpDown }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<!-- and / or -->
|
||||||
<v-col
|
<v-col
|
||||||
:cols="attrs.fields.logicalOperator.cols"
|
:cols="config.items.logicalOperator.cols"
|
||||||
:class="attrs.col.class"
|
:class="config.col.class"
|
||||||
:style="attrs.fields.logicalOperator.style"
|
:style="config.items.logicalOperator.style"
|
||||||
>
|
>
|
||||||
<v-select
|
<v-select
|
||||||
v-if="index"
|
v-if="index"
|
||||||
v-model="field.logicalOperator"
|
:model-value="field.logicalOperator"
|
||||||
:items="[logOps.AND, logOps.OR]"
|
:items="[logOps.AND, logOps.OR]"
|
||||||
item-text="label"
|
item-title="label"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
@input="setLogicalOperatorValue(field, index, $event)"
|
variant="underlined"
|
||||||
|
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
|
||||||
>
|
>
|
||||||
<template #selection="{ item }">
|
<template #chip="{ item }">
|
||||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
<span :class="config.select.textClass" style="width: 100%;">
|
||||||
{{ item.label }}
|
{{ item.raw.label }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<!-- left parenthesis -->
|
||||||
<v-col
|
<v-col
|
||||||
v-if="showAdvanced"
|
v-if="showAdvanced"
|
||||||
:cols="attrs.fields.leftParens.cols"
|
:cols="config.items.leftParens.cols"
|
||||||
:class="attrs.col.class"
|
:class="config.col.class"
|
||||||
:style="attrs.fields.leftParens.style"
|
:style="config.items.leftParens.style"
|
||||||
>
|
>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="field.leftParenthesis"
|
:model-value="field.leftParenthesis"
|
||||||
:items="['', '(', '((', '(((']"
|
:items="['', '(', '((', '(((']"
|
||||||
@input="setLeftParenthesisValue(field, index, $event)"
|
variant="underlined"
|
||||||
|
@update:model-value="setLeftParenthesisValue(field, index, $event)"
|
||||||
>
|
>
|
||||||
<template #selection="{ item }">
|
<template #chip="{ item }">
|
||||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
<span :class="config.select.textClass" style="width: 100%;">
|
||||||
{{ item }}
|
{{ item.raw }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<!-- field name -->
|
||||||
<v-col
|
<v-col
|
||||||
:cols="attrs.fields.fieldName.cols"
|
:cols="config.items.fieldName.cols"
|
||||||
:class="attrs.col.class"
|
:class="config.col.class"
|
||||||
:style="attrs.fields.fieldName.style"
|
:style="config.items.fieldName.style"
|
||||||
>
|
>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="field.label"
|
chips
|
||||||
|
:model-value="field.label"
|
||||||
:items="fieldDefs"
|
:items="fieldDefs"
|
||||||
item-text="label"
|
variant="underlined"
|
||||||
@change="setField(index, $event)"
|
item-title="label"
|
||||||
|
@update:model-value="setField(index, $event)"
|
||||||
>
|
>
|
||||||
<template #selection="{ item }">
|
<template #chip="{ item }">
|
||||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
<span :class="config.select.textClass" style="width: 100%;">
|
||||||
{{ item.label }}
|
{{ item.raw.label }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<!-- relational operator -->
|
||||||
<v-col
|
<v-col
|
||||||
:cols="attrs.fields.relationalOperator.cols"
|
:cols="config.items.relationalOperator.cols"
|
||||||
:class="attrs.col.class"
|
:class="config.col.class"
|
||||||
:style="attrs.fields.relationalOperator.style"
|
:style="config.items.relationalOperator.style"
|
||||||
>
|
>
|
||||||
<v-select
|
<v-select
|
||||||
v-if="field.type !== 'boolean'"
|
v-if="field.type !== 'boolean'"
|
||||||
v-model="field.relationalOperatorValue"
|
:model-value="field.relationalOperatorValue"
|
||||||
:items="field.relationalOperatorOptions"
|
:items="field.relationalOperatorOptions"
|
||||||
item-text="label"
|
item-title="label"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
@input="setRelationalOperatorValue(field, index, $event)"
|
variant="underlined"
|
||||||
|
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
|
||||||
>
|
>
|
||||||
<template #selection="{ item }">
|
<template #chip="{ item }">
|
||||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
<span :class="config.select.textClass" style="width: 100%;">
|
||||||
{{ item.label }}
|
{{ item.raw.label }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<!-- field value -->
|
||||||
<v-col
|
<v-col
|
||||||
:cols="attrs.fields.fieldValue.cols"
|
:cols="config.items.fieldValue.cols"
|
||||||
:class="attrs.col.class"
|
:class="config.col.class"
|
||||||
:style="attrs.fields.fieldValue.style"
|
:style="config.items.fieldValue.style"
|
||||||
>
|
>
|
||||||
<v-select
|
<v-select
|
||||||
v-if="field.fieldOptions"
|
v-if="field.fieldOptions"
|
||||||
v-model="field.values"
|
:model-value="field.values"
|
||||||
:items="field.fieldOptions"
|
:items="field.fieldOptions"
|
||||||
item-text="label"
|
item-title="label"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
multiple
|
multiple
|
||||||
@input="setFieldValues(field, index, $event)"
|
variant="underlined"
|
||||||
|
@update:model-value="setFieldValues(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-else-if="field.type === 'string'"
|
v-else-if="field.type === 'string'"
|
||||||
v-model="field.value"
|
:model-value="field.value"
|
||||||
@input="setFieldValue(field, index, $event)"
|
variant="underlined"
|
||||||
|
@update:model-value="setFieldValue(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
v-model="field.value"
|
:model-value="field.value"
|
||||||
type="number"
|
type="number"
|
||||||
@input="setFieldValue(field, index, $event)"
|
variant="underlined"
|
||||||
|
@:model-value="setFieldValue(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-else-if="field.type === 'boolean'"
|
v-else-if="field.type === 'boolean'"
|
||||||
v-model="field.value"
|
:model-value="field.value"
|
||||||
@change="setFieldValue(field, index, $event)"
|
@update:model-value="setFieldValue(field, index, $event!)"
|
||||||
/>
|
/>
|
||||||
<v-menu
|
<v-menu
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
@@ -148,137 +163,153 @@
|
|||||||
max-width="290px"
|
max-width="290px"
|
||||||
min-width="auto"
|
min-width="auto"
|
||||||
>
|
>
|
||||||
<template #activator="{ on, attrs: menuAttrs }">
|
<template #activator="{ props }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
:prepend-icon="$globals.icons.calendar"
|
:prepend-icon="$globals.icons.calendar"
|
||||||
v-bind="menuAttrs"
|
variant="underlined"
|
||||||
|
color="primary"
|
||||||
|
v-bind="props"
|
||||||
readonly
|
readonly
|
||||||
v-on="on"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<v-date-picker
|
<v-date-picker
|
||||||
v-model="field.value"
|
:model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
|
||||||
no-title
|
hide-header
|
||||||
:first-day-of-week="firstDayOfWeek"
|
:first-day-of-week="firstDayOfWeek"
|
||||||
:local="$i18n.locale"
|
:local="$i18n.locale"
|
||||||
@input="setFieldValue(field, index, $event)"
|
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
|
||||||
/>
|
/>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
<RecipeOrganizerSelector
|
<RecipeOrganizerSelector
|
||||||
v-else-if="field.type === Organizer.Category"
|
v-else-if="field.type === Organizer.Category"
|
||||||
v-model="field.organizers"
|
:model-value="field.organizers"
|
||||||
:selector-type="Organizer.Category"
|
:selector-type="Organizer.Category"
|
||||||
:show-add="false"
|
:show-add="false"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
@input="setOrganizerValues(field, index, $event)"
|
variant="underlined"
|
||||||
|
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
<RecipeOrganizerSelector
|
<RecipeOrganizerSelector
|
||||||
v-else-if="field.type === Organizer.Tag"
|
v-else-if="field.type === Organizer.Tag"
|
||||||
v-model="field.organizers"
|
:model-value="field.organizers"
|
||||||
:selector-type="Organizer.Tag"
|
:selector-type="Organizer.Tag"
|
||||||
:show-add="false"
|
:show-add="false"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
@input="setOrganizerValues(field, index, $event)"
|
variant="underlined"
|
||||||
|
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
<RecipeOrganizerSelector
|
<RecipeOrganizerSelector
|
||||||
v-else-if="field.type === Organizer.Tool"
|
v-else-if="field.type === Organizer.Tool"
|
||||||
v-model="field.organizers"
|
:model-value="field.organizers"
|
||||||
:selector-type="Organizer.Tool"
|
:selector-type="Organizer.Tool"
|
||||||
:show-add="false"
|
:show-add="false"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
@input="setOrganizerValues(field, index, $event)"
|
variant="underlined"
|
||||||
|
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
<RecipeOrganizerSelector
|
<RecipeOrganizerSelector
|
||||||
v-else-if="field.type === Organizer.Food"
|
v-else-if="field.type === Organizer.Food"
|
||||||
v-model="field.organizers"
|
:model-value="field.organizers"
|
||||||
:selector-type="Organizer.Food"
|
:selector-type="Organizer.Food"
|
||||||
:show-add="false"
|
:show-add="false"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
@input="setOrganizerValues(field, index, $event)"
|
variant="underlined"
|
||||||
|
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
<RecipeOrganizerSelector
|
<RecipeOrganizerSelector
|
||||||
v-else-if="field.type === Organizer.Household"
|
v-else-if="field.type === Organizer.Household"
|
||||||
v-model="field.organizers"
|
:model-value="field.organizers"
|
||||||
:selector-type="Organizer.Household"
|
:selector-type="Organizer.Household"
|
||||||
:show-add="false"
|
:show-add="false"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
@input="setOrganizerValues(field, index, $event)"
|
variant="underlined"
|
||||||
|
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<!-- right parenthesis -->
|
||||||
<v-col
|
<v-col
|
||||||
v-if="showAdvanced"
|
v-if="showAdvanced"
|
||||||
:cols="attrs.fields.rightParens.cols"
|
:cols="config.items.rightParens.cols"
|
||||||
:class="attrs.col.class"
|
:class="config.col.class"
|
||||||
:style="attrs.fields.rightParens.style"
|
:style="config.items.rightParens.style"
|
||||||
>
|
>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="field.rightParenthesis"
|
:model-value="field.rightParenthesis"
|
||||||
:items="['', ')', '))', ')))']"
|
:items="['', ')', '))', ')))']"
|
||||||
@input="setRightParenthesisValue(field, index, $event)"
|
variant="underlined"
|
||||||
|
@update:model-value="setRightParenthesisValue(field, index, $event)"
|
||||||
>
|
>
|
||||||
<template #selection="{ item }">
|
<template #chip="{ item }">
|
||||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
<span :class="config.select.textClass" style="width: 100%;">
|
||||||
{{ item }}
|
{{ item.raw }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<!-- field actions -->
|
||||||
<v-col
|
<v-col
|
||||||
:cols="attrs.fields.fieldActions.cols"
|
:cols="config.items.fieldActions.cols"
|
||||||
:class="attrs.col.class"
|
:class="config.col.class"
|
||||||
:style="attrs.fields.fieldActions.style"
|
:style="config.items.fieldActions.style"
|
||||||
>
|
>
|
||||||
<BaseButtonGroup
|
<BaseButtonGroup
|
||||||
:buttons="[
|
:buttons="[
|
||||||
{
|
{
|
||||||
icon: $globals.icons.delete,
|
icon: $globals.icons.delete,
|
||||||
text: $tc('general.delete'),
|
text: $t('general.delete'),
|
||||||
event: 'delete',
|
event: 'delete',
|
||||||
disabled: fields.length === 1,
|
disabled: fields.length === 1,
|
||||||
}
|
},
|
||||||
]"
|
]"
|
||||||
class="my-auto"
|
class="my-auto"
|
||||||
@delete="removeField(index)"
|
@delete="removeField(index)"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</draggable>
|
</VueDraggable>
|
||||||
</v-container>
|
</v-container>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-container fluid class="d-flex justify-end pa-0 mx-2">
|
<v-row fluid class="d-flex justify-end pa-0 mx-2">
|
||||||
|
<v-spacer />
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-model="showAdvanced"
|
v-model="showAdvanced"
|
||||||
hide-details
|
hide-details
|
||||||
:label="$tc('general.show-advanced')"
|
:label="$t('general.show-advanced')"
|
||||||
class="my-auto mr-4"
|
class="my-auto mr-4"
|
||||||
|
color="primary"
|
||||||
/>
|
/>
|
||||||
<BaseButton create :text="$tc('general.add-field')" @click="addField(fieldDefs[0])" />
|
<BaseButton
|
||||||
</v-container>
|
create
|
||||||
|
:text="$t('general.add-field')"
|
||||||
|
class="my-auto"
|
||||||
|
@click="addField(fieldDefs[0])"
|
||||||
|
/>
|
||||||
|
</v-row>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import draggable from "vuedraggable";
|
import { VueDraggable } from "vue-draggable-plus";
|
||||||
import { computed, defineComponent, reactive, ref, toRefs, watch } from "@nuxtjs/composition-api";
|
import { useDebounceFn } from "@vueuse/core";
|
||||||
import { useHouseholdSelf } from "~/composables/use-households";
|
import { useHouseholdSelf } from "~/composables/use-households";
|
||||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||||
import { Organizer } from "~/lib/api/types/non-generated";
|
import { Organizer } from "~/lib/api/types/non-generated";
|
||||||
import { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||||
import { Field, FieldDefinition, FieldValue, OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
draggable,
|
VueDraggable,
|
||||||
RecipeOrganizerSelector,
|
RecipeOrganizerSelector,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
@@ -289,8 +320,9 @@ export default defineComponent({
|
|||||||
initialQueryFilter: {
|
initialQueryFilter: {
|
||||||
type: Object as () => QueryFilterJSON | null,
|
type: Object as () => QueryFilterJSON | null,
|
||||||
default: null,
|
default: null,
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
emits: ["input", "inputJSON"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { household } = useHouseholdSelf();
|
const { household } = useHouseholdSelf();
|
||||||
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
||||||
@@ -321,21 +353,27 @@ export default defineComponent({
|
|||||||
const newIndex: number = event.newIndex;
|
const newIndex: number = event.newIndex;
|
||||||
state.datePickers[oldIndex] = false;
|
state.datePickers[oldIndex] = false;
|
||||||
state.datePickers[newIndex] = false;
|
state.datePickers[newIndex] = false;
|
||||||
|
|
||||||
const field = fields.value.splice(oldIndex, 1)[0];
|
|
||||||
fields.value.splice(newIndex, 0, field);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = ref<Field[]>([]);
|
// add id to fields to prevent reactivity issues
|
||||||
|
type FieldWithId = Field & { id: number };
|
||||||
|
const fields = ref<FieldWithId[]>([]);
|
||||||
|
|
||||||
|
const uid = ref(1); // init uid to pass to fields
|
||||||
|
function useUid() {
|
||||||
|
return uid.value++;
|
||||||
|
}
|
||||||
function addField(field: FieldDefinition) {
|
function addField(field: FieldDefinition) {
|
||||||
fields.value.push(getFieldFromFieldDef(field));
|
fields.value.push({
|
||||||
|
...getFieldFromFieldDef(field),
|
||||||
|
id: useUid(),
|
||||||
|
});
|
||||||
state.datePickers.push(false);
|
state.datePickers.push(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
function setField(index: number, fieldLabel: string) {
|
function setField(index: number, fieldLabel: string) {
|
||||||
state.datePickers[index] = false;
|
state.datePickers[index] = false;
|
||||||
const fieldDef = props.fieldDefs.find((fieldDef) => fieldDef.label === fieldLabel);
|
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.label === fieldLabel);
|
||||||
if (!fieldDef) {
|
if (!fieldDef) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -346,58 +384,44 @@ export default defineComponent({
|
|||||||
// we have to set this explicitly since it might be undefined
|
// we have to set this explicitly since it might be undefined
|
||||||
updatedField.fieldOptions = fieldDef.fieldOptions;
|
updatedField.fieldOptions = fieldDef.fieldOptions;
|
||||||
|
|
||||||
fields.value.splice(index, 1, getFieldFromFieldDef(updatedField, resetValue));
|
fields.value[index] = {
|
||||||
|
...getFieldFromFieldDef(updatedField, resetValue),
|
||||||
|
id: fields.value[index].id, // keep the id
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLeftParenthesisValue(field: Field, index: number, value: string) {
|
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||||
fields.value.splice(index, 1, {
|
fields.value[index].leftParenthesis = value;
|
||||||
...field,
|
|
||||||
leftParenthesis: value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRightParenthesisValue(field: Field, index: number, value: string) {
|
function setRightParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||||
fields.value.splice(index, 1, {
|
fields.value[index].rightParenthesis = value;
|
||||||
...field,
|
|
||||||
rightParenthesis: value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLogicalOperatorValue(field: Field, index: number, value: LogicalOperator | undefined) {
|
function setLogicalOperatorValue(field: FieldWithId, index: number, value: LogicalOperator | undefined) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
value = logOps.value.AND.value;
|
value = logOps.value.AND.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
fields.value.splice(index, 1, {
|
fields.value[index].logicalOperator = value ? logOps.value[value] : undefined;
|
||||||
...field,
|
|
||||||
logicalOperator: value ? logOps.value[value] : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRelationalOperatorValue(field: Field, index: number, value: RelationalKeyword | RelationalOperator) {
|
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
|
||||||
fields.value.splice(index, 1, {
|
fields.value[index].relationalOperatorValue = relOps.value[value];
|
||||||
...field,
|
|
||||||
relationalOperatorValue: relOps.value[value],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFieldValue(field: Field, index: number, value: FieldValue) {
|
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
|
||||||
state.datePickers[index] = false;
|
state.datePickers[index] = false;
|
||||||
fields.value.splice(index, 1, {
|
fields.value[index].value = value;
|
||||||
...field,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFieldValues(field: Field, index: number, values: FieldValue[]) {
|
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
|
||||||
fields.value.splice(index, 1, {
|
fields.value[index].values = values;
|
||||||
...field,
|
|
||||||
values,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setOrganizerValues(field: Field, index: number, values: OrganizerBase[]) {
|
function setOrganizerValues(field: FieldWithId, index: number, values: OrganizerBase[]) {
|
||||||
setFieldValues(field, index, values.map((value) => value.id.toString()));
|
setFieldValues(field, index, values.map(value => value.id.toString()));
|
||||||
|
fields.value[index].organizers = values;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeField(index: number) {
|
function removeField(index: number) {
|
||||||
@@ -405,24 +429,11 @@ export default defineComponent({
|
|||||||
state.datePickers.splice(index, 1);
|
state.datePickers.splice(index, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
||||||
// Toggling showAdvanced changes the builder logic without changing the field values,
|
/* newFields.forEach((field, index) => {
|
||||||
// so we need to manually trigger reactivity to re-run the builder.
|
|
||||||
() => state.showAdvanced,
|
|
||||||
() => {
|
|
||||||
if (fields.value?.length) {
|
|
||||||
fields.value = [...fields.value];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => fields.value,
|
|
||||||
(newFields) => {
|
|
||||||
newFields.forEach((field, index) => {
|
|
||||||
const updatedField = getFieldFromFieldDef(field);
|
const updatedField = getFieldFromFieldDef(field);
|
||||||
fields.value[index] = updatedField;
|
fields.value[index] = updatedField; // recursive!!!
|
||||||
});
|
}); */
|
||||||
|
|
||||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||||
if (qf) {
|
if (qf) {
|
||||||
@@ -432,13 +443,11 @@ export default defineComponent({
|
|||||||
|
|
||||||
context.emit("input", qf || undefined);
|
context.emit("input", qf || undefined);
|
||||||
context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
|
context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
|
||||||
},
|
}, 500);
|
||||||
{
|
|
||||||
deep: true
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
async function hydrateOrganizers(field: Field, index: number) {
|
watch(fields, fieldsUpdater, { deep: true });
|
||||||
|
|
||||||
|
async function hydrateOrganizers(field: FieldWithId, index: number) {
|
||||||
if (!field.values?.length || !isOrganizerType(field.type)) {
|
if (!field.values?.length || !isOrganizerType(field.type)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -450,9 +459,15 @@ export default defineComponent({
|
|||||||
await actions.refresh();
|
await actions.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
const organizers = field.values.map((value) => {
|
||||||
const organizers = field.values.map((value) => store.value.find((organizer) => organizer.id === value));
|
const organizer = store.value.find(item => item?.id?.toString() === value);
|
||||||
field.organizers = organizers.filter((organizer) => organizer !== undefined) as OrganizerBase[];
|
if (!organizer) {
|
||||||
|
console.error(`Could not find organizer with id ${value}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return organizer;
|
||||||
|
});
|
||||||
|
field.organizers = organizers.filter(organizer => organizer !== undefined) as OrganizerBase[];
|
||||||
setOrganizerValues(field, index, field.organizers);
|
setOrganizerValues(field, index, field.organizers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,22 +487,27 @@ export default defineComponent({
|
|||||||
return initFieldsError();
|
return initFieldsError();
|
||||||
};
|
};
|
||||||
|
|
||||||
const initFields: Field[] = [];
|
const initFields: FieldWithId[] = [];
|
||||||
let error = false;
|
let error = false;
|
||||||
props.initialQueryFilter.parts.forEach((part: QueryFilterJSONPart, index: number) => {
|
props.initialQueryFilter.parts.forEach((part: QueryFilterJSONPart, index: number) => {
|
||||||
const fieldDef = props.fieldDefs.find((fieldDef) => fieldDef.name === part.attributeName);
|
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.name === part.attributeName);
|
||||||
if (!fieldDef) {
|
if (!fieldDef) {
|
||||||
error = true;
|
error = true;
|
||||||
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
|
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const field = getFieldFromFieldDef(fieldDef);
|
const field: FieldWithId = {
|
||||||
|
...getFieldFromFieldDef(fieldDef),
|
||||||
|
id: useUid(),
|
||||||
|
};
|
||||||
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
|
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
|
||||||
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
|
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
|
||||||
field.logicalOperator = part.logicalOperator ?
|
field.logicalOperator = part.logicalOperator
|
||||||
logOps.value[part.logicalOperator] : field.logicalOperator;
|
? logOps.value[part.logicalOperator]
|
||||||
field.relationalOperatorValue = part.relationalOperator ?
|
: field.logicalOperator;
|
||||||
relOps.value[part.relationalOperator] : field.relationalOperatorValue;
|
field.relationalOperatorValue = part.relationalOperator
|
||||||
|
? relOps.value[part.relationalOperator]
|
||||||
|
: field.relationalOperatorValue;
|
||||||
|
|
||||||
if (field.leftParenthesis || field.rightParenthesis) {
|
if (field.leftParenthesis || field.rightParenthesis) {
|
||||||
state.showAdvanced = true;
|
state.showAdvanced = true;
|
||||||
@@ -496,35 +516,39 @@ export default defineComponent({
|
|||||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||||
if (typeof part.value === "string") {
|
if (typeof part.value === "string") {
|
||||||
field.values = part.value ? [part.value] : [];
|
field.values = part.value ? [part.value] : [];
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
field.values = part.value || [];
|
field.values = part.value || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOrganizerType(field.type)) {
|
if (isOrganizerType(field.type)) {
|
||||||
hydrateOrganizers(field, index);
|
hydrateOrganizers(field, index);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (field.type === "boolean") {
|
else if (field.type === "boolean") {
|
||||||
const boolString = part.value || "false";
|
const boolString = part.value || "false";
|
||||||
field.value = (
|
field.value = (
|
||||||
boolString[0].toLowerCase() === "t" ||
|
boolString[0].toLowerCase() === "t"
|
||||||
boolString[0].toLowerCase() === "y" ||
|
|| boolString[0].toLowerCase() === "y"
|
||||||
boolString[0] === "1"
|
|| boolString[0] === "1"
|
||||||
);
|
);
|
||||||
} else if (field.type === "number") {
|
}
|
||||||
|
else if (field.type === "number") {
|
||||||
field.value = Number(part.value as string || "0");
|
field.value = Number(part.value as string || "0");
|
||||||
if (isNaN(field.value)) {
|
if (isNaN(field.value)) {
|
||||||
error = true;
|
error = true;
|
||||||
return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`);
|
return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`);
|
||||||
}
|
}
|
||||||
} else if (field.type === "date") {
|
}
|
||||||
|
else if (field.type === "date") {
|
||||||
field.value = part.value as string || "";
|
field.value = part.value as string || "";
|
||||||
const date = new Date(field.value);
|
const date = new Date(field.value);
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(date.getTime())) {
|
||||||
error = true;
|
error = true;
|
||||||
return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`);
|
return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`);
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
field.value = part.value as string || "";
|
field.value = part.value as string || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,14 +557,16 @@ export default defineComponent({
|
|||||||
|
|
||||||
if (initFields.length && !error) {
|
if (initFields.length && !error) {
|
||||||
fields.value = initFields;
|
fields.value = initFields;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
initFieldsError();
|
initFieldsError();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
initializeFields();
|
initializeFields();
|
||||||
} catch (error) {
|
}
|
||||||
|
catch (error) {
|
||||||
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
|
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,10 +581,12 @@ export default defineComponent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||||
part.value = field.values.map((value) => value.toString());
|
part.value = field.values.map(value => value.toString());
|
||||||
} else if (field.type === "boolean") {
|
}
|
||||||
|
else if (field.type === "boolean") {
|
||||||
part.value = field.value ? "true" : "false";
|
part.value = field.value ? "true" : "false";
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
part.value = (field.value || "").toString();
|
part.value = (field.value || "").toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,17 +598,16 @@ export default defineComponent({
|
|||||||
return qfJSON;
|
return qfJSON;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const config = computed(() => {
|
||||||
const attrs = computed(() => {
|
|
||||||
const baseColMaxWidth = 55;
|
const baseColMaxWidth = 55;
|
||||||
const attrs = {
|
return {
|
||||||
col: {
|
col: {
|
||||||
class: "d-flex justify-center align-end field-col pa-1",
|
class: "d-flex justify-center align-end field-col pa-1",
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
textClass: "d-flex justify-center text-center",
|
textClass: "d-flex justify-center text-center",
|
||||||
},
|
},
|
||||||
fields: {
|
items: {
|
||||||
icon: {
|
icon: {
|
||||||
cols: 1,
|
cols: 1,
|
||||||
style: "width: fit-content;",
|
style: "width: fit-content;",
|
||||||
@@ -614,17 +641,15 @@ export default defineComponent({
|
|||||||
style: `min-width: ${baseColMaxWidth}px;`,
|
style: `min-width: ${baseColMaxWidth}px;`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
});
|
||||||
return attrs;
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Organizer,
|
Organizer,
|
||||||
...toRefs(state),
|
...toRefs(state),
|
||||||
logOps,
|
logOps,
|
||||||
relOps,
|
relOps,
|
||||||
attrs,
|
config,
|
||||||
firstDayOfWeek,
|
firstDayOfWeek,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
// Fields
|
// Fields
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-toolbar
|
<v-toolbar
|
||||||
rounded
|
|
||||||
height="0"
|
|
||||||
class="fixed-bar mt-0"
|
class="fixed-bar mt-0"
|
||||||
color="rgb(255, 0, 0, 0.0)"
|
style="z-index: 2; position: sticky; background: transparent; box-shadow: none;"
|
||||||
flat
|
density="compact"
|
||||||
style="z-index: 2; position: sticky"
|
elevation="0"
|
||||||
>
|
|
||||||
<BaseDialog
|
|
||||||
v-model="deleteDialog"
|
|
||||||
:title="$tc('recipe.delete-recipe')"
|
|
||||||
color="error"
|
|
||||||
:icon="$globals.icons.alertCircle"
|
|
||||||
@confirm="emitDelete()"
|
|
||||||
>
|
>
|
||||||
|
<BaseDialog v-model="deleteDialog" :title="$t('recipe.delete-recipe')" color="error"
|
||||||
|
:icon="$globals.icons.alertCircle" can-confirm @confirm="emitDelete()">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
{{ $t("recipe.delete-confirmation") }}
|
{{ $t("recipe.delete-confirmation") }}
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer />
|
||||||
<div v-if="!open" class="custom-btn-group ma-1">
|
<div v-if="!open" class="custom-btn-group ma-1">
|
||||||
<RecipeFavoriteBadge v-if="loggedIn" class="ml-1" color="info" button-style :recipe-id="recipe.id" show-always />
|
<RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always />
|
||||||
<RecipeTimelineBadge v-if="loggedIn" button-style class="ml-1" :slug="recipe.slug" :recipe-name="recipe.name" />
|
<RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" />
|
||||||
<div v-if="loggedIn">
|
<div v-if="loggedIn">
|
||||||
<v-tooltip v-if="canEdit" bottom color="info">
|
<v-tooltip v-if="canEdit" bottom color="info">
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ props }">
|
||||||
<v-btn fab small class="ml-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
|
<v-btn
|
||||||
<v-icon> {{ $globals.icons.edit }} </v-icon>
|
icon
|
||||||
|
variant="flat"
|
||||||
|
rounded="circle"
|
||||||
|
size="small"
|
||||||
|
color="info"
|
||||||
|
class="ml-1"
|
||||||
|
v-bind="props"
|
||||||
|
@click="$emit('edit', true)"
|
||||||
|
>
|
||||||
|
<v-icon size="x-large">
|
||||||
|
{{ $globals.icons.edit }}
|
||||||
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<span>{{ $t("general.edit") }}</span>
|
<span>{{ $t("general.edit") }}</span>
|
||||||
@@ -37,14 +41,14 @@
|
|||||||
<RecipeContextMenu
|
<RecipeContextMenu
|
||||||
show-print
|
show-print
|
||||||
:menu-top="false"
|
:menu-top="false"
|
||||||
:name="recipe.name"
|
:name="recipe.name!"
|
||||||
:slug="recipe.slug"
|
:slug="recipe.slug!"
|
||||||
:menu-icon="$globals.icons.dotsVertical"
|
:menu-icon="$globals.icons.dotsVertical"
|
||||||
fab
|
fab
|
||||||
color="info"
|
color="info"
|
||||||
:card-menu="false"
|
:card-menu="false"
|
||||||
:recipe="recipe"
|
:recipe="recipe"
|
||||||
:recipe-id="recipe.id"
|
:recipe-id="recipe.id!"
|
||||||
:recipe-scale="recipeScale"
|
:recipe-scale="recipeScale"
|
||||||
:use-items="{
|
:use-items="{
|
||||||
edit: false,
|
edit: false,
|
||||||
@@ -66,31 +70,34 @@
|
|||||||
<v-btn
|
<v-btn
|
||||||
v-for="(btn, index) in editorButtons"
|
v-for="(btn, index) in editorButtons"
|
||||||
:key="index"
|
:key="index"
|
||||||
:fab="$vuetify.breakpoint.xs"
|
:class="{ 'rounded-circle': $vuetify.display.xs }"
|
||||||
:small="$vuetify.breakpoint.xs"
|
:size="$vuetify.display.xs ? 'small' : undefined"
|
||||||
:color="btn.color"
|
:color="btn.color"
|
||||||
|
variant="elevated"
|
||||||
|
:icon="$vuetify.display.xs"
|
||||||
@click="emitHandler(btn.event)"
|
@click="emitHandler(btn.event)"
|
||||||
>
|
>
|
||||||
<v-icon :left="!$vuetify.breakpoint.xs">{{ btn.icon }}</v-icon>
|
<v-icon :left="!$vuetify.display.xs">
|
||||||
{{ $vuetify.breakpoint.xs ? "" : btn.text }}
|
{{ btn.icon }}
|
||||||
|
</v-icon>
|
||||||
|
{{ $vuetify.display.xs ? "" : btn.text }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||||
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
|
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
const SAVE_EVENT = "save";
|
const SAVE_EVENT = "save";
|
||||||
const DELETE_EVENT = "delete";
|
const DELETE_EVENT = "delete";
|
||||||
const CLOSE_EVENT = "close";
|
const CLOSE_EVENT = "close";
|
||||||
const JSON_EVENT = "json";
|
const JSON_EVENT = "json";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
|
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
|
||||||
props: {
|
props: {
|
||||||
recipe: {
|
recipe: {
|
||||||
@@ -126,10 +133,12 @@ export default defineComponent({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["print", "input", "delete", "close", "edit"],
|
||||||
setup(_, context) {
|
setup(_, context) {
|
||||||
const deleteDialog = ref(false);
|
const deleteDialog = ref(false);
|
||||||
|
|
||||||
const { i18n, $globals } = useContext();
|
const i18n = useI18n();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
const editorButtons = [
|
const editorButtons = [
|
||||||
{
|
{
|
||||||
text: i18n.t("general.delete"),
|
text: i18n.t("general.delete"),
|
||||||
@@ -209,9 +218,13 @@ export default defineComponent({
|
|||||||
|
|
||||||
.fixed-bar {
|
.fixed-bar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
position: -webkit-sticky; /* for Safari */
|
|
||||||
top: 4.5em;
|
top: 4.5em;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fixed-bar-mobile {
|
.fixed-bar-mobile {
|
||||||
|
|||||||
@@ -1,74 +1,110 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="value.length > 0 || edit">
|
<div v-if="model.length > 0 || edit">
|
||||||
<v-card class="mt-4">
|
<v-card class="mt-4">
|
||||||
<v-card-title class="py-2">
|
<v-card-title class="py-2">
|
||||||
{{ $t("asset.assets") }}
|
{{ $t("asset.assets") }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider class="mx-2"></v-divider>
|
<v-divider class="mx-2" />
|
||||||
<v-list v-if="value.length > 0" :flat="!edit">
|
<v-list
|
||||||
<v-list-item v-for="(item, i) in value" :key="i">
|
v-if="model.length > 0"
|
||||||
<v-list-item-icon class="ma-auto">
|
:flat="!edit"
|
||||||
|
>
|
||||||
|
<v-list-item
|
||||||
|
v-for="(item, i) in model"
|
||||||
|
:key="i"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<div class="ma-auto">
|
||||||
<v-tooltip bottom>
|
<v-tooltip bottom>
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ props: tooltipProps }">
|
||||||
<v-icon v-bind="attrs" v-on="on">
|
<v-icon v-bind="tooltipProps">
|
||||||
{{ getIconDefinition(item.icon).icon }}
|
{{ getIconDefinition(item.icon).icon }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</template>
|
</template>
|
||||||
<span>{{ getIconDefinition(item.icon).title }}</span>
|
<span>{{ getIconDefinition(item.icon).title }}</span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-list-item-icon>
|
</div>
|
||||||
<v-list-item-content>
|
</template>
|
||||||
<v-list-item-title class="pl-2">
|
<v-list-item-title class="pl-2">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
</v-list-item-content>
|
|
||||||
<v-list-item-action>
|
<v-list-item-action>
|
||||||
<v-btn v-if="!edit" color="primary" icon :href="assetURL(item.fileName)" target="_blank" top>
|
<v-btn
|
||||||
|
v-if="!edit"
|
||||||
|
color="primary"
|
||||||
|
icon
|
||||||
|
:href="assetURL(item.fileName ?? '')"
|
||||||
|
target="_blank"
|
||||||
|
top
|
||||||
|
>
|
||||||
<v-icon> {{ $globals.icons.download }} </v-icon>
|
<v-icon> {{ $globals.icons.download }} </v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<v-btn color="error" icon top @click="value.splice(i, 1)">
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
icon
|
||||||
|
top
|
||||||
|
@click="model.splice(i, 1)"
|
||||||
|
>
|
||||||
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<AppButtonCopy color="" :copy-text="assetEmbed(item.fileName)" />
|
<AppButtonCopy
|
||||||
|
color=""
|
||||||
|
:copy-text="assetEmbed(item.fileName ?? '')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</v-list-item-action>
|
</v-list-item-action>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-card>
|
</v-card>
|
||||||
<div class="d-flex ml-auto mt-2">
|
<div class="d-flex ml-auto mt-2">
|
||||||
<v-spacer></v-spacer>
|
<v-spacer />
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-model="state.newAssetDialog"
|
v-model="state.newAssetDialog"
|
||||||
:title="$tc('asset.new-asset')"
|
:title="$t('asset.new-asset')"
|
||||||
:icon="getIconDefinition(state.newAsset.icon).icon"
|
:icon="getIconDefinition(state.newAsset.icon).icon"
|
||||||
|
can-submit
|
||||||
@submit="addAsset"
|
@submit="addAsset"
|
||||||
>
|
>
|
||||||
<template #activator>
|
<template #activator>
|
||||||
<BaseButton v-if="edit" small create @click="state.newAssetDialog = true" />
|
<BaseButton
|
||||||
|
v-if="edit"
|
||||||
|
size="small"
|
||||||
|
create
|
||||||
|
@click="state.newAssetDialog = true"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<v-card-text class="pt-4">
|
<v-card-text class="pt-4">
|
||||||
<v-text-field v-model="state.newAsset.name" dense :label="$t('general.name')"></v-text-field>
|
<v-text-field
|
||||||
|
v-model="state.newAsset.name"
|
||||||
|
density="compact"
|
||||||
|
:label="$t('general.name')"
|
||||||
|
/>
|
||||||
<div class="d-flex justify-space-between">
|
<div class="d-flex justify-space-between">
|
||||||
<v-select
|
<v-select
|
||||||
v-model="state.newAsset.icon"
|
v-model="state.newAsset.icon"
|
||||||
dense
|
density="compact"
|
||||||
:prepend-icon="getIconDefinition(state.newAsset.icon).icon"
|
:prepend-icon="getIconDefinition(state.newAsset.icon).icon"
|
||||||
:items="iconOptions"
|
:items="iconOptions"
|
||||||
item-text="title"
|
item-title="title"
|
||||||
item-value="name"
|
item-value="name"
|
||||||
class="mr-2"
|
class="mr-2"
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<v-list-item-avatar>
|
<v-avatar>
|
||||||
<v-icon class="mr-auto">
|
<v-icon class="mr-auto">
|
||||||
{{ item.icon }}
|
{{ item.raw.icon }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-list-item-avatar>
|
</v-avatar>
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
<AppButtonUpload :post="false" file-name="file" :text-btn="false" @uploaded="setFileObject" />
|
<AppButtonUpload
|
||||||
|
:post="false"
|
||||||
|
file-name="file"
|
||||||
|
:text-btn="false"
|
||||||
|
@uploaded="setFileObject"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{{ state.fileObject.name }}
|
{{ state.fileObject.name }}
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
@@ -77,15 +113,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent, reactive, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
import { detectServerBaseUrl } from "~/composables/use-utils";
|
import type { RecipeAsset } from "~/lib/api/types/recipe";
|
||||||
import { RecipeAsset } from "~/lib/api/types/recipe";
|
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps({
|
||||||
props: {
|
|
||||||
slug: {
|
slug: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -94,16 +127,14 @@ export default defineComponent({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
value: {
|
|
||||||
type: Array as () => RecipeAsset[],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
edit: {
|
edit: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
setup(props, context) {
|
|
||||||
|
const model = defineModel<RecipeAsset[]>({ required: true });
|
||||||
|
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
@@ -115,7 +146,8 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { $globals, i18n, req } = useContext();
|
const i18n = useI18n();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
|
||||||
const iconOptions = [
|
const iconOptions = [
|
||||||
{
|
{
|
||||||
@@ -145,10 +177,10 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const serverBase = detectServerBaseUrl(req);
|
const serverBase = useRequestURL().origin;
|
||||||
|
|
||||||
function getIconDefinition(icon: string) {
|
function getIconDefinition(icon: string) {
|
||||||
return iconOptions.find((item) => item.name === icon) || iconOptions[0];
|
return iconOptions.find(item => item.name === icon) || iconOptions[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { recipeAssetPath } = useStaticRoutes();
|
const { recipeAssetPath } = useStaticRoutes();
|
||||||
@@ -180,21 +212,10 @@ export default defineComponent({
|
|||||||
file: state.fileObject,
|
file: state.fileObject,
|
||||||
extension: state.fileObject.name.split(".").pop() || "",
|
extension: state.fileObject.name.split(".").pop() || "",
|
||||||
});
|
});
|
||||||
|
if (data) {
|
||||||
context.emit("input", [...props.value, data]);
|
model.value = [...model.value, data];
|
||||||
|
}
|
||||||
state.newAsset = { name: "", icon: "mdi-file" };
|
state.newAsset = { name: "", icon: "mdi-file" };
|
||||||
state.fileObject = {} as File;
|
state.fileObject = {} as File;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
addAsset,
|
|
||||||
assetURL,
|
|
||||||
assetEmbed,
|
|
||||||
getIconDefinition,
|
|
||||||
iconOptions,
|
|
||||||
setFileObject,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
|
||||||
<v-lazy>
|
<v-lazy>
|
||||||
<v-hover v-slot="{ hover }" :open-delay="50">
|
<div>
|
||||||
|
<v-hover
|
||||||
|
v-slot="{ isHovering, props }"
|
||||||
|
:open-delay="50"
|
||||||
|
>
|
||||||
<v-card
|
<v-card
|
||||||
:class="{ 'on-hover': hover }"
|
v-bind="props"
|
||||||
|
:class="{ 'on-hover': isHovering }"
|
||||||
:style="{ cursor }"
|
:style="{ cursor }"
|
||||||
:elevation="hover ? 12 : 2"
|
:elevation="isHovering ? 12 : 2"
|
||||||
:to="recipeRoute"
|
:to="recipeRoute"
|
||||||
:min-height="imageHeight + 75"
|
:min-height="imageHeight + 75"
|
||||||
@click.self="$emit('click')"
|
@click.self="$emit('click')"
|
||||||
@@ -14,11 +20,15 @@
|
|||||||
:height="imageHeight"
|
:height="imageHeight"
|
||||||
:slug="slug"
|
:slug="slug"
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
small
|
size="small"
|
||||||
:image-version="image"
|
:image-version="image"
|
||||||
>
|
>
|
||||||
<v-expand-transition v-if="description">
|
<v-expand-transition v-if="description">
|
||||||
<div v-if="hover" class="d-flex transition-fast-in-fast-out secondary v-card--reveal" style="height: 100%">
|
<div
|
||||||
|
v-if="isHovering"
|
||||||
|
class="d-flex transition-fast-in-fast-out bg-secondary v-card--reveal"
|
||||||
|
style="height: 100%"
|
||||||
|
>
|
||||||
<v-card-text class="v-card--text-show white--text">
|
<v-card-text class="v-card--text-show white--text">
|
||||||
<div class="descriptionWrapper">
|
<div class="descriptionWrapper">
|
||||||
<SafeMarkdown :source="description" />
|
<SafeMarkdown :source="description" />
|
||||||
@@ -27,24 +37,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</v-expand-transition>
|
</v-expand-transition>
|
||||||
</RecipeCardImage>
|
</RecipeCardImage>
|
||||||
<v-card-title class="my-n3 px-2 mb-n6">
|
<v-card-title class="mb-n3 px-4">
|
||||||
<div class="headerClass">
|
<div class="headerClass">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</div>
|
</div>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<slot name="actions">
|
<slot name="actions">
|
||||||
<v-card-actions v-if="showRecipeContent" class="px-1">
|
<v-card-actions
|
||||||
<RecipeFavoriteBadge v-if="isOwnGroup" class="absolute" :recipe-id="recipeId" show-always />
|
v-if="showRecipeContent"
|
||||||
|
class="px-1"
|
||||||
|
>
|
||||||
|
<RecipeFavoriteBadge
|
||||||
|
v-if="isOwnGroup"
|
||||||
|
class="absolute"
|
||||||
|
:recipe-id="recipeId"
|
||||||
|
show-always
|
||||||
|
/>
|
||||||
|
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
|
||||||
|
|
||||||
<RecipeRating class="pb-1" :value="rating" :recipe-id="recipeId" :slug="slug" :small="true" />
|
<RecipeRating
|
||||||
<v-spacer></v-spacer>
|
class="ml-n2"
|
||||||
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" v-on="$listeners" />
|
:value="rating"
|
||||||
|
:recipe-id="recipeId"
|
||||||
|
:slug="slug"
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
<v-spacer />
|
||||||
|
<RecipeChips
|
||||||
|
:truncate="true"
|
||||||
|
:items="tags"
|
||||||
|
:title="false"
|
||||||
|
:limit="2"
|
||||||
|
small
|
||||||
|
url-prefix="tags"
|
||||||
|
v-bind="$attrs"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||||
<RecipeContextMenu
|
<RecipeContextMenu
|
||||||
v-if="isOwnGroup"
|
v-if="isOwnGroup"
|
||||||
color="grey darken-2"
|
color="grey-darken-2"
|
||||||
:slug="slug"
|
:slug="slug"
|
||||||
:name="name"
|
:name="name"
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
@@ -62,14 +95,14 @@
|
|||||||
/>
|
/>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</slot>
|
</slot>
|
||||||
<slot></slot>
|
<slot />
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-hover>
|
</v-hover>
|
||||||
|
</div>
|
||||||
</v-lazy>
|
</v-lazy>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
|
||||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||||
import RecipeChips from "./RecipeChips.vue";
|
import RecipeChips from "./RecipeChips.vue";
|
||||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||||
@@ -77,7 +110,7 @@ import RecipeCardImage from "./RecipeCardImage.vue";
|
|||||||
import RecipeRating from "./RecipeRating.vue";
|
import RecipeRating from "./RecipeRating.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage },
|
components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage },
|
||||||
props: {
|
props: {
|
||||||
name: {
|
name: {
|
||||||
@@ -119,12 +152,13 @@ export default defineComponent({
|
|||||||
default: 200,
|
default: 200,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["click", "delete"],
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { $auth } = useContext();
|
const $auth = useMealieAuth();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||||
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||||
const recipeRoute = computed<string>(() => {
|
const recipeRoute = computed<string>(() => {
|
||||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<v-img
|
<v-img
|
||||||
v-if="!fallBackImage"
|
v-if="!fallBackImage"
|
||||||
:height="height"
|
:height="height"
|
||||||
|
cover
|
||||||
min-height="125"
|
min-height="125"
|
||||||
max-height="fill-height"
|
max-height="fill-height"
|
||||||
:src="getImage(recipeId)"
|
:src="getImage(recipeId)"
|
||||||
@@ -9,21 +10,28 @@
|
|||||||
@load="fallBackImage = false"
|
@load="fallBackImage = false"
|
||||||
@error="fallBackImage = true"
|
@error="fallBackImage = true"
|
||||||
>
|
>
|
||||||
<slot> </slot>
|
<slot />
|
||||||
</v-img>
|
</v-img>
|
||||||
<div v-else class="icon-slot" @click="$emit('click')">
|
<div
|
||||||
<v-icon color="primary" class="icon-position" :size="iconSize">
|
v-else
|
||||||
|
class="icon-slot"
|
||||||
|
@click="$emit('click')"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
color="primary"
|
||||||
|
class="icon-position"
|
||||||
|
:size="iconSize"
|
||||||
|
>
|
||||||
{{ $globals.icons.primary }}
|
{{ $globals.icons.primary }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<slot> </slot>
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, ref, watch } from "@nuxtjs/composition-api";
|
|
||||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
tiny: {
|
tiny: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -55,9 +63,10 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
default: "fill-height",
|
default: "100%",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["click"],
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
|
|
||||||
@@ -75,7 +84,7 @@ export default defineComponent({
|
|||||||
() => props.recipeId,
|
() => props.recipeId,
|
||||||
() => {
|
() => {
|
||||||
fallBackImage.value = false;
|
fallBackImage.value = false;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function getImage(recipeId: string) {
|
function getImage(recipeId: string) {
|
||||||
|
|||||||
@@ -1,57 +1,97 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :style="`height: ${height}`">
|
<div :style="`height: ${height}px;`">
|
||||||
<v-expand-transition>
|
<v-expand-transition>
|
||||||
<v-card
|
<v-card
|
||||||
:ripple="false"
|
:ripple="false"
|
||||||
:class="isFlat ? 'mx-auto flat' : 'mx-auto'"
|
:class="isFlat ? 'mx-auto flat' : 'mx-auto'"
|
||||||
:style="{ cursor }"
|
:style="{ cursor }"
|
||||||
hover
|
hover
|
||||||
:to="$listeners.selected ? undefined : recipeRoute"
|
height="100%"
|
||||||
|
:to="$attrs.selected ? undefined : recipeRoute"
|
||||||
@click="$emit('selected')"
|
@click="$emit('selected')"
|
||||||
>
|
>
|
||||||
<v-img v-if="vertical" class="rounded-sm">
|
<v-img
|
||||||
|
v-if="vertical"
|
||||||
|
class="rounded-sm"
|
||||||
|
cover
|
||||||
|
>
|
||||||
<RecipeCardImage
|
<RecipeCardImage
|
||||||
:icon-size="100"
|
:icon-size="100"
|
||||||
:height="height"
|
|
||||||
:slug="slug"
|
:slug="slug"
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
small
|
size="small"
|
||||||
:image-version="image"
|
:image-version="image"
|
||||||
|
:height="height"
|
||||||
/>
|
/>
|
||||||
</v-img>
|
</v-img>
|
||||||
<v-list-item three-line :class="vertical ? 'px-2' : 'px-0'">
|
<v-list-item
|
||||||
<slot v-if="!vertical" name="avatar">
|
lines="two"
|
||||||
<v-list-item-avatar tile :height="height" width="125" class="v-mobile-img rounded-sm my-0">
|
class="py-0"
|
||||||
|
:class="vertical ? 'px-2' : 'px-0'"
|
||||||
|
item-props
|
||||||
|
height="100%"
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<slot
|
||||||
|
v-if="!vertical"
|
||||||
|
name="avatar"
|
||||||
|
>
|
||||||
<RecipeCardImage
|
<RecipeCardImage
|
||||||
:icon-size="100"
|
:icon-size="100"
|
||||||
:height="height"
|
|
||||||
:slug="slug"
|
:slug="slug"
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
:image-version="image"
|
:image-version="image"
|
||||||
small
|
size="small"
|
||||||
|
width="125"
|
||||||
|
:height="height"
|
||||||
/>
|
/>
|
||||||
</v-list-item-avatar>
|
|
||||||
</slot>
|
</slot>
|
||||||
<v-list-item-content class="py-0">
|
</template>
|
||||||
<v-list-item-title class="mt-1 mb-1 text-top">{{ name }}</v-list-item-title>
|
<div class="pl-4 d-flex flex-column justify-space-between align-stretch pr-2">
|
||||||
|
<v-list-item-title class="mt-3 mb-1 text-top text-truncate w-100">
|
||||||
|
{{ name }}
|
||||||
|
</v-list-item-title>
|
||||||
<v-list-item-subtitle class="ma-0 text-top">
|
<v-list-item-subtitle class="ma-0 text-top">
|
||||||
<SafeMarkdown :source="description" />
|
<SafeMarkdown v-if="description" :source="description" />
|
||||||
|
<p v-else>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
</p>
|
||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
<div class="d-flex flex-wrap justify-start ma-0">
|
<div
|
||||||
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" v-on="$listeners" />
|
class="d-flex flex-nowrap justify-start ma-0 pt-2 pb-0"
|
||||||
|
style="overflow-x: hidden; overflow-y: hidden; white-space: nowrap;"
|
||||||
|
>
|
||||||
|
<RecipeChips
|
||||||
|
:truncate="true"
|
||||||
|
:items="tags"
|
||||||
|
:title="false"
|
||||||
|
:limit="2"
|
||||||
|
small
|
||||||
|
url-prefix="tags"
|
||||||
|
v-bind="$attrs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap justify-end align-center">
|
|
||||||
<slot name="actions">
|
<slot name="actions">
|
||||||
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :recipe-id="recipeId" show-always />
|
<v-card-actions class="w-100 my-0 px-1 py-0">
|
||||||
|
<RecipeFavoriteBadge
|
||||||
|
v-if="isOwnGroup && showRecipeContent"
|
||||||
|
:recipe-id="recipeId"
|
||||||
|
show-always
|
||||||
|
class="ma-0 pa-0"
|
||||||
|
/>
|
||||||
|
<div v-else class="my-0 px-1 py-0" /> <!-- Empty div to keep the layout consistent -->
|
||||||
<RecipeRating
|
<RecipeRating
|
||||||
v-if="showRecipeContent"
|
v-if="showRecipeContent"
|
||||||
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'"
|
:class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']"
|
||||||
:value="rating"
|
:value="rating"
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
:slug="slug"
|
:slug="slug"
|
||||||
:small="true"
|
small
|
||||||
/>
|
/>
|
||||||
<v-spacer></v-spacer>
|
|
||||||
|
|
||||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||||
<!-- We also add padding to the v-rating above to compensate -->
|
<!-- We also add padding to the v-rating above to compensate -->
|
||||||
@@ -61,6 +101,7 @@
|
|||||||
:menu-icon="$globals.icons.dotsHorizontal"
|
:menu-icon="$globals.icons.dotsHorizontal"
|
||||||
:name="name"
|
:name="name"
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
|
class="ml-auto"
|
||||||
:use-items="{
|
:use-items="{
|
||||||
delete: false,
|
delete: false,
|
||||||
edit: false,
|
edit: false,
|
||||||
@@ -73,9 +114,8 @@
|
|||||||
}"
|
}"
|
||||||
@deleted="$emit('delete', slug)"
|
@deleted="$emit('delete', slug)"
|
||||||
/>
|
/>
|
||||||
|
</v-card-actions>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<slot />
|
<slot />
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -84,7 +124,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
|
||||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||||
import RecipeCardImage from "./RecipeCardImage.vue";
|
import RecipeCardImage from "./RecipeCardImage.vue";
|
||||||
@@ -92,7 +131,7 @@ import RecipeRating from "./RecipeRating.vue";
|
|||||||
import RecipeChips from "./RecipeChips.vue";
|
import RecipeChips from "./RecipeChips.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeFavoriteBadge,
|
RecipeFavoriteBadge,
|
||||||
RecipeContextMenu,
|
RecipeContextMenu,
|
||||||
@@ -139,27 +178,23 @@ export default defineComponent({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
type: [Number, String],
|
type: [Number],
|
||||||
default: 150,
|
default: 150,
|
||||||
},
|
},
|
||||||
imageHeight: {
|
|
||||||
type: [Number, String],
|
|
||||||
default: "fill-height",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
emits: ["selected", "delete"],
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { $auth } = useContext();
|
const $auth = useMealieAuth();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||||
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||||
const recipeRoute = computed<string>(() => {
|
const recipeRoute = computed<string>(() => {
|
||||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||||
});
|
});
|
||||||
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
|
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOwnGroup,
|
isOwnGroup,
|
||||||
recipeRoute,
|
recipeRoute,
|
||||||
@@ -170,7 +205,10 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
|
:deep(.v-list-item__prepend) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
.v-mobile-img {
|
.v-mobile-img {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
@@ -198,7 +236,8 @@ export default defineComponent({
|
|||||||
align-self: start !important;
|
align-self: start !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flat, .theme--dark .flat {
|
.flat,
|
||||||
|
.theme--dark .flat {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,102 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-app-bar v-if="!disableToolbar" color="transparent" flat class="mt-n1 flex-sm-wrap rounded">
|
<v-app-bar
|
||||||
|
v-if="!disableToolbar"
|
||||||
|
color="transparent"
|
||||||
|
:absolute="false"
|
||||||
|
flat
|
||||||
|
class="mt-n1 flex-sm-wrap rounded position-relative w-100 left-0 top-0"
|
||||||
|
>
|
||||||
<slot name="title">
|
<slot name="title">
|
||||||
<v-icon v-if="title" large left>
|
<v-icon
|
||||||
|
v-if="title"
|
||||||
|
size="large"
|
||||||
|
start
|
||||||
|
>
|
||||||
{{ displayTitleIcon }}
|
{{ displayTitleIcon }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
|
<v-toolbar-title class="headline">
|
||||||
|
{{ title }}
|
||||||
|
</v-toolbar-title>
|
||||||
</slot>
|
</slot>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer />
|
||||||
<v-btn :icon="$vuetify.breakpoint.xsOnly" text :disabled="recipes.length === 0" @click="navigateRandom">
|
<v-btn
|
||||||
<v-icon :left="!$vuetify.breakpoint.xsOnly">
|
:icon="$vuetify.display.xs"
|
||||||
|
variant="text"
|
||||||
|
:disabled="recipes.length === 0"
|
||||||
|
@click="navigateRandom"
|
||||||
|
>
|
||||||
|
<v-icon :start="!$vuetify.display.xs">
|
||||||
{{ $globals.icons.diceMultiple }}
|
{{ $globals.icons.diceMultiple }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.random") }}
|
{{ $vuetify.display.xs ? null : $t("general.random") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
<v-menu
|
||||||
<v-menu v-if="$listeners.sortRecipes" offset-y left>
|
v-if="!disableSort"
|
||||||
<template #activator="{ on, attrs }">
|
offset-y
|
||||||
<v-btn text :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" :loading="sortLoading" v-on="on">
|
start
|
||||||
<v-icon :left="!$vuetify.breakpoint.xsOnly">
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
:icon="$vuetify.display.xs"
|
||||||
|
v-bind="props"
|
||||||
|
:loading="sortLoading"
|
||||||
|
>
|
||||||
|
<v-icon :start="!$vuetify.display.xs">
|
||||||
{{ preferences.sortIcon }}
|
{{ preferences.sortIcon }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.sort") }}
|
{{ $vuetify.display.xs ? null : $t("general.sort") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<v-list>
|
<v-list>
|
||||||
<v-list-item @click="sortRecipes(EVENTS.az)">
|
<v-list-item @click="sortRecipes(EVENTS.az)">
|
||||||
<v-icon left>
|
<div class="d-flex align-center flex-nowrap">
|
||||||
|
<v-icon class="mr-2" inline>
|
||||||
{{ $globals.icons.orderAlphabeticalAscending }}
|
{{ $globals.icons.orderAlphabeticalAscending }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
|
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
|
||||||
|
</div>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item @click="sortRecipes(EVENTS.rating)">
|
<v-list-item @click="sortRecipes(EVENTS.rating)">
|
||||||
<v-icon left>
|
<div class="d-flex align-center flex-nowrap">
|
||||||
|
<v-icon class="mr-2" inline>
|
||||||
{{ $globals.icons.star }}
|
{{ $globals.icons.star }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title>
|
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title>
|
||||||
|
</div>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item @click="sortRecipes(EVENTS.created)">
|
<v-list-item @click="sortRecipes(EVENTS.created)">
|
||||||
<v-icon left>
|
<div class="d-flex align-center flex-nowrap">
|
||||||
|
<v-icon class="mr-2" inline>
|
||||||
{{ $globals.icons.newBox }}
|
{{ $globals.icons.newBox }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title>
|
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title>
|
||||||
|
</div>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item @click="sortRecipes(EVENTS.updated)">
|
<v-list-item @click="sortRecipes(EVENTS.updated)">
|
||||||
<v-icon left>
|
<div class="d-flex align-center flex-nowrap">
|
||||||
|
<v-icon class="mr-2" inline>
|
||||||
{{ $globals.icons.update }}
|
{{ $globals.icons.update }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
|
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
|
||||||
|
</div>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item @click="sortRecipes(EVENTS.lastMade)">
|
<v-list-item @click="sortRecipes(EVENTS.lastMade)">
|
||||||
<v-icon left>
|
<div class="d-flex align-center flex-nowrap">
|
||||||
|
<v-icon class="mr-2" inline>
|
||||||
{{ $globals.icons.chefHat }}
|
{{ $globals.icons.chefHat }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
|
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
|
||||||
|
</div>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
v-if="!$vuetify.breakpoint.smAndDown"
|
v-if="!$vuetify.display.smAndDown"
|
||||||
:items="[
|
:items="[
|
||||||
{
|
{
|
||||||
title: $tc('general.toggle-view'),
|
title: $t('general.toggle-view'),
|
||||||
icon: $globals.icons.eye,
|
icon: $globals.icons.eye,
|
||||||
event: 'toggle-dense-view',
|
event: 'toggle-dense-view',
|
||||||
},
|
},
|
||||||
@@ -72,84 +107,75 @@
|
|||||||
<div v-if="recipes && ready">
|
<div v-if="recipes && ready">
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<v-row v-if="!useMobileCards">
|
<v-row v-if="!useMobileCards">
|
||||||
<v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3">
|
|
||||||
<v-lazy>
|
|
||||||
<RecipeCard
|
|
||||||
:name="recipe.name"
|
|
||||||
:description="recipe.description"
|
|
||||||
:slug="recipe.slug"
|
|
||||||
:rating="recipe.rating"
|
|
||||||
:image="recipe.image"
|
|
||||||
:tags="recipe.tags"
|
|
||||||
:recipe-id="recipe.id"
|
|
||||||
|
|
||||||
v-on="$listeners"
|
|
||||||
/>
|
|
||||||
</v-lazy>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
<v-row v-else dense>
|
|
||||||
<v-col
|
<v-col
|
||||||
v-for="recipe in recipes"
|
v-for="recipe in recipes"
|
||||||
:key="recipe.name"
|
:key="recipe.id!"
|
||||||
|
:sm="6"
|
||||||
|
:md="6"
|
||||||
|
:lg="4"
|
||||||
|
:xl="3"
|
||||||
|
>
|
||||||
|
<RecipeCard
|
||||||
|
:name="recipe.name!"
|
||||||
|
:description="recipe.description!"
|
||||||
|
:slug="recipe.slug!"
|
||||||
|
:rating="recipe.rating!"
|
||||||
|
:image="recipe.image!"
|
||||||
|
:tags="recipe.tags!"
|
||||||
|
:recipe-id="recipe.id!"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row
|
||||||
|
v-else
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
<v-col
|
||||||
|
v-for="recipe in recipes"
|
||||||
|
:key="recipe.id!"
|
||||||
cols="12"
|
cols="12"
|
||||||
:sm="singleColumn ? '12' : '12'"
|
:sm="singleColumn ? '12' : '12'"
|
||||||
:md="singleColumn ? '12' : '6'"
|
:md="singleColumn ? '12' : '6'"
|
||||||
:lg="singleColumn ? '12' : '4'"
|
:lg="singleColumn ? '12' : '4'"
|
||||||
:xl="singleColumn ? '12' : '3'"
|
:xl="singleColumn ? '12' : '3'"
|
||||||
>
|
>
|
||||||
<v-lazy>
|
|
||||||
<RecipeCardMobile
|
<RecipeCardMobile
|
||||||
:name="recipe.name"
|
:name="recipe.name!"
|
||||||
:description="recipe.description"
|
:description="recipe.description!"
|
||||||
:slug="recipe.slug"
|
:slug="recipe.slug!"
|
||||||
:rating="recipe.rating"
|
:rating="recipe.rating!"
|
||||||
:image="recipe.image"
|
:image="recipe.image!"
|
||||||
:tags="recipe.tags"
|
:tags="recipe.tags!"
|
||||||
:recipe-id="recipe.id"
|
:recipe-id="recipe.id!"
|
||||||
|
|
||||||
v-on="$listeners"
|
|
||||||
/>
|
/>
|
||||||
</v-lazy>
|
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
<v-card v-intersect="infiniteScroll"></v-card>
|
<v-card v-intersect="infiniteScroll" />
|
||||||
<v-fade-transition>
|
<v-fade-transition>
|
||||||
<AppLoader v-if="loading" :loading="loading" />
|
<AppLoader
|
||||||
|
v-if="loading"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
</v-fade-transition>
|
</v-fade-transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
|
||||||
computed,
|
|
||||||
defineComponent,
|
|
||||||
onMounted,
|
|
||||||
reactive,
|
|
||||||
ref,
|
|
||||||
toRefs,
|
|
||||||
useAsync,
|
|
||||||
useContext,
|
|
||||||
useRoute,
|
|
||||||
useRouter,
|
|
||||||
watch,
|
|
||||||
} from "@nuxtjs/composition-api";
|
|
||||||
import { useThrottleFn } from "@vueuse/core";
|
import { useThrottleFn } from "@vueuse/core";
|
||||||
import RecipeCard from "./RecipeCard.vue";
|
import RecipeCard from "./RecipeCard.vue";
|
||||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { useAsyncKey } from "~/composables/use-utils";
|
|
||||||
import { useLazyRecipes } from "~/composables/recipes";
|
import { useLazyRecipes } from "~/composables/recipes";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { useUserSortPreferences } from "~/composables/use-users/preferences";
|
import { useUserSortPreferences } from "~/composables/use-users/preferences";
|
||||||
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||||
|
|
||||||
const REPLACE_RECIPES_EVENT = "replaceRecipes";
|
const REPLACE_RECIPES_EVENT = "replaceRecipes";
|
||||||
const APPEND_RECIPES_EVENT = "appendRecipes";
|
const APPEND_RECIPES_EVENT = "appendRecipes";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeCard,
|
RecipeCard,
|
||||||
RecipeCardMobile,
|
RecipeCardMobile,
|
||||||
@@ -159,6 +185,10 @@ export default defineComponent({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
disableSort: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
icon: {
|
icon: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
@@ -181,6 +211,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
|
const { $vuetify } = useNuxtApp();
|
||||||
const preferences = useUserSortPreferences();
|
const preferences = useUserSortPreferences();
|
||||||
|
|
||||||
const EVENTS = {
|
const EVENTS = {
|
||||||
@@ -192,10 +223,11 @@ export default defineComponent({
|
|||||||
shuffle: "shuffle",
|
shuffle: "shuffle",
|
||||||
};
|
};
|
||||||
|
|
||||||
const { $auth, $globals, $vuetify } = useContext();
|
const $auth = useMealieAuth();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
const useMobileCards = computed(() => {
|
const useMobileCards = computed(() => {
|
||||||
return $vuetify.breakpoint.smAndDown || preferences.value.useMobileCards;
|
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayTitleIcon = computed(() => {
|
const displayTitleIcon = computed(() => {
|
||||||
@@ -207,7 +239,7 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const perPage = 32;
|
const perPage = 32;
|
||||||
@@ -259,14 +291,14 @@ export default defineComponent({
|
|||||||
watch(
|
watch(
|
||||||
() => props.query,
|
() => props.query,
|
||||||
async (newValue: RecipeSearchQuery | undefined) => {
|
async (newValue: RecipeSearchQuery | undefined) => {
|
||||||
const newValueString = JSON.stringify(newValue)
|
const newValueString = JSON.stringify(newValue);
|
||||||
if (lastQuery !== newValueString) {
|
if (lastQuery !== newValueString) {
|
||||||
lastQuery = newValueString;
|
lastQuery = newValueString;
|
||||||
ready.value = false;
|
ready.value = false;
|
||||||
await initRecipes();
|
await initRecipes();
|
||||||
ready.value = true;
|
ready.value = true;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
async function initRecipes() {
|
async function initRecipes() {
|
||||||
@@ -286,8 +318,7 @@ export default defineComponent({
|
|||||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||||
}
|
}
|
||||||
|
|
||||||
const infiniteScroll = useThrottleFn(() => {
|
const infiniteScroll = useThrottleFn(async () => {
|
||||||
useAsync(async () => {
|
|
||||||
if (!hasMore.value || loading.value) {
|
if (!hasMore.value || loading.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -304,11 +335,9 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}, useAsyncKey());
|
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
|
async function sortRecipes(sortType: string) {
|
||||||
function sortRecipes(sortType: string) {
|
|
||||||
if (state.sortLoading || loading.value) {
|
if (state.sortLoading || loading.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -318,13 +347,14 @@ export default defineComponent({
|
|||||||
ascIcon: string,
|
ascIcon: string,
|
||||||
descIcon: string,
|
descIcon: string,
|
||||||
defaultOrderDirection = "asc",
|
defaultOrderDirection = "asc",
|
||||||
filterNull = false
|
filterNull = false,
|
||||||
) {
|
) {
|
||||||
if (preferences.value.orderBy !== orderBy) {
|
if (preferences.value.orderBy !== orderBy) {
|
||||||
preferences.value.orderBy = orderBy;
|
preferences.value.orderBy = orderBy;
|
||||||
preferences.value.orderDirection = defaultOrderDirection;
|
preferences.value.orderDirection = defaultOrderDirection;
|
||||||
preferences.value.filterNull = filterNull;
|
preferences.value.filterNull = filterNull;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||||
}
|
}
|
||||||
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
|
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
|
||||||
@@ -337,7 +367,7 @@ export default defineComponent({
|
|||||||
$globals.icons.sortAlphabeticalAscending,
|
$globals.icons.sortAlphabeticalAscending,
|
||||||
$globals.icons.sortAlphabeticalDescending,
|
$globals.icons.sortAlphabeticalDescending,
|
||||||
"asc",
|
"asc",
|
||||||
false
|
false,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case EVENTS.rating:
|
case EVENTS.rating:
|
||||||
@@ -349,7 +379,7 @@ export default defineComponent({
|
|||||||
$globals.icons.sortCalendarAscending,
|
$globals.icons.sortCalendarAscending,
|
||||||
$globals.icons.sortCalendarDescending,
|
$globals.icons.sortCalendarDescending,
|
||||||
"desc",
|
"desc",
|
||||||
false
|
false,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case EVENTS.updated:
|
case EVENTS.updated:
|
||||||
@@ -361,7 +391,7 @@ export default defineComponent({
|
|||||||
$globals.icons.sortCalendarAscending,
|
$globals.icons.sortCalendarAscending,
|
||||||
$globals.icons.sortCalendarDescending,
|
$globals.icons.sortCalendarDescending,
|
||||||
"desc",
|
"desc",
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -369,7 +399,6 @@ export default defineComponent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
useAsync(async () => {
|
|
||||||
// reset pagination
|
// reset pagination
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
hasMore.value = true;
|
hasMore.value = true;
|
||||||
@@ -383,7 +412,6 @@ export default defineComponent({
|
|||||||
|
|
||||||
state.sortLoading = false;
|
state.sortLoading = false;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}, useAsyncKey());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function navigateRandom() {
|
async function navigateRandom() {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="items.length > 0">
|
<div v-if="items.length > 0">
|
||||||
<h2 v-if="title" class="mt-4">{{ title }}</h2>
|
<h2
|
||||||
|
v-if="title"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
<v-chip
|
<v-chip
|
||||||
v-for="category in items.slice(0, limit)"
|
v-for="category in items.slice(0, limit)"
|
||||||
:key="category.name"
|
:key="category.name"
|
||||||
label
|
label
|
||||||
class="ma-1"
|
class="mr-1 mt-1"
|
||||||
color="accent"
|
color="accent"
|
||||||
:small="small"
|
variant="flat"
|
||||||
|
:size="small ? 'small' : 'default'"
|
||||||
dark
|
dark
|
||||||
|
|
||||||
@click.prevent="() => $emit('item-selected', category, urlPrefix)"
|
@click.prevent="() => $emit('item-selected', category, urlPrefix)"
|
||||||
@@ -18,12 +24,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||||
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
|
||||||
|
|
||||||
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
truncate: {
|
truncate: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -54,13 +59,14 @@ export default defineComponent({
|
|||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["item-selected"],
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { $auth } = useContext();
|
const $auth = useMealieAuth();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "")
|
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||||
const baseRecipeRoute = computed<string>(() => {
|
const baseRecipeRoute = computed<string>(() => {
|
||||||
return `/g/${groupSlug.value}`
|
return `/g/${groupSlug.value}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
function truncateText(text: string, length = 20, clamp = "...") {
|
function truncateText(text: string, length = 20, clamp = "...") {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
:title="$t('recipe.delete-recipe')"
|
:title="$t('recipe.delete-recipe')"
|
||||||
color="error"
|
color="error"
|
||||||
:icon="$globals.icons.alertCircle"
|
:icon="$globals.icons.alertCircle"
|
||||||
|
can-confirm
|
||||||
@confirm="deleteRecipe()"
|
@confirm="deleteRecipe()"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
@@ -19,16 +20,17 @@
|
|||||||
:title="$t('recipe.duplicate')"
|
:title="$t('recipe.duplicate')"
|
||||||
color="primary"
|
color="primary"
|
||||||
:icon="$globals.icons.duplicate"
|
:icon="$globals.icons.duplicate"
|
||||||
|
can-confirm
|
||||||
@confirm="duplicateRecipe()"
|
@confirm="duplicateRecipe()"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="recipeName"
|
v-model="recipeName"
|
||||||
dense
|
density="compact"
|
||||||
:label="$t('recipe.recipe-name')"
|
:label="$t('recipe.recipe-name')"
|
||||||
autofocus
|
autofocus
|
||||||
@keyup.enter="duplicateRecipe()"
|
@keyup.enter="duplicateRecipe()"
|
||||||
></v-text-field>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
:title="$t('recipe.add-recipe-to-mealplan')"
|
:title="$t('recipe.add-recipe-to-mealplan')"
|
||||||
color="primary"
|
color="primary"
|
||||||
:icon="$globals.icons.calendar"
|
:icon="$globals.icons.calendar"
|
||||||
|
can-confirm
|
||||||
@confirm="addRecipeToPlan()"
|
@confirm="addRecipeToPlan()"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
@@ -47,22 +50,21 @@
|
|||||||
max-width="290px"
|
max-width="290px"
|
||||||
min-width="auto"
|
min-width="auto"
|
||||||
>
|
>
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ props }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="newMealdate"
|
v-model="newMealdateString"
|
||||||
:label="$t('general.date')"
|
:label="$t('general.date')"
|
||||||
:prepend-icon="$globals.icons.calendar"
|
:prepend-icon="$globals.icons.calendar"
|
||||||
v-bind="attrs"
|
v-bind="props"
|
||||||
readonly
|
readonly
|
||||||
v-on="on"
|
/>
|
||||||
></v-text-field>
|
|
||||||
</template>
|
</template>
|
||||||
<v-date-picker
|
<v-date-picker
|
||||||
v-model="newMealdate"
|
v-model="newMealdate"
|
||||||
no-title
|
hide-header
|
||||||
:first-day-of-week="firstDayOfWeek"
|
:first-day-of-week="firstDayOfWeek"
|
||||||
:local="$i18n.locale"
|
:local="$i18n.locale"
|
||||||
@input="pickerMenu = false"
|
@update:model-value="pickerMenu = false"
|
||||||
/>
|
/>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
<v-select
|
<v-select
|
||||||
@@ -70,7 +72,9 @@
|
|||||||
:return-object="false"
|
:return-object="false"
|
||||||
:items="planTypeOptions"
|
:items="planTypeOptions"
|
||||||
:label="$t('recipe.entry-type')"
|
:label="$t('recipe.entry-type')"
|
||||||
></v-select>
|
item-title="text"
|
||||||
|
item-value="value"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
<RecipeDialogAddToShoppingList
|
<RecipeDialogAddToShoppingList
|
||||||
@@ -81,35 +85,53 @@
|
|||||||
/>
|
/>
|
||||||
<v-menu
|
<v-menu
|
||||||
offset-y
|
offset-y
|
||||||
left
|
start
|
||||||
:bottom="!menuTop"
|
:bottom="!menuTop"
|
||||||
:nudge-bottom="!menuTop ? '5' : '0'"
|
:nudge-bottom="!menuTop ? '5' : '0'"
|
||||||
:top="menuTop"
|
:top="menuTop"
|
||||||
:nudge-top="menuTop ? '5' : '0'"
|
:nudge-top="menuTop ? '5' : '0'"
|
||||||
allow-overflow
|
allow-overflow
|
||||||
close-delay="125"
|
close-delay="125"
|
||||||
:open-on-hover="$vuetify.breakpoint.mdAndUp"
|
:open-on-hover="$vuetify.display.mdAndUp"
|
||||||
content-class="d-print-none"
|
content-class="d-print-none"
|
||||||
>
|
>
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ props }">
|
||||||
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent>
|
<v-btn
|
||||||
<v-icon>{{ icon }}</v-icon>
|
icon
|
||||||
|
:variant="fab ? 'flat' : undefined"
|
||||||
|
:rounded="fab ? 'circle' : undefined"
|
||||||
|
:size="fab ? 'small' : undefined"
|
||||||
|
:color="fab ? 'info' : 'secondary'"
|
||||||
|
:fab="fab"
|
||||||
|
v-bind="props"
|
||||||
|
@click.prevent
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
:size="!fab ? undefined : 'x-large'"
|
||||||
|
:color="fab ? 'white' : 'secondary'"
|
||||||
|
>
|
||||||
|
{{ icon }}
|
||||||
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<v-list dense>
|
<v-list density="compact">
|
||||||
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
|
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
|
||||||
<v-list-item-icon>
|
<template #prepend>
|
||||||
<v-icon :color="item.color"> {{ item.icon }} </v-icon>
|
<v-icon :color="item.color">
|
||||||
</v-list-item-icon>
|
{{ item.icon }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
|
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<v-list-group @click.stop>
|
<v-list-group @click.stop>
|
||||||
<template #activator>
|
<template #activator="{ props }">
|
||||||
<v-list-item-title>{{ $tc("recipe.recipe-actions") }}</v-list-item-title>
|
<v-list-item-title v-bind="props">
|
||||||
|
{{ $t("recipe.recipe-actions") }}
|
||||||
|
</v-list-item-title>
|
||||||
</template>
|
</template>
|
||||||
<v-list dense class="ma-0 pa-0">
|
<v-list density="compact" class="ma-0 pa-0">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="(action, index) in recipeActions"
|
v-for="(action, index) in recipeActions"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -129,7 +151,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, reactive, toRefs, useContext, useRoute, useRouter, ref } from "@nuxtjs/composition-api";
|
|
||||||
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
|
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
|
||||||
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
|
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
|
||||||
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
||||||
@@ -139,15 +160,16 @@ import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
|
|||||||
import { useHouseholdSelf } from "~/composables/use-households";
|
import { useHouseholdSelf } from "~/composables/use-households";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
|
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
|
import type { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
|
||||||
import { PlanEntryType } from "~/lib/api/types/meal-plan";
|
import type { PlanEntryType } from "~/lib/api/types/meal-plan";
|
||||||
import { useAxiosDownloader } from "~/composables/api/use-axios-download";
|
import { useDownloader } from "~/composables/api/use-downloader";
|
||||||
|
|
||||||
export interface ContextMenuIncludes {
|
export interface ContextMenuIncludes {
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
edit: boolean;
|
edit: boolean;
|
||||||
download: boolean;
|
download: boolean;
|
||||||
|
duplicate: boolean;
|
||||||
mealplanner: boolean;
|
mealplanner: boolean;
|
||||||
shoppingList: boolean;
|
shoppingList: boolean;
|
||||||
print: boolean;
|
print: boolean;
|
||||||
@@ -164,7 +186,7 @@ export interface ContextMenuItem {
|
|||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeDialogAddToShoppingList,
|
RecipeDialogAddToShoppingList,
|
||||||
RecipeDialogPrintPreferences,
|
RecipeDialogPrintPreferences,
|
||||||
@@ -233,6 +255,7 @@ export default defineComponent({
|
|||||||
default: 1,
|
default: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["delete"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
|
|
||||||
@@ -246,17 +269,23 @@ export default defineComponent({
|
|||||||
recipeName: props.name,
|
recipeName: props.name,
|
||||||
loading: false,
|
loading: false,
|
||||||
menuItems: [] as ContextMenuItem[],
|
menuItems: [] as ContextMenuItem[],
|
||||||
newMealdate: "",
|
newMealdate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
|
||||||
newMealType: "dinner" as PlanEntryType,
|
newMealType: "dinner" as PlanEntryType,
|
||||||
pickerMenu: false,
|
pickerMenu: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { i18n, $auth, $globals } = useContext();
|
const newMealdateString = computed(() => {
|
||||||
|
return state.newMealdate.toISOString().substring(0, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
const { household } = useHouseholdSelf();
|
const { household } = useHouseholdSelf();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
const firstDayOfWeek = computed(() => {
|
const firstDayOfWeek = computed(() => {
|
||||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||||
@@ -267,63 +296,63 @@ export default defineComponent({
|
|||||||
|
|
||||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||||
edit: {
|
edit: {
|
||||||
title: i18n.tc("general.edit"),
|
title: i18n.t("general.edit"),
|
||||||
icon: $globals.icons.edit,
|
icon: $globals.icons.edit,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "edit",
|
event: "edit",
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
title: i18n.tc("general.delete"),
|
title: i18n.t("general.delete"),
|
||||||
icon: $globals.icons.delete,
|
icon: $globals.icons.delete,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "delete",
|
event: "delete",
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
},
|
},
|
||||||
download: {
|
download: {
|
||||||
title: i18n.tc("general.download"),
|
title: i18n.t("general.download"),
|
||||||
icon: $globals.icons.download,
|
icon: $globals.icons.download,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "download",
|
event: "download",
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
},
|
},
|
||||||
duplicate: {
|
duplicate: {
|
||||||
title: i18n.tc("general.duplicate"),
|
title: i18n.t("general.duplicate"),
|
||||||
icon: $globals.icons.duplicate,
|
icon: $globals.icons.duplicate,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "duplicate",
|
event: "duplicate",
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
},
|
},
|
||||||
mealplanner: {
|
mealplanner: {
|
||||||
title: i18n.tc("recipe.add-to-plan"),
|
title: i18n.t("recipe.add-to-plan"),
|
||||||
icon: $globals.icons.calendar,
|
icon: $globals.icons.calendar,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "mealplanner",
|
event: "mealplanner",
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
},
|
},
|
||||||
shoppingList: {
|
shoppingList: {
|
||||||
title: i18n.tc("recipe.add-to-list"),
|
title: i18n.t("recipe.add-to-list"),
|
||||||
icon: $globals.icons.cartCheck,
|
icon: $globals.icons.cartCheck,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "shoppingList",
|
event: "shoppingList",
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
},
|
},
|
||||||
print: {
|
print: {
|
||||||
title: i18n.tc("general.print"),
|
title: i18n.t("general.print"),
|
||||||
icon: $globals.icons.printer,
|
icon: $globals.icons.printer,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "print",
|
event: "print",
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
},
|
},
|
||||||
printPreferences: {
|
printPreferences: {
|
||||||
title: i18n.tc("general.print-preferences"),
|
title: i18n.t("general.print-preferences"),
|
||||||
icon: $globals.icons.printerSettings,
|
icon: $globals.icons.printerSettings,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "printPreferences",
|
event: "printPreferences",
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
},
|
},
|
||||||
share: {
|
share: {
|
||||||
title: i18n.tc("general.share"),
|
title: i18n.t("general.share"),
|
||||||
icon: $globals.icons.shareVariant,
|
icon: $globals.icons.shareVariant,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "share",
|
event: "share",
|
||||||
@@ -350,8 +379,10 @@ export default defineComponent({
|
|||||||
// Context Menu Event Handler
|
// Context Menu Event Handler
|
||||||
|
|
||||||
const shoppingLists = ref<ShoppingListSummary[]>();
|
const shoppingLists = ref<ShoppingListSummary[]>();
|
||||||
const recipeRef = ref<Recipe>(props.recipe);
|
const recipeRef = ref<Recipe | undefined>(props.recipe);
|
||||||
const recipeRefWithScale = computed(() => recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined);
|
const recipeRefWithScale = computed(() =>
|
||||||
|
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
async function getShoppingLists() {
|
async function getShoppingLists() {
|
||||||
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
||||||
@@ -371,13 +402,15 @@ export default defineComponent({
|
|||||||
const groupRecipeActionsStore = useGroupRecipeActions();
|
const groupRecipeActionsStore = useGroupRecipeActions();
|
||||||
|
|
||||||
async function executeRecipeAction(action: GroupRecipeActionOut) {
|
async function executeRecipeAction(action: GroupRecipeActionOut) {
|
||||||
|
if (!props.recipe) return;
|
||||||
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
|
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
|
||||||
|
|
||||||
if (action.actionType === "post") {
|
if (action.actionType === "post") {
|
||||||
if (!response?.error) {
|
if (!response?.error) {
|
||||||
alert.success(i18n.tc("events.message-sent"));
|
alert.success(i18n.t("events.message-sent"));
|
||||||
} else {
|
}
|
||||||
alert.error(i18n.tc("events.something-went-wrong"));
|
else {
|
||||||
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,7 +423,7 @@ export default defineComponent({
|
|||||||
context.emit("delete", props.slug);
|
context.emit("delete", props.slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
const download = useAxiosDownloader();
|
const download = useDownloader();
|
||||||
|
|
||||||
async function handleDownloadEvent() {
|
async function handleDownloadEvent() {
|
||||||
const { data } = await api.recipes.getZipToken(props.slug);
|
const { data } = await api.recipes.getZipToken(props.slug);
|
||||||
@@ -402,7 +435,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
async function addRecipeToPlan() {
|
async function addRecipeToPlan() {
|
||||||
const { response } = await api.mealplans.createOne({
|
const { response } = await api.mealplans.createOne({
|
||||||
date: state.newMealdate,
|
date: newMealdateString.value,
|
||||||
entryType: state.newMealType,
|
entryType: state.newMealType,
|
||||||
title: "",
|
title: "",
|
||||||
text: "",
|
text: "",
|
||||||
@@ -411,7 +444,8 @@ export default defineComponent({
|
|||||||
|
|
||||||
if (response?.status === 201) {
|
if (response?.status === 201) {
|
||||||
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
|
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
|
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,6 +458,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Note: Print is handled as an event in the parent component
|
// Note: Print is handled as an event in the parent component
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||||
delete: () => {
|
delete: () => {
|
||||||
state.recipeDeleteDialog = true;
|
state.recipeDeleteDialog = true;
|
||||||
@@ -448,7 +483,9 @@ export default defineComponent({
|
|||||||
promises.push(refreshRecipe());
|
promises.push(refreshRecipe());
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.allSettled(promises).then(() => { state.shoppingListDialog = true });
|
Promise.allSettled(promises).then(() => {
|
||||||
|
state.shoppingListDialog = true;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
share: () => {
|
share: () => {
|
||||||
state.shareDialog = true;
|
state.shareDialog = true;
|
||||||
@@ -472,6 +509,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...toRefs(state),
|
...toRefs(state),
|
||||||
|
newMealdateString,
|
||||||
recipeRef,
|
recipeRef,
|
||||||
recipeRefWithScale,
|
recipeRefWithScale,
|
||||||
executeRecipeAction,
|
executeRecipeAction,
|
||||||
|
|||||||
@@ -1,41 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<BaseDialog
|
<BaseDialog v-model="dialog" :title="$t('data-pages.manage-aliases')" :icon="$globals.icons.edit"
|
||||||
v-model="dialog"
|
:submit-icon="$globals.icons.check" :submit-text="$t('general.confirm')" can-submit @submit="saveAliases"
|
||||||
:title="$t('data-pages.manage-aliases')"
|
@cancel="$emit('cancel')">
|
||||||
:icon="$globals.icons.edit"
|
|
||||||
:submit-icon="$globals.icons.check"
|
|
||||||
:submit-text="$tc('general.confirm')"
|
|
||||||
@submit="saveAliases"
|
|
||||||
@cancel="$emit('cancel')"
|
|
||||||
>
|
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-container>
|
<v-container>
|
||||||
<v-row v-for="alias, i in aliases" :key="i">
|
<v-row v-for="alias, i in aliases" :key="i">
|
||||||
<v-col cols="10">
|
<v-col cols="10">
|
||||||
<v-text-field
|
<v-text-field v-model="alias.name" :label="$t('general.name')" :rules="[validators.required]" />
|
||||||
v-model="alias.name"
|
|
||||||
:label="$t('general.name')"
|
|
||||||
:rules="[validators.required]"
|
|
||||||
/>
|
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="2">
|
<v-col cols="2">
|
||||||
<BaseButtonGroup
|
<BaseButtonGroup :buttons="[
|
||||||
:buttons="[
|
|
||||||
{
|
{
|
||||||
icon: $globals.icons.delete,
|
icon: $globals.icons.delete,
|
||||||
text: $tc('general.delete'),
|
text: $t('general.delete'),
|
||||||
event: 'delete'
|
event: 'delete',
|
||||||
}
|
},
|
||||||
]"
|
]" @delete="deleteAlias(i)" />
|
||||||
@delete="deleteAlias(i)"
|
|
||||||
/>
|
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-container>
|
</v-container>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<template #custom-card-action>
|
<template #custom-card-action>
|
||||||
<BaseButton edit @click="createAlias">{{ $t('data-pages.create-alias') }}
|
<BaseButton edit @click="createAlias">
|
||||||
|
{{ $t('data-pages.create-alias') }}
|
||||||
<template #icon>
|
<template #icon>
|
||||||
{{ $globals.icons.create }}
|
{{ $globals.icons.create }}
|
||||||
</template>
|
</template>
|
||||||
@@ -46,18 +34,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, ref } from "@nuxtjs/composition-api";
|
|
||||||
import { whenever } from "@vueuse/core";
|
import { whenever } from "@vueuse/core";
|
||||||
import { validators } from "~/composables/use-validators";
|
import { validators } from "~/composables/use-validators";
|
||||||
import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export interface GenericAlias {
|
export interface GenericAlias {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
@@ -66,21 +53,22 @@ export default defineComponent({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["submit", "update:modelValue", "cancel"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
// V-Model Support
|
// V-Model Support
|
||||||
const dialog = computed({
|
const dialog = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return props.value;
|
return props.modelValue;
|
||||||
},
|
},
|
||||||
set: (val) => {
|
set: (val) => {
|
||||||
context.emit("input", val);
|
context.emit("update:modelValue", val);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function createAlias() {
|
function createAlias() {
|
||||||
aliases.value.push({
|
aliases.value.push({
|
||||||
"name": "",
|
name: "",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteAlias(index: number) {
|
function deleteAlias(index: number) {
|
||||||
@@ -97,11 +85,11 @@ export default defineComponent({
|
|||||||
|
|
||||||
initAliases();
|
initAliases();
|
||||||
whenever(
|
whenever(
|
||||||
() => props.value,
|
() => props.modelValue,
|
||||||
() => {
|
() => {
|
||||||
initAliases();
|
initAliases();
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function saveAliases() {
|
function saveAliases() {
|
||||||
const seenAliasNames: string[] = [];
|
const seenAliasNames: string[] = [];
|
||||||
@@ -111,9 +99,7 @@ export default defineComponent({
|
|||||||
!alias.name
|
!alias.name
|
||||||
|| alias.name === props.data.name
|
|| alias.name === props.data.name
|
||||||
|| alias.name === props.data.pluralName
|
|| alias.name === props.data.pluralName
|
||||||
// @ts-ignore only applies to units
|
|
||||||
|| alias.name === props.data.abbreviation
|
|| alias.name === props.data.abbreviation
|
||||||
// @ts-ignore only applies to units
|
|
||||||
|| alias.name === props.data.pluralAbbreviation
|
|| alias.name === props.data.pluralAbbreviation
|
||||||
|| seenAliasNames.includes(alias.name)
|
|| seenAliasNames.includes(alias.name)
|
||||||
) {
|
) {
|
||||||
@@ -122,7 +108,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
keepAliases.push(alias);
|
keepAliases.push(alias);
|
||||||
seenAliasNames.push(alias.name);
|
seenAliasNames.push(alias.name);
|
||||||
})
|
});
|
||||||
|
|
||||||
aliases.value = keepAliases;
|
aliases.value = keepAliases;
|
||||||
context.emit("submit", keepAliases);
|
context.emit("submit", keepAliases);
|
||||||
@@ -135,7 +121,7 @@ export default defineComponent({
|
|||||||
deleteAlias,
|
deleteAlias,
|
||||||
saveAliases,
|
saveAliases,
|
||||||
validators,
|
validators,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,60 +3,73 @@
|
|||||||
v-model="selected"
|
v-model="selected"
|
||||||
item-key="id"
|
item-key="id"
|
||||||
show-select
|
show-select
|
||||||
sort-by="dateAdded"
|
:sort-by="[{ key: 'dateAdded', order: 'desc' }]"
|
||||||
sort-desc
|
|
||||||
:headers="headers"
|
:headers="headers"
|
||||||
:items="recipes"
|
:items="recipes"
|
||||||
:items-per-page="15"
|
:items-per-page="15"
|
||||||
class="elevation-0"
|
class="elevation-0"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@input="setValue(selected)"
|
|
||||||
>
|
>
|
||||||
<template #body.preappend>
|
<template #[`item.name`]="{ item }">
|
||||||
<tr>
|
<a
|
||||||
<td></td>
|
:href="`/g/${groupSlug}/r/${item.slug}`"
|
||||||
<td>Hello</td>
|
style="color: inherit; text-decoration: inherit; "
|
||||||
<td colspan="4"></td>
|
@click="$emit('click')"
|
||||||
</tr>
|
>{{ item.name }}</a>
|
||||||
</template>
|
</template>
|
||||||
<template #item.name="{ item }">
|
<template #[`item.tags`]="{ item }">
|
||||||
<a :href="`/g/${groupSlug}/r/${item.slug}`" style="color: inherit; text-decoration: inherit; " @click="$emit('click')">{{ item.name }}</a>
|
<RecipeChip
|
||||||
|
small
|
||||||
|
:items="item.tags!"
|
||||||
|
:is-category="false"
|
||||||
|
url-prefix="tags"
|
||||||
|
@item-selected="filterItems"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #item.tags="{ item }">
|
<template #[`item.recipeCategory`]="{ item }">
|
||||||
<RecipeChip small :items="item.tags" :is-category="false" url-prefix="tags" @item-selected="filterItems" />
|
<RecipeChip
|
||||||
|
small
|
||||||
|
:items="item.recipeCategory!"
|
||||||
|
@item-selected="filterItems"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #item.recipeCategory="{ item }">
|
<template #[`item.tools`]="{ item }">
|
||||||
<RecipeChip small :items="item.recipeCategory" @item-selected="filterItems" />
|
<RecipeChip
|
||||||
|
small
|
||||||
|
:items="item.tools"
|
||||||
|
url-prefix="tools"
|
||||||
|
@item-selected="filterItems"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #item.tools="{ item }">
|
<template #[`item.userId`]="{ item }">
|
||||||
<RecipeChip small :items="item.tools" url-prefix="tools" @item-selected="filterItems" />
|
<div class="d-flex align-center">
|
||||||
|
<UserAvatar
|
||||||
|
:user-id="item.userId!"
|
||||||
|
:tooltip="false"
|
||||||
|
size="40"
|
||||||
|
/>
|
||||||
|
<div class="pl-2">
|
||||||
|
<span class="text-left">
|
||||||
|
{{ getMember(item.userId!) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #item.userId="{ item }">
|
<template #[`item.dateAdded`]="{ item }">
|
||||||
<v-list-item class="justify-start">
|
{{ formatDate(item.dateAdded!) }}
|
||||||
<UserAvatar :user-id="item.userId" :tooltip="false" size="40" />
|
|
||||||
<v-list-item-content class="pl-2">
|
|
||||||
<v-list-item-title class="text-left">
|
|
||||||
{{ getMember(item.userId) }}
|
|
||||||
</v-list-item-title>
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
</template>
|
|
||||||
<template #item.dateAdded="{ item }">
|
|
||||||
{{ formatDate(item.dateAdded) }}
|
|
||||||
</template>
|
</template>
|
||||||
</v-data-table>
|
</v-data-table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, onMounted, ref, useContext, useRouter } from "@nuxtjs/composition-api";
|
|
||||||
import UserAvatar from "../User/UserAvatar.vue";
|
import UserAvatar from "../User/UserAvatar.vue";
|
||||||
import RecipeChip from "./RecipeChips.vue";
|
import RecipeChip from "./RecipeChips.vue";
|
||||||
import { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe";
|
import type { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { UserSummary } from "~/lib/api/types/user";
|
import type { UserSummary } from "~/lib/api/types/user";
|
||||||
import { RecipeTag } from "~/lib/api/types/household";
|
import type { RecipeTag } from "~/lib/api/types/household";
|
||||||
|
|
||||||
const INPUT_EVENT = "input";
|
const INPUT_EVENT = "update:modelValue";
|
||||||
|
|
||||||
interface ShowHeaders {
|
interface ShowHeaders {
|
||||||
id: boolean;
|
id: boolean;
|
||||||
@@ -70,11 +83,11 @@ interface ShowHeaders {
|
|||||||
dateAdded: boolean;
|
dateAdded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: { RecipeChip, UserAvatar },
|
components: { RecipeChip, UserAvatar },
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Array,
|
type: Array as PropType<Recipe[]>,
|
||||||
required: false,
|
required: false,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
@@ -104,45 +117,48 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["click"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { $auth, i18n } = useContext();
|
const i18n = useI18n();
|
||||||
const groupSlug = $auth.user?.groupSlug;
|
const $auth = useMealieAuth();
|
||||||
|
const groupSlug = $auth.user.value?.groupSlug;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
function setValue(value: Recipe[]) {
|
const selected = computed({
|
||||||
context.emit(INPUT_EVENT, value);
|
get: () => props.modelValue,
|
||||||
}
|
set: value => context.emit(INPUT_EVENT, value),
|
||||||
|
});
|
||||||
|
|
||||||
const headers = computed(() => {
|
const headers = computed(() => {
|
||||||
const hdrs = [];
|
const hdrs: Array<{ title: string; value: string; align?: string; sortable?: boolean }> = [];
|
||||||
|
|
||||||
if (props.showHeaders.id) {
|
if (props.showHeaders.id) {
|
||||||
hdrs.push({ text: i18n.t("general.id"), value: "id" });
|
hdrs.push({ title: i18n.t("general.id"), value: "id" });
|
||||||
}
|
}
|
||||||
if (props.showHeaders.owner) {
|
if (props.showHeaders.owner) {
|
||||||
hdrs.push({ text: i18n.t("general.owner"), value: "userId", align: "center" });
|
hdrs.push({ title: i18n.t("general.owner"), value: "userId", align: "center", sortable: true });
|
||||||
}
|
}
|
||||||
hdrs.push({ text: i18n.t("general.name"), value: "name" });
|
hdrs.push({ title: i18n.t("general.name"), value: "name", sortable: true });
|
||||||
if (props.showHeaders.categories) {
|
if (props.showHeaders.categories) {
|
||||||
hdrs.push({ text: i18n.t("recipe.categories"), value: "recipeCategory" });
|
hdrs.push({ title: i18n.t("recipe.categories"), value: "recipeCategory", sortable: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.showHeaders.tags) {
|
if (props.showHeaders.tags) {
|
||||||
hdrs.push({ text: i18n.t("tag.tags"), value: "tags" });
|
hdrs.push({ title: i18n.t("tag.tags"), value: "tags", sortable: true });
|
||||||
}
|
}
|
||||||
if (props.showHeaders.tools) {
|
if (props.showHeaders.tools) {
|
||||||
hdrs.push({ text: i18n.t("tool.tools"), value: "tools" });
|
hdrs.push({ title: i18n.t("tool.tools"), value: "tools", sortable: true });
|
||||||
}
|
}
|
||||||
if (props.showHeaders.recipeServings) {
|
if (props.showHeaders.recipeServings) {
|
||||||
hdrs.push({ text: i18n.t("recipe.servings"), value: "recipeServings" });
|
hdrs.push({ title: i18n.t("recipe.servings"), value: "recipeServings", sortable: true });
|
||||||
}
|
}
|
||||||
if (props.showHeaders.recipeYieldQuantity) {
|
if (props.showHeaders.recipeYieldQuantity) {
|
||||||
hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYieldQuantity" });
|
hdrs.push({ title: i18n.t("recipe.yield"), value: "recipeYieldQuantity", sortable: true });
|
||||||
}
|
}
|
||||||
if (props.showHeaders.recipeYield) {
|
if (props.showHeaders.recipeYield) {
|
||||||
hdrs.push({ text: i18n.t("recipe.yield-text"), value: "recipeYield" });
|
hdrs.push({ title: i18n.t("recipe.yield-text"), value: "recipeYield", sortable: true });
|
||||||
}
|
}
|
||||||
if (props.showHeaders.dateAdded) {
|
if (props.showHeaders.dateAdded) {
|
||||||
hdrs.push({ text: i18n.t("general.date-added"), value: "dateAdded" });
|
hdrs.push({ title: i18n.t("general.date-added"), value: "dateAdded", sortable: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return hdrs;
|
return hdrs;
|
||||||
@@ -151,7 +167,8 @@ export default defineComponent({
|
|||||||
function formatDate(date: string) {
|
function formatDate(date: string) {
|
||||||
try {
|
try {
|
||||||
return i18n.d(Date.parse(date), "medium");
|
return i18n.d(Date.parse(date), "medium");
|
||||||
} catch {
|
}
|
||||||
|
catch {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,15 +198,15 @@ export default defineComponent({
|
|||||||
|
|
||||||
function getMember(id: string) {
|
function getMember(id: string) {
|
||||||
if (members.value[0]) {
|
if (members.value[0]) {
|
||||||
return members.value.find((m) => m.id === id)?.fullName;
|
return members.value.find(m => m.id === id)?.fullName;
|
||||||
}
|
}
|
||||||
|
|
||||||
return i18n.t("general.none");
|
return i18n.t("general.none");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
selected,
|
||||||
groupSlug,
|
groupSlug,
|
||||||
setValue,
|
|
||||||
headers,
|
headers,
|
||||||
formatDate,
|
formatDate,
|
||||||
members,
|
members,
|
||||||
@@ -197,16 +214,5 @@ export default defineComponent({
|
|||||||
filterItems,
|
filterItems,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
selected: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
value(val) {
|
|
||||||
this.selected = val;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="dialog">
|
<div v-if="dialog">
|
||||||
<BaseDialog v-if="shoppingListDialog && ready" v-model="dialog" :title="$t('recipe.add-to-list')" :icon="$globals.icons.cartCheck">
|
<BaseDialog
|
||||||
|
v-if="shoppingListDialog && ready"
|
||||||
|
v-model="dialog"
|
||||||
|
:title="$t('recipe.add-to-list')"
|
||||||
|
:icon="$globals.icons.cartCheck"
|
||||||
|
>
|
||||||
<v-container v-if="!shoppingListChoices.length">
|
<v-container v-if="!shoppingListChoices.length">
|
||||||
<BasePageTitle>
|
<BasePageTitle>
|
||||||
<template #title>{{ $t('shopping-list.no-shopping-lists-found') }}</template>
|
<template #title>
|
||||||
|
{{ $t('shopping-list.no-shopping-lists-found') }}
|
||||||
|
</template>
|
||||||
</BasePageTitle>
|
</BasePageTitle>
|
||||||
</v-container>
|
</v-container>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
@@ -21,14 +28,23 @@
|
|||||||
</v-card-text>
|
</v-card-text>
|
||||||
<template #card-actions>
|
<template #card-actions>
|
||||||
<v-btn
|
<v-btn
|
||||||
text
|
variant="text"
|
||||||
color="grey"
|
color="grey"
|
||||||
@click="dialog = false"
|
@click="dialog = false"
|
||||||
>
|
>
|
||||||
{{ $t("general.cancel") }}
|
{{ $t("general.cancel") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<div class="d-flex justify-end" style="width: 100%;">
|
<div
|
||||||
<v-checkbox v-model="preferences.viewAllLists" hide-details :label="$tc('general.show-all')" class="my-auto mr-4" @click="setShowAllToggled()" />
|
class="d-flex justify-end"
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
<v-checkbox
|
||||||
|
v-model="preferences.viewAllLists"
|
||||||
|
hide-details
|
||||||
|
:label="$t('general.show-all')"
|
||||||
|
class="my-auto mr-4"
|
||||||
|
@click="setShowAllToggled()"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
@@ -38,32 +54,52 @@
|
|||||||
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
|
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
|
||||||
:icon="$globals.icons.cartCheck"
|
:icon="$globals.icons.cartCheck"
|
||||||
width="70%"
|
width="70%"
|
||||||
:submit-text="$tc('recipe.add-to-list')"
|
:submit-text="$t('recipe.add-to-list')"
|
||||||
|
can-submit
|
||||||
@submit="addRecipesToList()"
|
@submit="addRecipesToList()"
|
||||||
>
|
>
|
||||||
<div style="max-height: 70vh; overflow-y: auto">
|
<div style="max-height: 70vh; overflow-y: auto">
|
||||||
<v-card
|
<v-card
|
||||||
v-for="(recipeSection, recipeSectionIndex) in recipeIngredientSections" :key="recipeSection.recipeId + recipeSectionIndex"
|
v-for="(recipeSection, recipeSectionIndex) in recipeIngredientSections"
|
||||||
|
:key="recipeSection.recipeId + recipeSectionIndex"
|
||||||
elevation="0"
|
elevation="0"
|
||||||
height="fit-content"
|
height="fit-content"
|
||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
<v-divider v-if="recipeSectionIndex > 0" class="mt-3" />
|
<v-divider
|
||||||
|
v-if="recipeSectionIndex > 0"
|
||||||
|
class="mt-3"
|
||||||
|
/>
|
||||||
<v-card-title
|
<v-card-title
|
||||||
v-if="recipeIngredientSections.length > 1"
|
v-if="recipeIngredientSections.length > 1"
|
||||||
class="justify-center text-h5"
|
class="justify-center text-h5"
|
||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
<v-container style="width: 100%;">
|
<v-container style="width: 100%;">
|
||||||
<v-row no-gutters class="ma-0 pa-0">
|
<v-row
|
||||||
<v-col cols="12" align-self="center" class="text-center">
|
no-gutters
|
||||||
|
class="ma-0 pa-0"
|
||||||
|
>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
align-self="center"
|
||||||
|
class="text-center"
|
||||||
|
>
|
||||||
{{ recipeSection.recipeName }}
|
{{ recipeSection.recipeName }}
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row v-if="recipeSection.recipeScale > 1" no-gutters class="ma-0 pa-0">
|
<v-row
|
||||||
|
v-if="recipeSection.recipeScale > 1"
|
||||||
|
no-gutters
|
||||||
|
class="ma-0 pa-0"
|
||||||
|
>
|
||||||
<!-- TODO: make this editable in the dialog and visible on single-recipe lists -->
|
<!-- TODO: make this editable in the dialog and visible on single-recipe lists -->
|
||||||
<v-col cols="12" align-self="center" class="text-center">
|
<v-col
|
||||||
({{ $tc("recipe.quantity") }}: {{ recipeSection.recipeScale }})
|
cols="12"
|
||||||
|
align-self="center"
|
||||||
|
class="text-center"
|
||||||
|
>
|
||||||
|
({{ $t("recipe.quantity") }}: {{ recipeSection.recipeScale }})
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-container>
|
</v-container>
|
||||||
@@ -73,17 +109,20 @@
|
|||||||
v-for="(ingredientSection, ingredientSectionIndex) in recipeSection.ingredientSections"
|
v-for="(ingredientSection, ingredientSectionIndex) in recipeSection.ingredientSections"
|
||||||
:key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex"
|
:key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex"
|
||||||
>
|
>
|
||||||
<v-card-title v-if="ingredientSection.sectionName" class="ingredient-title mt-2 pb-0 text-h6">
|
<v-card-title
|
||||||
|
v-if="ingredientSection.sectionName"
|
||||||
|
class="ingredient-title mt-2 pb-0 text-h6"
|
||||||
|
>
|
||||||
{{ ingredientSection.sectionName }}
|
{{ ingredientSection.sectionName }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<div
|
<div
|
||||||
:class="$vuetify.breakpoint.smAndDown ? '' : 'ingredient-grid'"
|
:class="$vuetify.display.smAndDown ? '' : 'ingredient-grid'"
|
||||||
:style="$vuetify.breakpoint.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
:style="$vuetify.display.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
||||||
>
|
>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="(ingredientData, i) in ingredientSection.ingredients"
|
v-for="(ingredientData, i) in ingredientSection.ingredients"
|
||||||
:key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex + i"
|
:key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex + i"
|
||||||
dense
|
density="compact"
|
||||||
@click="recipeIngredientSections[recipeSectionIndex]
|
@click="recipeIngredientSections[recipeSectionIndex]
|
||||||
.ingredientSections[ingredientSectionIndex]
|
.ingredientSections[ingredientSectionIndex]
|
||||||
.ingredients[i].checked = !recipeIngredientSections[recipeSectionIndex]
|
.ingredients[i].checked = !recipeIngredientSections[recipeSectionIndex]
|
||||||
@@ -93,16 +132,18 @@
|
|||||||
>
|
>
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
hide-details
|
hide-details
|
||||||
:input-value="ingredientData.checked"
|
:model-value="ingredientData.checked"
|
||||||
class="pt-0 my-auto py-auto"
|
class="pt-0 my-auto py-auto"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
density="compact"
|
||||||
/>
|
/>
|
||||||
<v-list-item-content :key="ingredientData.ingredient.quantity">
|
<div :key="ingredientData.ingredient.quantity">
|
||||||
<RecipeIngredientListItem
|
<RecipeIngredientListItem
|
||||||
:ingredient="ingredientData.ingredient"
|
:ingredient="ingredientData.ingredient"
|
||||||
:disable-amount="ingredientData.disableAmount"
|
:disable-amount="ingredientData.disableAmount"
|
||||||
:scale="recipeSection.recipeScale" />
|
:scale="recipeSection.recipeScale"
|
||||||
</v-list-item-content>
|
/>
|
||||||
|
</div>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,12 +155,12 @@
|
|||||||
:buttons="[
|
:buttons="[
|
||||||
{
|
{
|
||||||
icon: $globals.icons.checkboxBlankOutline,
|
icon: $globals.icons.checkboxBlankOutline,
|
||||||
text: $tc('shopping-list.uncheck-all-items'),
|
text: $t('shopping-list.uncheck-all-items'),
|
||||||
event: 'uncheck',
|
event: 'uncheck',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.checkboxOutline,
|
icon: $globals.icons.checkboxOutline,
|
||||||
text: $tc('shopping-list.check-all-items'),
|
text: $t('shopping-list.check-all-items'),
|
||||||
event: 'check',
|
event: 'check',
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
@@ -132,14 +173,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, reactive, ref, useContext, watchEffect } from "@nuxtjs/composition-api";
|
|
||||||
import { toRefs } from "@vueuse/core";
|
import { toRefs } from "@vueuse/core";
|
||||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
import { useShoppingListPreferences } from "~/composables/use-users/preferences";
|
import { useShoppingListPreferences } from "~/composables/use-users/preferences";
|
||||||
import { RecipeIngredient, ShoppingListAddRecipeParamsBulk, ShoppingListSummary } from "~/lib/api/types/household";
|
import type { RecipeIngredient, ShoppingListAddRecipeParamsBulk, ShoppingListSummary } from "~/lib/api/types/household";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export interface RecipeWithScale extends Recipe {
|
export interface RecipeWithScale extends Recipe {
|
||||||
scale: number;
|
scale: number;
|
||||||
@@ -163,12 +203,12 @@ export interface ShoppingListRecipeIngredientSection {
|
|||||||
ingredientSections: ShoppingListIngredientSection[];
|
ingredientSections: ShoppingListIngredientSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeIngredientListItem,
|
RecipeIngredientListItem,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
@@ -181,8 +221,10 @@ export default defineComponent({
|
|||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { $auth, i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const preferences = useShoppingListPreferences();
|
const preferences = useShoppingListPreferences();
|
||||||
const ready = ref(false);
|
const ready = ref(false);
|
||||||
@@ -190,10 +232,10 @@ export default defineComponent({
|
|||||||
// v-model support
|
// v-model support
|
||||||
const dialog = computed({
|
const dialog = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return props.value;
|
return props.modelValue;
|
||||||
},
|
},
|
||||||
set: (val) => {
|
set: (val) => {
|
||||||
context.emit("input", val);
|
context.emit("update:modelValue", val);
|
||||||
initState();
|
initState();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -205,11 +247,11 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userHousehold = computed(() => {
|
const userHousehold = computed(() => {
|
||||||
return $auth.user?.householdSlug || "";
|
return $auth.user.value?.householdSlug || "";
|
||||||
});
|
});
|
||||||
|
|
||||||
const shoppingListChoices = computed(() => {
|
const shoppingListChoices = computed(() => {
|
||||||
return props.shoppingLists.filter((list) => preferences.value.viewAllLists || list.userId === $auth.user?.id);
|
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
||||||
@@ -220,7 +262,8 @@ export default defineComponent({
|
|||||||
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
|
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||||
selectedShoppingList.value = shoppingListChoices.value[0];
|
selectedShoppingList.value = shoppingListChoices.value[0];
|
||||||
openShoppingListIngredientDialog(selectedShoppingList.value);
|
openShoppingListIngredientDialog(selectedShoppingList.value);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
ready.value = true;
|
ready.value = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -234,7 +277,6 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (recipeSectionMap.has(recipe.slug)) {
|
if (recipeSectionMap.has(recipe.slug)) {
|
||||||
// @ts-ignore not undefined, see above
|
|
||||||
recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
|
recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -247,7 +289,8 @@ export default defineComponent({
|
|||||||
recipe.id = data.id || "";
|
recipe.id = data.id || "";
|
||||||
recipe.name = data.name || "";
|
recipe.name = data.name || "";
|
||||||
recipe.recipeIngredient = data.recipeIngredient;
|
recipe.recipeIngredient = data.recipeIngredient;
|
||||||
} else if (!recipe.recipeIngredient.length) {
|
}
|
||||||
|
else if (!recipe.recipeIngredient.length) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +300,7 @@ export default defineComponent({
|
|||||||
checked: !householdsWithFood.includes(userHousehold.value),
|
checked: !householdsWithFood.includes(userHousehold.value),
|
||||||
ingredient: ing,
|
ingredient: ing,
|
||||||
disableAmount: recipe.settings?.disableAmount || false,
|
disableAmount: recipe.settings?.disableAmount || false,
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let currentTitle = "";
|
let currentTitle = "";
|
||||||
@@ -300,7 +343,7 @@ export default defineComponent({
|
|||||||
recipeName: recipe.name,
|
recipeName: recipe.name,
|
||||||
recipeScale: recipe.scale,
|
recipeScale: recipe.scale,
|
||||||
ingredientSections: shoppingListIngredientSections,
|
ingredientSections: shoppingListIngredientSections,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
|
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
|
||||||
@@ -366,13 +409,13 @@ export default defineComponent({
|
|||||||
recipeId: section.recipeId,
|
recipeId: section.recipeId,
|
||||||
recipeIncrementQuantity: section.recipeScale,
|
recipeIncrementQuantity: section.recipeScale,
|
||||||
recipeIngredients: ingredients,
|
recipeIngredients: ingredients,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
|
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
|
||||||
error ? alert.error(i18n.tc("recipe.failed-to-add-recipes-to-list"))
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
: alert.success(i18n.tc("recipe.successfully-added-to-list"));
|
error ? alert.error(i18n.t("recipe.failed-to-add-recipes-to-list")) : alert.success(i18n.t("recipe.successfully-added-to-list"));
|
||||||
|
|
||||||
state.shoppingListDialog = false;
|
state.shoppingListDialog = false;
|
||||||
state.shoppingListIngredientDialog = false;
|
state.shoppingListIngredientDialog = false;
|
||||||
@@ -391,9 +434,9 @@ export default defineComponent({
|
|||||||
setShowAllToggled,
|
setShowAllToggled,
|
||||||
recipeIngredientSections,
|
recipeIngredientSections,
|
||||||
selectedShoppingList,
|
selectedShoppingList,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="css">
|
<style scoped lang="css">
|
||||||
|
|||||||
@@ -1,54 +1,88 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<v-dialog v-model="dialog" width="800">
|
<v-dialog
|
||||||
<template #activator="{ on, attrs }">
|
v-model="dialog"
|
||||||
<BaseButton v-bind="attrs" v-on="on" @click="inputText = inputTextProp">
|
width="800"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<BaseButton
|
||||||
|
v-bind="props"
|
||||||
|
@click="inputText = inputTextProp"
|
||||||
|
>
|
||||||
{{ $t("new-recipe.bulk-add") }}
|
{{ $t("new-recipe.bulk-add") }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-app-bar dense dark color="primary" class="mb-2">
|
<v-app-bar
|
||||||
<v-icon large left>
|
density="compact"
|
||||||
|
dark
|
||||||
|
color="primary"
|
||||||
|
class="mb-2 position-relative left-0 top-0 w-100"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
size="large"
|
||||||
|
start
|
||||||
|
>
|
||||||
{{ $globals.icons.createAlt }}
|
{{ $globals.icons.createAlt }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-toolbar-title class="headline"> {{ $t("new-recipe.bulk-add") }}</v-toolbar-title>
|
<v-toolbar-title class="headline">
|
||||||
<v-spacer></v-spacer>
|
{{ $t("new-recipe.bulk-add") }}
|
||||||
|
</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
|
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
outlined
|
variant="outlined"
|
||||||
rows="12"
|
rows="12"
|
||||||
hide-details
|
hide-details
|
||||||
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
|
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
|
||||||
>
|
/>
|
||||||
</v-textarea>
|
|
||||||
|
|
||||||
<v-divider></v-divider>
|
<v-divider />
|
||||||
<template v-for="(util, idx) in utilities">
|
<template
|
||||||
<v-list-item :key="util.id" dense class="py-1">
|
v-for="(util) in utilities"
|
||||||
|
:key="util.id"
|
||||||
|
>
|
||||||
|
<v-list-item
|
||||||
|
density="compact"
|
||||||
|
class="py-1"
|
||||||
|
>
|
||||||
<v-list-item-title>
|
<v-list-item-title>
|
||||||
<v-list-item-subtitle class="wrap-word">
|
<v-list-item-subtitle class="wrap-word">
|
||||||
{{ util.description }}
|
{{ util.description }}
|
||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<BaseButton small color="info" @click="util.action">
|
<BaseButton
|
||||||
<template #icon> {{ $globals.icons.robot }}</template>
|
size="small"
|
||||||
|
color="info"
|
||||||
|
@click="util.action"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
{{ $globals.icons.robot }}
|
||||||
|
</template>
|
||||||
{{ $t("general.run") }}
|
{{ $t("general.run") }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-divider :key="`divider-${idx}`" class="mx-2"></v-divider>
|
<v-divider class="mx-2" />
|
||||||
</template>
|
</template>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
<v-divider></v-divider>
|
<v-divider />
|
||||||
|
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<BaseButton cancel @click="dialog = false"> </BaseButton>
|
<BaseButton
|
||||||
<v-spacer></v-spacer>
|
cancel
|
||||||
<BaseButton save color="success" @click="save"> </BaseButton>
|
@click="dialog = false"
|
||||||
|
/>
|
||||||
|
<v-spacer />
|
||||||
|
<BaseButton
|
||||||
|
save
|
||||||
|
color="success"
|
||||||
|
@click="save"
|
||||||
|
/>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
@@ -56,8 +90,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { reactive, toRefs, defineComponent, useContext } from "@nuxtjs/composition-api";
|
export default defineNuxtComponent({
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
props: {
|
||||||
inputTextProp: {
|
inputTextProp: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -65,6 +98,7 @@ export default defineComponent({
|
|||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["bulk-data"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
dialog: false,
|
dialog: false,
|
||||||
@@ -72,12 +106,12 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function splitText() {
|
function splitText() {
|
||||||
return state.inputText.split("\n").filter((line) => !(line === "\n" || !line));
|
return state.inputText.split("\n").filter(line => !(line === "\n" || !line));
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFirstCharacter() {
|
function removeFirstCharacter() {
|
||||||
state.inputText = splitText()
|
state.inputText = splitText()
|
||||||
.map((line) => line.substring(1))
|
.map(line => line.substring(1))
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,22 +142,22 @@ export default defineComponent({
|
|||||||
state.dialog = false;
|
state.dialog = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const utilities = [
|
const utilities = [
|
||||||
{
|
{
|
||||||
id: "trim-whitespace",
|
id: "trim-whitespace",
|
||||||
description: i18n.tc("new-recipe.trim-whitespace-description"),
|
description: i18n.t("new-recipe.trim-whitespace-description"),
|
||||||
action: trimAllLines,
|
action: trimAllLines,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "trim-prefix",
|
id: "trim-prefix",
|
||||||
description: i18n.tc("new-recipe.trim-prefix-description"),
|
description: i18n.t("new-recipe.trim-prefix-description"),
|
||||||
action: removeFirstCharacter,
|
action: removeFirstCharacter,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "split-by-numbered-line",
|
id: "split-by-numbered-line",
|
||||||
description: i18n.tc("new-recipe.split-by-numbered-line-description"),
|
description: i18n.t("new-recipe.split-by-numbered-line-description"),
|
||||||
action: splitByNumberedLine,
|
action: splitByNumberedLine,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,16 +2,29 @@
|
|||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-model="dialog"
|
v-model="dialog"
|
||||||
:icon="$globals.icons.printerSettings"
|
:icon="$globals.icons.printerSettings"
|
||||||
:title="$tc('general.print-preferences')"
|
:title="$t('general.print-preferences')"
|
||||||
width="70%"
|
width="70%"
|
||||||
max-width="816px"
|
max-width="816px"
|
||||||
>
|
>
|
||||||
<div class="pa-6">
|
<div class="pa-6">
|
||||||
<v-container class="print-config mb-3 pa-0">
|
<v-container class="print-config mb-3 pa-0">
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="auto" align-self="center" class="text-center">
|
<v-col
|
||||||
<div class="text-subtitle-2" style="text-align: center;">{{ $tc('recipe.recipe-image') }}</div>
|
cols="auto"
|
||||||
<v-btn-toggle v-model="preferences.imagePosition" mandatory style="width: fit-content;">
|
align-self="center"
|
||||||
|
class="text-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="text-subtitle-2"
|
||||||
|
style="text-align: center;"
|
||||||
|
>
|
||||||
|
{{ $t('recipe.recipe-image') }}
|
||||||
|
</div>
|
||||||
|
<v-btn-toggle
|
||||||
|
v-model="preferences.imagePosition"
|
||||||
|
mandatory="force"
|
||||||
|
style="width: fit-content;"
|
||||||
|
>
|
||||||
<v-btn :value="ImagePosition.left">
|
<v-btn :value="ImagePosition.left">
|
||||||
<v-icon>{{ $globals.icons.dockLeft }}</v-icon>
|
<v-icon>{{ $globals.icons.dockLeft }}</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -23,20 +36,37 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</v-btn-toggle>
|
</v-btn-toggle>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="auto" align-self="start">
|
<v-col
|
||||||
|
cols="auto"
|
||||||
|
align-self="start"
|
||||||
|
>
|
||||||
<v-row no-gutters>
|
<v-row no-gutters>
|
||||||
<v-switch v-model="preferences.showDescription" hide-details :label="$tc('recipe.description')" />
|
<v-switch
|
||||||
|
v-model="preferences.showDescription"
|
||||||
|
hide-details
|
||||||
|
:label="$t('recipe.description')"
|
||||||
|
/>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row no-gutters>
|
<v-row no-gutters>
|
||||||
<v-switch v-model="preferences.showNotes" hide-details :label="$tc('recipe.notes')" />
|
<v-switch
|
||||||
|
v-model="preferences.showNotes"
|
||||||
|
hide-details
|
||||||
|
:label="$t('recipe.notes')"
|
||||||
|
/>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="auto" align-self="start">
|
<v-col
|
||||||
<v-row no-gutters>
|
cols="auto"
|
||||||
<v-switch v-model="preferences.showNutrition" hide-details :label="$tc('recipe.nutrition')" />
|
align-self="start"
|
||||||
</v-row>
|
>
|
||||||
<v-row no-gutters>
|
<v-row no-gutters>
|
||||||
|
<v-switch
|
||||||
|
v-model="preferences.showNutrition"
|
||||||
|
hide-details
|
||||||
|
:label="$t('recipe.nutrition')"
|
||||||
|
/>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
<v-row no-gutters />
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-container>
|
</v-container>
|
||||||
@@ -54,35 +84,36 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
|
||||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||||
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
||||||
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipePrintView,
|
RecipePrintView,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
recipe: {
|
recipe: {
|
||||||
type: Object as () => Recipe,
|
type: Object as () => NoUndefinedField<Recipe>,
|
||||||
default: undefined,
|
default: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const preferences = useUserPrintPreferences();
|
const preferences = useUserPrintPreferences();
|
||||||
|
|
||||||
// V-Model Support
|
// V-Model Support
|
||||||
const dialog = computed({
|
const dialog = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return props.value;
|
return props.modelValue;
|
||||||
},
|
},
|
||||||
set: (val) => {
|
set: (val) => {
|
||||||
context.emit("input", val);
|
context.emit("update:modelValue", val);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +121,7 @@ export default defineComponent({
|
|||||||
dialog,
|
dialog,
|
||||||
ImagePosition,
|
ImagePosition,
|
||||||
preferences,
|
preferences,
|
||||||
}
|
};
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,37 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<slot v-bind="{ open, close }"> </slot>
|
<slot v-bind="{ open, close }" />
|
||||||
<v-dialog v-model="dialog" max-width="988px" content-class="top-dialog" :scrollable="false">
|
<v-dialog
|
||||||
<v-app-bar sticky dark color="primary lighten-1" :rounded="!$vuetify.breakpoint.xs">
|
v-model="dialog"
|
||||||
|
max-width="988px"
|
||||||
|
content-class="top-dialog"
|
||||||
|
:scrollable="false"
|
||||||
|
>
|
||||||
|
<v-app-bar
|
||||||
|
sticky
|
||||||
|
dark
|
||||||
|
color="primary-lighten-1 top-0 position-relative left-0"
|
||||||
|
:rounded="!$vuetify.display.xs"
|
||||||
|
>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
id="arrow-search"
|
id="arrow-search"
|
||||||
v-model="search.query.value"
|
v-model="search.query.value"
|
||||||
autofocus
|
autofocus
|
||||||
solo
|
variant="solo"
|
||||||
flat
|
flat
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
background-color="primary lighten-1"
|
bg-color="primary-lighten-1"
|
||||||
color="white"
|
color="white"
|
||||||
dense
|
density="compact"
|
||||||
class="mx-2 arrow-search"
|
class="mx-2 arrow-search"
|
||||||
hide-details
|
hide-details
|
||||||
single-line
|
single-line
|
||||||
:placeholder="$t('search.search')"
|
:placeholder="$t('search.search')"
|
||||||
:prepend-inner-icon="$globals.icons.search"
|
:prepend-inner-icon="$globals.icons.search"
|
||||||
></v-text-field>
|
/>
|
||||||
|
|
||||||
<v-btn v-if="$vuetify.breakpoint.xs" x-small fab light @click="dialog = false">
|
<v-btn
|
||||||
|
v-if="$vuetify.display.xs"
|
||||||
|
size="x-small"
|
||||||
|
class="rounded-circle"
|
||||||
|
light
|
||||||
|
@click="dialog = false"
|
||||||
|
>
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ $globals.icons.close }}
|
{{ $globals.icons.close }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
<v-card class="mt-1 pa-1 scroll" max-height="700px" relative :loading="loading">
|
<v-card
|
||||||
|
class="position-relative mt-1 pa-1 scroll"
|
||||||
|
max-height="700px"
|
||||||
|
relative
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<div class="mr-auto">
|
<div class="mr-auto">
|
||||||
{{ $t("search.results") }}
|
{{ $t("search.results") }}
|
||||||
</div>
|
</div>
|
||||||
<router-link :to="advancedSearchUrl"> {{ $t("search.advanced-search") }} </router-link>
|
<!-- <router-link
|
||||||
|
:to="advancedSearchUrl"
|
||||||
|
class="text-primary"
|
||||||
|
> {{ $t("search.advanced-search") }} </router-link> -->
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
|
|
||||||
<RecipeCardMobile
|
<RecipeCardMobile
|
||||||
@@ -39,13 +63,13 @@
|
|||||||
:key="index"
|
:key="index"
|
||||||
:tabindex="index"
|
:tabindex="index"
|
||||||
class="ma-1 arrow-nav"
|
class="ma-1 arrow-nav"
|
||||||
:name="recipe.name"
|
:name="recipe.name ?? ''"
|
||||||
:description="recipe.description || ''"
|
:description="recipe.description ?? ''"
|
||||||
:slug="recipe.slug"
|
:slug="recipe.slug ?? ''"
|
||||||
:rating="recipe.rating"
|
:rating="recipe.rating ?? 0"
|
||||||
:image="recipe.image"
|
:image="recipe.image"
|
||||||
:recipe-id="recipe.id"
|
:recipe-id="recipe.id ?? ''"
|
||||||
v-on="$listeners.selected ? { selected: () => handleSelect(recipe) } : {}"
|
v-bind="$attrs.selected ? { selected: () => handleSelect(recipe) } : {}"
|
||||||
/>
|
/>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
@@ -53,21 +77,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, toRefs, reactive, ref, watch, useContext, useRoute } from "@nuxtjs/composition-api";
|
|
||||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { RecipeSummary } from "~/lib/api/types/recipe";
|
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
|
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
|
||||||
import { usePublicExploreApi } from "~/composables/api/api-client";
|
import { usePublicExploreApi } from "~/composables/api/api-client";
|
||||||
|
|
||||||
const SELECTED_EVENT = "selected";
|
const SELECTED_EVENT = "selected";
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeCardMobile,
|
RecipeCardMobile,
|
||||||
},
|
},
|
||||||
|
|
||||||
setup(_, context) {
|
setup(_, context) {
|
||||||
const { $auth } = useContext();
|
const $auth = useMealieAuth();
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
loading: false,
|
loading: false,
|
||||||
selectedIndex: -1,
|
selectedIndex: -1,
|
||||||
@@ -110,13 +134,16 @@ export default defineComponent({
|
|||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
console.log(document.activeElement);
|
console.log(document.activeElement);
|
||||||
// (document.activeElement as HTMLElement).click();
|
// (document.activeElement as HTMLElement).click();
|
||||||
} else if (e.key === "ArrowUp") {
|
}
|
||||||
|
else if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
state.selectedIndex--;
|
state.selectedIndex--;
|
||||||
} else if (e.key === "ArrowDown") {
|
}
|
||||||
|
else if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
state.selectedIndex++;
|
state.selectedIndex++;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selectRecipe();
|
selectRecipe();
|
||||||
@@ -125,14 +152,15 @@ export default defineComponent({
|
|||||||
watch(dialog, (val) => {
|
watch(dialog, (val) => {
|
||||||
if (!val) {
|
if (!val) {
|
||||||
document.removeEventListener("keyup", onUpDown);
|
document.removeEventListener("keyup", onUpDown);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
document.addEventListener("keyup", onUpDown);
|
document.addEventListener("keyup", onUpDown);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`)
|
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`);
|
||||||
watch(route, close);
|
watch(route, close);
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<BaseDialog v-model="dialog" :title="$t('recipe-share.share-recipe')" :icon="$globals.icons.link">
|
<BaseDialog
|
||||||
|
v-model="dialog"
|
||||||
|
:title="$t('recipe-share.share-recipe')"
|
||||||
|
:icon="$globals.icons.link"
|
||||||
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-menu
|
<v-menu
|
||||||
v-model="datePickerMenu"
|
v-model="datePickerMenu"
|
||||||
@@ -10,68 +14,94 @@
|
|||||||
max-width="290px"
|
max-width="290px"
|
||||||
min-width="auto"
|
min-width="auto"
|
||||||
>
|
>
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ props }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="expirationDate"
|
v-model="expirationDateString"
|
||||||
:label="$t('recipe-share.expiration-date')"
|
:label="$t('recipe-share.expiration-date')"
|
||||||
:hint="$t('recipe-share.default-30-days')"
|
:hint="$t('recipe-share.default-30-days')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
:prepend-icon="$globals.icons.calendar"
|
:prepend-icon="$globals.icons.calendar"
|
||||||
v-bind="attrs"
|
v-bind="props"
|
||||||
readonly
|
readonly
|
||||||
v-on="on"
|
/>
|
||||||
></v-text-field>
|
|
||||||
</template>
|
</template>
|
||||||
<v-date-picker
|
<v-date-picker
|
||||||
v-model="expirationDate"
|
v-model="expirationDate"
|
||||||
no-title
|
hide-header
|
||||||
:first-day-of-week="firstDayOfWeek"
|
:first-day-of-week="firstDayOfWeek"
|
||||||
:local="$i18n.locale"
|
:local="$i18n.locale"
|
||||||
@input="datePickerMenu = false"
|
@update:model-value="datePickerMenu = false"
|
||||||
/>
|
/>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions class="justify-end">
|
<v-card-actions class="justify-end">
|
||||||
<BaseButton small @click="createNewToken"> {{ $t("general.new") }}</BaseButton>
|
<BaseButton
|
||||||
|
size="small"
|
||||||
|
@click="createNewToken"
|
||||||
|
>
|
||||||
|
{{ $t("general.new") }}
|
||||||
|
</BaseButton>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
|
|
||||||
<v-list-item v-for="token in tokens" :key="token.id" @click="shareRecipe(token.id)">
|
<v-list-item
|
||||||
<v-list-item-avatar color="grey">
|
v-for="token in tokens"
|
||||||
<v-icon dark class="pa-2"> {{ $globals.icons.link }} </v-icon>
|
:key="token.id"
|
||||||
</v-list-item-avatar>
|
class="px-2"
|
||||||
|
style="padding-top: 8px; padding-bottom: 8px;"
|
||||||
|
@click="shareRecipe(token.id)"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center" style="width: 100%;">
|
||||||
|
<v-avatar color="grey">
|
||||||
|
<v-icon>
|
||||||
|
{{ $globals.icons.link }}
|
||||||
|
</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
<v-list-item-content>
|
<div class="pl-3 flex-grow-1">
|
||||||
<v-list-item-title> {{ $t("recipe-share.expires-at") }} </v-list-item-title>
|
<v-list-item-title>
|
||||||
|
{{ $t("recipe-share.expires-at") }}
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
{{ $d(new Date(token.expiresAt!), "long") }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
<v-list-item-subtitle>{{ $d(new Date(token.expiresAt), "long") }}</v-list-item-subtitle>
|
<v-btn
|
||||||
</v-list-item-content>
|
icon
|
||||||
|
variant="text"
|
||||||
<v-list-item-action>
|
class="ml-2"
|
||||||
<v-btn icon @click.stop="deleteToken(token.id)">
|
@click.stop="deleteToken(token.id)"
|
||||||
<v-icon color="error lighten-1"> {{ $globals.icons.delete }} </v-icon>
|
>
|
||||||
|
<v-icon color="error-lighten-1">
|
||||||
|
{{ $globals.icons.delete }}
|
||||||
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-list-item-action>
|
<v-btn
|
||||||
<v-list-item-action>
|
icon
|
||||||
<v-btn icon @click.stop="copyTokenLink(token.id)">
|
variant="text"
|
||||||
<v-icon color="info lighten-1"> {{ $globals.icons.contentCopy }} </v-icon>
|
class="ml-2"
|
||||||
|
@click.stop="copyTokenLink(token.id)"
|
||||||
|
>
|
||||||
|
<v-icon color="info-lighten-1">
|
||||||
|
{{ $globals.icons.contentCopy }}
|
||||||
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-list-item-action>
|
</div>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, toRefs, reactive, useContext, useRoute } from "@nuxtjs/composition-api";
|
|
||||||
import { useClipboard, useShare, whenever } from "@vueuse/core";
|
import { useClipboard, useShare, whenever } from "@vueuse/core";
|
||||||
import { RecipeShareToken } from "~/lib/api/types/recipe";
|
import type { RecipeShareToken } from "~/lib/api/types/recipe";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { useHouseholdSelf } from "~/composables/use-households";
|
import { useHouseholdSelf } from "~/composables/use-households";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
@@ -84,38 +114,43 @@ export default defineComponent({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
// V-Model Support
|
// V-Model Support
|
||||||
const dialog = computed({
|
const dialog = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return props.value;
|
return props.modelValue;
|
||||||
},
|
},
|
||||||
set: (val) => {
|
set: (val) => {
|
||||||
context.emit("input", val);
|
context.emit("update:modelValue", val);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
datePickerMenu: false,
|
datePickerMenu: false,
|
||||||
expirationDate: "",
|
expirationDate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
|
||||||
tokens: [] as RecipeShareToken[],
|
tokens: [] as RecipeShareToken[],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const expirationDateString = computed(() => {
|
||||||
|
return state.expirationDate.toISOString().substring(0, 10);
|
||||||
|
});
|
||||||
|
|
||||||
whenever(
|
whenever(
|
||||||
() => props.value,
|
() => props.modelValue,
|
||||||
() => {
|
() => {
|
||||||
// Set expiration date to today + 30 Days
|
// Set expiration date to today + 30 Days
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
state.expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||||
state.expirationDate = expirationDate.toISOString().substring(0, 10);
|
|
||||||
refreshTokens();
|
refreshTokens();
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { $auth, i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
const { household } = useHouseholdSelf();
|
const { household } = useHouseholdSelf();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
const firstDayOfWeek = computed(() => {
|
const firstDayOfWeek = computed(() => {
|
||||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||||
@@ -128,11 +163,9 @@ export default defineComponent({
|
|||||||
|
|
||||||
async function createNewToken() {
|
async function createNewToken() {
|
||||||
// Convert expiration date to timestamp
|
// Convert expiration date to timestamp
|
||||||
const expirationDate = new Date(state.expirationDate);
|
|
||||||
|
|
||||||
const { data } = await userApi.recipes.share.createOne({
|
const { data } = await userApi.recipes.share.createOne({
|
||||||
recipeId: props.recipeId,
|
recipeId: props.recipeId,
|
||||||
expiresAt: expirationDate.toISOString(),
|
expiresAt: state.expirationDate.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -142,7 +175,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
async function deleteToken(id: string) {
|
async function deleteToken(id: string) {
|
||||||
await userApi.recipes.share.deleteOne(id);
|
await userApi.recipes.share.deleteOne(id);
|
||||||
state.tokens = state.tokens.filter((token) => token.id !== id);
|
state.tokens = state.tokens.filter(token => token.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshTokens() {
|
async function refreshTokens() {
|
||||||
@@ -187,13 +220,15 @@ export default defineComponent({
|
|||||||
url: getTokenLink(token),
|
url: getTokenLink(token),
|
||||||
text: getRecipeText() as string,
|
text: getRecipeText() as string,
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
await copyTokenLink(token);
|
await copyTokenLink(token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...toRefs(state),
|
...toRefs(state),
|
||||||
|
expirationDateString,
|
||||||
dialog,
|
dialog,
|
||||||
createNewToken,
|
createNewToken,
|
||||||
deleteToken,
|
deleteToken,
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="pa-0">
|
<v-container
|
||||||
<div class="search-container py-8">
|
fluid
|
||||||
<form class="search-box pa-2" @submit.prevent="search">
|
class="px-0"
|
||||||
<div class="d-flex justify-center my-2">
|
>
|
||||||
|
<div class="search-container pb-8">
|
||||||
|
<form
|
||||||
|
class="search-box pa-2"
|
||||||
|
@submit.prevent="search"
|
||||||
|
>
|
||||||
|
<div class="d-flex justify-center mb-2">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
ref="input"
|
ref="input"
|
||||||
v-model="state.search"
|
v-model="state.search"
|
||||||
outlined
|
variant="outlined"
|
||||||
hide-details
|
hide-details
|
||||||
clearable
|
clearable
|
||||||
color="primary"
|
color="primary"
|
||||||
:placeholder="$tc('search.search-placeholder')"
|
:placeholder="$t('search.search-placeholder')"
|
||||||
:prepend-inner-icon="$globals.icons.search"
|
:prepend-inner-icon="$globals.icons.search"
|
||||||
@keyup.enter="hideKeyboard"
|
@keyup.enter="hideKeyboard"
|
||||||
/>
|
/>
|
||||||
@@ -20,134 +26,184 @@
|
|||||||
<SearchFilter
|
<SearchFilter
|
||||||
v-if="categories"
|
v-if="categories"
|
||||||
v-model="selectedCategories"
|
v-model="selectedCategories"
|
||||||
:require-all.sync="state.requireAllCategories"
|
v-model:require-all="state.requireAllCategories"
|
||||||
:items="categories"
|
:items="categories"
|
||||||
>
|
>
|
||||||
<v-icon left>
|
<v-icon start>
|
||||||
{{ $globals.icons.categories }}
|
{{ $globals.icons.categories }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $t("category.categories") }}
|
{{ $t("category.categories") }}
|
||||||
</SearchFilter>
|
</SearchFilter>
|
||||||
|
|
||||||
<!-- Tag Filter -->
|
<!-- Tag Filter -->
|
||||||
<SearchFilter v-if="tags" v-model="selectedTags" :require-all.sync="state.requireAllTags" :items="tags">
|
<SearchFilter
|
||||||
<v-icon left>
|
v-if="tags"
|
||||||
|
v-model="selectedTags"
|
||||||
|
v-model:require-all="state.requireAllTags"
|
||||||
|
:items="tags"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
{{ $globals.icons.tags }}
|
{{ $globals.icons.tags }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $t("tag.tags") }}
|
{{ $t("tag.tags") }}
|
||||||
</SearchFilter>
|
</SearchFilter>
|
||||||
|
|
||||||
<!-- Tool Filter -->
|
<!-- Tool Filter -->
|
||||||
<SearchFilter v-if="tools" v-model="selectedTools" :require-all.sync="state.requireAllTools" :items="tools">
|
<SearchFilter
|
||||||
<v-icon left>
|
v-if="tools"
|
||||||
|
v-model="selectedTools"
|
||||||
|
v-model:require-all="state.requireAllTools"
|
||||||
|
:items="tools"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
{{ $globals.icons.potSteam }}
|
{{ $globals.icons.potSteam }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $t("tool.tools") }}
|
{{ $t("tool.tools") }}
|
||||||
</SearchFilter>
|
</SearchFilter>
|
||||||
|
|
||||||
<!-- Food Filter -->
|
<!-- Food Filter -->
|
||||||
<SearchFilter v-if="foods" v-model="selectedFoods" :require-all.sync="state.requireAllFoods" :items="foods">
|
<SearchFilter
|
||||||
<v-icon left>
|
v-if="foods"
|
||||||
|
v-model="selectedFoods"
|
||||||
|
v-model:require-all="state.requireAllFoods"
|
||||||
|
:items="foods"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
{{ $globals.icons.foods }}
|
{{ $globals.icons.foods }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $t("general.foods") }}
|
{{ $t("general.foods") }}
|
||||||
</SearchFilter>
|
</SearchFilter>
|
||||||
|
|
||||||
<!-- Household Filter -->
|
<!-- Household Filter -->
|
||||||
<SearchFilter v-if="households.length > 1" v-model="selectedHouseholds" :items="households" radio>
|
<SearchFilter
|
||||||
<v-icon left>
|
v-if="households.length > 1"
|
||||||
|
v-model="selectedHouseholds"
|
||||||
|
:items="households"
|
||||||
|
radio
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
{{ $globals.icons.household }}
|
{{ $globals.icons.household }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $t("household.households") }}
|
{{ $t("household.households") }}
|
||||||
</SearchFilter>
|
</SearchFilter>
|
||||||
|
|
||||||
<!-- Sort Options -->
|
<!-- Sort Options -->
|
||||||
<v-menu offset-y nudge-bottom="3">
|
<v-menu
|
||||||
<template #activator="{ on, attrs }">
|
offset-y
|
||||||
<v-btn class="ml-auto" small color="accent" v-bind="attrs" v-on="on">
|
nudge-bottom="3"
|
||||||
<v-icon :left="!$vuetify.breakpoint.xsOnly">
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
class="ml-auto"
|
||||||
|
size="small"
|
||||||
|
color="accent"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon :start="!$vuetify.display.xs">
|
||||||
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
|
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $vuetify.breakpoint.xsOnly ? null : sortText }}
|
{{ $vuetify.display.xs ? null : sortText }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-list>
|
<v-list>
|
||||||
<v-list-item @click="toggleOrderDirection()">
|
<v-list-item
|
||||||
<v-icon left>
|
slim
|
||||||
{{
|
density="comfortable"
|
||||||
state.orderDirection === "asc" ?
|
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
|
||||||
$globals.icons.sortDescending : $globals.icons.sortAscending
|
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
||||||
}}
|
@click="toggleOrderDirection()"
|
||||||
</v-icon>
|
/>
|
||||||
<v-list-item-title>
|
|
||||||
{{ state.orderDirection === "asc" ? $tc("general.sort-descending") : $tc("general.sort-ascending") }}
|
|
||||||
</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="v in sortable"
|
v-for="v in sortable"
|
||||||
:key="v.name"
|
:key="v.name"
|
||||||
:input-value="state.orderBy === v.value"
|
:active="state.orderBy === v.value"
|
||||||
|
slim
|
||||||
|
density="comfortable"
|
||||||
|
:prepend-icon="v.icon"
|
||||||
|
:title="v.name"
|
||||||
@click="state.orderBy = v.value"
|
@click="state.orderBy = v.value"
|
||||||
>
|
/>
|
||||||
<v-icon left>
|
|
||||||
{{ v.icon }}
|
|
||||||
</v-icon>
|
|
||||||
<v-list-item-title>{{ v.name }}</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<v-menu offset-y bottom left nudge-bottom="3" :close-on-content-click="false">
|
<v-menu
|
||||||
<template #activator="{ on, attrs }">
|
offset-y
|
||||||
<v-btn small color="accent" dark v-bind="attrs" v-on="on">
|
bottom
|
||||||
<v-icon small>
|
start
|
||||||
|
nudge-bottom="3"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
color="accent"
|
||||||
|
dark
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon size="small">
|
||||||
{{ $globals.icons.cog }}
|
{{ $globals.icons.cog }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-switch v-model="state.auto" :label="$t('search.auto-search')" single-line></v-switch>
|
<v-switch
|
||||||
<v-btn block color="primary" @click="reset">
|
v-model="state.auto"
|
||||||
{{ $tc("general.reset") }}
|
:label="$t('search.auto-search')"
|
||||||
|
single-line
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
color="primary"
|
||||||
|
@click="reset"
|
||||||
|
>
|
||||||
|
{{ $t("general.reset") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!state.auto" class="search-button-container">
|
<div
|
||||||
<v-btn x-large color="primary" type="submit" block>
|
v-if="!state.auto"
|
||||||
<v-icon left>
|
class="search-button-container"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
size="x-large"
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
{{ $globals.icons.search }}
|
{{ $globals.icons.search }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $tc("search.search") }}
|
{{ $t("search.search") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<v-divider></v-divider>
|
<v-divider />
|
||||||
<v-container class="mt-6 px-md-6">
|
<v-container class="mt-6 px-md-6">
|
||||||
<RecipeCardSection
|
<RecipeCardSection
|
||||||
v-if="state.ready"
|
v-if="state.ready"
|
||||||
class="mt-n5"
|
class="mt-n5"
|
||||||
:icon="$globals.icons.silverwareForkKnife"
|
:icon="$globals.icons.silverwareForkKnife"
|
||||||
:title="$tc('general.recipes')"
|
:title="$t('general.recipes')"
|
||||||
:recipes="recipes"
|
:recipes="recipes"
|
||||||
:query="passedQueryWithSeed"
|
:query="passedQueryWithSeed"
|
||||||
|
disable-sort
|
||||||
@item-selected="filterItems"
|
@item-selected="filterItems"
|
||||||
@replaceRecipes="replaceRecipes"
|
@replace-recipes="replaceRecipes"
|
||||||
@appendRecipes="appendRecipes"
|
@append-recipes="appendRecipes"
|
||||||
/>
|
/>
|
||||||
</v-container>
|
</v-container>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ref, defineComponent, useRouter, onMounted, useContext, computed, Ref, useRoute, watch } from "@nuxtjs/composition-api";
|
|
||||||
import { watchDebounced } from "@vueuse/shared";
|
import { watchDebounced } from "@vueuse/shared";
|
||||||
import SearchFilter from "~/components/Domain/SearchFilter.vue";
|
import SearchFilter from "~/components/Domain/SearchFilter.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
@@ -165,17 +221,19 @@ import {
|
|||||||
} from "~/composables/store";
|
} from "~/composables/store";
|
||||||
import { useUserSearchQuerySession } from "~/composables/use-users/preferences";
|
import { useUserSearchQuerySession } from "~/composables/use-users/preferences";
|
||||||
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
||||||
import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { useLazyRecipes } from "~/composables/recipes";
|
import { useLazyRecipes } from "~/composables/recipes";
|
||||||
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: { SearchFilter, RecipeCardSection },
|
components: { SearchFilter, RecipeCardSection },
|
||||||
setup() {
|
setup() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { $auth, $globals, i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
const state = ref({
|
const state = ref({
|
||||||
@@ -193,7 +251,7 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
const searchQuerySession = useUserSearchQuerySession();
|
const searchQuerySession = useUserSearchQuerySession();
|
||||||
|
|
||||||
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||||
@@ -236,9 +294,9 @@ export default defineComponent({
|
|||||||
const passedQueryWithSeed = computed(() => {
|
const passedQueryWithSeed = computed(() => {
|
||||||
return {
|
return {
|
||||||
...passedQuery.value,
|
...passedQuery.value,
|
||||||
_searchSeed: Date.now().toString()
|
_searchSeed: Date.now().toString(),
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
const queryDefaults = {
|
const queryDefaults = {
|
||||||
search: "",
|
search: "",
|
||||||
@@ -248,7 +306,7 @@ export default defineComponent({
|
|||||||
requireAllTags: false,
|
requireAllTags: false,
|
||||||
requireAllTools: false,
|
requireAllTools: false,
|
||||||
requireAllFoods: false,
|
requireAllFoods: false,
|
||||||
}
|
};
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
state.value.search = queryDefaults.search;
|
state.value.search = queryDefaults.search;
|
||||||
@@ -271,11 +329,11 @@ export default defineComponent({
|
|||||||
|
|
||||||
function toIDArray(array: { id: string }[]) {
|
function toIDArray(array: { id: string }[]) {
|
||||||
// we sort the array to make sure the query is always the same
|
// we sort the array to make sure the query is always the same
|
||||||
return array.map((item) => item.id).sort();
|
return array.map(item => item.id).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideKeyboard() {
|
function hideKeyboard() {
|
||||||
input.value.blur()
|
input.value.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
const input: Ref<any> = ref(null);
|
const input: Ref<any> = ref(null);
|
||||||
@@ -306,7 +364,7 @@ export default defineComponent({
|
|||||||
requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
|
requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
|
||||||
requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined,
|
requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
await router.push({ query });
|
await router.push({ query });
|
||||||
searchQuerySession.value.recipe = JSON.stringify(query);
|
searchQuerySession.value.recipe = JSON.stringify(query);
|
||||||
}
|
}
|
||||||
@@ -314,7 +372,7 @@ export default defineComponent({
|
|||||||
function waitUntilAndExecute(
|
function waitUntilAndExecute(
|
||||||
condition: () => boolean,
|
condition: () => boolean,
|
||||||
callback: () => void,
|
callback: () => void,
|
||||||
opts = { timeout: 2000, interval: 500 }
|
opts = { timeout: 2000, interval: 500 },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const state = {
|
const state = {
|
||||||
@@ -341,7 +399,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sortText = computed(() => {
|
const sortText = computed(() => {
|
||||||
const sort = sortable.find((s) => s.value === state.value.orderBy);
|
const sort = sortable.find(s => s.value === state.value.orderBy);
|
||||||
if (!sort) return "";
|
if (!sort) return "";
|
||||||
return `${sort.name}`;
|
return `${sort.name}`;
|
||||||
});
|
});
|
||||||
@@ -349,103 +407,112 @@ export default defineComponent({
|
|||||||
const sortable = [
|
const sortable = [
|
||||||
{
|
{
|
||||||
icon: $globals.icons.orderAlphabeticalAscending,
|
icon: $globals.icons.orderAlphabeticalAscending,
|
||||||
name: i18n.tc("general.sort-alphabetically"),
|
name: i18n.t("general.sort-alphabetically"),
|
||||||
value: "name",
|
value: "name",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.newBox,
|
icon: $globals.icons.newBox,
|
||||||
name: i18n.tc("general.created"),
|
name: i18n.t("general.created"),
|
||||||
value: "created_at",
|
value: "created_at",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.chefHat,
|
icon: $globals.icons.chefHat,
|
||||||
name: i18n.tc("general.last-made"),
|
name: i18n.t("general.last-made"),
|
||||||
value: "last_made",
|
value: "last_made",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.star,
|
icon: $globals.icons.star,
|
||||||
name: i18n.tc("general.rating"),
|
name: i18n.t("general.rating"),
|
||||||
value: "rating",
|
value: "rating",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.update,
|
icon: $globals.icons.update,
|
||||||
name: i18n.tc("general.updated"),
|
name: i18n.t("general.updated"),
|
||||||
value: "updated_at",
|
value: "updated_at",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.diceMultiple,
|
icon: $globals.icons.diceMultiple,
|
||||||
name: i18n.tc("general.random"),
|
name: i18n.t("general.random"),
|
||||||
value: "random",
|
value: "random",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.value.query,
|
() => route.query,
|
||||||
() => {
|
() => {
|
||||||
if (!Object.keys(route.value.query).length) {
|
if (!Object.keys(route.query).length) {
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) {
|
function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) {
|
||||||
if (urlPrefix === "categories") {
|
if (urlPrefix === "categories") {
|
||||||
const result = categories.store.value.filter((category) => (category.id as string).includes(item.id as string));
|
const result = categories.store.value.filter(category => (category.id as string).includes(item.id as string));
|
||||||
selectedCategories.value = result as NoUndefinedField<RecipeTag>[];
|
selectedCategories.value = result as NoUndefinedField<RecipeTag>[];
|
||||||
} else if (urlPrefix === "tags") {
|
}
|
||||||
const result = tags.store.value.filter((tag) => (tag.id as string).includes(item.id as string));
|
else if (urlPrefix === "tags") {
|
||||||
|
const result = tags.store.value.filter(tag => (tag.id as string).includes(item.id as string));
|
||||||
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
||||||
} else if (urlPrefix === "tools") {
|
}
|
||||||
const result = tools.store.value.filter((tool) => (tool.id ).includes(item.id || "" ));
|
else if (urlPrefix === "tools") {
|
||||||
|
const result = tools.store.value.filter(tool => (tool.id).includes(item.id || ""));
|
||||||
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateSearch() {
|
async function hydrateSearch() {
|
||||||
const query = router.currentRoute.query;
|
const query = router.currentRoute.value.query;
|
||||||
if (query.auto?.length) {
|
if (query.auto?.length) {
|
||||||
state.value.auto = query.auto === "true";
|
state.value.auto = query.auto === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.search?.length) {
|
if (query.search?.length) {
|
||||||
state.value.search = query.search as string;
|
state.value.search = query.search as string;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
state.value.search = queryDefaults.search;
|
state.value.search = queryDefaults.search;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.orderBy?.length) {
|
if (query.orderBy?.length) {
|
||||||
state.value.orderBy = query.orderBy as string;
|
state.value.orderBy = query.orderBy as string;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
state.value.orderBy = queryDefaults.orderBy;
|
state.value.orderBy = queryDefaults.orderBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.orderDirection?.length) {
|
if (query.orderDirection?.length) {
|
||||||
state.value.orderDirection = query.orderDirection as "asc" | "desc";
|
state.value.orderDirection = query.orderDirection as "asc" | "desc";
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
state.value.orderDirection = queryDefaults.orderDirection;
|
state.value.orderDirection = queryDefaults.orderDirection;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.requireAllCategories?.length) {
|
if (query.requireAllCategories?.length) {
|
||||||
state.value.requireAllCategories = query.requireAllCategories === "true";
|
state.value.requireAllCategories = query.requireAllCategories === "true";
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
state.value.requireAllCategories = queryDefaults.requireAllCategories;
|
state.value.requireAllCategories = queryDefaults.requireAllCategories;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.requireAllTags?.length) {
|
if (query.requireAllTags?.length) {
|
||||||
state.value.requireAllTags = query.requireAllTags === "true";
|
state.value.requireAllTags = query.requireAllTags === "true";
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
state.value.requireAllTags = queryDefaults.requireAllTags;
|
state.value.requireAllTags = queryDefaults.requireAllTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.requireAllTools?.length) {
|
if (query.requireAllTools?.length) {
|
||||||
state.value.requireAllTools = query.requireAllTools === "true";
|
state.value.requireAllTools = query.requireAllTools === "true";
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
state.value.requireAllTools = queryDefaults.requireAllTools;
|
state.value.requireAllTools = queryDefaults.requireAllTools;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.requireAllFoods?.length) {
|
if (query.requireAllFoods?.length) {
|
||||||
state.value.requireAllFoods = query.requireAllFoods === "true";
|
state.value.requireAllFoods = query.requireAllFoods === "true";
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
state.value.requireAllFoods = queryDefaults.requireAllFoods;
|
state.value.requireAllFoods = queryDefaults.requireAllFoods;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,15 +523,16 @@ export default defineComponent({
|
|||||||
waitUntilAndExecute(
|
waitUntilAndExecute(
|
||||||
() => categories.store.value.length > 0,
|
() => categories.store.value.length > 0,
|
||||||
() => {
|
() => {
|
||||||
const result = categories.store.value.filter((item) =>
|
const result = categories.store.value.filter(item =>
|
||||||
(query.categories as string[]).includes(item.id as string)
|
(query.categories as string[]).includes(item.id as string),
|
||||||
);
|
);
|
||||||
|
|
||||||
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
|
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
selectedCategories.value = [];
|
selectedCategories.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,12 +541,13 @@ export default defineComponent({
|
|||||||
waitUntilAndExecute(
|
waitUntilAndExecute(
|
||||||
() => tags.store.value.length > 0,
|
() => tags.store.value.length > 0,
|
||||||
() => {
|
() => {
|
||||||
const result = tags.store.value.filter((item) => (query.tags as string[]).includes(item.id as string));
|
const result = tags.store.value.filter(item => (query.tags as string[]).includes(item.id as string));
|
||||||
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
selectedTags.value = [];
|
selectedTags.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,12 +556,13 @@ export default defineComponent({
|
|||||||
waitUntilAndExecute(
|
waitUntilAndExecute(
|
||||||
() => tools.store.value.length > 0,
|
() => tools.store.value.length > 0,
|
||||||
() => {
|
() => {
|
||||||
const result = tools.store.value.filter((item) => (query.tools as string[]).includes(item.id));
|
const result = tools.store.value.filter(item => (query.tools as string[]).includes(item.id));
|
||||||
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
|
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
selectedTools.value = [];
|
selectedTools.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,12 +576,13 @@ export default defineComponent({
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
const result = foods.store.value?.filter((item) => (query.foods as string[]).includes(item.id));
|
const result = foods.store.value?.filter(item => (query.foods as string[]).includes(item.id));
|
||||||
selectedFoods.value = result ?? [];
|
selectedFoods.value = result ?? [];
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
selectedFoods.value = [];
|
selectedFoods.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,12 +596,13 @@ export default defineComponent({
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
const result = households.store.value?.filter((item) => (query.households as string[]).includes(item.id));
|
const result = households.store.value?.filter(item => (query.households as string[]).includes(item.id));
|
||||||
selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? [];
|
selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? [];
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
selectedHouseholds.value = [];
|
selectedHouseholds.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,11 +611,12 @@ export default defineComponent({
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// restore the user's last search query
|
// restore the user's last search query
|
||||||
if (searchQuerySession.value.recipe && !(Object.keys(route.value.query).length > 0)) {
|
if (searchQuerySession.value.recipe && !(Object.keys(route.query).length > 0)) {
|
||||||
try {
|
try {
|
||||||
const query = JSON.parse(searchQuerySession.value.recipe);
|
const query = JSON.parse(searchQuerySession.value.recipe);
|
||||||
await router.replace({ query });
|
await router.replace({ query });
|
||||||
} catch (error) {
|
}
|
||||||
|
catch {
|
||||||
searchQuerySession.value.recipe = "";
|
searchQuerySession.value.recipe = "";
|
||||||
router.replace({ query: {} });
|
router.replace({ query: {} });
|
||||||
}
|
}
|
||||||
@@ -576,7 +649,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
debounce: 500,
|
debounce: 500,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -610,7 +683,6 @@ export default defineComponent({
|
|||||||
filterItems,
|
filterItems,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
head: {},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-tooltip bottom nudge-right="50" :color="buttonStyle ? 'info' : 'secondary'">
|
<v-tooltip
|
||||||
<template #activator="{ on, attrs }">
|
location="bottom"
|
||||||
|
nudge-right="50"
|
||||||
|
:color="buttonStyle ? 'info' : 'secondary'"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isFavorite || showAlways"
|
v-if="isFavorite || showAlways"
|
||||||
small
|
icon
|
||||||
|
:variant="buttonStyle ? 'flat' : undefined"
|
||||||
|
:rounded="buttonStyle ? 'circle' : undefined"
|
||||||
|
size="small"
|
||||||
:color="buttonStyle ? 'info' : 'secondary'"
|
:color="buttonStyle ? 'info' : 'secondary'"
|
||||||
:icon="!buttonStyle"
|
|
||||||
:fab="buttonStyle"
|
:fab="buttonStyle"
|
||||||
v-bind="attrs"
|
v-bind="{ ...props, ...$attrs }"
|
||||||
@click.prevent="toggleFavorite"
|
@click.prevent="toggleFavorite"
|
||||||
v-on="on"
|
|
||||||
>
|
>
|
||||||
<v-icon :small="!buttonStyle" :color="buttonStyle ? 'white' : 'secondary'">
|
<v-icon
|
||||||
|
:size="!buttonStyle ? undefined : 'x-large'"
|
||||||
|
:color="buttonStyle ? 'white' : 'secondary'"
|
||||||
|
>
|
||||||
{{ isFavorite ? $globals.icons.heart : $globals.icons.heartOutline }}
|
{{ isFavorite ? $globals.icons.heart : $globals.icons.heartOutline }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -21,11 +29,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import { useUserSelfRatings } from "~/composables/use-users";
|
import { useUserSelfRatings } from "~/composables/use-users";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { UserOut } from "~/lib/api/types/user";
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
recipeId: {
|
recipeId: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -42,22 +49,21 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const { $auth } = useContext();
|
const $auth = useMealieAuth();
|
||||||
const { userRatings, refreshUserRatings } = useUserSelfRatings();
|
const { userRatings, refreshUserRatings } = useUserSelfRatings();
|
||||||
|
|
||||||
// TODO Setup the correct type for $auth.user
|
|
||||||
// See https://github.com/nuxt-community/auth-module/issues/1097
|
|
||||||
const user = computed(() => $auth.user as unknown as UserOut);
|
|
||||||
const isFavorite = computed(() => {
|
const isFavorite = computed(() => {
|
||||||
const rating = userRatings.value.find((r) => r.recipeId === props.recipeId);
|
const rating = userRatings.value.find(r => r.recipeId === props.recipeId);
|
||||||
return rating?.isFavorite || false;
|
return rating?.isFavorite || false;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function toggleFavorite() {
|
async function toggleFavorite() {
|
||||||
|
if (!$auth.user.value) return;
|
||||||
if (!isFavorite.value) {
|
if (!isFavorite.value) {
|
||||||
await api.users.addFavorite(user.value?.id, props.recipeId);
|
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
||||||
} else {
|
}
|
||||||
await api.users.removeFavorite(user.value?.id, props.recipeId);
|
else {
|
||||||
|
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
||||||
}
|
}
|
||||||
await refreshUserRatings();
|
await refreshUserRatings();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<v-menu v-model="menu" offset-y top nudge-top="6" :close-on-content-click="false">
|
<v-menu
|
||||||
<template #activator="{ on, attrs }">
|
v-model="menu"
|
||||||
<v-btn color="accent" dark v-bind="attrs" v-on="on">
|
offset-y
|
||||||
<v-icon left>
|
top
|
||||||
|
nudge-top="6"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
color="accent"
|
||||||
|
dark
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
{{ $globals.icons.fileImage }}
|
{{ $globals.icons.fileImage }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $t("general.image") }}
|
{{ $t("general.image") }}
|
||||||
@@ -25,9 +35,21 @@
|
|||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text class="mt-n5">
|
<v-card-text class="mt-n5">
|
||||||
<div>
|
<div>
|
||||||
<v-text-field v-model="url" :label="$t('general.url')" class="pt-5" clearable :messages="messages">
|
<v-text-field
|
||||||
<template #append-outer>
|
v-model="url"
|
||||||
<v-btn class="ml-2" color="primary" :loading="loading" :disabled="!slug" @click="getImageFromURL">
|
:label="$t('general.url')"
|
||||||
|
class="pt-5"
|
||||||
|
clearable
|
||||||
|
:messages="messages"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<v-btn
|
||||||
|
class="ml-2"
|
||||||
|
color="primary"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!slug"
|
||||||
|
@click="getImageFromURL"
|
||||||
|
>
|
||||||
{{ $t("general.get") }}
|
{{ $t("general.get") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
@@ -40,13 +62,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
|
||||||
const REFRESH_EVENT = "refresh";
|
const REFRESH_EVENT = "refresh";
|
||||||
const UPLOAD_EVENT = "upload";
|
const UPLOAD_EVENT = "upload";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
slug: {
|
slug: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -58,7 +79,7 @@ export default defineComponent({
|
|||||||
url: "",
|
url: "",
|
||||||
loading: false,
|
loading: false,
|
||||||
menu: false,
|
menu: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
function uploadImage(fileObject: File) {
|
function uploadImage(fileObject: File) {
|
||||||
context.emit(UPLOAD_EVENT, fileObject);
|
context.emit(UPLOAD_EVENT, fileObject);
|
||||||
@@ -75,7 +96,7 @@ export default defineComponent({
|
|||||||
state.menu = false;
|
state.menu = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")];
|
const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,101 +1,148 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-if="value.title || showTitle"
|
v-if="model.title || showTitle"
|
||||||
v-model="value.title"
|
v-model="model.title"
|
||||||
dense
|
density="compact"
|
||||||
|
variant="underlined"
|
||||||
hide-details
|
hide-details
|
||||||
class="mx-1 mt-3 mb-4"
|
class="mx-1 mt-3 mb-4"
|
||||||
:placeholder="$t('recipe.section-title')"
|
:placeholder="$t('recipe.section-title')"
|
||||||
style="max-width: 500px"
|
style="max-width: 500px"
|
||||||
@click="$emit('clickIngredientField', 'title')"
|
@click="$emit('clickIngredientField', 'title')"
|
||||||
>
|
/>
|
||||||
</v-text-field>
|
<v-row
|
||||||
<v-row :no-gutters="$vuetify.breakpoint.mdAndUp" dense class="d-flex flex-wrap my-1">
|
:no-gutters="mdAndUp"
|
||||||
<v-col v-if="!disableAmount" sm="12" md="2" cols="12" class="flex-grow-0 flex-shrink-0">
|
|
||||||
<v-text-field
|
|
||||||
v-model="value.quantity"
|
|
||||||
solo
|
|
||||||
hide-details
|
|
||||||
dense
|
dense
|
||||||
|
class="d-flex flex-wrap my-1"
|
||||||
|
>
|
||||||
|
<v-col
|
||||||
|
v-if="!disableAmount"
|
||||||
|
sm="12"
|
||||||
|
md="2"
|
||||||
|
cols="12"
|
||||||
|
class="flex-grow-0 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="model.quantity"
|
||||||
|
variant="solo"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
type="number"
|
type="number"
|
||||||
:placeholder="$t('recipe.quantity')"
|
:placeholder="$t('recipe.quantity')"
|
||||||
@keypress="quantityFilter"
|
@keypress="quantityFilter"
|
||||||
>
|
>
|
||||||
<v-icon v-if="$listeners && $listeners.delete" slot="prepend" class="mr-n1 handle">
|
<template #prepend>
|
||||||
|
<v-icon
|
||||||
|
class="mr-n1 handle"
|
||||||
|
>
|
||||||
{{ $globals.icons.arrowUpDown }}
|
{{ $globals.icons.arrowUpDown }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
|
</template>
|
||||||
</v-text-field>
|
</v-text-field>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col v-if="!disableAmount" sm="12" md="3" cols="12">
|
<v-col
|
||||||
|
v-if="!disableAmount"
|
||||||
|
sm="12"
|
||||||
|
md="3"
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
ref="unitAutocomplete"
|
ref="unitAutocomplete"
|
||||||
v-model="value.unit"
|
v-model="model.unit"
|
||||||
:search-input.sync="unitSearch"
|
v-model:search="unitSearch"
|
||||||
auto-select-first
|
auto-select-first
|
||||||
hide-details
|
hide-details
|
||||||
dense
|
density="compact"
|
||||||
solo
|
variant="solo"
|
||||||
return-object
|
return-object
|
||||||
:items="units || []"
|
:items="units || []"
|
||||||
item-text="name"
|
item-title="name"
|
||||||
class="mx-1"
|
class="mx-1"
|
||||||
:placeholder="$t('recipe.choose-unit')"
|
:placeholder="$t('recipe.choose-unit')"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="handleUnitEnter"
|
@keyup.enter="handleUnitEnter"
|
||||||
>
|
>
|
||||||
<template #no-data>
|
<template #no-data>
|
||||||
<div class="caption text-center pb-2">{{ $t("recipe.press-enter-to-create") }}</div>
|
<div class="caption text-center pb-2">
|
||||||
|
{{ $t("recipe.press-enter-to-create") }}
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #append-item>
|
<template #append-item>
|
||||||
<div class="px-2">
|
<div class="px-2">
|
||||||
<BaseButton block small @click="createAssignUnit()"></BaseButton>
|
<BaseButton
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
@click="createAssignUnit()"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-autocomplete>
|
</v-autocomplete>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- Foods Input -->
|
<!-- Foods Input -->
|
||||||
<v-col v-if="!disableAmount" m="12" md="3" cols="12" class="">
|
<v-col
|
||||||
|
v-if="!disableAmount"
|
||||||
|
m="12"
|
||||||
|
md="3"
|
||||||
|
cols="12"
|
||||||
|
class=""
|
||||||
|
>
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
ref="foodAutocomplete"
|
ref="foodAutocomplete"
|
||||||
v-model="value.food"
|
v-model="model.food"
|
||||||
:search-input.sync="foodSearch"
|
v-model:search="foodSearch"
|
||||||
auto-select-first
|
auto-select-first
|
||||||
hide-details
|
hide-details
|
||||||
dense
|
density="compact"
|
||||||
solo
|
variant="solo"
|
||||||
return-object
|
return-object
|
||||||
:items="foods || []"
|
:items="foods || []"
|
||||||
item-text="name"
|
item-title="name"
|
||||||
class="mx-1 py-0"
|
class="mx-1 py-0"
|
||||||
:placeholder="$t('recipe.choose-food')"
|
:placeholder="$t('recipe.choose-food')"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="handleFoodEnter"
|
@keyup.enter="handleFoodEnter"
|
||||||
>
|
>
|
||||||
<template #no-data>
|
<template #no-data>
|
||||||
<div class="caption text-center pb-2">{{ $t("recipe.press-enter-to-create") }}</div>
|
<div class="caption text-center pb-2">
|
||||||
|
{{ $t("recipe.press-enter-to-create") }}
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #append-item>
|
<template #append-item>
|
||||||
<div class="px-2">
|
<div class="px-2">
|
||||||
<BaseButton block small @click="createAssignFood()"></BaseButton>
|
<BaseButton
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
@click="createAssignFood()"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-autocomplete>
|
</v-autocomplete>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col sm="12" md="" cols="12">
|
<v-col
|
||||||
|
sm="12"
|
||||||
|
md=""
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="value.note"
|
v-model="model.note"
|
||||||
hide-details
|
hide-details
|
||||||
dense
|
density="compact"
|
||||||
solo
|
variant="solo"
|
||||||
:placeholder="$t('recipe.notes')"
|
:placeholder="$t('recipe.notes')"
|
||||||
|
class="mb-auto"
|
||||||
@click="$emit('clickIngredientField', 'note')"
|
@click="$emit('clickIngredientField', 'note')"
|
||||||
>
|
>
|
||||||
<v-icon v-if="disableAmount && $listeners && $listeners.delete" slot="prepend" class="mr-n1 handle">
|
<template #prepend>
|
||||||
|
<v-icon
|
||||||
|
v-if="disableAmount && $attrs && $attrs.delete"
|
||||||
|
class="mr-n1 handle"
|
||||||
|
>
|
||||||
{{ $globals.icons.arrowUpDown }}
|
{{ $globals.icons.arrowUpDown }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
|
</template>
|
||||||
</v-text-field>
|
</v-text-field>
|
||||||
<BaseButtonGroup
|
<BaseButtonGroup
|
||||||
hover
|
hover
|
||||||
@@ -112,26 +159,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<p v-if="showOriginalText" class="text-caption">
|
<p
|
||||||
{{ $t("recipe.original-text-with-value", { originalText: value.originalText }) }}
|
v-if="showOriginalText"
|
||||||
|
class="text-caption"
|
||||||
|
>
|
||||||
|
{{ $t("recipe.original-text-with-value", { originalText: model.originalText }) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<v-divider v-if="!$vuetify.breakpoint.mdAndUp" class="my-4"></v-divider>
|
<v-divider
|
||||||
|
v-if="!mdAndUp"
|
||||||
|
class="my-4"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
import { ref, computed, reactive, toRefs } from "vue";
|
||||||
|
import { useDisplay } from "vuetify";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||||
import { validators } from "~/composables/use-validators";
|
import { useNuxtApp } from "#app";
|
||||||
import { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineComponent({
|
// defineModel replaces modelValue prop
|
||||||
props: {
|
const model = defineModel<RecipeIngredient>({ required: true });
|
||||||
value: {
|
|
||||||
type: Object as () => RecipeIngredient,
|
const props = defineProps({
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
disableAmount: {
|
disableAmount: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -139,45 +192,52 @@ export default defineComponent({
|
|||||||
allowInsertIngredient: {
|
allowInsertIngredient: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
}
|
|
||||||
},
|
},
|
||||||
setup(props, { listeners }) {
|
});
|
||||||
const { i18n, $globals } = useContext();
|
|
||||||
|
defineEmits([
|
||||||
|
"clickIngredientField",
|
||||||
|
"insert-above",
|
||||||
|
"insert-below",
|
||||||
|
"insert-ingredient",
|
||||||
|
"delete",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { mdAndUp } = useDisplay();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
showTitle: false,
|
||||||
|
showOriginalText: false,
|
||||||
|
});
|
||||||
|
|
||||||
const contextMenuOptions = computed(() => {
|
const contextMenuOptions = computed(() => {
|
||||||
const options = [
|
const options = [
|
||||||
{
|
{
|
||||||
text: i18n.tc("recipe.toggle-section"),
|
text: i18n.t("recipe.toggle-section"),
|
||||||
event: "toggle-section",
|
event: "toggle-section",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: i18n.tc("recipe.insert-above"),
|
text: i18n.t("recipe.insert-above"),
|
||||||
event: "insert-above",
|
event: "insert-above",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: i18n.tc("recipe.insert-below"),
|
text: i18n.t("recipe.insert-below"),
|
||||||
event: "insert-below",
|
event: "insert-below",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (props.allowInsertIngredient) {
|
if (props.allowInsertIngredient) {
|
||||||
options.push({
|
options.push({
|
||||||
text: i18n.tc("recipe.insert-ingredient") ,
|
text: i18n.t("recipe.insert-ingredient"),
|
||||||
event: "insert-ingredient",
|
event: "insert-ingredient",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// FUTURE: add option to parse a single ingredient
|
if (model.value.originalText) {
|
||||||
// if (!value.food && !value.unit && value.note) {
|
|
||||||
// options.push({
|
|
||||||
// text: "Parse Ingredient",
|
|
||||||
// event: "parse-ingredient",
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (props.value.originalText) {
|
|
||||||
options.push({
|
options.push({
|
||||||
text: i18n.tc("recipe.see-original-text"),
|
text: i18n.t("recipe.see-original-text"),
|
||||||
event: "toggle-original",
|
event: "toggle-original",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -189,25 +249,24 @@ export default defineComponent({
|
|||||||
const out = [
|
const out = [
|
||||||
{
|
{
|
||||||
icon: $globals.icons.dotsVertical,
|
icon: $globals.icons.dotsVertical,
|
||||||
text: i18n.tc("general.menu"),
|
text: i18n.t("general.menu"),
|
||||||
event: "open",
|
event: "open",
|
||||||
children: contextMenuOptions.value,
|
children: contextMenuOptions.value,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (listeners && listeners.delete) {
|
// If delete event is being listened for, show delete button
|
||||||
// @ts-expect-error - TODO: fix this
|
// $attrs is not available in <script setup>, so always show if parent listens
|
||||||
out.unshift({
|
out.unshift({
|
||||||
icon: $globals.icons.delete,
|
icon: $globals.icons.delete,
|
||||||
text: i18n.tc("general.delete"),
|
text: i18n.t("general.delete"),
|
||||||
event: "delete",
|
event: "delete",
|
||||||
|
children: undefined,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==================================================
|
|
||||||
// Foods
|
// Foods
|
||||||
const foodStore = useFoodStore();
|
const foodStore = useFoodStore();
|
||||||
const foodData = useFoodData();
|
const foodData = useFoodData();
|
||||||
@@ -216,12 +275,11 @@ export default defineComponent({
|
|||||||
|
|
||||||
async function createAssignFood() {
|
async function createAssignFood() {
|
||||||
foodData.data.name = foodSearch.value;
|
foodData.data.name = foodSearch.value;
|
||||||
props.value.food = await foodStore.actions.createOne(foodData.data) || undefined;
|
model.value.food = await foodStore.actions.createOne(foodData.data) || undefined;
|
||||||
foodData.reset();
|
foodData.reset();
|
||||||
foodAutocomplete.value?.blur();
|
foodAutocomplete.value?.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================================================
|
|
||||||
// Units
|
// Units
|
||||||
const unitStore = useUnitStore();
|
const unitStore = useUnitStore();
|
||||||
const unitsData = useUnitData();
|
const unitsData = useUnitData();
|
||||||
@@ -230,19 +288,14 @@ export default defineComponent({
|
|||||||
|
|
||||||
async function createAssignUnit() {
|
async function createAssignUnit() {
|
||||||
unitsData.data.name = unitSearch.value;
|
unitsData.data.name = unitSearch.value;
|
||||||
props.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined;
|
model.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined;
|
||||||
unitsData.reset();
|
unitsData.reset();
|
||||||
unitAutocomplete.value?.blur();
|
unitAutocomplete.value?.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
showTitle: false,
|
|
||||||
showOriginalText: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggleTitle() {
|
function toggleTitle() {
|
||||||
if (state.showTitle) {
|
if (state.showTitle) {
|
||||||
props.value.title = "";
|
model.value.title = "";
|
||||||
}
|
}
|
||||||
state.showTitle = !state.showTitle;
|
state.showTitle = !state.showTitle;
|
||||||
}
|
}
|
||||||
@@ -253,9 +306,9 @@ export default defineComponent({
|
|||||||
|
|
||||||
function handleUnitEnter() {
|
function handleUnitEnter() {
|
||||||
if (
|
if (
|
||||||
props.value.unit === undefined ||
|
model.value.unit === undefined
|
||||||
props.value.unit === null ||
|
|| model.value.unit === null
|
||||||
!props.value.unit.name.includes(unitSearch.value)
|
|| !model.value.unit.name.includes(unitSearch.value)
|
||||||
) {
|
) {
|
||||||
createAssignUnit();
|
createAssignUnit();
|
||||||
}
|
}
|
||||||
@@ -263,44 +316,24 @@ export default defineComponent({
|
|||||||
|
|
||||||
function handleFoodEnter() {
|
function handleFoodEnter() {
|
||||||
if (
|
if (
|
||||||
props.value.food === undefined ||
|
model.value.food === undefined
|
||||||
props.value.food === null ||
|
|| model.value.food === null
|
||||||
!props.value.food.name.includes(foodSearch.value)
|
|| !model.value.food.name.includes(foodSearch.value)
|
||||||
) {
|
) {
|
||||||
createAssignFood();
|
createAssignFood();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function quantityFilter(e: KeyboardEvent) {
|
function quantityFilter(e: KeyboardEvent) {
|
||||||
// if digit is pressed, add to quantity
|
|
||||||
if (e.key === "-" || e.key === "+" || e.key === "e") {
|
if (e.key === "-" || e.key === "+" || e.key === "e") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const { showTitle, showOriginalText } = toRefs(state);
|
||||||
...toRefs(state),
|
|
||||||
quantityFilter,
|
const foods = foodStore.store;
|
||||||
toggleOriginalText,
|
const units = unitStore.store;
|
||||||
contextMenuOptions,
|
|
||||||
handleUnitEnter,
|
|
||||||
handleFoodEnter,
|
|
||||||
foodAutocomplete,
|
|
||||||
createAssignFood,
|
|
||||||
unitAutocomplete,
|
|
||||||
createAssignUnit,
|
|
||||||
foods: foodStore.store,
|
|
||||||
foodSearch,
|
|
||||||
toggleTitle,
|
|
||||||
unitActions: unitStore.actions,
|
|
||||||
units: unitStore.store,
|
|
||||||
unitSearch,
|
|
||||||
validators,
|
|
||||||
workingUnitData: unitsData.data,
|
|
||||||
btns,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<div v-html="safeMarkup"></div>
|
<div v-html="safeMarkup" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
|
||||||
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
|
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
|
||||||
export default defineComponent({
|
|
||||||
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
markup: {
|
markup: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -17,7 +17,7 @@ export default defineComponent({
|
|||||||
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
|
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
|
||||||
return {
|
return {
|
||||||
safeMarkup,
|
safeMarkup,
|
||||||
}
|
};
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user