mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-03-03 03:07:02 +00:00
feat: Migrate to Nuxt 3 framework (#5184)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com> Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
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
|
||||||
|
|||||||
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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -156,12 +156,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 +174,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():
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
###############################################
|
###############################################
|
||||||
# Frontend Build
|
# Frontend Build
|
||||||
###############################################
|
###############################################
|
||||||
FROM node:16 AS frontend-builder
|
FROM node:20 AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
|||||||
@@ -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 +1,390 @@
|
|||||||
/* cyrillic-ext */
|
/* cyrillic-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-100-cyrillic-ext1.woff2') format('woff2');
|
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;
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
}
|
}
|
||||||
/* cyrillic */
|
/* cyrillic */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-100-cyrillic2.woff2') format('woff2');
|
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;
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
/* greek-ext */
|
/* greek-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-100-greek-ext3.woff2') format('woff2');
|
src: url("~assets/fonts/Roboto-100-greek-ext3.woff2") format("woff2");
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
/* greek */
|
/* greek */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-100-greek4.woff2') format('woff2');
|
src: url("~assets/fonts/Roboto-100-greek4.woff2") format("woff2");
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
/* vietnamese */
|
/* vietnamese */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-100-vietnamese5.woff2') format('woff2');
|
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;
|
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 */
|
/* latin-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-100-latin-ext6.woff2') format('woff2');
|
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;
|
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 */
|
/* latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-100-latin7.woff2') format('woff2');
|
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;
|
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 */
|
/* cyrillic-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-300-cyrillic-ext8.woff2') format('woff2');
|
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;
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
}
|
}
|
||||||
/* cyrillic */
|
/* cyrillic */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-300-cyrillic9.woff2') format('woff2');
|
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;
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
/* greek-ext */
|
/* greek-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-300-greek-ext10.woff2') format('woff2');
|
src: url("~assets/fonts/Roboto-300-greek-ext10.woff2") format("woff2");
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
/* greek */
|
/* greek */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-300-greek11.woff2') format('woff2');
|
src: url("~assets/fonts/Roboto-300-greek11.woff2") format("woff2");
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
/* vietnamese */
|
/* vietnamese */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-300-vietnamese12.woff2') format('woff2');
|
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;
|
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 */
|
/* latin-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-300-latin-ext13.woff2') format('woff2');
|
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;
|
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 */
|
/* latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-300-latin14.woff2') format('woff2');
|
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;
|
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 */
|
/* cyrillic-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-400-cyrillic-ext15.woff2') format('woff2');
|
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;
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
}
|
}
|
||||||
/* cyrillic */
|
/* cyrillic */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-400-cyrillic16.woff2') format('woff2');
|
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;
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
/* greek-ext */
|
/* greek-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-400-greek-ext17.woff2') format('woff2');
|
src: url("~assets/fonts/Roboto-400-greek-ext17.woff2") format("woff2");
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
/* greek */
|
/* greek */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-400-greek18.woff2') format('woff2');
|
src: url("~assets/fonts/Roboto-400-greek18.woff2") format("woff2");
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
/* vietnamese */
|
/* vietnamese */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-400-vietnamese19.woff2') format('woff2');
|
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;
|
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 */
|
/* latin-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-400-latin-ext20.woff2') format('woff2');
|
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;
|
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 */
|
/* latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-400-latin21.woff2') format('woff2');
|
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;
|
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 */
|
/* cyrillic-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-500-cyrillic-ext22.woff2') format('woff2');
|
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;
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
}
|
}
|
||||||
/* cyrillic */
|
/* cyrillic */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-500-cyrillic23.woff2') format('woff2');
|
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;
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
/* greek-ext */
|
/* greek-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-500-greek-ext24.woff2') format('woff2');
|
src: url("~assets/fonts/Roboto-500-greek-ext24.woff2") format("woff2");
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
/* greek */
|
/* greek */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-500-greek25.woff2') format('woff2');
|
src: url("~assets/fonts/Roboto-500-greek25.woff2") format("woff2");
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
/* vietnamese */
|
/* vietnamese */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-500-vietnamese26.woff2') format('woff2');
|
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;
|
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 */
|
/* latin-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-500-latin-ext27.woff2') format('woff2');
|
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;
|
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 */
|
/* latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-500-latin28.woff2') format('woff2');
|
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;
|
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 */
|
/* cyrillic-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-700-cyrillic-ext29.woff2') format('woff2');
|
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;
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
}
|
}
|
||||||
/* cyrillic */
|
/* cyrillic */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-700-cyrillic30.woff2') format('woff2');
|
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;
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
/* greek-ext */
|
/* greek-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-700-greek-ext31.woff2') format('woff2');
|
src: url("~assets/fonts/Roboto-700-greek-ext31.woff2") format("woff2");
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
/* greek */
|
/* greek */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-700-greek32.woff2') format('woff2');
|
src: url("~assets/fonts/Roboto-700-greek32.woff2") format("woff2");
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
/* vietnamese */
|
/* vietnamese */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-700-vietnamese33.woff2') format('woff2');
|
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;
|
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 */
|
/* latin-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-700-latin-ext34.woff2') format('woff2');
|
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;
|
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 */
|
/* latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-700-latin35.woff2') format('woff2');
|
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;
|
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 */
|
/* cyrillic-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-900-cyrillic-ext36.woff2') format('woff2');
|
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;
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
}
|
}
|
||||||
/* cyrillic */
|
/* cyrillic */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-900-cyrillic37.woff2') format('woff2');
|
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;
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
/* greek-ext */
|
/* greek-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-900-greek-ext38.woff2') format('woff2');
|
src: url("~assets/fonts/Roboto-900-greek-ext38.woff2") format("woff2");
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
/* greek */
|
/* greek */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-900-greek39.woff2') format('woff2');
|
src: url("~assets/fonts/Roboto-900-greek39.woff2") format("woff2");
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
/* vietnamese */
|
/* vietnamese */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-900-vietnamese40.woff2') format('woff2');
|
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;
|
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 */
|
/* latin-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-900-latin-ext41.woff2') format('woff2');
|
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;
|
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 */
|
/* latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('~assets/fonts/Roboto-900-latin42.woff2') format('woff2');
|
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill-height {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,59 @@
|
|||||||
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">
|
fluid
|
||||||
<v-icon large left> {{ $globals.icons.pages }} </v-icon>
|
class="py-0 my-0"
|
||||||
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title>
|
>
|
||||||
<v-spacer></v-spacer>
|
<v-sheet
|
||||||
<BaseButton
|
color="transparent"
|
||||||
v-if="canEdit"
|
class="d-flex flex-column w-100 pa-0 ma-0"
|
||||||
class="mx-1"
|
elevation="0"
|
||||||
:edit="true"
|
>
|
||||||
@click="handleEditCookbook"
|
<div class="d-flex align-center w-100 mb-2">
|
||||||
/>
|
<v-icon size="large" class="mr-3">
|
||||||
</v-app-bar>
|
{{ $globals.icons.pages }}
|
||||||
<v-card flat>
|
</v-icon>
|
||||||
<v-card-text class="py-0">
|
<v-toolbar-title class="headline mb-0">
|
||||||
|
{{ book.name }}
|
||||||
|
</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
<BaseButton
|
||||||
|
v-if="canEdit"
|
||||||
|
class="mx-1"
|
||||||
|
:edit="true"
|
||||||
|
@click="handleEditCookbook"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="book.description" class="subtitle-1 text-grey-lighten-1 mb-2">
|
||||||
{{ 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>
|
||||||
@@ -52,92 +67,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 { useCookbook, useCookbooks } from "~/composables/use-group-cookbooks";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import type { RecipeCookBook } from "~/lib/api/types/cookbook";
|
||||||
import { 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 } = useCookbooks();
|
||||||
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;
|
|
||||||
})
|
|
||||||
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
|
||||||
|
|
||||||
const dialogStates = reactive({
|
|
||||||
edit: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const editTarget = ref<RecipeCookBook | null>(null);
|
|
||||||
function handleEditCookbook() {
|
|
||||||
dialogStates.edit = true;
|
|
||||||
editTarget.value = book.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editCookbook() {
|
return $auth.user.value.householdId === book.value.householdId;
|
||||||
if (!editTarget.value) {
|
});
|
||||||
return;
|
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
||||||
}
|
|
||||||
const response = await actions.updateOne(editTarget.value);
|
|
||||||
|
|
||||||
if (response?.slug && book.value?.slug !== response?.slug) {
|
const dialogStates = reactive({
|
||||||
// if name changed, redirect to new slug
|
edit: false,
|
||||||
router.push(`/g/${route.value.params.groupSlug}/cookbooks/${response?.slug}`);
|
});
|
||||||
} else {
|
|
||||||
// otherwise reload the page, since the recipe criteria changed
|
const editTarget = ref<RecipeCookBook | null>(null);
|
||||||
router.go(0);
|
function handleEditCookbook() {
|
||||||
}
|
dialogStates.edit = true;
|
||||||
dialogStates.edit = false;
|
editTarget.value = book.value;
|
||||||
editTarget.value = null;
|
}
|
||||||
|
|
||||||
|
async function editCookbook() {
|
||||||
|
if (!editTarget.value) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const response = await actions.updateOne(editTarget.value);
|
||||||
|
|
||||||
useMeta(() => {
|
if (response?.slug && book.value?.slug !== response?.slug) {
|
||||||
return {
|
// if name changed, redirect to new slug
|
||||||
title: book?.value?.name || "Cookbook",
|
router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
|
||||||
};
|
}
|
||||||
});
|
else {
|
||||||
|
// otherwise reload the page, since the recipe criteria changed
|
||||||
|
router.go(0);
|
||||||
|
}
|
||||||
|
dialogStates.edit = false;
|
||||||
|
editTarget.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
useSeoMeta({
|
||||||
book,
|
title: book?.value?.name || "Cookbook",
|
||||||
slug,
|
});
|
||||||
tab,
|
|
||||||
appendRecipes,
|
return {
|
||||||
assignSorted,
|
book,
|
||||||
recipes,
|
slug,
|
||||||
removeRecipe,
|
tab,
|
||||||
replaceRecipes,
|
appendRecipes,
|
||||||
canEdit,
|
assignSorted,
|
||||||
dialogStates,
|
recipes,
|
||||||
editTarget,
|
removeRecipe,
|
||||||
handleEditCookbook,
|
replaceRecipes,
|
||||||
editCookbook,
|
canEdit,
|
||||||
actions,
|
dialogStates,
|
||||||
};
|
editTarget,
|
||||||
},
|
handleEditCookbook,
|
||||||
head: {}, // Must include for useMeta
|
editCookbook,
|
||||||
});
|
actions,
|
||||||
</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">
|
||||||
@@ -15,20 +26,19 @@
|
|||||||
|
|
||||||
<!-- 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,8 +64,9 @@ 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" },
|
{ text: i18n.t("meal-plan.breakfast"), value: "breakfast" },
|
||||||
@@ -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,157 +1,144 @@
|
|||||||
<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"
|
<div class="ml-8">
|
||||||
hide-details
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
dense
|
{{ $t("household.private-household-description") }}
|
||||||
:label="$t('household.private-household')"
|
</p>
|
||||||
/>
|
<DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
|
||||||
<div class="ml-8">
|
</div>
|
||||||
<p class="text-subtitle-2 my-0 py-0">
|
</div>
|
||||||
{{ $t("household.private-household-description") }}
|
<div class="mb-6">
|
||||||
</p>
|
<v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
|
||||||
<DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
|
<div class="ml-8">
|
||||||
</div>
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
</div>
|
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
||||||
<div class="mb-6">
|
</p>
|
||||||
<v-checkbox
|
</div>
|
||||||
v-model="preferences.lockRecipeEditsFromOtherHouseholds"
|
</div>
|
||||||
hide-details
|
<v-select
|
||||||
dense
|
v-model="preferences.firstDayOfWeek"
|
||||||
:label="$t('household.lock-recipe-edits-from-other-households')"
|
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||||
/>
|
:items="allDays"
|
||||||
<div class="ml-8">
|
item-title="name"
|
||||||
<p class="text-subtitle-2 my-0 py-0">
|
item-value="value"
|
||||||
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
:label="$t('settings.first-day-of-week')"
|
||||||
</p>
|
variant="underlined"
|
||||||
</div>
|
flat
|
||||||
</div>
|
/>
|
||||||
<v-select
|
|
||||||
v-model="preferences.firstDayOfWeek"
|
|
||||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
|
||||||
:items="allDays"
|
|
||||||
item-text="name"
|
|
||||||
item-value="value"
|
|
||||||
:label="$t('settings.first-day-of-week')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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]"
|
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
||||||
hide-details
|
{{ p.description }}
|
||||||
dense
|
</p>
|
||||||
:label="p.label"
|
</div>
|
||||||
/>
|
</div>
|
||||||
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
</div>
|
||||||
{{ p.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const allDays = [
|
const allDays = [
|
||||||
{
|
{
|
||||||
name: i18n.t("general.sunday"),
|
name: i18n.t("general.sunday"),
|
||||||
value: 0,
|
value: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: i18n.t("general.monday"),
|
name: i18n.t("general.monday"),
|
||||||
value: 1,
|
value: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: i18n.t("general.tuesday"),
|
name: i18n.t("general.tuesday"),
|
||||||
value: 2,
|
value: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: i18n.t("general.wednesday"),
|
name: i18n.t("general.wednesday"),
|
||||||
value: 3,
|
value: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: i18n.t("general.thursday"),
|
name: i18n.t("general.thursday"),
|
||||||
value: 4,
|
value: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: i18n.t("general.friday"),
|
name: i18n.t("general.friday"),
|
||||||
value: 5,
|
value: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: i18n.t("general.saturday"),
|
name: i18n.t("general.saturday"),
|
||||||
value: 6,
|
value: 6,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allDays,
|
allDays,
|
||||||
preferences,
|
preferences,
|
||||||
recipePreferences,
|
recipePreferences,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
<BaseDialog v-model="deleteDialog" :title="$t('recipe.delete-recipe')" color="error"
|
||||||
v-model="deleteDialog"
|
:icon="$globals.icons.alertCircle" can-confirm @confirm="emitDelete()">
|
||||||
:title="$tc('recipe.delete-recipe')"
|
|
||||||
color="error"
|
|
||||||
:icon="$globals.icons.alertCircle"
|
|
||||||
@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,33 @@
|
|||||||
<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"
|
||||||
@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 +132,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 +217,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 {
|
||||||
|
|||||||
@@ -4,71 +4,107 @@
|
|||||||
<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="value.length > 0"
|
||||||
<v-list-item-icon class="ma-auto">
|
:flat="!edit"
|
||||||
<v-tooltip bottom>
|
>
|
||||||
<template #activator="{ on, attrs }">
|
<v-list-item
|
||||||
<v-icon v-bind="attrs" v-on="on">
|
v-for="(item, i) in value"
|
||||||
{{ getIconDefinition(item.icon).icon }}
|
:key="i"
|
||||||
</v-icon>
|
>
|
||||||
</template>
|
<template #prepend>
|
||||||
<span>{{ getIconDefinition(item.icon).title }}</span>
|
<div class="ma-auto">
|
||||||
</v-tooltip>
|
<v-tooltip bottom>
|
||||||
</v-list-item-icon>
|
<template #activator="{ props }">
|
||||||
<v-list-item-content>
|
<v-icon v-bind="props">
|
||||||
<v-list-item-title class="pl-2">
|
{{ getIconDefinition(item.icon).icon }}
|
||||||
{{ item.name }}
|
</v-icon>
|
||||||
</v-list-item-title>
|
</template>
|
||||||
</v-list-item-content>
|
<span>{{ getIconDefinition(item.icon).title }}</span>
|
||||||
|
</v-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title class="pl-2">
|
||||||
|
{{ item.name }}
|
||||||
|
</v-list-item-title>
|
||||||
<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="value.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>
|
||||||
@@ -78,13 +114,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script 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({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
slug: {
|
slug: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -94,7 +128,7 @@ export default defineComponent({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
value: {
|
modelValue: {
|
||||||
type: Array as () => RecipeAsset[],
|
type: Array as () => RecipeAsset[],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
@@ -103,6 +137,7 @@ export default defineComponent({
|
|||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
|
|
||||||
@@ -115,7 +150,8 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { $globals, i18n, req } = useContext();
|
const i18n = useI18n();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
|
||||||
const iconOptions = [
|
const iconOptions = [
|
||||||
{
|
{
|
||||||
@@ -145,10 +181,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();
|
||||||
@@ -181,7 +217,7 @@ export default defineComponent({
|
|||||||
extension: state.fileObject.name.split(".").pop() || "",
|
extension: state.fileObject.name.split(".").pop() || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
context.emit("input", [...props.value, data]);
|
context.emit("update:modelValue", [...props.modelValue, data]);
|
||||||
state.newAsset = { name: "", icon: "mdi-file" };
|
state.newAsset = { name: "", icon: "mdi-file" };
|
||||||
state.fileObject = {} as File;
|
state.fileObject = {} as File;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-lazy>
|
<v-lazy>
|
||||||
<v-hover v-slot="{ hover }" :open-delay="50">
|
<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 +18,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 +35,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 +93,13 @@
|
|||||||
/>
|
/>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</slot>
|
</slot>
|
||||||
<slot></slot>
|
<slot />
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-hover>
|
</v-hover>
|
||||||
</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 +107,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 +149,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}` : "";
|
||||||
@@ -159,7 +190,7 @@ export default defineComponent({
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.descriptionWrapper{
|
.descriptionWrapper {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 8;
|
-webkit-line-clamp: 8;
|
||||||
|
|||||||
@@ -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,81 +1,121 @@
|
|||||||
<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"
|
||||||
|
size="small"
|
||||||
|
width="125"
|
||||||
|
:height="height"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
<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">
|
||||||
|
<SafeMarkdown v-if="description" :source="description" />
|
||||||
|
<p v-else>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
</p>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
<div
|
||||||
|
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>
|
||||||
|
<slot name="actions">
|
||||||
|
<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
|
||||||
|
v-if="showRecipeContent"
|
||||||
|
:class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']"
|
||||||
|
:value="rating"
|
||||||
|
:recipe-id="recipeId"
|
||||||
|
:slug="slug"
|
||||||
small
|
small
|
||||||
/>
|
/>
|
||||||
</v-list-item-avatar>
|
|
||||||
</slot>
|
|
||||||
<v-list-item-content class="py-0">
|
|
||||||
<v-list-item-title class="mt-1 mb-1 text-top">{{ name }}</v-list-item-title>
|
|
||||||
<v-list-item-subtitle class="ma-0 text-top">
|
|
||||||
<SafeMarkdown :source="description" />
|
|
||||||
</v-list-item-subtitle>
|
|
||||||
<div class="d-flex flex-wrap justify-start ma-0">
|
|
||||||
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" v-on="$listeners" />
|
|
||||||
</div>
|
|
||||||
<div class="d-flex flex-wrap justify-end align-center">
|
|
||||||
<slot name="actions">
|
|
||||||
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :recipe-id="recipeId" show-always />
|
|
||||||
<RecipeRating
|
|
||||||
v-if="showRecipeContent"
|
|
||||||
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'"
|
|
||||||
:value="rating"
|
|
||||||
:recipe-id="recipeId"
|
|
||||||
:slug="slug"
|
|
||||||
:small="true"
|
|
||||||
/>
|
|
||||||
<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 -->
|
||||||
<RecipeContextMenu
|
<RecipeContextMenu
|
||||||
v-if="isOwnGroup && showRecipeContent"
|
v-if="isOwnGroup && showRecipeContent"
|
||||||
:slug="slug"
|
:slug="slug"
|
||||||
:menu-icon="$globals.icons.dotsHorizontal"
|
:menu-icon="$globals.icons.dotsHorizontal"
|
||||||
:name="name"
|
:name="name"
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
:use-items="{
|
class="ml-auto"
|
||||||
delete: false,
|
:use-items="{
|
||||||
edit: false,
|
delete: false,
|
||||||
download: true,
|
edit: false,
|
||||||
mealplanner: true,
|
download: true,
|
||||||
shoppingList: true,
|
mealplanner: true,
|
||||||
print: false,
|
shoppingList: true,
|
||||||
printPreferences: false,
|
print: false,
|
||||||
share: true,
|
printPreferences: false,
|
||||||
}"
|
share: true,
|
||||||
@deleted="$emit('delete', slug)"
|
}"
|
||||||
/>
|
@deleted="$emit('delete', slug)"
|
||||||
</slot>
|
/>
|
||||||
</div>
|
</v-card-actions>
|
||||||
</v-list-item-content>
|
</slot>
|
||||||
</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,8 +236,9 @@ export default defineComponent({
|
|||||||
align-self: start !important;
|
align-self: start !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flat, .theme--dark .flat {
|
.flat,
|
||||||
box-shadow: none!important;
|
.theme--dark .flat {
|
||||||
background-color: transparent!important;
|
box-shadow: none !important;
|
||||||
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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">
|
||||||
{{ $globals.icons.orderAlphabeticalAscending }}
|
<v-icon class="mr-2" inline>
|
||||||
</v-icon>
|
{{ $globals.icons.orderAlphabeticalAscending }}
|
||||||
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
|
</v-icon>
|
||||||
|
<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">
|
||||||
{{ $globals.icons.star }}
|
<v-icon class="mr-2" inline>
|
||||||
</v-icon>
|
{{ $globals.icons.star }}
|
||||||
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title>
|
</v-icon>
|
||||||
|
<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">
|
||||||
{{ $globals.icons.newBox }}
|
<v-icon class="mr-2" inline>
|
||||||
</v-icon>
|
{{ $globals.icons.newBox }}
|
||||||
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title>
|
</v-icon>
|
||||||
|
<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">
|
||||||
{{ $globals.icons.update }}
|
<v-icon class="mr-2" inline>
|
||||||
</v-icon>
|
{{ $globals.icons.update }}
|
||||||
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
|
</v-icon>
|
||||||
|
<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">
|
||||||
{{ $globals.icons.chefHat }}
|
<v-icon class="mr-2" inline>
|
||||||
</v-icon>
|
{{ $globals.icons.chefHat }}
|
||||||
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
|
</v-icon>
|
||||||
|
<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,29 +318,26 @@ 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;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
page.value = page.value + 1;
|
page.value = page.value + 1;
|
||||||
|
|
||||||
const newRecipes = await fetchRecipes();
|
const newRecipes = await fetchRecipes();
|
||||||
if (newRecipes.length < perPage) {
|
if (newRecipes.length < perPage) {
|
||||||
hasMore.value = false;
|
hasMore.value = false;
|
||||||
}
|
}
|
||||||
if (newRecipes.length) {
|
if (newRecipes.length) {
|
||||||
context.emit(APPEND_RECIPES_EVENT, newRecipes);
|
context.emit(APPEND_RECIPES_EVENT, newRecipes);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,21 +399,19 @@ export default defineComponent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
useAsync(async () => {
|
// reset pagination
|
||||||
// reset pagination
|
page.value = 1;
|
||||||
page.value = 1;
|
hasMore.value = true;
|
||||||
hasMore.value = true;
|
|
||||||
|
|
||||||
state.sortLoading = true;
|
state.sortLoading = true;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
// fetch new recipes
|
// fetch new recipes
|
||||||
const newRecipes = await fetchRecipes();
|
const newRecipes = await fetchRecipes();
|
||||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||||
|
|
||||||
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="ma-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,12 +186,12 @@ export interface ContextMenuItem {
|
|||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeDialogAddToShoppingList,
|
RecipeDialogAddToShoppingList,
|
||||||
RecipeDialogPrintPreferences,
|
RecipeDialogPrintPreferences,
|
||||||
RecipeDialogShare,
|
RecipeDialogShare,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
useItems: {
|
useItems: {
|
||||||
type: Object as () => ContextMenuIncludes,
|
type: Object as () => ContextMenuIncludes,
|
||||||
@@ -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: $t('general.delete'),
|
||||||
text: $tc('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,11 +1,18 @@
|
|||||||
<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-container v-if="!shoppingListChoices.length">
|
v-if="shoppingListDialog && ready"
|
||||||
<BasePageTitle>
|
v-model="dialog"
|
||||||
<template #title>{{ $t('shopping-list.no-shopping-lists-found') }}</template>
|
:title="$t('recipe.add-to-list')"
|
||||||
</BasePageTitle>
|
:icon="$globals.icons.cartCheck"
|
||||||
</v-container>
|
>
|
||||||
|
<v-container v-if="!shoppingListChoices.length">
|
||||||
|
<BasePageTitle>
|
||||||
|
<template #title>
|
||||||
|
{{ $t('shopping-list.no-shopping-lists-found') }}
|
||||||
|
</template>
|
||||||
|
</BasePageTitle>
|
||||||
|
</v-container>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-card
|
<v-card
|
||||||
v-for="list in shoppingListChoices"
|
v-for="list in shoppingListChoices"
|
||||||
@@ -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,36 +109,41 @@
|
|||||||
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]
|
||||||
.ingredientSections[ingredientSectionIndex]
|
.ingredientSections[ingredientSectionIndex]
|
||||||
.ingredients[i]
|
.ingredients[i]
|
||||||
.checked"
|
.checked"
|
||||||
>
|
>
|
||||||
<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>
|
||||||
@@ -47,42 +77,43 @@
|
|||||||
class="print-preview"
|
class="print-preview"
|
||||||
style="overflow-y: auto;"
|
style="overflow-y: auto;"
|
||||||
>
|
>
|
||||||
<RecipePrintView :recipe="recipe"/>
|
<RecipePrintView :recipe="recipe" />
|
||||||
</v-card>
|
</v-card>
|
||||||
</div>
|
</div>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
</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-filled"
|
||||||
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="pa-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
|
||||||
|
v-model="url"
|
||||||
|
:label="$t('general.url')"
|
||||||
|
class="pt-5"
|
||||||
|
clearable
|
||||||
|
:messages="messages"
|
||||||
|
>
|
||||||
<template #append-outer>
|
<template #append-outer>
|
||||||
<v-btn class="ml-2" color="primary" :loading="loading" :disabled="!slug" @click="getImageFromURL">
|
<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-row
|
||||||
|
:no-gutters="mdAndUp"
|
||||||
|
dense
|
||||||
|
class="d-flex flex-wrap my-1"
|
||||||
>
|
>
|
||||||
</v-text-field>
|
<v-col
|
||||||
<v-row :no-gutters="$vuetify.breakpoint.mdAndUp" dense class="d-flex flex-wrap my-1">
|
v-if="!disableAmount"
|
||||||
<v-col v-if="!disableAmount" sm="12" md="2" cols="12" class="flex-grow-0 flex-shrink-0">
|
sm="12"
|
||||||
|
md="2"
|
||||||
|
cols="12"
|
||||||
|
class="flex-grow-0 flex-shrink-0"
|
||||||
|
>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="value.quantity"
|
v-model="model.quantity"
|
||||||
solo
|
variant="solo"
|
||||||
hide-details
|
hide-details
|
||||||
dense
|
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>
|
||||||
{{ $globals.icons.arrowUpDown }}
|
<v-icon
|
||||||
</v-icon>
|
class="mr-n1 handle"
|
||||||
|
>
|
||||||
|
{{ $globals.icons.arrowUpDown }}
|
||||||
|
</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>
|
||||||
{{ $globals.icons.arrowUpDown }}
|
<v-icon
|
||||||
</v-icon>
|
v-if="disableAmount && $attrs && $attrs.delete"
|
||||||
|
class="mr-n1 handle"
|
||||||
|
>
|
||||||
|
{{ $globals.icons.arrowUpDown }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
</v-text-field>
|
</v-text-field>
|
||||||
<BaseButtonGroup
|
<BaseButtonGroup
|
||||||
hover
|
hover
|
||||||
@@ -112,195 +159,181 @@
|
|||||||
</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: {
|
||||||
},
|
type: Boolean,
|
||||||
disableAmount: {
|
default: false,
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
allowInsertIngredient: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
setup(props, { listeners }) {
|
allowInsertIngredient: {
|
||||||
const { i18n, $globals } = useContext();
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
const contextMenuOptions = computed(() => {
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
text: i18n.tc("recipe.toggle-section"),
|
|
||||||
event: "toggle-section",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: i18n.tc("recipe.insert-above"),
|
|
||||||
event: "insert-above",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: i18n.tc("recipe.insert-below"),
|
|
||||||
event: "insert-below",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (props.allowInsertIngredient) {
|
|
||||||
options.push({
|
|
||||||
text: i18n.tc("recipe.insert-ingredient") ,
|
|
||||||
event: "insert-ingredient",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// FUTURE: add option to parse a single ingredient
|
|
||||||
// if (!value.food && !value.unit && value.note) {
|
|
||||||
// options.push({
|
|
||||||
// text: "Parse Ingredient",
|
|
||||||
// event: "parse-ingredient",
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (props.value.originalText) {
|
|
||||||
options.push({
|
|
||||||
text: i18n.tc("recipe.see-original-text"),
|
|
||||||
event: "toggle-original",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
|
||||||
});
|
|
||||||
|
|
||||||
const btns = computed(() => {
|
|
||||||
const out = [
|
|
||||||
{
|
|
||||||
icon: $globals.icons.dotsVertical,
|
|
||||||
text: i18n.tc("general.menu"),
|
|
||||||
event: "open",
|
|
||||||
children: contextMenuOptions.value,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (listeners && listeners.delete) {
|
|
||||||
// @ts-expect-error - TODO: fix this
|
|
||||||
out.unshift({
|
|
||||||
icon: $globals.icons.delete,
|
|
||||||
text: i18n.tc("general.delete"),
|
|
||||||
event: "delete",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==================================================
|
|
||||||
// Foods
|
|
||||||
const foodStore = useFoodStore();
|
|
||||||
const foodData = useFoodData();
|
|
||||||
const foodSearch = ref("");
|
|
||||||
const foodAutocomplete = ref<HTMLInputElement>();
|
|
||||||
|
|
||||||
async function createAssignFood() {
|
|
||||||
foodData.data.name = foodSearch.value;
|
|
||||||
props.value.food = await foodStore.actions.createOne(foodData.data) || undefined;
|
|
||||||
foodData.reset();
|
|
||||||
foodAutocomplete.value?.blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================================================
|
|
||||||
// Units
|
|
||||||
const unitStore = useUnitStore();
|
|
||||||
const unitsData = useUnitData();
|
|
||||||
const unitSearch = ref("");
|
|
||||||
const unitAutocomplete = ref<HTMLInputElement>();
|
|
||||||
|
|
||||||
async function createAssignUnit() {
|
|
||||||
unitsData.data.name = unitSearch.value;
|
|
||||||
props.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined;
|
|
||||||
unitsData.reset();
|
|
||||||
unitAutocomplete.value?.blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
showTitle: false,
|
|
||||||
showOriginalText: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggleTitle() {
|
|
||||||
if (state.showTitle) {
|
|
||||||
props.value.title = "";
|
|
||||||
}
|
|
||||||
state.showTitle = !state.showTitle;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleOriginalText() {
|
|
||||||
state.showOriginalText = !state.showOriginalText;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUnitEnter() {
|
|
||||||
if (
|
|
||||||
props.value.unit === undefined ||
|
|
||||||
props.value.unit === null ||
|
|
||||||
!props.value.unit.name.includes(unitSearch.value)
|
|
||||||
) {
|
|
||||||
createAssignUnit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFoodEnter() {
|
|
||||||
if (
|
|
||||||
props.value.food === undefined ||
|
|
||||||
props.value.food === null ||
|
|
||||||
!props.value.food.name.includes(foodSearch.value)
|
|
||||||
) {
|
|
||||||
createAssignFood();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function quantityFilter(e: KeyboardEvent) {
|
|
||||||
// if digit is pressed, add to quantity
|
|
||||||
if (e.key === "-" || e.key === "+" || e.key === "e") {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...toRefs(state),
|
|
||||||
quantityFilter,
|
|
||||||
toggleOriginalText,
|
|
||||||
contextMenuOptions,
|
|
||||||
handleUnitEnter,
|
|
||||||
handleFoodEnter,
|
|
||||||
foodAutocomplete,
|
|
||||||
createAssignFood,
|
|
||||||
unitAutocomplete,
|
|
||||||
createAssignUnit,
|
|
||||||
foods: foodStore.store,
|
|
||||||
foodSearch,
|
|
||||||
toggleTitle,
|
|
||||||
unitActions: unitStore.actions,
|
|
||||||
units: unitStore.store,
|
|
||||||
unitSearch,
|
|
||||||
validators,
|
|
||||||
workingUnitData: unitsData.data,
|
|
||||||
btns,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 options = [
|
||||||
|
{
|
||||||
|
text: i18n.t("recipe.toggle-section"),
|
||||||
|
event: "toggle-section",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: i18n.t("recipe.insert-above"),
|
||||||
|
event: "insert-above",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: i18n.t("recipe.insert-below"),
|
||||||
|
event: "insert-below",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (props.allowInsertIngredient) {
|
||||||
|
options.push({
|
||||||
|
text: i18n.t("recipe.insert-ingredient"),
|
||||||
|
event: "insert-ingredient",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.value.originalText) {
|
||||||
|
options.push({
|
||||||
|
text: i18n.t("recipe.see-original-text"),
|
||||||
|
event: "toggle-original",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
const btns = computed(() => {
|
||||||
|
const out = [
|
||||||
|
{
|
||||||
|
icon: $globals.icons.dotsVertical,
|
||||||
|
text: i18n.t("general.menu"),
|
||||||
|
event: "open",
|
||||||
|
children: contextMenuOptions.value,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// If delete event is being listened for, show delete button
|
||||||
|
// $attrs is not available in <script setup>, so always show if parent listens
|
||||||
|
out.unshift({
|
||||||
|
icon: $globals.icons.delete,
|
||||||
|
text: i18n.t("general.delete"),
|
||||||
|
event: "delete",
|
||||||
|
children: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Foods
|
||||||
|
const foodStore = useFoodStore();
|
||||||
|
const foodData = useFoodData();
|
||||||
|
const foodSearch = ref("");
|
||||||
|
const foodAutocomplete = ref<HTMLInputElement>();
|
||||||
|
|
||||||
|
async function createAssignFood() {
|
||||||
|
foodData.data.name = foodSearch.value;
|
||||||
|
model.value.food = await foodStore.actions.createOne(foodData.data) || undefined;
|
||||||
|
foodData.reset();
|
||||||
|
foodAutocomplete.value?.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Units
|
||||||
|
const unitStore = useUnitStore();
|
||||||
|
const unitsData = useUnitData();
|
||||||
|
const unitSearch = ref("");
|
||||||
|
const unitAutocomplete = ref<HTMLInputElement>();
|
||||||
|
|
||||||
|
async function createAssignUnit() {
|
||||||
|
unitsData.data.name = unitSearch.value;
|
||||||
|
model.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined;
|
||||||
|
unitsData.reset();
|
||||||
|
unitAutocomplete.value?.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTitle() {
|
||||||
|
if (state.showTitle) {
|
||||||
|
model.value.title = "";
|
||||||
|
}
|
||||||
|
state.showTitle = !state.showTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOriginalText() {
|
||||||
|
state.showOriginalText = !state.showOriginalText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUnitEnter() {
|
||||||
|
if (
|
||||||
|
model.value.unit === undefined
|
||||||
|
|| model.value.unit === null
|
||||||
|
|| !model.value.unit.name.includes(unitSearch.value)
|
||||||
|
) {
|
||||||
|
createAssignUnit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFoodEnter() {
|
||||||
|
if (
|
||||||
|
model.value.food === undefined
|
||||||
|
|| model.value.food === null
|
||||||
|
|| !model.value.food.name.includes(foodSearch.value)
|
||||||
|
) {
|
||||||
|
createAssignFood();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quantityFilter(e: KeyboardEvent) {
|
||||||
|
if (e.key === "-" || e.key === "+" || e.key === "e") {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { showTitle, showOriginalText } = toRefs(state);
|
||||||
|
|
||||||
|
const foods = foodStore.store;
|
||||||
|
const units = unitStore.store;
|
||||||
</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>
|
||||||
|
|||||||
@@ -1,20 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ma-0 pa-0 text-subtitle-1 dense-markdown ingredient-item">
|
<div class="ma-0 pa-0 text-subtitle-1 dense-markdown ingredient-item">
|
||||||
<SafeMarkdown v-if="parsedIng.quantity" class="d-inline" :source="parsedIng.quantity" />
|
<SafeMarkdown
|
||||||
<template v-if="parsedIng.unit">{{ parsedIng.unit }} </template>
|
v-if="parsedIng.quantity"
|
||||||
<SafeMarkdown v-if="parsedIng.note && !parsedIng.name" class="text-bold d-inline" :source="parsedIng.note" />
|
class="d-inline"
|
||||||
|
:source="parsedIng.quantity"
|
||||||
|
/>
|
||||||
|
<template v-if="parsedIng.unit">
|
||||||
|
{{ parsedIng.unit }}
|
||||||
|
</template>
|
||||||
|
<SafeMarkdown
|
||||||
|
v-if="parsedIng.note && !parsedIng.name"
|
||||||
|
class="text-bold d-inline"
|
||||||
|
:source="parsedIng.note"
|
||||||
|
/>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SafeMarkdown v-if="parsedIng.name" class="text-bold d-inline" :source="parsedIng.name" />
|
<SafeMarkdown
|
||||||
<SafeMarkdown v-if="parsedIng.note" class="note" :source="parsedIng.note" />
|
v-if="parsedIng.name"
|
||||||
|
class="text-bold d-inline"
|
||||||
|
:source="parsedIng.name"
|
||||||
|
/>
|
||||||
|
<SafeMarkdown
|
||||||
|
v-if="parsedIng.note"
|
||||||
|
class="note"
|
||||||
|
:source="parsedIng.note"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
import type { RecipeIngredient } from "~/lib/api/types/household";
|
||||||
import { RecipeIngredient } from "~/lib/api/types/household";
|
|
||||||
import { useParsedIngredientText } from "~/composables/recipes";
|
import { useParsedIngredientText } from "~/composables/recipes";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
ingredient: {
|
ingredient: {
|
||||||
type: Object as () => RecipeIngredient,
|
type: Object as () => RecipeIngredient,
|
||||||
@@ -40,12 +58,20 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.ingredient-item {
|
.ingredient-item {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25em;
|
||||||
|
word-break: break-word;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
.d-inline {
|
.d-inline {
|
||||||
& > p {
|
& > p {
|
||||||
display: inline;
|
display: inline;
|
||||||
&:has(>sub)>sup {
|
&:has(> sub) > sup {
|
||||||
letter-spacing: -0.05rem;
|
letter-spacing: -0.05rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,7 +81,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sup {
|
sup {
|
||||||
&+span{
|
& + span {
|
||||||
letter-spacing: -0.05rem;
|
letter-spacing: -0.05rem;
|
||||||
}
|
}
|
||||||
&:before {
|
&:before {
|
||||||
@@ -66,12 +92,19 @@ export default defineComponent({
|
|||||||
|
|
||||||
.text-bold {
|
.text-bold {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
line-height: 1.25em;
|
flex-basis: 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
line-height: 1.3em;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,51 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="value && value.length > 0">
|
<div v-if="value && value.length > 0">
|
||||||
<div v-if="!isCookMode" class="d-flex justify-start" >
|
<div
|
||||||
<h2 class="mb-2 mt-1">{{ $t("recipe.ingredients") }}</h2>
|
v-if="!isCookMode"
|
||||||
<AppButtonCopy btn-class="ml-auto" :copy-text="ingredientCopyText" />
|
class="d-flex justify-start"
|
||||||
|
>
|
||||||
|
<h2 class="mt-1 text-h5 font-weight-medium opacity-80">
|
||||||
|
{{ $t("recipe.ingredients") }}
|
||||||
|
</h2>
|
||||||
|
<AppButtonCopy
|
||||||
|
btn-class="ml-auto"
|
||||||
|
:copy-text="ingredientCopyText"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div v-for="(ingredient, index) in value" :key="'ingredient' + index">
|
<div
|
||||||
|
v-for="(ingredient, index) in value"
|
||||||
|
:key="'ingredient' + index"
|
||||||
|
>
|
||||||
<template v-if="!isCookMode">
|
<template v-if="!isCookMode">
|
||||||
<h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3>
|
<h3
|
||||||
<v-divider v-if="showTitleEditor[index]"></v-divider>
|
v-if="showTitleEditor[index]"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
{{ ingredient.title }}
|
||||||
|
</h3>
|
||||||
|
<v-divider v-if="showTitleEditor[index]" />
|
||||||
</template>
|
</template>
|
||||||
<v-list-item dense @click.stop="toggleChecked(index)">
|
<v-list-item
|
||||||
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" />
|
density="compact"
|
||||||
<v-list-item-content :key="ingredient.quantity">
|
@click.stop="toggleChecked(index)"
|
||||||
<RecipeIngredientListItem :ingredient="ingredient" :disable-amount="disableAmount" :scale="scale" />
|
>
|
||||||
</v-list-item-content>
|
<template #prepend>
|
||||||
|
<v-checkbox
|
||||||
|
v-model="checked[index]"
|
||||||
|
hide-details
|
||||||
|
class="pt-0 my-auto py-auto"
|
||||||
|
color="secondary"
|
||||||
|
density="comfortable"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>
|
||||||
|
<RecipeIngredientListItem
|
||||||
|
:ingredient="ingredient"
|
||||||
|
:disable-amount="disableAmount"
|
||||||
|
:scale="scale"
|
||||||
|
/>
|
||||||
|
</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,12 +53,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, reactive, toRefs } from "@nuxtjs/composition-api";
|
|
||||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||||
import { parseIngredientText } from "~/composables/recipes";
|
import { parseIngredientText } from "~/composables/recipes";
|
||||||
import { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: { RecipeIngredientListItem },
|
components: { RecipeIngredientListItem },
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
@@ -45,7 +75,7 @@ export default defineComponent({
|
|||||||
isCookMode: {
|
isCookMode: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
function validateTitle(title?: string) {
|
function validateTitle(title?: string) {
|
||||||
@@ -54,7 +84,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
checked: props.value.map(() => false),
|
checked: props.value.map(() => false),
|
||||||
showTitleEditor: computed(() => props.value.map((x) => validateTitle(x.title))),
|
showTitleEditor: computed(() => props.value.map(x => validateTitle(x.title))),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ingredientCopyText = computed(() => {
|
const ingredientCopyText = computed(() => {
|
||||||
|
|||||||
@@ -4,46 +4,45 @@
|
|||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-model="madeThisDialog"
|
v-model="madeThisDialog"
|
||||||
:icon="$globals.icons.chefHat"
|
:icon="$globals.icons.chefHat"
|
||||||
:title="$tc('recipe.made-this')"
|
:title="$t('recipe.made-this')"
|
||||||
:submit-text="$tc('recipe.add-to-timeline')"
|
:submit-text="$t('recipe.add-to-timeline')"
|
||||||
|
can-submit
|
||||||
@submit="createTimelineEvent"
|
@submit="createTimelineEvent"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-form ref="domMadeThisForm">
|
<v-form ref="domMadeThisForm">
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-model="newTimelineEvent.eventMessage"
|
v-model="newTimelineEvent.eventMessage"
|
||||||
autofocus
|
autofocus
|
||||||
:label="$tc('recipe.comment')"
|
:label="$t('recipe.comment')"
|
||||||
:hint="$tc('recipe.how-did-it-turn-out')"
|
:hint="$t('recipe.how-did-it-turn-out')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
rows="4"
|
rows="4"
|
||||||
></v-textarea>
|
/>
|
||||||
<v-container>
|
<v-container>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="auto">
|
<v-col cols="6">
|
||||||
<v-menu
|
<v-menu
|
||||||
v-model="datePickerMenu"
|
v-model="datePickerMenu"
|
||||||
:close-on-content-click="false"
|
:close-on-content-click="false"
|
||||||
transition="scale-transition"
|
transition="scale-transition"
|
||||||
offset-y
|
offset-y
|
||||||
max-width="290px"
|
max-width="290px"
|
||||||
min-width="auto"
|
|
||||||
>
|
>
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ props }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="newTimelineEventTimestamp"
|
v-model="newTimelineEventTimestampString"
|
||||||
: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="newTimelineEventTimestamp"
|
v-model="newTimelineEventTimestamp"
|
||||||
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-col>
|
</v-col>
|
||||||
@@ -55,18 +54,16 @@
|
|||||||
url="none"
|
url="none"
|
||||||
file-name="image"
|
file-name="image"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
:text="$i18n.tc('recipe.upload-image')"
|
:text="$t('recipe.upload-image')"
|
||||||
:text-btn="false"
|
:text-btn="false"
|
||||||
:post="false"
|
:post="false"
|
||||||
@uploaded="uploadImage"
|
@uploaded="uploadImage"
|
||||||
/>
|
/>
|
||||||
<v-btn
|
<v-btn v-if="!!newTimelineEventImage" color="error" @click="clearImage">
|
||||||
v-if="!!newTimelineEventImage"
|
<v-icon start>
|
||||||
color="error"
|
{{ $globals.icons.close }}
|
||||||
@click="clearImage"
|
</v-icon>
|
||||||
>
|
{{ $t("recipe.remove-image") }}
|
||||||
<v-icon left>{{ $globals.icons.close }}</v-icon>
|
|
||||||
{{ $i18n.tc('recipe.remove-image') }}
|
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
@@ -87,24 +84,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="lastMadeReady" class="d-flex justify-center flex-wrap">
|
<div v-if="lastMadeReady" class="d-flex justify-center flex-wrap">
|
||||||
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger;">
|
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger">
|
||||||
<v-tooltip bottom>
|
<v-tooltip bottom>
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ props }">
|
||||||
<v-btn
|
<v-btn
|
||||||
rounded
|
rounded
|
||||||
outlined
|
variant="outlined"
|
||||||
x-large
|
size="x-large"
|
||||||
color="primary"
|
v-bind="props"
|
||||||
v-bind="attrs"
|
style="border-color: rgb(var(--v-theme-primary));"
|
||||||
v-on="on"
|
|
||||||
@click="madeThisDialog = true"
|
@click="madeThisDialog = true"
|
||||||
>
|
>
|
||||||
<v-icon left large>{{ $globals.icons.calendar }}</v-icon>
|
<v-icon start size="large" color="primary">
|
||||||
<span class="text--secondary" style="letter-spacing: normal;"><b>{{ $tc("general.last-made") }}</b><br>{{ lastMade ? new Date(lastMade).toLocaleDateString($i18n.locale) : $tc("general.never") }}</span>
|
{{ $globals.icons.calendar }}
|
||||||
<v-icon right large>{{ $globals.icons.createAlt }}</v-icon>
|
</v-icon>
|
||||||
|
<span class="text-body-1 opacity-80">
|
||||||
|
<b>{{ $t("general.last-made") }}</b>
|
||||||
|
<br>
|
||||||
|
{{ lastMade ? new Date(lastMade).toLocaleDateString($i18n.locale) : $t("general.never") }}
|
||||||
|
</span>
|
||||||
|
<v-icon end size="large" color="primary">
|
||||||
|
{{ $globals.icons.createAlt }}
|
||||||
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<span>{{ $tc("recipe.made-this") }}</span>
|
<span>{{ $t("recipe.made-this") }}</span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-row>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,25 +117,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, onMounted, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import { whenever } from "@vueuse/core";
|
import { whenever } from "@vueuse/core";
|
||||||
import { VForm } from "~/types/vuetify";
|
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { useHouseholdSelf } from "~/composables/use-households";
|
import { useHouseholdSelf } from "~/composables/use-households";
|
||||||
import { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
|
import type { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
|
||||||
|
import type { VForm } from "~/types/auto-forms";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
recipe: {
|
recipe: {
|
||||||
type: Object as () => Recipe,
|
type: Object as () => Recipe,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["eventCreated"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const madeThisDialog = ref(false);
|
const madeThisDialog = ref(false);
|
||||||
const userApi = useUserApi();
|
const userApi = useUserApi();
|
||||||
const { household } = useHouseholdSelf();
|
const { household } = useHouseholdSelf();
|
||||||
const { $auth, i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
const domMadeThisForm = ref<VForm>();
|
const domMadeThisForm = ref<VForm>();
|
||||||
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
||||||
subject: "",
|
subject: "",
|
||||||
@@ -143,14 +148,18 @@ export default defineComponent({
|
|||||||
const newTimelineEventImage = ref<Blob | File>();
|
const newTimelineEventImage = ref<Blob | File>();
|
||||||
const newTimelineEventImageName = ref<string>("");
|
const newTimelineEventImageName = ref<string>("");
|
||||||
const newTimelineEventImagePreviewUrl = ref<string>();
|
const newTimelineEventImagePreviewUrl = ref<string>();
|
||||||
const newTimelineEventTimestamp = ref<string>();
|
const newTimelineEventTimestamp = ref<Date>(new Date());
|
||||||
|
const newTimelineEventTimestampString = computed(() => {
|
||||||
|
return newTimelineEventTimestamp.value.toISOString().substring(0, 10);
|
||||||
|
});
|
||||||
|
|
||||||
const lastMade = ref(props.recipe.lastMade);
|
const lastMade = ref(props.recipe.lastMade);
|
||||||
const lastMadeReady = ref(false);
|
const lastMadeReady = ref(false);
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!$auth.user?.householdSlug) {
|
if (!$auth.user?.value?.householdSlug) {
|
||||||
lastMade.value = props.recipe.lastMade;
|
lastMade.value = props.recipe.lastMade;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
|
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
|
||||||
lastMade.value = data?.lastMade;
|
lastMade.value = data?.lastMade;
|
||||||
}
|
}
|
||||||
@@ -158,15 +167,12 @@ export default defineComponent({
|
|||||||
lastMadeReady.value = true;
|
lastMadeReady.value = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
whenever(
|
whenever(
|
||||||
() => madeThisDialog.value,
|
() => madeThisDialog.value,
|
||||||
() => {
|
() => {
|
||||||
// Set timestamp to now
|
// Set timestamp to now
|
||||||
newTimelineEventTimestamp.value = (
|
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
|
||||||
new Date(Date.now() - (new Date()).getTimezoneOffset() * 60000)
|
},
|
||||||
).toISOString().substring(0, 10);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const firstDayOfWeek = computed(() => {
|
const firstDayOfWeek = computed(() => {
|
||||||
@@ -190,19 +196,19 @@ export default defineComponent({
|
|||||||
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = reactive({datePickerMenu: false});
|
const state = reactive({ datePickerMenu: false });
|
||||||
async function createTimelineEvent() {
|
async function createTimelineEvent() {
|
||||||
if (!(newTimelineEventTimestamp.value && props.recipe?.id && props.recipe?.slug)) {
|
if (!(newTimelineEventTimestampString.value && props.recipe?.id && props.recipe?.slug)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
newTimelineEvent.value.recipeId = props.recipe.id
|
newTimelineEvent.value.recipeId = props.recipe.id;
|
||||||
// @ts-expect-error - TS doesn't like the $auth global user attribute
|
// Note: $auth.user is now a ref
|
||||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.fullName })
|
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
|
||||||
|
|
||||||
// the user only selects the date, so we set the time to end of day local time
|
// the user only selects the date, so we set the time to end of day local time
|
||||||
// we choose the end of day so it always comes after "new recipe" events
|
// we choose the end of day so it always comes after "new recipe" events
|
||||||
newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestamp.value + "T23:59:59").toISOString();
|
newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestampString.value + "T23:59:59").toISOString();
|
||||||
|
|
||||||
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
|
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
|
||||||
const newEvent = eventResponse.data;
|
const newEvent = eventResponse.data;
|
||||||
@@ -210,7 +216,7 @@ export default defineComponent({
|
|||||||
// we also update the recipe's last made value
|
// we also update the recipe's last made value
|
||||||
if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
|
if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
|
||||||
lastMade.value = newTimelineEvent.value.timestamp;
|
lastMade.value = newTimelineEvent.value.timestamp;
|
||||||
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
|
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// update the image, if provided
|
// update the image, if provided
|
||||||
@@ -221,7 +227,6 @@ export default defineComponent({
|
|||||||
newTimelineEventImageName.value,
|
newTimelineEventImageName.value,
|
||||||
);
|
);
|
||||||
if (imageResponse.data) {
|
if (imageResponse.data) {
|
||||||
// @ts-ignore the image response data will always match a value of TimelineEventImage
|
|
||||||
newEvent.image = imageResponse.data.image;
|
newEvent.image = imageResponse.data.image;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,6 +250,7 @@ export default defineComponent({
|
|||||||
newTimelineEventImage,
|
newTimelineEventImage,
|
||||||
newTimelineEventImagePreviewUrl,
|
newTimelineEventImagePreviewUrl,
|
||||||
newTimelineEventTimestamp,
|
newTimelineEventTimestamp,
|
||||||
|
newTimelineEventTimestampString,
|
||||||
lastMade,
|
lastMade,
|
||||||
lastMadeReady,
|
lastMadeReady,
|
||||||
createTimelineEvent,
|
createTimelineEvent,
|
||||||
|
|||||||
@@ -7,34 +7,57 @@
|
|||||||
:class="attrs.class.sheet"
|
:class="attrs.class.sheet"
|
||||||
:style="tile ? 'max-width: 100%; width: fit-content;' : 'width: 100%;'"
|
:style="tile ? 'max-width: 100%; width: fit-content;' : 'width: 100%;'"
|
||||||
>
|
>
|
||||||
<v-list-item :to="disabled ? '' : '/g/' + groupSlug + '/r/' + recipe.slug" :class="attrs.class.listItem">
|
<v-list-item
|
||||||
<v-list-item-avatar :class="attrs.class.avatar">
|
:to="disabled ? '' : '/g/' + groupSlug + '/r/' + recipe.slug"
|
||||||
<v-icon :class="attrs.class.icon" dark :small="small"> {{ $globals.icons.primary }} </v-icon>
|
:class="attrs.class.listItem"
|
||||||
</v-list-item-avatar>
|
>
|
||||||
<v-list-item-content :class="attrs.class.text">
|
<template #prepend>
|
||||||
<v-list-item-title :class="listItem && listItemDescriptions[index] ? '' : 'pr-4'" :style="attrs.style.text.title">
|
<v-avatar color="primary" :class="attrs.class.avatar">
|
||||||
|
<v-icon
|
||||||
|
:class="attrs.class.icon"
|
||||||
|
dark
|
||||||
|
:size="small ? 'small' : 'default'"
|
||||||
|
>
|
||||||
|
{{ $globals.icons.primary }}
|
||||||
|
</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<div :class="attrs.class.text">
|
||||||
|
<v-list-item-title
|
||||||
|
:class="listItem && listItemDescriptions[index] ? '' : 'pr-4'"
|
||||||
|
:style="attrs.style.text.title"
|
||||||
|
>
|
||||||
{{ recipe.name }}
|
{{ recipe.name }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<v-list-item-subtitle v-if="showDescription">{{ recipe.description }}</v-list-item-subtitle>
|
<v-list-item-subtitle v-if="showDescription">
|
||||||
<v-list-item-subtitle v-if="listItem && listItemDescriptions[index]" :style="attrs.style.text.subTitle">
|
{{ recipe.description }}
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
||||||
<div v-html="listItemDescriptions[index]"></div>
|
|
||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
</v-list-item-content>
|
<v-list-item-subtitle
|
||||||
<slot :name="'actions-' + recipe.id" :v-bind="{ item: recipe }"> </slot>
|
v-if="listItem && listItemDescriptions[index]"
|
||||||
|
:style="attrs.style.text.subTitle"
|
||||||
|
>
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div v-html="listItemDescriptions[index]" />
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</div>
|
||||||
|
<template #append>
|
||||||
|
<slot
|
||||||
|
:name="'actions-' + recipe.id"
|
||||||
|
:v-bind="{ item: recipe }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
</v-list>
|
</v-list>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { useFraction } from "~/composables/recipes/use-fraction";
|
import { useFraction } from "~/composables/recipes/use-fraction";
|
||||||
import { ShoppingListItemOut } from "~/lib/api/types/household";
|
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||||
import { RecipeSummary } from "~/lib/api/types/recipe";
|
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
recipes: {
|
recipes: {
|
||||||
type: Array as () => RecipeSummary[],
|
type: Array as () => RecipeSummary[],
|
||||||
@@ -59,44 +82,46 @@ export default defineComponent({
|
|||||||
disabled: {
|
disabled: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { $auth } = useContext();
|
const $auth = useMealieAuth();
|
||||||
const { frac } = useFraction();
|
const { frac } = useFraction();
|
||||||
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 attrs = computed(() => {
|
const attrs = computed(() => {
|
||||||
return props.small ? {
|
return props.small
|
||||||
class: {
|
? {
|
||||||
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
|
class: {
|
||||||
listItem: "px-0",
|
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||||
avatar: "ma-0",
|
listItem: "px-0",
|
||||||
icon: "ma-0 pa-0 primary",
|
avatar: "ma-0",
|
||||||
text: "pa-0",
|
icon: "ma-0 pa-0 primary",
|
||||||
},
|
text: "pa-0",
|
||||||
style: {
|
},
|
||||||
text: {
|
style: {
|
||||||
title: "font-size: small;",
|
text: {
|
||||||
subTitle: "font-size: x-small;",
|
title: "font-size: small;",
|
||||||
},
|
subTitle: "font-size: x-small;",
|
||||||
},
|
},
|
||||||
} : {
|
},
|
||||||
class: {
|
}
|
||||||
sheet: props.tile ? "mx-1 justify-center align-center" : "mb-1 justify-center align-center",
|
: {
|
||||||
listItem: "px-4",
|
class: {
|
||||||
avatar: "",
|
sheet: props.tile ? "mx-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||||
icon: "pa-1 primary",
|
listItem: "px-4",
|
||||||
text: "",
|
avatar: "",
|
||||||
},
|
icon: "pa-1 primary",
|
||||||
style: {
|
text: "",
|
||||||
text: {
|
},
|
||||||
title: "",
|
style: {
|
||||||
subTitle: "",
|
text: {
|
||||||
},
|
title: "",
|
||||||
},
|
subTitle: "",
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function sanitizeHTML(rawHtml: string) {
|
function sanitizeHTML(rawHtml: string) {
|
||||||
@@ -108,11 +133,11 @@ export default defineComponent({
|
|||||||
|
|
||||||
const listItemDescriptions = computed<string[]>(() => {
|
const listItemDescriptions = computed<string[]>(() => {
|
||||||
if (
|
if (
|
||||||
props.recipes.length === 1 // we don't need to specify details if there's only one recipe ref
|
props.recipes.length === 1 // we don't need to specify details if there's only one recipe ref
|
||||||
|| !props.listItem?.recipeReferences
|
|| !props.listItem?.recipeReferences
|
||||||
|| props.listItem.recipeReferences.length !== props.recipes.length
|
|| props.listItem.recipeReferences.length !== props.recipes.length
|
||||||
) {
|
) {
|
||||||
return props.recipes.map((_) => "")
|
return props.recipes.map(_ => "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const listItemDescriptions: string[] = [];
|
const listItemDescriptions: string[] = [];
|
||||||
@@ -120,36 +145,37 @@ export default defineComponent({
|
|||||||
const itemRef = props.listItem?.recipeReferences[i];
|
const itemRef = props.listItem?.recipeReferences[i];
|
||||||
const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1);
|
const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1);
|
||||||
|
|
||||||
let listItemDescription = ""
|
let listItemDescription = "";
|
||||||
if (props.listItem.unit?.fraction) {
|
if (props.listItem.unit?.fraction) {
|
||||||
const fraction = frac(quantity, 10, true);
|
const fraction = frac(quantity, 10, true);
|
||||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||||
listItemDescription += fraction[0];
|
listItemDescription += fraction[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fraction[1] > 0) {
|
if (fraction[1] > 0) {
|
||||||
listItemDescription += ` <sup>${fraction[1]}</sup>⁄<sub>${fraction[2]}</sub>`;
|
listItemDescription += ` <sup>${fraction[1]}</sup>⁄<sub>${fraction[2]}</sub>`;
|
||||||
}
|
|
||||||
else {
|
|
||||||
listItemDescription = (quantity).toString();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
listItemDescription = (Math.round(quantity*100)/100).toString();
|
listItemDescription = (quantity).toString();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
listItemDescription = (Math.round(quantity * 100) / 100).toString();
|
||||||
|
}
|
||||||
|
|
||||||
if (props.listItem.unit) {
|
if (props.listItem.unit) {
|
||||||
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
|
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
|
||||||
? props.listItem.unit.abbreviation : props.listItem.unit.name;
|
? props.listItem.unit.abbreviation
|
||||||
|
: props.listItem.unit.name;
|
||||||
|
|
||||||
listItemDescription += ` ${unitDisplay}`
|
listItemDescription += ` ${unitDisplay}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemRef.recipeNote) {
|
if (itemRef.recipeNote) {
|
||||||
listItemDescription += `, ${itemRef.recipeNote}`
|
listItemDescription += `, ${itemRef.recipeNote}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
listItemDescriptions.push(sanitizeHTML(listItemDescription));
|
listItemDescriptions.push(sanitizeHTML(listItemDescription));
|
||||||
}
|
}
|
||||||
|
|
||||||
return listItemDescriptions;
|
return listItemDescriptions;
|
||||||
|
|||||||
@@ -1,16 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="value.length > 0 || edit" class="mt-8">
|
<div
|
||||||
<h2 class="my-4">{{ $t("recipe.note") }}</h2>
|
v-if="model.length > 0 || edit"
|
||||||
<div v-for="(note, index) in value" :id="'note' + index" :key="'note' + index" class="mt-1">
|
class="mt-8"
|
||||||
|
>
|
||||||
|
<h2 class="my-4 text-h5 font-weight-medium opacity-80">
|
||||||
|
{{ $t("recipe.note") }}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
v-for="(note, index) in model"
|
||||||
|
:id="'note' + index"
|
||||||
|
:key="'note' + index"
|
||||||
|
class="mt-1"
|
||||||
|
>
|
||||||
<v-card v-if="edit">
|
<v-card v-if="edit">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<v-text-field v-model="value[index]['title']" :label="$t('recipe.title')" />
|
<v-text-field
|
||||||
<v-btn icon class="mr-2" elevation="0" @click="removeByIndex(value, index)">
|
v-model="model[index]['title']"
|
||||||
|
variant="underlined"
|
||||||
|
:label="$t('recipe.title')"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
class="mr-2"
|
||||||
|
elevation="0"
|
||||||
|
@click="removeByIndex(index)"
|
||||||
|
>
|
||||||
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
<v-textarea v-model="value[index]['text']" auto-grow :placeholder="$t('recipe.note')" />
|
<v-textarea
|
||||||
|
v-model="model[index]['text']"
|
||||||
|
variant="underlined"
|
||||||
|
auto-grow
|
||||||
|
:placeholder="$t('recipe.note')"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@@ -23,44 +47,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="edit" class="d-flex justify-end">
|
<div
|
||||||
<BaseButton class="ml-auto my-2" @click="addNote"> {{ $t("general.add") }}</BaseButton>
|
v-if="edit"
|
||||||
|
class="d-flex justify-end"
|
||||||
|
>
|
||||||
|
<BaseButton
|
||||||
|
class="ml-auto my-2"
|
||||||
|
@click="addNote"
|
||||||
|
>
|
||||||
|
{{ $t("general.add") }}
|
||||||
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
import type { RecipeNote } from "~/lib/api/types/recipe";
|
||||||
import { RecipeNote } from "~/lib/api/types/recipe";
|
|
||||||
|
|
||||||
export default defineComponent({
|
const model = defineModel<RecipeNote[]>({ default: () => [] });
|
||||||
props: {
|
|
||||||
value: {
|
|
||||||
type: Array as () => RecipeNote[],
|
|
||||||
required: false,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
|
|
||||||
edit: {
|
defineProps({
|
||||||
type: Boolean,
|
edit: {
|
||||||
default: true,
|
type: Boolean,
|
||||||
},
|
default: true,
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
function addNote() {
|
|
||||||
props.value.push({ title: "", text: "" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeByIndex(list: unknown[], index: number) {
|
|
||||||
list.splice(index, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
addNote,
|
|
||||||
removeByIndex,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
|
|
||||||
<style></style>
|
function addNote() {
|
||||||
|
model.value = [...model.value, { title: "", text: "" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeByIndex(index: number) {
|
||||||
|
const newNotes = [...model.value];
|
||||||
|
newNotes.splice(index, 1);
|
||||||
|
model.value = newNotes;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -4,23 +4,42 @@
|
|||||||
<v-card-title class="pt-2 pb-0">
|
<v-card-title class="pt-2 pb-0">
|
||||||
{{ $t("recipe.nutrition") }}
|
{{ $t("recipe.nutrition") }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider class="mx-2 my-1"></v-divider>
|
<v-divider class="mx-2 my-1" />
|
||||||
<v-card-text v-if="edit">
|
<v-card-text v-if="edit">
|
||||||
<div v-for="(item, key, index) in value" :key="index">
|
<div
|
||||||
|
v-for="(item, key, index) in modelValue"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
dense :value="value[key]" :label="labels[key].label" :suffix="labels[key].suffix" type="number"
|
density="compact"
|
||||||
autocomplete="off" @input="updateValue(key, $event)"></v-text-field>
|
:model-value="modelValue[key]"
|
||||||
|
:label="labels[key].label"
|
||||||
|
:suffix="labels[key].suffix"
|
||||||
|
type="number"
|
||||||
|
autocomplete="off"
|
||||||
|
@update:model-value="updateValue(key, $event)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-list v-if="showViewer" dense class="mt-0 pt-0">
|
<v-list
|
||||||
<v-list-item v-for="(item, key, index) in renderedList" :key="index" style="min-height: 25px" dense>
|
v-if="showViewer"
|
||||||
<v-list-item-content>
|
density="compact"
|
||||||
|
class="mt-0 pt-0"
|
||||||
|
>
|
||||||
|
<v-list-item
|
||||||
|
v-for="(item, key, index) in renderedList"
|
||||||
|
:key="index"
|
||||||
|
style="min-height: 25px"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
<v-list-item-title class="pl-4 caption flex row">
|
<v-list-item-title class="pl-4 caption flex row">
|
||||||
<div>{{ item.label }}</div>
|
<div>{{ item.label }}</div>
|
||||||
<div class="ml-auto mr-1">{{ item.value }}</div>
|
<div class="ml-auto mr-1">
|
||||||
|
{{ item.value }}
|
||||||
|
</div>
|
||||||
<div>{{ item.suffix }}</div>
|
<div>{{ item.suffix }}</div>
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
</v-list-item-content>
|
</div>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -28,13 +47,13 @@ dense :value="value[key]" :label="labels[key].label" :suffix="labels[key].suffix
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
|
||||||
import { useNutritionLabels } from "~/composables/recipes";
|
import { useNutritionLabels } from "~/composables/recipes";
|
||||||
import { Nutrition } from "~/lib/api/types/recipe";
|
import type { Nutrition } from "~/lib/api/types/recipe";
|
||||||
import { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
|
import type { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
|
||||||
export default defineComponent({
|
|
||||||
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Object as () => Nutrition,
|
type: Object as () => Nutrition,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
@@ -43,12 +62,13 @@ export default defineComponent({
|
|||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { labels } = useNutritionLabels();
|
const { labels } = useNutritionLabels();
|
||||||
const valueNotNull = computed(() => {
|
const valueNotNull = computed(() => {
|
||||||
let key: keyof Nutrition;
|
let key: keyof Nutrition;
|
||||||
for (key in props.value) {
|
for (key in props.modelValue) {
|
||||||
if (props.value[key] !== null) {
|
if (props.modelValue[key] !== null) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,16 +78,16 @@ export default defineComponent({
|
|||||||
const showViewer = computed(() => !props.edit && valueNotNull.value);
|
const showViewer = computed(() => !props.edit && valueNotNull.value);
|
||||||
|
|
||||||
function updateValue(key: number | string, event: Event) {
|
function updateValue(key: number | string, event: Event) {
|
||||||
context.emit("input", { ...props.value, [key]: event });
|
context.emit("update:modelValue", { ...props.modelValue, [key]: event });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a new list that only contains nutritional information that has a value
|
// Build a new list that only contains nutritional information that has a value
|
||||||
const renderedList = computed(() => {
|
const renderedList = computed(() => {
|
||||||
return Object.entries(labels).reduce((item: NutritionLabelType, [key, label]) => {
|
return Object.entries(labels).reduce((item: NutritionLabelType, [key, label]) => {
|
||||||
if (props.value[key]?.trim()) {
|
if (props.modelValue[key]?.trim()) {
|
||||||
item[key] = {
|
item[key] = {
|
||||||
...label,
|
...label,
|
||||||
value: props.value[key],
|
value: props.modelValue[key],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
|
|||||||
@@ -1,36 +1,58 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-dialog v-model="dialog" width="500">
|
<v-dialog
|
||||||
|
v-model="dialog"
|
||||||
|
width="500"
|
||||||
|
>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-app-bar dense dark color="primary mb-2">
|
<v-app-bar
|
||||||
<v-icon large left class="mt-1">
|
density="compact"
|
||||||
{{ itemType === Organizer.Tool ? $globals.icons.potSteam :
|
dark
|
||||||
itemType === Organizer.Category ? $globals.icons.categories :
|
color="primary mb-2 position-relative left-0 top-0 w-100 pl-3"
|
||||||
$globals.icons.tags }}
|
>
|
||||||
|
<v-icon
|
||||||
|
size="large"
|
||||||
|
start
|
||||||
|
class="mt-1"
|
||||||
|
>
|
||||||
|
{{ itemType === Organizer.Tool ? $globals.icons.potSteam
|
||||||
|
: itemType === Organizer.Category ? $globals.icons.categories
|
||||||
|
: $globals.icons.tags }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
|
|
||||||
<v-toolbar-title class="headline">
|
<v-toolbar-title class="headline">
|
||||||
{{ properties.title }}
|
{{ properties.title }}
|
||||||
</v-toolbar-title>
|
</v-toolbar-title>
|
||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer />
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
<v-card-title> </v-card-title>
|
<v-card-title />
|
||||||
<v-form @submit.prevent="select">
|
<v-form @submit.prevent="select">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="name"
|
v-model="name"
|
||||||
dense
|
density="compact"
|
||||||
:label="properties.label"
|
:label="properties.label"
|
||||||
:rules="[rules.required]"
|
:rules="[rules.required]"
|
||||||
autofocus
|
autofocus
|
||||||
></v-text-field>
|
/>
|
||||||
<v-checkbox v-if="itemType === Organizer.Tool" v-model="onHand" :label="$t('tool.on-hand')"></v-checkbox>
|
<v-checkbox
|
||||||
|
v-if="itemType === Organizer.Tool"
|
||||||
|
v-model="onHand"
|
||||||
|
:label="$t('tool.on-hand')"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<BaseButton cancel @click="dialog = false" />
|
<BaseButton
|
||||||
<v-spacer></v-spacer>
|
cancel
|
||||||
<BaseButton type="submit" create :disabled="!name" />
|
@click="dialog = false"
|
||||||
|
/>
|
||||||
|
<v-spacer />
|
||||||
|
<BaseButton
|
||||||
|
type="submit"
|
||||||
|
create
|
||||||
|
:disabled="!name"
|
||||||
|
/>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-form>
|
</v-form>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -39,16 +61,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, reactive, toRefs, useContext, watch } from "@nuxtjs/composition-api";
|
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
|
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
|
||||||
import { RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
|
import { type RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
|
||||||
|
|
||||||
const CREATED_ITEM_EVENT = "created-item";
|
const CREATED_ITEM_EVENT = "created-item";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
@@ -65,8 +86,9 @@ export default defineComponent({
|
|||||||
default: "category",
|
default: "category",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -75,18 +97,18 @@ export default defineComponent({
|
|||||||
|
|
||||||
const dialog = computed({
|
const dialog = computed({
|
||||||
get() {
|
get() {
|
||||||
return props.value;
|
return props.modelValue;
|
||||||
},
|
},
|
||||||
set(value) {
|
set(value) {
|
||||||
context.emit("input", value);
|
context.emit("update:modelValue", value);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.value,
|
() => props.modelValue,
|
||||||
(val: boolean) => {
|
(val: boolean) => {
|
||||||
if (!val) state.name = "";
|
if (!val) state.name = "";
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const userApi = useUserApi();
|
const userApi = useUserApi();
|
||||||
@@ -135,7 +157,7 @@ export default defineComponent({
|
|||||||
await store.actions.createOne({ ...state });
|
await store.actions.createOne({ ...state });
|
||||||
}
|
}
|
||||||
|
|
||||||
const newItem = store.store.value.find((item) => item.name === state.name);
|
const newItem = store.store.value.find(item => item.name === state.name);
|
||||||
|
|
||||||
context.emit(CREATED_ITEM_EVENT, newItem);
|
context.emit(CREATED_ITEM_EVENT, newItem);
|
||||||
dialog.value = false;
|
dialog.value = false;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="items">
|
<div v-if="items">
|
||||||
<RecipeOrganizerDialog v-model="dialogs.organizer" :item-type="itemType" />
|
<RecipeOrganizerDialog
|
||||||
|
v-model="dialogs.organizer"
|
||||||
|
:item-type="itemType"
|
||||||
|
/>
|
||||||
|
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-if="deleteTarget"
|
v-if="deleteTarget"
|
||||||
@@ -8,18 +11,34 @@
|
|||||||
:title="$t('general.delete-with-name', { name: $t(translationKey) })"
|
:title="$t('general.delete-with-name', { name: $t(translationKey) })"
|
||||||
color="error"
|
color="error"
|
||||||
:icon="$globals.icons.alertCircle"
|
:icon="$globals.icons.alertCircle"
|
||||||
|
can-confirm
|
||||||
@confirm="deleteOne()"
|
@confirm="deleteOne()"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<p>{{ $t("general.confirm-delete-generic-with-name", { name: $t(translationKey) }) }}</p>
|
<p>{{ $t("general.confirm-delete-generic-with-name", { name: $t(translationKey) }) }}</p>
|
||||||
<p class="mt-4 mb-0 ml-4">{{ deleteTarget.name }}</p>
|
<p class="mt-4 mb-0 ml-4">
|
||||||
|
{{ deleteTarget.name }}
|
||||||
|
</p>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
<BaseDialog v-if="updateTarget" v-model="dialogs.update" :title="$t('general.update')" @confirm="updateOne()">
|
<BaseDialog
|
||||||
|
v-if="updateTarget"
|
||||||
|
v-model="dialogs.update"
|
||||||
|
:title="$t('general.update')"
|
||||||
|
can-confirm
|
||||||
|
@confirm="updateOne()"
|
||||||
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-text-field v-model="updateTarget.name" :label="$t('general.name')"> </v-text-field>
|
<v-text-field
|
||||||
<v-checkbox v-if="itemType === Organizer.Tool" v-model="updateTarget.onHand" :label="$t('tool.on-hand')"></v-checkbox>
|
v-model="updateTarget.name"
|
||||||
|
:label="$t('general.name')"
|
||||||
|
/>
|
||||||
|
<v-checkbox
|
||||||
|
v-if="itemType === Organizer.Tool"
|
||||||
|
v-model="updateTarget.onHand"
|
||||||
|
:label="$t('tool.on-hand')"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
@@ -27,32 +46,61 @@
|
|||||||
<v-col>
|
<v-col>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="searchString"
|
v-model="searchString"
|
||||||
outlined
|
variant="outlined"
|
||||||
autofocus
|
autofocus
|
||||||
color="primary accent-3"
|
color="primary accent-3"
|
||||||
:placeholder="$t('search.search-placeholder')"
|
:placeholder="$t('search.search-placeholder')"
|
||||||
:prepend-inner-icon="$globals.icons.search"
|
:prepend-inner-icon="$globals.icons.search"
|
||||||
clearable
|
clearable
|
||||||
>
|
/>
|
||||||
</v-text-field>
|
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<v-app-bar color="transparent" flat class="mt-n1 rounded align-center">
|
<v-app-bar
|
||||||
<v-icon large left>
|
color="transparent"
|
||||||
|
flat
|
||||||
|
class="mt-n1 rounded align-center px-4 position-relative w-100 left-0 top-0"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
size="large"
|
||||||
|
start
|
||||||
|
>
|
||||||
{{ icon }}
|
{{ icon }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-toolbar-title class="headline">
|
<v-toolbar-title class="headline">
|
||||||
<slot name="title"> </slot>
|
<slot name="title" />
|
||||||
</v-toolbar-title>
|
</v-toolbar-title>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer />
|
||||||
<BaseButton create @click="dialogs.organizer = true" />
|
<BaseButton
|
||||||
|
create
|
||||||
|
@click="dialogs.organizer = true"
|
||||||
|
/>
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
<section v-for="(itms, key, idx) in itemsSorted" :key="'header' + idx" :class="idx === 1 ? null : 'my-4'">
|
<section
|
||||||
<BaseCardSectionTitle v-if="isTitle(key)" :title="key" />
|
v-for="(itms, key, idx) in itemsSorted"
|
||||||
|
:key="'header' + idx"
|
||||||
|
:class="idx === 1 ? null : 'my-4'"
|
||||||
|
>
|
||||||
|
<BaseCardSectionTitle
|
||||||
|
v-if="isTitle(key)"
|
||||||
|
:title="key"
|
||||||
|
/>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col v-for="(item, index) in itms" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3">
|
<v-col
|
||||||
<v-card v-if="item" class="left-border" hover :to="`/g/${groupSlug}?${itemType}=${item.id}`">
|
v-for="(item, index) in itms"
|
||||||
|
:key="'cat' + index"
|
||||||
|
cols="12"
|
||||||
|
:sm="12"
|
||||||
|
:md="6"
|
||||||
|
:lg="4"
|
||||||
|
:xl="3"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
v-if="item"
|
||||||
|
class="left-border"
|
||||||
|
hover
|
||||||
|
:to="`/g/${groupSlug}?${itemType}=${item.id}`"
|
||||||
|
>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ icon }}
|
{{ icon }}
|
||||||
@@ -60,7 +108,7 @@
|
|||||||
<v-card-title class="py-1">
|
<v-card-title class="py-1">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer />
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
:items="[presets.delete, presets.edit]"
|
:items="[presets.delete, presets.edit]"
|
||||||
@delete="confirmDelete(item)"
|
@delete="confirmDelete(item)"
|
||||||
@@ -76,10 +124,10 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import { defineComponent, computed, ref, reactive, useContext, useRoute } from "@nuxtjs/composition-api";
|
|
||||||
import { useContextPresets } from "~/composables/use-context-presents";
|
import { useContextPresets } from "~/composables/use-context-presents";
|
||||||
import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue";
|
import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue";
|
||||||
import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated";
|
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||||
import { useRouteQuery } from "~/composables/use-router";
|
import { useRouteQuery } from "~/composables/use-router";
|
||||||
import { deepCopy } from "~/composables/use-utils";
|
import { deepCopy } from "~/composables/use-utils";
|
||||||
|
|
||||||
@@ -90,7 +138,7 @@ interface GenericItem {
|
|||||||
onHand: boolean;
|
onHand: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeOrganizerDialog,
|
RecipeOrganizerDialog,
|
||||||
},
|
},
|
||||||
@@ -108,6 +156,7 @@ export default defineComponent({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update", "delete"],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
// Search Options
|
// Search Options
|
||||||
@@ -124,9 +173,9 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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 as string || $auth.user?.value?.groupSlug || "");
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// Context Menu
|
// Context Menu
|
||||||
@@ -141,11 +190,11 @@ export default defineComponent({
|
|||||||
|
|
||||||
const translationKey = computed<string>(() => {
|
const translationKey = computed<string>(() => {
|
||||||
const typeMap = {
|
const typeMap = {
|
||||||
"categories": "category.category",
|
categories: "category.category",
|
||||||
"tags": "tag.tag",
|
tags: "tag.tag",
|
||||||
"tools": "tool.tool",
|
tools: "tool.tool",
|
||||||
"foods": "shopping-list.food",
|
foods: "shopping-list.food",
|
||||||
"households": "household.household",
|
households: "household.household",
|
||||||
};
|
};
|
||||||
return typeMap[props.itemType] || "";
|
return typeMap[props.itemType] || "";
|
||||||
});
|
});
|
||||||
@@ -193,7 +242,7 @@ export default defineComponent({
|
|||||||
return props.items;
|
return props.items;
|
||||||
}
|
}
|
||||||
const result = fuse.value.search(searchString.value.trim() as string);
|
const result = fuse.value.search(searchString.value.trim() as string);
|
||||||
return result.map((x) => x.item);
|
return result.map(x => x.item);
|
||||||
});
|
});
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -206,7 +255,7 @@ export default defineComponent({
|
|||||||
return byLetter;
|
return byLetter;
|
||||||
}
|
}
|
||||||
|
|
||||||
fuzzyItems.value
|
[...fuzzyItems.value]
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
.forEach((item) => {
|
.forEach((item) => {
|
||||||
const letter = item.name[0].toUpperCase();
|
const letter = item.name[0].toUpperCase();
|
||||||
@@ -240,7 +289,5 @@ export default defineComponent({
|
|||||||
translationKey,
|
translationKey,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// Needed for useMeta
|
|
||||||
head: {},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,62 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="selected"
|
v-model="selected"
|
||||||
|
v-bind="inputAttrs"
|
||||||
|
v-model:search="searchInput"
|
||||||
:items="storeItem"
|
:items="storeItem"
|
||||||
:value="value"
|
|
||||||
:label="label"
|
:label="label"
|
||||||
chips
|
chips
|
||||||
deletable-chips
|
closable-chips
|
||||||
item-text="name"
|
item-title="name"
|
||||||
multiple
|
multiple
|
||||||
|
variant="underlined"
|
||||||
:prepend-inner-icon="icon"
|
:prepend-inner-icon="icon"
|
||||||
|
:append-icon="$globals.icons.create"
|
||||||
return-object
|
return-object
|
||||||
v-bind="inputAttrs"
|
|
||||||
auto-select-first
|
auto-select-first
|
||||||
:search-input.sync="searchInput"
|
|
||||||
class="pa-0"
|
class="pa-0"
|
||||||
@change="resetSearchInput"
|
@update:model-value="resetSearchInput"
|
||||||
|
@click:append="dialog = true"
|
||||||
>
|
>
|
||||||
<template #selection="data">
|
<template #chip="{ item, index }">
|
||||||
<v-chip
|
<v-chip
|
||||||
:key="data.index"
|
:key="index"
|
||||||
class="ma-1"
|
class="ma-1"
|
||||||
:input-value="data.selected"
|
|
||||||
small
|
|
||||||
close
|
|
||||||
label
|
|
||||||
color="accent"
|
color="accent"
|
||||||
dark
|
variant="flat"
|
||||||
@click:close="removeByIndex(data.index)"
|
label
|
||||||
|
|
||||||
|
closable
|
||||||
|
@click:close="removeByIndex(index)"
|
||||||
>
|
>
|
||||||
{{ data.item.name || data.item }}
|
{{ item.value }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="showAdd" #append-outer>
|
|
||||||
<v-btn icon @click="dialog = true">
|
<template
|
||||||
<v-icon>
|
v-if="showAdd"
|
||||||
{{ $globals.icons.create }}
|
#append
|
||||||
</v-icon>
|
>
|
||||||
</v-btn>
|
<RecipeOrganizerDialog
|
||||||
<RecipeOrganizerDialog v-model="dialog" :item-type="selectorType" @created-item="appendCreated" />
|
v-model="dialog"
|
||||||
|
:item-type="selectorType"
|
||||||
|
@created-item="appendCreated"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-autocomplete>
|
</v-autocomplete>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
|
import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
||||||
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
|
import type { RecipeTool } from "~/lib/api/types/admin";
|
||||||
import { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||||
import { RecipeTool } from "~/lib/api/types/admin";
|
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||||
import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated";
|
|
||||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
|
||||||
RecipeOrganizerDialog,
|
|
||||||
},
|
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Array as () => (
|
type: Array as () => (
|
||||||
| HouseholdSummary
|
| HouseholdSummary
|
||||||
| RecipeTag
|
| RecipeTag
|
||||||
@@ -95,12 +94,13 @@ export default defineComponent({
|
|||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,7 +110,8 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { $globals, i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
|
||||||
const label = computed(() => {
|
const label = computed(() => {
|
||||||
if (!props.showLabel) {
|
if (!props.showLabel) {
|
||||||
@@ -168,11 +169,11 @@ export default defineComponent({
|
|||||||
const store = computed(() => {
|
const store = computed(() => {
|
||||||
const { store } = storeMap[props.selectorType];
|
const { store } = storeMap[props.selectorType];
|
||||||
return store.value;
|
return store.value;
|
||||||
})
|
});
|
||||||
|
|
||||||
const items = computed(() => {
|
const items = computed(() => {
|
||||||
if (!props.returnObject) {
|
if (!props.returnObject) {
|
||||||
return store.value.map((item) => item.name);
|
return store.value.map(item => item.name);
|
||||||
}
|
}
|
||||||
return store.value;
|
return store.value;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-container v-show="!isCookMode" key="recipe-page" :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
|
<v-container v-show="!isCookMode" key="recipe-page" class="pt-0" :class="{ 'pa-0': $vuetify.display.smAndDown.value }">
|
||||||
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
|
<v-card :flat="$vuetify.display.smAndDown.value" class="d-print-none">
|
||||||
<RecipePageHeader
|
<RecipePageHeader
|
||||||
:recipe="recipe"
|
:recipe="recipe"
|
||||||
:recipe-scale="scale"
|
:recipe-scale="scale"
|
||||||
@@ -9,7 +9,13 @@
|
|||||||
@save="saveRecipe"
|
@save="saveRecipe"
|
||||||
@delete="deleteRecipe"
|
@delete="deleteRecipe"
|
||||||
/>
|
/>
|
||||||
<LazyRecipeJsonEditor v-if="isEditJSON" v-model="recipe" class="mt-10" :options="EDITOR_OPTIONS" />
|
<RecipeJsonEditor
|
||||||
|
v-if="isEditJSON"
|
||||||
|
v-model="recipe"
|
||||||
|
class="mt-10"
|
||||||
|
mode="text"
|
||||||
|
:main-menu-bar="false"
|
||||||
|
/>
|
||||||
<v-card-text v-else>
|
<v-card-text v-else>
|
||||||
<!--
|
<!--
|
||||||
This is where most of the main content is rendered. Some components include state for both Edit and View modes
|
This is where most of the main content is rendered. Some components include state for both Edit and View modes
|
||||||
@@ -21,10 +27,18 @@
|
|||||||
a significant amount of prop management. When we move to Vue 3 and have access to some of the newer API's the plan to update this
|
a significant amount of prop management. When we move to Vue 3 and have access to some of the newer API's the plan to update this
|
||||||
data management and mutation system we're using.
|
data management and mutation system we're using.
|
||||||
-->
|
-->
|
||||||
<RecipePageInfoEditor v-if="isEditMode" :recipe="recipe" :landscape="landscape" />
|
<div>
|
||||||
<RecipePageEditorToolbar v-if="isEditForm" :recipe="recipe" />
|
<RecipePageInfoEditor v-if="isEditMode" v-model="recipe" />
|
||||||
<RecipePageIngredientEditor v-if="isEditForm" :recipe="recipe" />
|
</div>
|
||||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
|
<div>
|
||||||
|
<RecipePageEditorToolbar v-if="isEditForm" v-model="recipe" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<RecipePageIngredientEditor v-if="isEditForm" v-model="recipe" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
This section contains the 2 column layout for the recipe steps and other content.
|
This section contains the 2 column layout for the recipe steps and other content.
|
||||||
@@ -35,9 +49,9 @@
|
|||||||
-->
|
-->
|
||||||
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4">
|
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4">
|
||||||
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
|
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
|
||||||
<RecipePageOrganizers v-if="$vuetify.breakpoint.mdAndUp" :recipe="recipe" @item-selected="chipClicked" />
|
<RecipePageOrganizers v-if="$vuetify.display.mdAndUp" v-model="recipe" @item-selected="chipClicked" />
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-divider v-if="$vuetify.breakpoint.mdAndUp && !isCookMode" class="my-divider" :vertical="true" />
|
<v-divider v-if="$vuetify.display.mdAndUp && !isCookMode" class="my-divider" :vertical="true" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
the right column is always rendered, but it's layout width is determined by where the left column is
|
the right column is always rendered, but it's layout width is determined by where the left column is
|
||||||
@@ -46,104 +60,102 @@
|
|||||||
<v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4" :lg="8 + (isCookMode ? 1 : 0) * 4">
|
<v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4" :lg="8 + (isCookMode ? 1 : 0) * 4">
|
||||||
<RecipePageInstructions
|
<RecipePageInstructions
|
||||||
v-model="recipe.recipeInstructions"
|
v-model="recipe.recipeInstructions"
|
||||||
:assets.sync="recipe.assets"
|
v-model:assets="recipe.assets"
|
||||||
:recipe="recipe"
|
:recipe="recipe"
|
||||||
:scale="scale"
|
:scale="scale"
|
||||||
/>
|
/>
|
||||||
<div v-if="isEditForm" class="d-flex">
|
<div v-if="isEditForm" class="d-flex">
|
||||||
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
|
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
|
||||||
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.add") }}</BaseButton>
|
<BaseButton class="my-2" @click="addStep()">
|
||||||
|
{{ $t("general.add") }}
|
||||||
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!$vuetify.breakpoint.mdAndUp">
|
<div v-if="!$vuetify.display.mdAndUp">
|
||||||
<RecipePageOrganizers :recipe="recipe" />
|
<RecipePageOrganizers v-model="recipe" />
|
||||||
</div>
|
</div>
|
||||||
<RecipeNotes v-model="recipe.notes" :edit="isEditForm" />
|
<RecipeNotes v-model="recipe.notes" :edit="isEditForm" />
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<RecipePageFooter :recipe="recipe" />
|
<RecipePageFooter v-model="recipe" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
<WakelockSwitch/>
|
<WakelockSwitch />
|
||||||
<RecipePageComments
|
<RecipePageComments
|
||||||
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
|
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
|
||||||
:recipe="recipe"
|
v-model="recipe"
|
||||||
class="px-1 my-4 d-print-none"
|
class="px-1 my-4 d-print-none"
|
||||||
/>
|
/>
|
||||||
<RecipePrintContainer :recipe="recipe" :scale="scale" />
|
<RecipePrintContainer :recipe="recipe" :scale="scale" />
|
||||||
</v-container>
|
</v-container>
|
||||||
<!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same timer -->
|
<!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same timer -->
|
||||||
<v-sheet v-show="isCookMode && !hasLinkedIngredients" key="cookmode" :style="{height: $vuetify.breakpoint.smAndUp ? 'calc(100vh - 48px)' : ''}"> <!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
|
<v-sheet
|
||||||
<v-row style="height: 100%;" no-gutters class="overflow-hidden">
|
v-show="isCookMode && !hasLinkedIngredients"
|
||||||
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%;">
|
key="cookmode"
|
||||||
|
:style="{ height: $vuetify.display.smAndUp ? 'calc(100vh - 48px)' : '' }"
|
||||||
|
>
|
||||||
|
<!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
|
||||||
|
<v-row style="height: 100%" no-gutters class="overflow-hidden">
|
||||||
|
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%">
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
|
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
||||||
</div>
|
</div>
|
||||||
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" :is-cook-mode="isCookMode" />
|
<RecipePageIngredientToolsView
|
||||||
<v-divider></v-divider>
|
v-if="!isEditForm"
|
||||||
|
:recipe="recipe"
|
||||||
|
:scale="scale"
|
||||||
|
:is-cook-mode="isCookMode"
|
||||||
|
/>
|
||||||
|
<v-divider />
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col class="overflow-y-auto py-2" style="height: 100%;" cols="12" sm="7">
|
<v-col class="overflow-y-auto py-2" style="height: 100%" cols="12" sm="7">
|
||||||
<RecipePageInstructions
|
<RecipePageInstructions
|
||||||
v-model="recipe.recipeInstructions"
|
v-model="recipe.recipeInstructions"
|
||||||
|
v-model:assets="recipe.assets"
|
||||||
class="overflow-y-hidden px-4"
|
class="overflow-y-hidden px-4"
|
||||||
:assets.sync="recipe.assets"
|
|
||||||
:recipe="recipe"
|
:recipe="recipe"
|
||||||
:scale="scale"
|
:scale="scale"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
<v-sheet v-show="isCookMode && hasLinkedIngredients">
|
<v-sheet v-show="isCookMode && hasLinkedIngredients">
|
||||||
<div class="mt-2 px-2 px-md-4">
|
<div class="mt-2 px-2 px-md-4">
|
||||||
<RecipePageScale :recipe="recipe" :scale.sync="scale"/>
|
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
||||||
</div>
|
</div>
|
||||||
<RecipePageInstructions
|
<RecipePageInstructions
|
||||||
v-model="recipe.recipeInstructions"
|
v-model="recipe.recipeInstructions"
|
||||||
|
v-model:assets="recipe.assets"
|
||||||
class="overflow-y-hidden mt-n5 px-2 px-md-4"
|
class="overflow-y-hidden mt-n5 px-2 px-md-4"
|
||||||
:assets.sync="recipe.assets"
|
|
||||||
:recipe="recipe"
|
:recipe="recipe"
|
||||||
:scale="scale"
|
:scale="scale"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="notLinkedIngredients.length > 0" class="px-2 px-md-4 pb-4 ">
|
<div v-if="notLinkedIngredients.length > 0" class="px-2 px-md-4 pb-4">
|
||||||
<v-divider></v-divider>
|
<v-divider />
|
||||||
<v-card flat>
|
<v-card flat>
|
||||||
<v-card-title>{{ $t('recipe.not-linked-ingredients') }}</v-card-title>
|
<v-card-title>{{ $t("recipe.not-linked-ingredients") }}</v-card-title>
|
||||||
<RecipeIngredients
|
<RecipeIngredients
|
||||||
:value="notLinkedIngredients"
|
:value="notLinkedIngredients"
|
||||||
:scale="scale"
|
:scale="scale"
|
||||||
:disable-amount="recipe.settings.disableAmount"
|
:disable-amount="recipe.settings.disableAmount"
|
||||||
:is-cook-mode="isCookMode">
|
:is-cook-mode="isCookMode"
|
||||||
|
/>
|
||||||
</RecipeIngredients>
|
|
||||||
</v-card>
|
</v-card>
|
||||||
</div>
|
</div>
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isCookMode"
|
v-if="isCookMode"
|
||||||
fab
|
icon
|
||||||
small
|
|
||||||
color="primary"
|
color="primary"
|
||||||
style="position: fixed; right: 12px; top: 60px;"
|
style="position: fixed; right: 12px; top: 60px"
|
||||||
@click="toggleCookMode()"
|
@click="toggleCookMode()"
|
||||||
>
|
>
|
||||||
<v-icon>mdi-close</v-icon>
|
<v-icon>{{ $globals.icons.close }}</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
|
||||||
defineComponent,
|
|
||||||
useContext,
|
|
||||||
useRouter,
|
|
||||||
computed,
|
|
||||||
ref,
|
|
||||||
onMounted,
|
|
||||||
onUnmounted,
|
|
||||||
useRoute,
|
|
||||||
} from "@nuxtjs/composition-api";
|
|
||||||
import { invoke, until } from "@vueuse/core";
|
import { invoke, until } from "@vueuse/core";
|
||||||
import RecipeIngredients from "../RecipeIngredients.vue";
|
import RecipeIngredients from "../RecipeIngredients.vue";
|
||||||
import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue";
|
import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue";
|
||||||
@@ -156,17 +168,14 @@ import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
|
|||||||
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
|
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
|
||||||
import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue";
|
import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue";
|
||||||
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
|
||||||
import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue";
|
import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue";
|
||||||
import {
|
import {
|
||||||
clearPageState,
|
clearPageState,
|
||||||
EditorMode,
|
|
||||||
PageMode,
|
PageMode,
|
||||||
usePageState,
|
usePageState,
|
||||||
usePageUser,
|
|
||||||
} from "~/composables/recipe-page/shared-state";
|
} from "~/composables/recipe-page/shared-state";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { Recipe, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
import type { Recipe, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||||
import { useRouteQuery } from "~/composables/use-router";
|
import { useRouteQuery } from "~/composables/use-router";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { uuid4, deepCopy } from "~/composables/use-utils";
|
import { uuid4, deepCopy } from "~/composables/use-utils";
|
||||||
@@ -174,214 +183,172 @@ import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.
|
|||||||
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
|
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
|
||||||
import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
||||||
|
|
||||||
const EDITOR_OPTIONS = {
|
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||||
mode: "code",
|
|
||||||
search: false,
|
|
||||||
mainMenuBar: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineComponent({
|
const { $vuetify } = useNuxtApp();
|
||||||
components: {
|
const i18n = useI18n();
|
||||||
RecipePageHeader,
|
const $auth = useMealieAuth();
|
||||||
RecipePrintContainer,
|
const route = useRoute();
|
||||||
RecipePageComments,
|
|
||||||
RecipePageInfoEditor,
|
|
||||||
RecipePageEditorToolbar,
|
|
||||||
RecipePageIngredientEditor,
|
|
||||||
RecipePageOrganizers,
|
|
||||||
RecipePageScale,
|
|
||||||
RecipePageIngredientToolsView,
|
|
||||||
RecipeDialogBulkAdd,
|
|
||||||
RecipeNotes,
|
|
||||||
RecipePageInstructions,
|
|
||||||
RecipePageFooter,
|
|
||||||
RecipeIngredients,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
recipe: {
|
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const { $auth } = useContext();
|
|
||||||
const route = useRoute();
|
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
|
||||||
|
|
||||||
const router = useRouter();
|
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
|
||||||
const api = useUserApi();
|
|
||||||
const { pageMode, editMode, setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode } =
|
|
||||||
usePageState(props.recipe.slug);
|
|
||||||
const { deactivateNavigationWarning } = useNavigationWarning();
|
|
||||||
const notLinkedIngredients = computed(() => {
|
|
||||||
return props.recipe.recipeIngredient.filter((ingredient) => {
|
|
||||||
return !props.recipe.recipeInstructions.some((step) => step.ingredientReferences?.map((ref) => ref.referenceId).includes(ingredient.referenceId));
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/** =============================================================
|
const router = useRouter();
|
||||||
* Recipe Snapshot on Mount
|
const api = useUserApi();
|
||||||
* this is used to determine if the recipe has been changed since the last save
|
const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode }
|
||||||
* and prompts the user to save if they have unsaved changes.
|
= usePageState(recipe.value.slug);
|
||||||
*/
|
const { deactivateNavigationWarning } = useNavigationWarning();
|
||||||
const originalRecipe = ref<Recipe | null>(null);
|
const notLinkedIngredients = computed(() => {
|
||||||
|
return recipe.value.recipeIngredient.filter((ingredient) => {
|
||||||
invoke(async () => {
|
return !recipe.value.recipeInstructions.some(step =>
|
||||||
await until(props.recipe).not.toBeNull();
|
step.ingredientReferences?.map(ref => ref.referenceId).includes(ingredient.referenceId),
|
||||||
originalRecipe.value = deepCopy(props.recipe);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(async () => {
|
|
||||||
const isSame = JSON.stringify(props.recipe) === JSON.stringify(originalRecipe.value);
|
|
||||||
if (isEditMode.value && !isSame && props.recipe?.slug !== undefined) {
|
|
||||||
const save = window.confirm(
|
|
||||||
i18n.tc("general.unsaved-changes"),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (save) {
|
|
||||||
await api.recipes.updateOne(props.recipe.slug, props.recipe);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
deactivateNavigationWarning();
|
|
||||||
toggleCookMode()
|
|
||||||
|
|
||||||
clearPageState(props.recipe.slug || "");
|
|
||||||
console.debug("reset RecipePage state during unmount");
|
|
||||||
});
|
|
||||||
const hasLinkedIngredients = computed(() => {
|
|
||||||
return props.recipe.recipeInstructions.some((step) => step.ingredientReferences && step.ingredientReferences.length > 0);
|
|
||||||
})
|
|
||||||
/** =============================================================
|
|
||||||
* Set State onMounted
|
|
||||||
*/
|
|
||||||
|
|
||||||
type BooleanString = "true" | "false" | "";
|
|
||||||
|
|
||||||
const edit = useRouteQuery<BooleanString>("edit", "");
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (edit.value === "true") {
|
|
||||||
setMode(PageMode.EDIT);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/** =============================================================
|
|
||||||
* Recipe Save Delete
|
|
||||||
*/
|
|
||||||
|
|
||||||
async function saveRecipe() {
|
|
||||||
const { data } = await api.recipes.updateOne(props.recipe.slug, props.recipe);
|
|
||||||
setMode(PageMode.VIEW);
|
|
||||||
if (data?.slug) {
|
|
||||||
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteRecipe() {
|
|
||||||
const { data } = await api.recipes.deleteOne(props.recipe.slug);
|
|
||||||
if (data?.slug) {
|
|
||||||
router.push(`/g/${groupSlug.value}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** =============================================================
|
|
||||||
* View Preferences
|
|
||||||
*/
|
|
||||||
const { $vuetify, i18n } = useContext();
|
|
||||||
|
|
||||||
const landscape = computed(() => {
|
|
||||||
const preferLandscape = props.recipe.settings.landscapeView;
|
|
||||||
const smallScreen = !$vuetify.breakpoint.smAndUp;
|
|
||||||
|
|
||||||
if (preferLandscape) {
|
|
||||||
return true;
|
|
||||||
} else if (smallScreen) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** =============================================================
|
|
||||||
* Bulk Step Editor
|
|
||||||
* TODO: Move to RecipePageInstructions component
|
|
||||||
*/
|
|
||||||
|
|
||||||
function addStep(steps: Array<string> | null = null) {
|
|
||||||
if (!props.recipe.recipeInstructions) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (steps) {
|
|
||||||
const cleanedSteps = steps.map((step) => {
|
|
||||||
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
|
|
||||||
});
|
|
||||||
|
|
||||||
props.recipe.recipeInstructions.push(...cleanedSteps);
|
|
||||||
} else {
|
|
||||||
props.recipe.recipeInstructions.push({ id: uuid4(), text: "", title: "", ingredientReferences: [] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** =============================================================
|
|
||||||
* Meta Tags
|
|
||||||
*/
|
|
||||||
const { user } = usePageUser();
|
|
||||||
|
|
||||||
/** =============================================================
|
|
||||||
* RecipeChip Clicked
|
|
||||||
*/
|
|
||||||
|
|
||||||
function chipClicked(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
|
|
||||||
if (!item.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(`/g/${groupSlug.value}?${itemType}=${item.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
isOwnGroup,
|
|
||||||
api,
|
|
||||||
scale: ref(1),
|
|
||||||
EDITOR_OPTIONS,
|
|
||||||
landscape,
|
|
||||||
|
|
||||||
pageMode,
|
|
||||||
editMode,
|
|
||||||
PageMode,
|
|
||||||
EditorMode,
|
|
||||||
isEditMode,
|
|
||||||
isEditForm,
|
|
||||||
isEditJSON,
|
|
||||||
isCookMode,
|
|
||||||
toggleCookMode,
|
|
||||||
saveRecipe,
|
|
||||||
deleteRecipe,
|
|
||||||
addStep,
|
|
||||||
hasLinkedIngredients,
|
|
||||||
notLinkedIngredients,
|
|
||||||
chipClicked,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
head: {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** =============================================================
|
||||||
|
* Recipe Snapshot on Mount
|
||||||
|
* this is used to determine if the recipe has been changed since the last save
|
||||||
|
* and prompts the user to save if they have unsaved changes.
|
||||||
|
*/
|
||||||
|
const originalRecipe = ref<Recipe | null>(null);
|
||||||
|
|
||||||
|
invoke(async () => {
|
||||||
|
await until(recipe.value).not.toBeNull();
|
||||||
|
originalRecipe.value = deepCopy(recipe.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(async () => {
|
||||||
|
const isSame = JSON.stringify(recipe.value) === JSON.stringify(originalRecipe.value);
|
||||||
|
if (isEditMode.value && !isSame && recipe.value?.slug !== undefined) {
|
||||||
|
const save = window.confirm(i18n.t("general.unsaved-changes"));
|
||||||
|
|
||||||
|
if (save) {
|
||||||
|
await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deactivateNavigationWarning();
|
||||||
|
toggleCookMode();
|
||||||
|
|
||||||
|
clearPageState(recipe.value.slug || "");
|
||||||
|
console.debug("reset RecipePage state during unmount");
|
||||||
|
});
|
||||||
|
const hasLinkedIngredients = computed(() => {
|
||||||
|
return recipe.value.recipeInstructions.some(
|
||||||
|
step => step.ingredientReferences && step.ingredientReferences.length > 0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
/** =============================================================
|
||||||
|
* Set State onMounted
|
||||||
|
*/
|
||||||
|
|
||||||
|
type BooleanString = "true" | "false" | "";
|
||||||
|
|
||||||
|
const edit = useRouteQuery<BooleanString>("edit", "");
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (edit.value === "true") {
|
||||||
|
setMode(PageMode.EDIT);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** =============================================================
|
||||||
|
* Recipe Save Delete
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function saveRecipe() {
|
||||||
|
const { data } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||||
|
setMode(PageMode.VIEW);
|
||||||
|
if (data?.slug) {
|
||||||
|
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRecipe() {
|
||||||
|
const { data } = await api.recipes.deleteOne(recipe.value.slug);
|
||||||
|
if (data?.slug) {
|
||||||
|
router.push(`/g/${groupSlug.value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** =============================================================
|
||||||
|
* View Preferences
|
||||||
|
*/
|
||||||
|
const landscape = computed(() => {
|
||||||
|
const preferLandscape = recipe.value.settings.landscapeView;
|
||||||
|
const smallScreen = !$vuetify.display.smAndUp.value;
|
||||||
|
|
||||||
|
if (preferLandscape) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else if (smallScreen) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** =============================================================
|
||||||
|
* Bulk Step Editor
|
||||||
|
* TODO: Move to RecipePageInstructions component
|
||||||
|
*/
|
||||||
|
|
||||||
|
function addStep(steps: Array<string> | null = null) {
|
||||||
|
if (!recipe.value.recipeInstructions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps) {
|
||||||
|
const cleanedSteps = steps.map((step) => {
|
||||||
|
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
recipe.value.recipeInstructions.push(...cleanedSteps);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
recipe.value.recipeInstructions.push({
|
||||||
|
id: uuid4(),
|
||||||
|
text: "",
|
||||||
|
title: "",
|
||||||
|
summary: "",
|
||||||
|
ingredientReferences: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** =============================================================
|
||||||
|
* RecipeChip Clicked
|
||||||
|
*/
|
||||||
|
|
||||||
|
function chipClicked(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
|
||||||
|
if (!item.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/g/${groupSlug.value}?${itemType}=${item.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = ref(1);
|
||||||
|
|
||||||
|
// expose to template
|
||||||
|
// (all variables used in template are top-level in <script setup>)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="css">
|
<style lang="css">
|
||||||
.flip-list-move {
|
.flip-list-move {
|
||||||
transition: transform 0.5s;
|
transition: transform 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-move {
|
.no-move {
|
||||||
transition: transform 0s;
|
transition: transform 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost {
|
.ghost {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group {
|
.list-group {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item i {
|
.list-group-item i {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,44 +6,73 @@
|
|||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $t("recipe.comments") }}
|
{{ $t("recipe.comments") }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider class="mx-2"></v-divider>
|
<v-divider class="mx-2" />
|
||||||
<div v-if="user.id" class="d-flex flex-column">
|
<div
|
||||||
<div class="d-flex mt-3" style="gap: 10px">
|
v-if="user.id"
|
||||||
<UserAvatar :tooltip="false" size="40" :user-id="user.id" />
|
class="d-flex flex-column"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="d-flex mt-3"
|
||||||
|
style="gap: 10px"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
:tooltip="false"
|
||||||
|
size="40"
|
||||||
|
:user-id="user.id"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-model="comment"
|
v-model="comment"
|
||||||
hide-details=""
|
hide-details
|
||||||
dense
|
density="compact"
|
||||||
single-line
|
single-line
|
||||||
outlined
|
variant="outlined"
|
||||||
auto-grow
|
auto-grow
|
||||||
rows="2"
|
rows="2"
|
||||||
:placeholder="$t('recipe.join-the-conversation')"
|
:placeholder="$t('recipe.join-the-conversation')"
|
||||||
>
|
/>
|
||||||
</v-textarea>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-auto mt-1">
|
<div class="ml-auto mt-1">
|
||||||
<BaseButton small :disabled="!comment" @click="submitComment">
|
<BaseButton
|
||||||
<template #icon>{{ $globals.icons.check }}</template>
|
size="small"
|
||||||
|
:disabled="!comment"
|
||||||
|
@click="submitComment"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
{{ $globals.icons.check }}
|
||||||
|
</template>
|
||||||
{{ $t("general.submit") }}
|
{{ $t("general.submit") }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="comment in recipe.comments" :key="comment.id" class="d-flex my-2" style="gap: 10px">
|
<div
|
||||||
<UserAvatar :tooltip="false" size="40" :user-id="comment.userId" />
|
v-for="recipeComment in recipe.comments"
|
||||||
<v-card outlined class="flex-grow-1">
|
:key="recipeComment.id"
|
||||||
|
class="d-flex my-2"
|
||||||
|
style="gap: 10px"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
:tooltip="false"
|
||||||
|
size="40"
|
||||||
|
:user-id="recipeComment.userId"
|
||||||
|
/>
|
||||||
|
<v-card
|
||||||
|
variant="outlined"
|
||||||
|
class="flex-grow-1"
|
||||||
|
>
|
||||||
<v-card-text class="pa-3 pb-0">
|
<v-card-text class="pa-3 pb-0">
|
||||||
<p class="">{{ comment.user.fullName }} • {{ $d(Date.parse(comment.createdAt), "medium") }}</p>
|
<p class="">
|
||||||
<SafeMarkdown :source="comment.text" />
|
{{ recipeComment.user.fullName }} • {{ $d(Date.parse(recipeComment.createdAt), "medium") }}
|
||||||
|
</p>
|
||||||
|
<SafeMarkdown :source="recipeComment.text" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions class="justify-end mt-0 pt-0">
|
<v-card-actions class="justify-end mt-0 pt-0">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="user.id == comment.user.id || user.admin"
|
v-if="user.id == recipeComment.user.id || user.admin"
|
||||||
color="error"
|
color="error"
|
||||||
text
|
variant="text"
|
||||||
x-small
|
size="x-small"
|
||||||
@click="deleteComment(comment.id)"
|
@click="deleteComment(recipeComment.id)"
|
||||||
>
|
>
|
||||||
{{ $t("general.delete") }}
|
{{ $t("general.delete") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -53,58 +82,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent, toRefs, reactive } from "@nuxtjs/composition-api";
|
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { usePageUser } from "~/composables/recipe-page/shared-state";
|
import { usePageUser } from "~/composables/recipe-page/shared-state";
|
||||||
import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
|
import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
|
||||||
|
|
||||||
export default defineComponent({
|
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||||
components: {
|
const api = useUserApi();
|
||||||
UserAvatar,
|
const { user } = usePageUser();
|
||||||
SafeMarkdown
|
const comment = ref("");
|
||||||
},
|
|
||||||
props: {
|
|
||||||
recipe: {
|
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const api = useUserApi();
|
|
||||||
|
|
||||||
const { user } = usePageUser();
|
async function submitComment() {
|
||||||
|
const { data } = await api.recipes.comments.createOne({
|
||||||
|
recipeId: recipe.value.id,
|
||||||
|
text: comment.value,
|
||||||
|
});
|
||||||
|
|
||||||
const state = reactive({
|
if (data) {
|
||||||
comment: "",
|
recipe.value.comments.push(data);
|
||||||
});
|
}
|
||||||
|
|
||||||
async function submitComment() {
|
comment.value = "";
|
||||||
const { data } = await api.recipes.comments.createOne({
|
}
|
||||||
recipeId: props.recipe.id,
|
|
||||||
text: state.comment,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data) {
|
async function deleteComment(id: string) {
|
||||||
// @ts-ignore username is always populated here
|
const { response } = await api.recipes.comments.deleteOne(id);
|
||||||
props.recipe.comments.push(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
state.comment = "";
|
if (response?.status === 200) {
|
||||||
}
|
recipe.value.comments = recipe.value.comments.filter(comment => comment.id !== id);
|
||||||
|
}
|
||||||
async function deleteComment(id: string) {
|
}
|
||||||
const { response } = await api.recipes.comments.deleteOne(id);
|
|
||||||
|
|
||||||
if (response?.status === 200) {
|
|
||||||
props.recipe.comments = props.recipe.comments.filter((comment) => comment.id !== id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { api, ...toRefs(state), submitComment, deleteComment, user };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="d-flex justify-start align-top py-2">
|
<div class="d-flex justify-start align-top py-2">
|
||||||
<RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" />
|
<RecipeImageUploadBtn
|
||||||
|
class="my-1"
|
||||||
|
:slug="recipe.slug"
|
||||||
|
@upload="uploadImage"
|
||||||
|
@refresh="imageKey++"
|
||||||
|
/>
|
||||||
<RecipeSettingsMenu
|
<RecipeSettingsMenu
|
||||||
|
v-model="recipe.settings"
|
||||||
class="my-1 mx-1"
|
class="my-1 mx-1"
|
||||||
:value="recipe.settings"
|
|
||||||
:is-owner="recipe.userId == user.id"
|
:is-owner="recipe.userId == user.id"
|
||||||
@upload="uploadImage"
|
@upload="uploadImage"
|
||||||
/>
|
/>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-container class="py-0" style="width: 40%;">
|
<v-container
|
||||||
|
class="py-0"
|
||||||
|
style="width: 40%;"
|
||||||
|
>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="recipe.userId"
|
v-model="recipe.userId"
|
||||||
:items="allUsers"
|
:items="allUsers"
|
||||||
item-text="fullName"
|
item-title="fullName"
|
||||||
item-value="id"
|
item-value="id"
|
||||||
:label="$tc('general.owner')"
|
:label="$t('general.owner')"
|
||||||
hide-details
|
hide-details
|
||||||
:disabled="!canEditOwner"
|
:disabled="!canEditOwner"
|
||||||
|
variant="underlined"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<UserAvatar :user-id="recipe.userId" :tooltip="false" />
|
<UserAvatar
|
||||||
|
:user-id="recipe.userId"
|
||||||
|
:tooltip="false"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
<v-card-text v-if="ownerHousehold" class="pa-0 d-flex" style="align-items: flex-end;">
|
<v-card-text
|
||||||
|
v-if="ownerHousehold"
|
||||||
|
class="pa-0 d-flex"
|
||||||
|
style="align-items: flex-end;"
|
||||||
|
>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-icon>{{ $globals.icons.household }}</v-icon>
|
<v-icon>{{ $globals.icons.household }}</v-icon>
|
||||||
<span class="pl-1">{{ ownerHousehold.name }}</span>
|
<span class="pl-1">{{ ownerHousehold.name }}</span>
|
||||||
@@ -31,11 +47,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
import { computed } from "vue";
|
||||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBtn.vue";
|
import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBtn.vue";
|
||||||
import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue";
|
import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue";
|
||||||
@@ -43,57 +59,34 @@ import { useUserStore } from "~/composables/store/use-user-store";
|
|||||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||||
import { useHouseholdStore } from "~/composables/store";
|
import { useHouseholdStore } from "~/composables/store";
|
||||||
|
|
||||||
export default defineComponent({
|
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||||
components: {
|
|
||||||
RecipeImageUploadBtn,
|
|
||||||
RecipeSettingsMenu,
|
|
||||||
UserAvatar,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
recipe: {
|
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const { user } = usePageUser();
|
|
||||||
const api = useUserApi();
|
|
||||||
const { imageKey } = usePageState(props.recipe.slug);
|
|
||||||
|
|
||||||
const canEditOwner = computed(() => {
|
const { user } = usePageUser();
|
||||||
return user.id === props.recipe.userId || user.admin;
|
const api = useUserApi();
|
||||||
})
|
const { imageKey } = usePageState(recipe.value.slug);
|
||||||
|
|
||||||
const { store: allUsers } = useUserStore();
|
const canEditOwner = computed(() => {
|
||||||
const { store: households } = useHouseholdStore();
|
return user.id === recipe.value.userId || user.admin;
|
||||||
const ownerHousehold = computed(() => {
|
|
||||||
const owner = allUsers.value.find((u) => u.id === props.recipe.userId);
|
|
||||||
if (!owner) {
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return households.value.find((h) => h.id === owner.householdId);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function uploadImage(fileObject: File) {
|
|
||||||
if (!props.recipe || !props.recipe.slug) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newVersion = await api.recipes.updateImage(props.recipe.slug, fileObject);
|
|
||||||
if (newVersion?.data?.image) {
|
|
||||||
props.recipe.image = newVersion.data.image;
|
|
||||||
}
|
|
||||||
imageKey.value++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
canEditOwner,
|
|
||||||
uploadImage,
|
|
||||||
imageKey,
|
|
||||||
allUsers,
|
|
||||||
ownerHousehold,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { store: allUsers } = useUserStore();
|
||||||
|
const { store: households } = useHouseholdStore();
|
||||||
|
const ownerHousehold = computed(() => {
|
||||||
|
const owner = allUsers.value.find(u => u.id === recipe.value.userId);
|
||||||
|
if (!owner) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return households.value.find(h => h.id === owner.householdId);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function uploadImage(fileObject: File) {
|
||||||
|
if (!recipe.value || !recipe.value.slug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newVersion = await api.recipes.updateImage(recipe.value.slug, fileObject);
|
||||||
|
if (newVersion?.data?.image) {
|
||||||
|
recipe.value.image = newVersion.data.image;
|
||||||
|
}
|
||||||
|
imageKey.value++;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,35 +5,54 @@
|
|||||||
v-if="isEditForm"
|
v-if="isEditForm"
|
||||||
v-model="recipe.orgURL"
|
v-model="recipe.orgURL"
|
||||||
class="mt-10"
|
class="mt-10"
|
||||||
|
variant="underlined"
|
||||||
:label="$t('recipe.original-url')"
|
:label="$t('recipe.original-url')"
|
||||||
></v-text-field>
|
/>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-else-if="recipe.orgURL && !isCookMode"
|
v-else-if="recipe.orgURL && !isCookMode"
|
||||||
dense
|
|
||||||
small
|
|
||||||
:hover="false"
|
:hover="false"
|
||||||
type="label"
|
|
||||||
:ripple="false"
|
:ripple="false"
|
||||||
elevation="0"
|
variant="flat"
|
||||||
:href="recipe.orgURL"
|
:href="recipe.orgURL"
|
||||||
color="secondary darken-1"
|
color="secondary-darken-1"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="rounded-sm mr-n2"
|
class="mr-n2"
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
{{ $t("recipe.original-url") }}
|
{{ $t("recipe.original-url") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
<AdvancedOnly>
|
<AdvancedOnly>
|
||||||
<v-card v-if="isEditForm" flat class="mb-2 mx-n2">
|
<v-card
|
||||||
<v-card-title> {{ $t('recipe.api-extras') }} </v-card-title>
|
v-if="isEditForm"
|
||||||
<v-divider class="ml-4"></v-divider>
|
flat
|
||||||
|
class="mb-2 mx-n2"
|
||||||
|
>
|
||||||
|
<v-card-title class="text-h5 font-weight-medium opacity-80">
|
||||||
|
{{ $t('recipe.api-extras') }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider class="ml-4" />
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
{{ $t('recipe.api-extras-description') }}
|
{{ $t('recipe.api-extras-description') }}
|
||||||
<v-row v-for="(_, key) in recipe.extras" :key="key" class="mt-1">
|
<v-row
|
||||||
|
v-for="(_, key) in recipe.extras"
|
||||||
|
:key="key"
|
||||||
|
class="mt-1"
|
||||||
|
>
|
||||||
<v-col style="max-width: 400px;">
|
<v-col style="max-width: 400px;">
|
||||||
<v-text-field v-model="recipe.extras[key]" dense :label="key">
|
<v-text-field
|
||||||
|
v-model="recipe.extras[key]"
|
||||||
|
density="compact"
|
||||||
|
variant="underlined"
|
||||||
|
:label="key"
|
||||||
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-btn color="error" icon class="mt-n4" @click="removeApiExtra(key)">
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
icon
|
||||||
|
class="mt-n4"
|
||||||
|
@click="removeApiExtra(key)"
|
||||||
|
>
|
||||||
<v-icon> {{ $globals.icons.delete }} </v-icon>
|
<v-icon> {{ $globals.icons.delete }} </v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
@@ -43,69 +62,58 @@
|
|||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions class="d-flex ml-2 mt-n3">
|
<v-card-actions class="d-flex ml-2 mt-n3">
|
||||||
<div>
|
<div>
|
||||||
<v-text-field v-model="apiNewKey" :label="$t('recipe.message-key')"></v-text-field>
|
<v-text-field
|
||||||
|
v-model="apiNewKey"
|
||||||
|
min-width="200px"
|
||||||
|
:label="$t('recipe.message-key')"
|
||||||
|
variant="underlined"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<BaseButton create small class="ml-5" @click="createApiExtra" />
|
<BaseButton
|
||||||
|
create
|
||||||
|
size="small"
|
||||||
|
class="ml-5"
|
||||||
|
@click="createApiExtra"
|
||||||
|
/>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</AdvancedOnly>
|
</AdvancedOnly>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
|
||||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
|
||||||
recipe: {
|
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const { isEditForm, isCookMode } = usePageState(props.recipe.slug);
|
|
||||||
const apiNewKey = ref("");
|
|
||||||
|
|
||||||
function createApiExtra() {
|
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||||
if (!props.recipe) {
|
const { isEditForm, isCookMode } = usePageState(recipe.value.slug);
|
||||||
return;
|
const apiNewKey = ref("");
|
||||||
}
|
|
||||||
|
|
||||||
if (!props.recipe.extras) {
|
function createApiExtra() {
|
||||||
props.recipe.extras = {};
|
if (!recipe.value) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
if (!recipe.value.extras) {
|
||||||
|
recipe.value.extras = {};
|
||||||
|
}
|
||||||
|
// check for duplicate keys
|
||||||
|
if (Object.keys(recipe.value.extras).includes(apiNewKey.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recipe.value.extras[apiNewKey.value] = "";
|
||||||
|
apiNewKey.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
// check for duplicate keys
|
function removeApiExtra(key: string | number) {
|
||||||
if (Object.keys(props.recipe.extras).includes(apiNewKey.value)) {
|
if (!recipe.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!recipe.value.extras) {
|
||||||
props.recipe.extras[apiNewKey.value] = "";
|
return;
|
||||||
|
}
|
||||||
apiNewKey.value = "";
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||||
}
|
delete recipe.value.extras[key];
|
||||||
|
recipe.value.extras = { ...recipe.value.extras };
|
||||||
function removeApiExtra(key: string | number) {
|
}
|
||||||
if (!props.recipe) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!props.recipe.extras) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
delete props.recipe.extras[key];
|
|
||||||
props.recipe.extras = { ...props.recipe.extras };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
removeApiExtra,
|
|
||||||
createApiExtra,
|
|
||||||
apiNewKey,
|
|
||||||
isEditForm,
|
|
||||||
isCookMode,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<RecipePageInfoCard :recipe="recipe" :recipe-scale="recipeScale" :landscape="landscape" />
|
<RecipePageInfoCard
|
||||||
|
:recipe="recipe"
|
||||||
|
:recipe-scale="recipeScale"
|
||||||
|
:landscape="landscape"
|
||||||
|
/>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<RecipeActionMenu
|
<RecipeActionMenu
|
||||||
:recipe="recipe"
|
:recipe="recipe"
|
||||||
@@ -11,7 +15,7 @@
|
|||||||
:logged-in="isOwnGroup"
|
:logged-in="isOwnGroup"
|
||||||
:open="isEditMode"
|
:open="isEditMode"
|
||||||
:recipe-id="recipe.id"
|
:recipe-id="recipe.id"
|
||||||
class="ml-auto mt-n2 pb-4"
|
class="ml-auto mt-n7 pb-4"
|
||||||
@close="setMode(PageMode.VIEW)"
|
@close="setMode(PageMode.VIEW)"
|
||||||
@json="toggleEditMode()"
|
@json="toggleEditMode()"
|
||||||
@edit="setMode(PageMode.EDIT)"
|
@edit="setMode(PageMode.EDIT)"
|
||||||
@@ -23,17 +27,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
|
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { useRecipePermissions } from "~/composables/recipes";
|
import { useRecipePermissions } from "~/composables/recipes";
|
||||||
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
|
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
|
||||||
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
||||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
|
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
|
||||||
export default defineComponent({
|
|
||||||
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipePageInfoCard,
|
RecipePageInfoCard,
|
||||||
RecipeActionMenu,
|
RecipeActionMenu,
|
||||||
@@ -52,8 +56,9 @@ export default defineComponent({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["save", "delete"],
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { $vuetify } = useContext();
|
const { $vuetify } = useNuxtApp();
|
||||||
const { recipeImage } = useStaticRoutes();
|
const { recipeImage } = useStaticRoutes();
|
||||||
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
||||||
const { user } = usePageUser();
|
const { user } = usePageUser();
|
||||||
@@ -74,7 +79,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
const hideImage = ref(false);
|
const hideImage = ref(false);
|
||||||
const imageHeight = computed(() => {
|
const imageHeight = computed(() => {
|
||||||
return $vuetify.breakpoint.xs ? "200" : "400";
|
return $vuetify.display.xs.value ? "200" : "400";
|
||||||
});
|
});
|
||||||
|
|
||||||
const recipeImageUrl = computed(() => {
|
const recipeImageUrl = computed(() => {
|
||||||
@@ -85,7 +90,7 @@ export default defineComponent({
|
|||||||
() => recipeImageUrl.value,
|
() => recipeImageUrl.value,
|
||||||
() => {
|
() => {
|
||||||
hideImage.value = false;
|
hideImage.value = false;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="d-flex justify-end flex-wrap align-stretch">
|
<div class="d-flex justify-end flex-wrap align-stretch">
|
||||||
<RecipePageInfoCardImage v-if="landscape" :recipe="recipe" />
|
<RecipePageInfoCardImage
|
||||||
|
v-if="landscape"
|
||||||
|
:recipe="recipe"
|
||||||
|
/>
|
||||||
<v-card
|
<v-card
|
||||||
:width="landscape ? '100%' : '50%'"
|
:width="landscape ? '100%' : '50%'"
|
||||||
flat
|
flat
|
||||||
class="d-flex flex-column justify-center align-center"
|
class="d-flex flex-column justify-center align-center"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text class="w-100">
|
||||||
<v-card-title class="headline pa-0 flex-column align-center">
|
<v-card-title class="text-h5 font-weight-regular pa-0 d-flex flex-column align-center justify-center opacity-80">
|
||||||
{{ recipe.name }}
|
{{ recipe.name }}
|
||||||
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
|
<RecipeRating
|
||||||
|
:key="recipe.slug"
|
||||||
|
:value="recipe.rating"
|
||||||
|
:recipe-id="recipe.id"
|
||||||
|
:slug="recipe.slug"
|
||||||
|
/>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider class="my-2" />
|
<v-divider class="my-2" />
|
||||||
<SafeMarkdown :source="recipe.description" />
|
<SafeMarkdown :source="recipe.description" class="my-3" />
|
||||||
<v-divider v-if="recipe.description" />
|
<v-divider v-if="recipe.description" />
|
||||||
<v-container class="d-flex flex-row flex-wrap justify-center">
|
<v-container class="d-flex flex-row flex-wrap justify-center">
|
||||||
<div class="mx-6">
|
<div class="mx-6">
|
||||||
<v-row no-gutters>
|
<v-row no-gutters>
|
||||||
<v-col v-if="recipe.recipeYieldQuantity || recipe.recipeYield" cols="12" class="d-flex flex-wrap justify-center">
|
<v-col
|
||||||
|
v-if="recipe.recipeYieldQuantity || recipe.recipeYield"
|
||||||
|
cols="12"
|
||||||
|
class="d-flex flex-wrap justify-center"
|
||||||
|
>
|
||||||
<RecipeYield
|
<RecipeYield
|
||||||
:yield-quantity="recipe.recipeYieldQuantity"
|
:yield-quantity="recipe.recipeYieldQuantity"
|
||||||
:yield="recipe.recipeYield"
|
:yield="recipe.recipeYield"
|
||||||
@@ -28,7 +40,10 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row no-gutters>
|
<v-row no-gutters>
|
||||||
<v-col cols="12" class="d-flex flex-wrap justify-center">
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
class="d-flex flex-wrap justify-center"
|
||||||
|
>
|
||||||
<RecipeLastMade
|
<RecipeLastMade
|
||||||
v-if="isOwnGroup"
|
v-if="isOwnGroup"
|
||||||
:recipe="recipe"
|
:recipe="recipe"
|
||||||
@@ -49,22 +64,27 @@
|
|||||||
</v-container>
|
</v-container>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
<RecipePageInfoCardImage v-if="!landscape" :recipe="recipe" max-width="50%" class="my-auto" />
|
<RecipePageInfoCardImage
|
||||||
|
v-if="!landscape"
|
||||||
|
:recipe="recipe"
|
||||||
|
max-width="50%"
|
||||||
|
class="my-auto"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
||||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||||
import RecipeYield from "~/components/Domain/Recipe/RecipeYield.vue";
|
import RecipeYield from "~/components/Domain/Recipe/RecipeYield.vue";
|
||||||
import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue";
|
import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
export default defineComponent({
|
|
||||||
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeRating,
|
RecipeRating,
|
||||||
RecipeLastMade,
|
RecipeLastMade,
|
||||||
@@ -87,15 +107,11 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { $vuetify } = useContext();
|
|
||||||
const useMobile = computed(() => $vuetify.breakpoint.smAndDown);
|
|
||||||
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOwnGroup,
|
isOwnGroup,
|
||||||
useMobile,
|
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
:key="imageKey"
|
:key="imageKey"
|
||||||
:max-width="maxWidth"
|
:max-width="maxWidth"
|
||||||
min-height="50"
|
min-height="50"
|
||||||
|
cover
|
||||||
|
width="100%"
|
||||||
:height="hideImage ? undefined : imageHeight"
|
:height="hideImage ? undefined : imageHeight"
|
||||||
:src="recipeImageUrl"
|
:src="recipeImageUrl"
|
||||||
class="d-print-none"
|
class="d-print-none"
|
||||||
@@ -11,13 +13,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
|
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
|
||||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
export default defineComponent({
|
|
||||||
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
recipe: {
|
recipe: {
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
type: Object as () => NoUndefinedField<Recipe>,
|
||||||
@@ -29,7 +31,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { $vuetify } = useContext();
|
const { $vuetify } = useNuxtApp();
|
||||||
const { recipeImage } = useStaticRoutes();
|
const { recipeImage } = useStaticRoutes();
|
||||||
const { imageKey } = usePageState(props.recipe.slug);
|
const { imageKey } = usePageState(props.recipe.slug);
|
||||||
const { user } = usePageUser();
|
const { user } = usePageUser();
|
||||||
@@ -44,7 +46,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
const hideImage = ref(false);
|
const hideImage = ref(false);
|
||||||
const imageHeight = computed(() => {
|
const imageHeight = computed(() => {
|
||||||
return $vuetify.breakpoint.xs ? "200" : "400";
|
return $vuetify.display.xs.value ? "200" : "400";
|
||||||
});
|
});
|
||||||
|
|
||||||
const recipeImageUrl = computed(() => {
|
const recipeImageUrl = computed(() => {
|
||||||
@@ -55,7 +57,7 @@ export default defineComponent({
|
|||||||
() => recipeImageUrl.value,
|
() => recipeImageUrl.value,
|
||||||
() => {
|
() => {
|
||||||
hideImage.value = false;
|
hideImage.value = false;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -64,6 +66,6 @@ export default defineComponent({
|
|||||||
hideImage,
|
hideImage,
|
||||||
imageHeight,
|
imageHeight,
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,103 +5,117 @@
|
|||||||
class="my-3"
|
class="my-3"
|
||||||
:label="$t('recipe.recipe-name')"
|
:label="$t('recipe.recipe-name')"
|
||||||
:rules="[validators.required]"
|
:rules="[validators.required]"
|
||||||
|
density="compact"
|
||||||
|
variant="underlined"
|
||||||
/>
|
/>
|
||||||
<v-container class="ma-0 pa-0">
|
<v-container class="ma-0 pa-0">
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="3">
|
<v-col cols="3">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="recipeServings"
|
:model-value="recipeServings"
|
||||||
type="number"
|
type="number"
|
||||||
:min="0"
|
:min="0"
|
||||||
hide-spin-buttons
|
hide-spin-buttons
|
||||||
dense
|
density="compact"
|
||||||
:label="$t('recipe.servings')"
|
:label="$t('recipe.servings')"
|
||||||
@input="validateInput($event, 'recipeServings')"
|
variant="underlined"
|
||||||
|
@update:model-value="validateInput($event, 'recipeServings')"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="3">
|
<v-col cols="3">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="recipeYieldQuantity"
|
:model-value="recipeYieldQuantity"
|
||||||
type="number"
|
type="number"
|
||||||
:min="0"
|
:min="0"
|
||||||
hide-spin-buttons
|
hide-spin-buttons
|
||||||
dense
|
density="compact"
|
||||||
:label="$t('recipe.yield')"
|
:label="$t('recipe.yield')"
|
||||||
@input="validateInput($event, 'recipeYieldQuantity')"
|
variant="underlined"
|
||||||
|
@update:model-value="validateInput($event, 'recipeYieldQuantity')"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="6">
|
<v-col cols="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="recipe.recipeYield"
|
v-model="recipe.recipeYield"
|
||||||
dense
|
density="compact"
|
||||||
:label="$t('recipe.yield-text')"
|
:label="$t('recipe.yield-text')"
|
||||||
/>
|
variant="underlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-container>
|
</v-container>
|
||||||
|
|
||||||
<div class="d-flex flex-wrap" style="gap: 1rem">
|
<div
|
||||||
<v-text-field v-model="recipe.totalTime" :label="$t('recipe.total-time')" />
|
class="d-flex flex-wrap"
|
||||||
<v-text-field v-model="recipe.prepTime" :label="$t('recipe.prep-time')" />
|
style="gap: 1rem"
|
||||||
<v-text-field v-model="recipe.performTime" :label="$t('recipe.perform-time')" />
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="recipe.totalTime"
|
||||||
|
:label="$t('recipe.total-time')"
|
||||||
|
density="compact"
|
||||||
|
variant="underlined"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="recipe.prepTime"
|
||||||
|
:label="$t('recipe.prep-time')"
|
||||||
|
density="compact"
|
||||||
|
variant="underlined"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="recipe.performTime"
|
||||||
|
:label="$t('recipe.perform-time')"
|
||||||
|
density="compact"
|
||||||
|
variant="underlined"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')" />
|
<v-textarea
|
||||||
|
v-model="recipe.description"
|
||||||
|
auto-grow
|
||||||
|
min-height="100"
|
||||||
|
:label="$t('recipe.description')"
|
||||||
|
density="compact"
|
||||||
|
variant="underlined"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
|
||||||
import { validators } from "~/composables/use-validators";
|
import { validators } from "~/composables/use-validators";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineComponent({
|
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||||
props: {
|
|
||||||
recipe: {
|
const recipeServings = computed<number>({
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
get() {
|
||||||
required: true,
|
return recipe.value.recipeServings;
|
||||||
},
|
|
||||||
},
|
},
|
||||||
setup(props) {
|
set(val) {
|
||||||
const recipeServings = computed<number>({
|
validateInput(val.toString(), "recipeServings");
|
||||||
get() {
|
|
||||||
return props.recipe.recipeServings;
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
validateInput(val.toString(), "recipeServings");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const recipeYieldQuantity = computed<number>({
|
|
||||||
get() {
|
|
||||||
return props.recipe.recipeYieldQuantity;
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
validateInput(val.toString(), "recipeYieldQuantity");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
|
|
||||||
if (!value) {
|
|
||||||
props.recipe[property] = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const number = parseFloat(value.replace(/[^0-9.]/g, ""));
|
|
||||||
if (isNaN(number) || number <= 0) {
|
|
||||||
props.recipe[property] = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
props.recipe[property] = number;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
validators,
|
|
||||||
recipeServings,
|
|
||||||
recipeYieldQuantity,
|
|
||||||
validateInput,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const recipeYieldQuantity = computed<number>({
|
||||||
|
get() {
|
||||||
|
return recipe.value.recipeYieldQuantity;
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
validateInput(val.toString(), "recipeYieldQuantity");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
|
||||||
|
if (!value) {
|
||||||
|
recipe.value[property] = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = parseFloat(value.replace(/[^0-9.]/g, ""));
|
||||||
|
if (isNaN(number) || number <= 0) {
|
||||||
|
recipe.value[property] = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recipe.value[property] = number;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
<!-- eslint-disable vue/no-mutating-props -->
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
|
<h2 class="mb-4 text-h5 font-weight-medium opacity-80">
|
||||||
<draggable
|
{{ $t("recipe.ingredients") }}
|
||||||
|
</h2>
|
||||||
|
<VueDraggable
|
||||||
v-if="recipe.recipeIngredient.length > 0"
|
v-if="recipe.recipeIngredient.length > 0"
|
||||||
v-model="recipe.recipeIngredient"
|
v-model="recipe.recipeIngredient"
|
||||||
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,
|
||||||
@@ -16,7 +19,9 @@
|
|||||||
@start="drag = true"
|
@start="drag = true"
|
||||||
@end="drag = false"
|
@end="drag = false"
|
||||||
>
|
>
|
||||||
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
|
<TransitionGroup
|
||||||
|
type="transition"
|
||||||
|
>
|
||||||
<RecipeIngredientEditor
|
<RecipeIngredientEditor
|
||||||
v-for="(ingredient, index) in recipe.recipeIngredient"
|
v-for="(ingredient, index) in recipe.recipeIngredient"
|
||||||
:key="ingredient.referenceId"
|
:key="ingredient.referenceId"
|
||||||
@@ -25,21 +30,29 @@
|
|||||||
:disable-amount="recipe.settings.disableAmount"
|
:disable-amount="recipe.settings.disableAmount"
|
||||||
@delete="recipe.recipeIngredient.splice(index, 1)"
|
@delete="recipe.recipeIngredient.splice(index, 1)"
|
||||||
@insert-above="insertNewIngredient(index)"
|
@insert-above="insertNewIngredient(index)"
|
||||||
@insert-below="insertNewIngredient(index+1)"
|
@insert-below="insertNewIngredient(index + 1)"
|
||||||
/>
|
/>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</draggable>
|
</VueDraggable>
|
||||||
<v-skeleton-loader v-else boilerplate elevation="2" type="list-item"> </v-skeleton-loader>
|
<v-skeleton-loader
|
||||||
|
v-else
|
||||||
|
boilerplate
|
||||||
|
elevation="2"
|
||||||
|
type="list-item"
|
||||||
|
/>
|
||||||
<div class="d-flex flex-wrap justify-center justify-sm-end mt-3">
|
<div class="d-flex flex-wrap justify-center justify-sm-end mt-3">
|
||||||
<v-tooltip top color="accent">
|
<v-tooltip
|
||||||
<template #activator="{ on, attrs }">
|
top
|
||||||
<span v-on="on">
|
color="accent"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<span>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
class="mb-1"
|
class="mb-1"
|
||||||
:disabled="recipe.settings.disableAmount || hasFoodOrUnit"
|
:disabled="recipe.settings.disableAmount || hasFoodOrUnit"
|
||||||
color="accent"
|
color="accent"
|
||||||
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
|
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
|
||||||
v-bind="attrs"
|
v-bind="props"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
{{ $globals.icons.foods }}
|
{{ $globals.icons.foods }}
|
||||||
@@ -50,124 +63,106 @@
|
|||||||
</template>
|
</template>
|
||||||
<span>{{ parserToolTip }}</span>
|
<span>{{ parserToolTip }}</span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<RecipeDialogBulkAdd class="mx-1 mb-1" @bulk-data="addIngredient" />
|
<RecipeDialogBulkAdd
|
||||||
<BaseButton class="mb-1" @click="addIngredient" > {{ $t("general.add") }} </BaseButton>
|
class="mx-1 mb-1"
|
||||||
|
@bulk-data="addIngredient"
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
class="mb-1"
|
||||||
|
@click="addIngredient"
|
||||||
|
>
|
||||||
|
{{ $t("general.add") }}
|
||||||
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import draggable from "vuedraggable";
|
import { VueDraggable } from "vue-draggable-plus";
|
||||||
import { computed, defineComponent, ref, useContext, useRoute } from "@nuxtjs/composition-api";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
|
||||||
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
|
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
|
||||||
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
||||||
import { uuid4 } from "~/composables/use-utils";
|
import { uuid4 } from "~/composables/use-utils";
|
||||||
export default defineComponent({
|
|
||||||
components: {
|
|
||||||
draggable,
|
|
||||||
RecipeDialogBulkAdd,
|
|
||||||
RecipeIngredientEditor,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
recipe: {
|
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const { user } = usePageUser();
|
|
||||||
const { imageKey } = usePageState(props.recipe.slug);
|
|
||||||
const { $auth, i18n } = useContext();
|
|
||||||
|
|
||||||
const drag = ref(false);
|
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||||
|
const i18n = useI18n();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
|
||||||
const route = useRoute();
|
const drag = ref(false);
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
|
||||||
|
|
||||||
const hasFoodOrUnit = computed(() => {
|
const route = useRoute();
|
||||||
if (!props.recipe) {
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (props.recipe.recipeIngredient) {
|
|
||||||
for (const ingredient of props.recipe.recipeIngredient) {
|
|
||||||
if (ingredient.food || ingredient.unit) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
const hasFoodOrUnit = computed(() => {
|
||||||
});
|
if (!recipe.value) {
|
||||||
|
return false;
|
||||||
const parserToolTip = computed(() => {
|
}
|
||||||
if (props.recipe.settings.disableAmount) {
|
if (recipe.value.recipeIngredient) {
|
||||||
return i18n.t("recipe.enable-ingredient-amounts-to-use-this-feature");
|
for (const ingredient of recipe.value.recipeIngredient) {
|
||||||
} else if (hasFoodOrUnit.value) {
|
if (ingredient.food || ingredient.unit) {
|
||||||
return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed");
|
return true;
|
||||||
}
|
|
||||||
return i18n.t("recipe.parse-ingredients");
|
|
||||||
});
|
|
||||||
|
|
||||||
function addIngredient(ingredients: Array<string> | null = null) {
|
|
||||||
if (ingredients?.length) {
|
|
||||||
const newIngredients = ingredients.map((x) => {
|
|
||||||
return {
|
|
||||||
referenceId: uuid4(),
|
|
||||||
title: "",
|
|
||||||
note: x,
|
|
||||||
unit: undefined,
|
|
||||||
food: undefined,
|
|
||||||
disableAmount: true,
|
|
||||||
quantity: 1,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newIngredients) {
|
|
||||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
|
||||||
props.recipe.recipeIngredient.push(...newIngredients);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
props.recipe.recipeIngredient.push({
|
|
||||||
referenceId: uuid4(),
|
|
||||||
title: "",
|
|
||||||
note: "",
|
|
||||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
|
||||||
unit: undefined,
|
|
||||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
|
||||||
food: undefined,
|
|
||||||
disableAmount: true,
|
|
||||||
quantity: 1,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
function insertNewIngredient(dest: number) {
|
return false;
|
||||||
props.recipe.recipeIngredient.splice(dest, 0, {
|
|
||||||
referenceId: uuid4(),
|
|
||||||
title: "",
|
|
||||||
note: "",
|
|
||||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
|
||||||
unit: undefined,
|
|
||||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
|
||||||
food: undefined,
|
|
||||||
disableAmount: true,
|
|
||||||
quantity: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
groupSlug,
|
|
||||||
addIngredient,
|
|
||||||
parserToolTip,
|
|
||||||
hasFoodOrUnit,
|
|
||||||
imageKey,
|
|
||||||
drag,
|
|
||||||
insertNewIngredient,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const parserToolTip = computed(() => {
|
||||||
|
if (recipe.value.settings.disableAmount) {
|
||||||
|
return i18n.t("recipe.enable-ingredient-amounts-to-use-this-feature");
|
||||||
|
}
|
||||||
|
else if (hasFoodOrUnit.value) {
|
||||||
|
return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed");
|
||||||
|
}
|
||||||
|
return i18n.t("recipe.parse-ingredients");
|
||||||
|
});
|
||||||
|
|
||||||
|
function addIngredient(ingredients: Array<string> | null = null) {
|
||||||
|
if (ingredients?.length) {
|
||||||
|
const newIngredients = ingredients.map((x) => {
|
||||||
|
return {
|
||||||
|
referenceId: uuid4(),
|
||||||
|
title: "",
|
||||||
|
note: x,
|
||||||
|
unit: undefined,
|
||||||
|
food: undefined,
|
||||||
|
disableAmount: true,
|
||||||
|
quantity: 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newIngredients) {
|
||||||
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
|
recipe.value.recipeIngredient.push(...newIngredients);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
recipe.value.recipeIngredient.push({
|
||||||
|
referenceId: uuid4(),
|
||||||
|
title: "",
|
||||||
|
note: "",
|
||||||
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
|
unit: undefined,
|
||||||
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
|
food: undefined,
|
||||||
|
disableAmount: true,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertNewIngredient(dest: number) {
|
||||||
|
recipe.value.recipeIngredient.splice(dest, 0, {
|
||||||
|
referenceId: uuid4(),
|
||||||
|
title: "",
|
||||||
|
note: "",
|
||||||
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
|
unit: undefined,
|
||||||
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
|
food: undefined,
|
||||||
|
disableAmount: true,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,38 +7,47 @@
|
|||||||
:is-cook-mode="isCookMode"
|
:is-cook-mode="isCookMode"
|
||||||
/>
|
/>
|
||||||
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
|
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
|
||||||
<h2 class="mb-2 mt-4">{{ $t('tool.required-tools') }}</h2>
|
<h2 class="mt-4 text-h5 font-weight-medium opacity-80">
|
||||||
<v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense>
|
{{ $t('tool.required-tools') }}
|
||||||
<v-checkbox
|
</h2>
|
||||||
v-model="recipeTools[index].onHand"
|
<v-list density="compact">
|
||||||
hide-details
|
<v-list-item
|
||||||
class="pt-0 my-auto py-auto"
|
v-for="(tool, index) in recipe.tools"
|
||||||
color="secondary"
|
:key="index"
|
||||||
@change="updateTool(index)"
|
density="compact"
|
||||||
>
|
>
|
||||||
</v-checkbox>
|
<template #prepend>
|
||||||
<v-list-item-content>
|
<v-checkbox
|
||||||
{{ tool.name }}
|
v-model="recipeTools[index].onHand"
|
||||||
</v-list-item-content>
|
hide-details
|
||||||
</v-list-item>
|
class="pt-0 my-auto py-auto"
|
||||||
|
color="secondary"
|
||||||
|
density="compact"
|
||||||
|
@change="updateTool(index)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>
|
||||||
|
{{ tool.name }}
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||||
import { useToolStore } from "~/composables/store";
|
import { useToolStore } from "~/composables/store";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { Recipe, RecipeTool } from "~/lib/api/types/recipe";
|
import type { Recipe, RecipeTool } from "~/lib/api/types/recipe";
|
||||||
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
|
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
|
||||||
|
|
||||||
interface RecipeToolWithOnHand extends RecipeTool {
|
interface RecipeToolWithOnHand extends RecipeTool {
|
||||||
onHand: boolean;
|
onHand: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeIngredients,
|
RecipeIngredients,
|
||||||
},
|
},
|
||||||
@@ -54,7 +63,7 @@ export default defineComponent({
|
|||||||
isCookMode: {
|
isCookMode: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
@@ -65,14 +74,15 @@ export default defineComponent({
|
|||||||
|
|
||||||
const recipeTools = computed(() => {
|
const recipeTools = computed(() => {
|
||||||
if (!(user.householdSlug && toolStore)) {
|
if (!(user.householdSlug && toolStore)) {
|
||||||
return props.recipe.tools.map((tool) => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
|
return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
return props.recipe.tools.map((tool) => {
|
return props.recipe.tools.map((tool) => {
|
||||||
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
|
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
|
||||||
return { ...tool, onHand } as RecipeToolWithOnHand;
|
return { ...tool, onHand } as RecipeToolWithOnHand;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
function updateTool(index: number) {
|
function updateTool(index: number) {
|
||||||
if (user.id && user.householdSlug && toolStore) {
|
if (user.id && user.householdSlug && toolStore) {
|
||||||
@@ -80,15 +90,18 @@ export default defineComponent({
|
|||||||
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
|
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||||
if (!tool.householdsWithTool) {
|
if (!tool.householdsWithTool) {
|
||||||
tool.householdsWithTool = [user.householdSlug];
|
tool.householdsWithTool = [user.householdSlug];
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
tool.householdsWithTool.push(user.householdSlug);
|
tool.householdsWithTool.push(user.householdSlug);
|
||||||
}
|
}
|
||||||
} else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
|
}
|
||||||
tool.householdsWithTool = tool.householdsWithTool.filter((household) => household !== user.householdSlug);
|
else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||||
|
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== user.householdSlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
toolStore.actions.updateOne(tool);
|
toolStore.actions.updateOne(tool);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
console.log("no user, skipping server update");
|
console.log("no user, skipping server update");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<section @keyup.ctrl.90="undoMerge">
|
<section @keyup.ctrl.z="undoMerge">
|
||||||
<!-- Ingredient Link Editor -->
|
<!-- Ingredient Link Editor -->
|
||||||
<v-dialog v-if="dialog" v-model="dialog" width="600">
|
<v-dialog
|
||||||
|
v-if="dialog"
|
||||||
|
v-model="dialog"
|
||||||
|
width="600"
|
||||||
|
>
|
||||||
<v-card :ripple="false">
|
<v-card :ripple="false">
|
||||||
<v-app-bar dark color="primary" class="mt-n1 mb-3">
|
<v-sheet
|
||||||
<v-icon large left>
|
color="primary"
|
||||||
|
class="mt-n1 mb-3 pa-3 d-flex align-center"
|
||||||
|
style="border-radius: 6px; width: 100%;"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
size="large"
|
||||||
|
start
|
||||||
|
>
|
||||||
{{ $globals.icons.link }}
|
{{ $globals.icons.link }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-toolbar-title class="headline"> {{ $t("recipe.ingredient-linker") }} </v-toolbar-title>
|
<v-toolbar-title class="headline">
|
||||||
<v-spacer></v-spacer>
|
{{ $t("recipe.ingredient-linker") }}
|
||||||
</v-app-bar>
|
</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
</v-sheet>
|
||||||
|
|
||||||
<v-card-text class="pt-4">
|
<v-card-text class="pt-4">
|
||||||
<p>
|
<p>
|
||||||
{{ activeText }}
|
{{ activeText }}
|
||||||
</p>
|
</p>
|
||||||
<v-divider class="mb-4"></v-divider>
|
<v-divider class="mb-4" />
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-for="ing in unusedIngredients"
|
v-for="ing in unusedIngredients"
|
||||||
:key="ing.referenceId"
|
:key="ing.referenceId"
|
||||||
@@ -29,7 +42,9 @@
|
|||||||
</v-checkbox>
|
</v-checkbox>
|
||||||
|
|
||||||
<template v-if="usedIngredients.length > 0">
|
<template v-if="usedIngredients.length > 0">
|
||||||
<h4 class="py-3 ml-1">{{ $t("recipe.linked-to-other-step") }}</h4>
|
<h4 class="py-3 ml-1">
|
||||||
|
{{ $t("recipe.linked-to-other-step") }}
|
||||||
|
</h4>
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-for="ing in usedIngredients"
|
v-for="ing in usedIngredients"
|
||||||
:key="ing.referenceId"
|
:key="ing.referenceId"
|
||||||
@@ -44,19 +59,38 @@
|
|||||||
</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
|
||||||
|
@click="dialog = false"
|
||||||
|
/>
|
||||||
|
<v-spacer />
|
||||||
<div class="d-flex flex-wrap justify-end">
|
<div class="d-flex flex-wrap justify-end">
|
||||||
<BaseButton class="my-1" color="info" @click="autoSetReferences">
|
<BaseButton
|
||||||
<template #icon> {{ $globals.icons.robot }}</template>
|
class="my-1"
|
||||||
|
color="info"
|
||||||
|
@click="autoSetReferences"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
{{ $globals.icons.robot }}
|
||||||
|
</template>
|
||||||
{{ $t("recipe.auto") }}
|
{{ $t("recipe.auto") }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
<BaseButton class="ml-2 my-1" save @click="setIngredientIds"> </BaseButton>
|
<BaseButton
|
||||||
<BaseButton v-if="availableNextStep" class="ml-2 my-1" @click="saveAndOpenNextLinkIngredients">
|
class="ml-2 my-1"
|
||||||
<template #icon> {{ $globals.icons.forward }}</template>
|
save
|
||||||
|
@click="setIngredientIds"
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
v-if="availableNextStep"
|
||||||
|
class="ml-2 my-1"
|
||||||
|
@click="saveAndOpenNextLinkIngredients"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
{{ $globals.icons.forward }}
|
||||||
|
</template>
|
||||||
{{ $t("recipe.nextStep") }}
|
{{ $t("recipe.nextStep") }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,169 +99,200 @@
|
|||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
<div class="d-flex justify-space-between justify-start">
|
<div class="d-flex justify-space-between justify-start">
|
||||||
<h2 v-if="!isCookMode" class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
|
<h2
|
||||||
<BaseButton v-if="!isEditForm && !isCookMode" minor cancel color="primary" @click="toggleCookMode()">
|
v-if="!isCookMode"
|
||||||
|
class="mt-1 text-h5 font-weight-medium opacity-80"
|
||||||
|
>
|
||||||
|
{{ $t("recipe.instructions") }}
|
||||||
|
</h2>
|
||||||
|
<BaseButton
|
||||||
|
v-if="!isEditForm && !isCookMode"
|
||||||
|
minor
|
||||||
|
cancel
|
||||||
|
color="primary"
|
||||||
|
@click="toggleCookMode()"
|
||||||
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
{{ $globals.icons.primary }}
|
{{ $globals.icons.primary }}
|
||||||
</template>
|
</template>
|
||||||
{{ $t("recipe.cook-mode") }}
|
{{ $t("recipe.cook-mode") }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
<draggable
|
<VueDraggable
|
||||||
|
v-model="instructionList"
|
||||||
:disabled="!isEditForm"
|
:disabled="!isEditForm"
|
||||||
:value="value"
|
|
||||||
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,
|
||||||
group: 'recipe-instructions',
|
group: 'recipe-instructions',
|
||||||
ghostClass: 'ghost',
|
ghostClass: 'ghost',
|
||||||
}"
|
}"
|
||||||
@input="updateIndex"
|
|
||||||
@start="drag = true"
|
@start="drag = true"
|
||||||
@end="drag = false"
|
@end="onDragEnd"
|
||||||
>
|
>
|
||||||
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
|
<TransitionGroup
|
||||||
<div v-for="(step, index) in value" :key="step.id" class="list-group-item">
|
type="transition"
|
||||||
<v-app-bar
|
>
|
||||||
|
<div
|
||||||
|
v-for="(step, index) in instructionList"
|
||||||
|
:key="step.id!"
|
||||||
|
class="list-group-item"
|
||||||
|
>
|
||||||
|
<v-sheet
|
||||||
v-if="step.id && showTitleEditor[step.id]"
|
v-if="step.id && showTitleEditor[step.id]"
|
||||||
class="primary mt-6"
|
color="primary"
|
||||||
style="cursor: pointer"
|
class="mt-6 mb-2 d-flex align-center"
|
||||||
dark
|
:class="isEditForm ? 'pa-2' : 'pa-3'"
|
||||||
dense
|
style="border-radius: 6px; cursor: pointer; width: 100%;"
|
||||||
rounded
|
|
||||||
@click="toggleCollapseSection(index)"
|
@click="toggleCollapseSection(index)"
|
||||||
>
|
>
|
||||||
<v-toolbar-title v-if="!isEditForm" class="headline">
|
<template v-if="isEditForm">
|
||||||
<v-app-bar-title> {{ step.title }} </v-app-bar-title>
|
<v-text-field
|
||||||
</v-toolbar-title>
|
v-model="step.title"
|
||||||
<v-text-field
|
class="pa-0"
|
||||||
v-if="isEditForm"
|
density="compact"
|
||||||
v-model="step.title"
|
variant="solo"
|
||||||
class="headline pa-0 mt-5"
|
flat
|
||||||
dense
|
:placeholder="$t('recipe.section-title')"
|
||||||
solo
|
bg-color="primary"
|
||||||
flat
|
hide-details
|
||||||
:placeholder="$t('recipe.section-title')"
|
/>
|
||||||
background-color="primary"
|
</template>
|
||||||
>
|
<template v-else>
|
||||||
</v-text-field>
|
<v-toolbar-title class="section-title-text">
|
||||||
</v-app-bar>
|
{{ step.title }}
|
||||||
<v-hover v-slot="{ hover }">
|
</v-toolbar-title>
|
||||||
|
</template>
|
||||||
|
</v-sheet>
|
||||||
|
<v-hover v-slot="{ isHovering }">
|
||||||
<v-card
|
<v-card
|
||||||
class="my-3"
|
class="my-3"
|
||||||
:class="[{ 'on-hover': hover }, isChecked(index)]"
|
:class="[{ 'on-hover': isHovering }, isChecked(index)]"
|
||||||
:elevation="hover ? 12 : 2"
|
:elevation="isHovering ? 12 : 2"
|
||||||
:ripple="false"
|
:ripple="false"
|
||||||
@click="toggleDisabled(index)"
|
@click="toggleDisabled(index)"
|
||||||
>
|
>
|
||||||
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
|
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
|
||||||
<v-text-field
|
<div class="d-flex align-center">
|
||||||
v-if="isEditForm"
|
<v-text-field
|
||||||
v-model="step.summary"
|
v-if="isEditForm"
|
||||||
class="headline handle"
|
v-model="step.summary"
|
||||||
hide-details
|
class="headline handle"
|
||||||
dense
|
hide-details
|
||||||
solo
|
density="compact"
|
||||||
flat
|
variant="solo"
|
||||||
:placeholder="$t('recipe.step-index', { step: index + 1 })"
|
flat
|
||||||
>
|
:placeholder="$t('recipe.step-index', { step: index + 1 })"
|
||||||
<template #prepend>
|
>
|
||||||
<v-icon size="26">{{ $globals.icons.arrowUpDown }}</v-icon>
|
<template #prepend>
|
||||||
|
<v-icon size="26">
|
||||||
|
{{ $globals.icons.arrowUpDown }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
<span v-else>
|
||||||
|
{{ step.summary ? step.summary : $t("recipe.step-index", { step: index + 1 }) }}
|
||||||
|
</span>
|
||||||
|
<template v-if="isEditForm">
|
||||||
|
<div class="ml-auto">
|
||||||
|
<BaseButtonGroup
|
||||||
|
:large="false"
|
||||||
|
:buttons="[
|
||||||
|
{
|
||||||
|
icon: $globals.icons.delete,
|
||||||
|
text: $t('general.delete'),
|
||||||
|
event: 'delete',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.dotsVertical,
|
||||||
|
text: '',
|
||||||
|
event: 'open',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
text: $t('recipe.toggle-section'),
|
||||||
|
event: 'toggle-section',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $t('recipe.link-ingredients'),
|
||||||
|
event: 'link-ingredients',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $t('recipe.upload-image'),
|
||||||
|
event: 'upload-image',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye,
|
||||||
|
text: previewStates[index] ? $t('recipe.edit-markdown') : $t('markdown-editor.preview-markdown-button-label'),
|
||||||
|
event: 'preview-step',
|
||||||
|
divider: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $t('recipe.merge-above'),
|
||||||
|
event: 'merge-above',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $t('recipe.move-to-top'),
|
||||||
|
event: 'move-to-top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $t('recipe.move-to-bottom'),
|
||||||
|
event: 'move-to-bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $t('recipe.insert-above'),
|
||||||
|
event: 'insert-above',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $t('recipe.insert-below'),
|
||||||
|
event: 'insert-below',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
@merge-above="mergeAbove(index - 1, index)"
|
||||||
|
@move-to-top="moveTo('top', index)"
|
||||||
|
@move-to-bottom="moveTo('bottom', index)"
|
||||||
|
@insert-above="insert(index)"
|
||||||
|
@insert-below="insert(index + 1)"
|
||||||
|
@toggle-section="toggleShowTitle(step.id!)"
|
||||||
|
@link-ingredients="openDialog(index, step.text, step.ingredientReferences)"
|
||||||
|
@preview-step="togglePreviewState(index)"
|
||||||
|
@upload-image="openImageUpload(index)"
|
||||||
|
@delete="instructionList.splice(index, 1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-text-field>
|
<v-fade-transition>
|
||||||
<span v-else>
|
<v-icon
|
||||||
{{ step.summary ? step.summary : $t("recipe.step-index", { step: index + 1 }) }}
|
v-show="isChecked(index)"
|
||||||
</span>
|
size="24"
|
||||||
<template v-if="isEditForm">
|
class="ml-auto"
|
||||||
<div class="ml-auto">
|
color="success"
|
||||||
<BaseButtonGroup
|
>
|
||||||
:large="false"
|
{{ $globals.icons.checkboxMarkedCircle }}
|
||||||
:buttons="[
|
</v-icon>
|
||||||
{
|
</v-fade-transition>
|
||||||
icon: $globals.icons.delete,
|
</div>
|
||||||
text: $tc('general.delete'),
|
|
||||||
event: 'delete',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: $globals.icons.dotsVertical,
|
|
||||||
text: '',
|
|
||||||
event: 'open',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
text: $tc('recipe.toggle-section'),
|
|
||||||
event: 'toggle-section',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: $tc('recipe.link-ingredients'),
|
|
||||||
event: 'link-ingredients',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: $tc('recipe.upload-image'),
|
|
||||||
event: 'upload-image'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye,
|
|
||||||
text: previewStates[index] ? $tc('recipe.edit-markdown') : $tc('markdown-editor.preview-markdown-button-label'),
|
|
||||||
event: 'preview-step',
|
|
||||||
divider: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: $tc('recipe.merge-above'),
|
|
||||||
event: 'merge-above',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: $tc('recipe.move-to-top'),
|
|
||||||
event: 'move-to-top',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: $tc('recipe.move-to-bottom'),
|
|
||||||
event: 'move-to-bottom',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: $tc('recipe.insert-above'),
|
|
||||||
event: 'insert-above'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: $tc('recipe.insert-below'),
|
|
||||||
event: 'insert-below'
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
@merge-above="mergeAbove(index - 1, index)"
|
|
||||||
@move-to-top="moveTo('top', index)"
|
|
||||||
@move-to-bottom="moveTo('bottom', index)"
|
|
||||||
@insert-above="insert(index)"
|
|
||||||
@insert-below="insert(index+1)"
|
|
||||||
@toggle-section="toggleShowTitle(step.id)"
|
|
||||||
@link-ingredients="openDialog(index, step.text, step.ingredientReferences)"
|
|
||||||
@preview-step="togglePreviewState(index)"
|
|
||||||
@upload-image="openImageUpload(index)"
|
|
||||||
@delete="value.splice(index, 1)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<v-fade-transition>
|
|
||||||
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
|
|
||||||
{{ $globals.icons.checkboxMarkedCircle }}
|
|
||||||
</v-icon>
|
|
||||||
</v-fade-transition>
|
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<v-progress-linear v-if="isEditForm && loadingStates[index]" :active="true" :indeterminate="true" />
|
<v-progress-linear
|
||||||
|
v-if="isEditForm && loadingStates[index]"
|
||||||
|
:active="true"
|
||||||
|
:indeterminate="true"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<DropZone @drop="(f) => handleImageDrop(index, f)">
|
<DropZone @drop="(f) => handleImageDrop(index, f)">
|
||||||
<v-card-text
|
<v-card-text
|
||||||
v-if="isEditForm"
|
v-if="isEditForm"
|
||||||
@click="$emit('click-instruction-field', `${index}.text`)"
|
@click="$emit('click-instruction-field', `${index}.text`)"
|
||||||
>
|
>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
v-model="value[index]['text']"
|
v-model="instructionList[index]['text']"
|
||||||
|
v-model:preview="previewStates[index]"
|
||||||
class="mb-2"
|
class="mb-2"
|
||||||
:preview.sync="previewStates[index]"
|
|
||||||
:display-preview="false"
|
:display-preview="false"
|
||||||
:textarea="{
|
:textarea="{
|
||||||
hint: $t('recipe.attach-images-hint'),
|
hint: $t('recipe.attach-images-hint'),
|
||||||
@@ -236,14 +301,16 @@
|
|||||||
/>
|
/>
|
||||||
<RecipeIngredientHtml
|
<RecipeIngredientHtml
|
||||||
v-for="ing in step.ingredientReferences"
|
v-for="ing in step.ingredientReferences"
|
||||||
:key="ing.referenceId"
|
:key="ing.referenceId!"
|
||||||
:markup="getIngredientByRefId(ing.referenceId)"
|
:markup="getIngredientByRefId(ing.referenceId!)"
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</DropZone>
|
</DropZone>
|
||||||
<v-expand-transition>
|
<v-expand-transition>
|
||||||
<div v-show="!isChecked(index) && !isEditForm" class="m-0 p-0">
|
<div
|
||||||
|
v-if="!isChecked(index) && !isEditForm"
|
||||||
|
class="m-0 p-0"
|
||||||
|
>
|
||||||
<v-card-text class="markdown">
|
<v-card-text class="markdown">
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col
|
<v-col
|
||||||
@@ -254,7 +321,7 @@
|
|||||||
<div class="ml-n4">
|
<div class="ml-n4">
|
||||||
<RecipeIngredients
|
<RecipeIngredients
|
||||||
:value="recipe.recipeIngredient.filter((ing) => {
|
:value="recipe.recipeIngredient.filter((ing) => {
|
||||||
if(!step.ingredientReferences) return false
|
if (!step.ingredientReferences) return false
|
||||||
return step.ingredientReferences.map((ref) => ref.referenceId).includes(ing.referenceId || '')
|
return step.ingredientReferences.map((ref) => ref.referenceId).includes(ing.referenceId || '')
|
||||||
})"
|
})"
|
||||||
:scale="scale"
|
:scale="scale"
|
||||||
@@ -263,9 +330,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-divider v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0 && $vuetify.breakpoint.smAndUp" vertical ></v-divider>
|
<v-divider
|
||||||
|
v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0 && $vuetify.display.smAndUp"
|
||||||
|
vertical
|
||||||
|
/>
|
||||||
<v-col>
|
<v-col>
|
||||||
<SafeMarkdown class="markdown" :source="step.text" />
|
<SafeMarkdown
|
||||||
|
class="markdown"
|
||||||
|
:source="step.text"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
@@ -275,34 +348,27 @@
|
|||||||
</v-hover>
|
</v-hover>
|
||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</draggable>
|
</VueDraggable>
|
||||||
<v-divider v-if="!isCookMode" class="mt-10 d-flex d-md-none"/>
|
<v-divider
|
||||||
|
v-if="!isCookMode"
|
||||||
|
class="mt-10 d-flex d-md-none"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import draggable from "vuedraggable";
|
import { VueDraggable } from "vue-draggable-plus";
|
||||||
import {
|
|
||||||
ref,
|
|
||||||
toRefs,
|
|
||||||
reactive,
|
|
||||||
defineComponent,
|
|
||||||
watch,
|
|
||||||
onMounted,
|
|
||||||
useContext,
|
|
||||||
computed,
|
|
||||||
nextTick,
|
|
||||||
} from "@nuxtjs/composition-api";
|
|
||||||
import RecipeIngredientHtml from "../../RecipeIngredientHtml.vue";
|
import RecipeIngredientHtml from "../../RecipeIngredientHtml.vue";
|
||||||
import { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe";
|
import type { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe";
|
||||||
import { parseIngredientText } from "~/composables/recipes";
|
import { parseIngredientText } from "~/composables/recipes";
|
||||||
import { uuid4, detectServerBaseUrl } from "~/composables/use-utils";
|
import { uuid4 } from "~/composables/use-utils";
|
||||||
import { useUserApi, useStaticRoutes } from "~/composables/api";
|
import { useUserApi, useStaticRoutes } from "~/composables/api";
|
||||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||||
import { useExtractIngredientReferences } from "~/composables/recipe-page/use-extract-ingredient-references";
|
import { useExtractIngredientReferences } from "~/composables/recipe-page/use-extract-ingredient-references";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import DropZone from "~/components/global/DropZone.vue";
|
import DropZone from "~/components/global/DropZone.vue";
|
||||||
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
|
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
|
||||||
|
|
||||||
interface MergerHistory {
|
interface MergerHistory {
|
||||||
target: number;
|
target: number;
|
||||||
source: number;
|
source: number;
|
||||||
@@ -310,15 +376,15 @@ interface MergerHistory {
|
|||||||
sourceText: string;
|
sourceText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
draggable,
|
VueDraggable,
|
||||||
RecipeIngredientHtml,
|
RecipeIngredientHtml,
|
||||||
DropZone,
|
DropZone,
|
||||||
RecipeIngredients
|
RecipeIngredients,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Array as () => RecipeStep[],
|
type: Array as () => RecipeStep[],
|
||||||
required: false,
|
required: false,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
@@ -336,10 +402,11 @@ export default defineComponent({
|
|||||||
default: 1,
|
default: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue", "click-instruction-field", "update:assets"],
|
||||||
|
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { i18n, req } = useContext();
|
const i18n = useI18n();
|
||||||
const BASE_URL = detectServerBaseUrl(req);
|
const BASE_URL = useRequestURL().origin;
|
||||||
|
|
||||||
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
|
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
|
||||||
|
|
||||||
@@ -374,12 +441,12 @@ export default defineComponent({
|
|||||||
return !(title === null || title === "" || title === undefined);
|
return !(title === null || title === "" || title === undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(props.value, (v) => {
|
watch(props.modelValue, (v) => {
|
||||||
state.disabledSteps = [];
|
state.disabledSteps = [];
|
||||||
|
|
||||||
v.forEach((element: RecipeStep) => {
|
v.forEach((element: RecipeStep) => {
|
||||||
if (element.id !== undefined) {
|
if (element.id !== undefined) {
|
||||||
showTitleEditor.value[element.id] = hasSectionTitle(element.title);
|
showTitleEditor.value[element.id!] = hasSectionTitle(element.title!);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -388,9 +455,9 @@ export default defineComponent({
|
|||||||
|
|
||||||
// Eliminate state with an eager call to watcher?
|
// Eliminate state with an eager call to watcher?
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
props.value.forEach((element: RecipeStep) => {
|
props.modelValue.forEach((element: RecipeStep) => {
|
||||||
if (element.id !== undefined) {
|
if (element.id !== undefined) {
|
||||||
showTitleEditor.value[element.id] = hasSectionTitle(element.title);
|
showTitleEditor.value[element.id!] = hasSectionTitle(element.title!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// showCookMode.value = false;
|
// showCookMode.value = false;
|
||||||
@@ -411,7 +478,8 @@ export default defineComponent({
|
|||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
state.disabledSteps.splice(index, 1);
|
state.disabledSteps.splice(index, 1);
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
state.disabledSteps.push(stepIndex);
|
state.disabledSteps.push(stepIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,8 +501,19 @@ export default defineComponent({
|
|||||||
showTitleEditor.value = temp;
|
showTitleEditor.value = temp;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateIndex(data: RecipeStep) {
|
const instructionList = ref<RecipeStep[]>([...props.modelValue]);
|
||||||
context.emit("input", data);
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal) => {
|
||||||
|
instructionList.value = [...newVal];
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
context.emit("update:modelValue", [...instructionList.value]);
|
||||||
|
drag.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===============================================================
|
// ===============================================================
|
||||||
@@ -445,21 +524,21 @@ export default defineComponent({
|
|||||||
|
|
||||||
function openDialog(idx: number, text: string, refs?: IngredientReferences[]) {
|
function openDialog(idx: number, text: string, refs?: IngredientReferences[]) {
|
||||||
if (!refs) {
|
if (!refs) {
|
||||||
props.value[idx].ingredientReferences = [];
|
instructionList.value[idx].ingredientReferences = [];
|
||||||
refs = props.value[idx].ingredientReferences as IngredientReferences[];
|
refs = props.modelValue[idx].ingredientReferences as IngredientReferences[];
|
||||||
}
|
}
|
||||||
|
|
||||||
setUsedIngredients();
|
setUsedIngredients();
|
||||||
activeText.value = text;
|
activeText.value = text;
|
||||||
activeIndex.value = idx;
|
activeIndex.value = idx;
|
||||||
state.dialog = true;
|
state.dialog = true;
|
||||||
activeRefs.value = refs.map((ref) => ref.referenceId ?? "");
|
activeRefs.value = refs.map(ref => ref.referenceId ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableNextStep = computed(() => activeIndex.value < props.value.length - 1);
|
const availableNextStep = computed(() => activeIndex.value < props.modelValue.length - 1);
|
||||||
|
|
||||||
function setIngredientIds() {
|
function setIngredientIds() {
|
||||||
const instruction = props.value[activeIndex.value];
|
const instruction = props.modelValue[activeIndex.value];
|
||||||
instruction.ingredientReferences = activeRefs.value.map((ref) => {
|
instruction.ingredientReferences = activeRefs.value.map((ref) => {
|
||||||
return {
|
return {
|
||||||
referenceId: ref,
|
referenceId: ref,
|
||||||
@@ -468,7 +547,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
// Update the visibility of the cook mode button
|
// Update the visibility of the cook mode button
|
||||||
showCookMode.value = false;
|
showCookMode.value = false;
|
||||||
props.value.forEach((element) => {
|
props.modelValue.forEach((element) => {
|
||||||
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
|
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
|
||||||
showCookMode.value = true;
|
showCookMode.value = true;
|
||||||
}
|
}
|
||||||
@@ -479,24 +558,23 @@ export default defineComponent({
|
|||||||
function saveAndOpenNextLinkIngredients() {
|
function saveAndOpenNextLinkIngredients() {
|
||||||
const currentStepIndex = activeIndex.value;
|
const currentStepIndex = activeIndex.value;
|
||||||
|
|
||||||
if(!availableNextStep.value) {
|
if (!availableNextStep.value) {
|
||||||
return; // no next step, the button calling this function should not be shown
|
return; // no next step, the button calling this function should not be shown
|
||||||
}
|
}
|
||||||
|
|
||||||
setIngredientIds();
|
setIngredientIds();
|
||||||
const nextStep = props.value[currentStepIndex + 1];
|
const nextStep = props.modelValue[currentStepIndex + 1];
|
||||||
// close dialog before opening to reset the scroll position
|
// close dialog before opening to reset the scroll position
|
||||||
nextTick(() => openDialog(currentStepIndex + 1, nextStep.text, nextStep.ingredientReferences));
|
nextTick(() => openDialog(currentStepIndex + 1, nextStep.text, nextStep.ingredientReferences));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUsedIngredients() {
|
function setUsedIngredients() {
|
||||||
const usedRefs: { [key: string]: boolean } = {};
|
const usedRefs: { [key: string]: boolean } = {};
|
||||||
|
|
||||||
props.value.forEach((element) => {
|
props.modelValue.forEach((element) => {
|
||||||
element.ingredientReferences?.forEach((ref) => {
|
element.ingredientReferences?.forEach((ref) => {
|
||||||
if (ref.referenceId !== undefined) {
|
if (ref.referenceId !== undefined) {
|
||||||
usedRefs[ref.referenceId] = true;
|
usedRefs[ref.referenceId!] = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -515,7 +593,7 @@ export default defineComponent({
|
|||||||
props.recipe.recipeIngredient,
|
props.recipe.recipeIngredient,
|
||||||
activeRefs.value,
|
activeRefs.value,
|
||||||
activeText.value,
|
activeText.value,
|
||||||
props.recipe.settings.disableAmount
|
props.recipe.settings.disableAmount,
|
||||||
).forEach((ingredient: string) => activeRefs.value.push(ingredient));
|
).forEach((ingredient: string) => activeRefs.value.push(ingredient));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,10 +613,8 @@ export default defineComponent({
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const ing = ingredientLookup.value[refId] ?? "";
|
const ing = ingredientLookup.value[refId];
|
||||||
if (ing === "") {
|
if (!ing) return "";
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return parseIngredientText(ing, props.recipe.settings.disableAmount, props.scale);
|
return parseIngredientText(ing, props.recipe.settings.disableAmount, props.scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,12 +630,12 @@ export default defineComponent({
|
|||||||
mergeHistory.value.push({
|
mergeHistory.value.push({
|
||||||
target,
|
target,
|
||||||
source,
|
source,
|
||||||
targetText: props.value[target].text,
|
targetText: props.modelValue[target].text,
|
||||||
sourceText: props.value[source].text,
|
sourceText: props.modelValue[source].text,
|
||||||
});
|
});
|
||||||
|
|
||||||
props.value[target].text += " " + props.value[source].text;
|
instructionList.value[target].text += " " + props.modelValue[source].text;
|
||||||
props.value.splice(source, 1);
|
instructionList.value.splice(source, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function undoMerge(event: KeyboardEvent) {
|
function undoMerge(event: KeyboardEvent) {
|
||||||
@@ -573,8 +649,8 @@ export default defineComponent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
props.value[lastMerge.target].text = lastMerge.targetText;
|
instructionList.value[lastMerge.target].text = lastMerge.targetText;
|
||||||
props.value.splice(lastMerge.source, 0, {
|
instructionList.value.splice(lastMerge.source, 0, {
|
||||||
id: uuid4(),
|
id: uuid4(),
|
||||||
title: "",
|
title: "",
|
||||||
text: lastMerge.sourceText,
|
text: lastMerge.sourceText,
|
||||||
@@ -585,14 +661,15 @@ export default defineComponent({
|
|||||||
|
|
||||||
function moveTo(dest: string, source: number) {
|
function moveTo(dest: string, source: number) {
|
||||||
if (dest === "top") {
|
if (dest === "top") {
|
||||||
props.value.unshift(props.value.splice(source, 1)[0]);
|
instructionList.value.unshift(instructionList.value.splice(source, 1)[0]);
|
||||||
} else {
|
}
|
||||||
props.value.push(props.value.splice(source, 1)[0]);
|
else {
|
||||||
|
instructionList.value.push(instructionList.value.splice(source, 1)[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function insert(dest: number) {
|
function insert(dest: number) {
|
||||||
props.value.splice(dest, 0, { id: uuid4(), text: "", title: "", ingredientReferences: [] });
|
instructionList.value.splice(dest, 0, { id: uuid4(), text: "", title: "", ingredientReferences: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewStates = ref<boolean[]>([]);
|
const previewStates = ref<boolean[]>([]);
|
||||||
@@ -606,19 +683,21 @@ export default defineComponent({
|
|||||||
function toggleCollapseSection(index: number) {
|
function toggleCollapseSection(index: number) {
|
||||||
const sectionSteps: number[] = [];
|
const sectionSteps: number[] = [];
|
||||||
|
|
||||||
for (let i = index; i < props.value.length; i++) {
|
for (let i = index; i < instructionList.value.length; i++) {
|
||||||
if (!(i === index) && hasSectionTitle(props.value[i].title)) {
|
if (!(i === index) && hasSectionTitle(instructionList.value[i].title!)) {
|
||||||
break;
|
break;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
sectionSteps.push(i);
|
sectionSteps.push(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allCollapsed = sectionSteps.every((idx) => state.disabledSteps.includes(idx));
|
const allCollapsed = sectionSteps.every(idx => state.disabledSteps.includes(idx));
|
||||||
|
|
||||||
if (allCollapsed) {
|
if (allCollapsed) {
|
||||||
state.disabledSteps = state.disabledSteps.filter((idx) => !sectionSteps.includes(idx));
|
state.disabledSteps = state.disabledSteps.filter(idx => !sectionSteps.includes(idx));
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
state.disabledSteps = [...state.disabledSteps, ...sectionSteps];
|
state.disabledSteps = [...state.disabledSteps, ...sectionSteps];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -674,7 +753,7 @@ export default defineComponent({
|
|||||||
context.emit("update:assets", [...props.assets, data]);
|
context.emit("update:assets", [...props.assets, data]);
|
||||||
const assetUrl = BASE_URL + recipeAssetPath(props.recipe.id, data.fileName as string);
|
const assetUrl = BASE_URL + recipeAssetPath(props.recipe.id, data.fileName as string);
|
||||||
const text = `<img src="${assetUrl}" height="100%" width="100%"/>`;
|
const text = `<img src="${assetUrl}" height="100%" width="100%"/>`;
|
||||||
props.value[index].text += text;
|
instructionList.value[index].text += text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openImageUpload(index: number) {
|
function openImageUpload(index: number) {
|
||||||
@@ -690,6 +769,8 @@ export default defineComponent({
|
|||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const breakpoint = useDisplay();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Image Uploader
|
// Image Uploader
|
||||||
toggleDragMode,
|
toggleDragMode,
|
||||||
@@ -699,6 +780,7 @@ export default defineComponent({
|
|||||||
loadingStates,
|
loadingStates,
|
||||||
|
|
||||||
// Rest
|
// Rest
|
||||||
|
onDragEnd,
|
||||||
drag,
|
drag,
|
||||||
togglePreviewState,
|
togglePreviewState,
|
||||||
toggleCollapseSection,
|
toggleCollapseSection,
|
||||||
@@ -719,7 +801,7 @@ export default defineComponent({
|
|||||||
toggleDisabled,
|
toggleDisabled,
|
||||||
isChecked,
|
isChecked,
|
||||||
toggleShowTitle,
|
toggleShowTitle,
|
||||||
updateIndex,
|
instructionList,
|
||||||
autoSetReferences,
|
autoSetReferences,
|
||||||
parseIngredientText,
|
parseIngredientText,
|
||||||
toggleCookMode,
|
toggleCookMode,
|
||||||
@@ -727,6 +809,7 @@ export default defineComponent({
|
|||||||
isCookMode,
|
isCookMode,
|
||||||
isEditForm,
|
isEditForm,
|
||||||
insert,
|
insert,
|
||||||
|
breakpoint,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -738,28 +821,32 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Select all li under .markdown class */
|
/** Select all li under .markdown class */
|
||||||
.markdown >>> ul > li {
|
.markdown :deep(ul > li) {
|
||||||
display: list-item;
|
display: list-item;
|
||||||
list-style-type: disc !important;
|
list-style-type: disc !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Select all li under .markdown class */
|
/** Select all li under .markdown class */
|
||||||
.markdown >>> ol > li {
|
.markdown :deep(ol > li) {
|
||||||
display: list-item;
|
display: list-item;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flip-list-move {
|
.flip-list-move {
|
||||||
transition: transform 0.5s;
|
transition: transform 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-move {
|
.no-move {
|
||||||
transition: transform 0s;
|
transition: transform 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost {
|
.ghost {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group {
|
.list-group {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item i {
|
.list-group-item i {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -780,4 +867,8 @@ export default defineComponent({
|
|||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v-text-field >>> input {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Recipe Categories -->
|
<!-- Recipe Categories -->
|
||||||
<v-card v-if="recipe.recipeCategory.length > 0 || isEditForm" :class="{'mt-10': !isEditForm}">
|
<v-card
|
||||||
|
v-if="recipe.recipeCategory.length > 0 || isEditForm"
|
||||||
|
:class="{ 'mt-10': !isEditForm }"
|
||||||
|
>
|
||||||
<v-card-title class="py-2">
|
<v-card-title class="py-2">
|
||||||
{{ $t("recipe.categories") }}
|
{{ $t("recipe.categories") }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
@@ -14,12 +17,19 @@
|
|||||||
:show-add="true"
|
:show-add="true"
|
||||||
selector-type="categories"
|
selector-type="categories"
|
||||||
/>
|
/>
|
||||||
<RecipeChips v-else :items="recipe.recipeCategory" v-on="$listeners" />
|
<RecipeChips
|
||||||
|
v-else
|
||||||
|
:items="recipe.recipeCategory"
|
||||||
|
v-bind="$attrs"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- Recipe Tags -->
|
<!-- Recipe Tags -->
|
||||||
<v-card v-if="recipe.tags.length > 0 || isEditForm" class="mt-4">
|
<v-card
|
||||||
|
v-if="recipe.tags.length > 0 || isEditForm"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
<v-card-title class="py-2">
|
<v-card-title class="py-2">
|
||||||
{{ $t("tag.tags") }}
|
{{ $t("tag.tags") }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
@@ -32,20 +42,39 @@
|
|||||||
:show-add="true"
|
:show-add="true"
|
||||||
selector-type="tags"
|
selector-type="tags"
|
||||||
/>
|
/>
|
||||||
<RecipeChips v-else :items="recipe.tags" url-prefix="tags" v-on="$listeners" />
|
<RecipeChips
|
||||||
|
v-else
|
||||||
|
:items="recipe.tags"
|
||||||
|
url-prefix="tags"
|
||||||
|
v-bind="$attrs"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- Recipe Tools Edit -->
|
<!-- Recipe Tools Edit -->
|
||||||
<v-card v-if="isEditForm" class="mt-2">
|
<v-card
|
||||||
<v-card-title class="py-2"> {{ $t('tool.required-tools') }} </v-card-title>
|
v-if="isEditForm"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<v-card-title class="py-2">
|
||||||
|
{{ $t('tool.required-tools') }}
|
||||||
|
</v-card-title>
|
||||||
<v-divider class="mx-2" />
|
<v-divider class="mx-2" />
|
||||||
<v-card-text class="pt-0">
|
<v-card-text class="pt-0">
|
||||||
<RecipeOrganizerSelector v-model="recipe.tools" selector-type="tools" v-on="$listeners" />
|
<RecipeOrganizerSelector
|
||||||
|
v-model="recipe.tools"
|
||||||
|
selector-type="tools"
|
||||||
|
v-bind="$attrs"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<RecipeNutrition v-if="recipe.settings.showNutrition" v-model="recipe.nutrition" class="mt-4" :edit="isEditForm" />
|
<RecipeNutrition
|
||||||
|
v-if="recipe.settings.showNutrition"
|
||||||
|
v-model="recipe.nutrition"
|
||||||
|
class="mt-4"
|
||||||
|
:edit="isEditForm"
|
||||||
|
/>
|
||||||
<RecipeAssets
|
<RecipeAssets
|
||||||
v-if="recipe.settings.showAssets"
|
v-if="recipe.settings.showAssets"
|
||||||
v-model="recipe.assets"
|
v-model="recipe.assets"
|
||||||
@@ -56,38 +85,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
|
||||||
import RecipeOrganizerSelector from "@/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
import RecipeOrganizerSelector from "@/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||||
import RecipeNutrition from "~/components/Domain/Recipe/RecipeNutrition.vue";
|
import RecipeNutrition from "~/components/Domain/Recipe/RecipeNutrition.vue";
|
||||||
import RecipeChips from "@/components/Domain/Recipe/RecipeChips.vue";
|
import RecipeChips from "@/components/Domain/Recipe/RecipeChips.vue";
|
||||||
import RecipeAssets from "@/components/Domain/Recipe/RecipeAssets.vue";
|
import RecipeAssets from "@/components/Domain/Recipe/RecipeAssets.vue";
|
||||||
export default defineComponent({
|
|
||||||
components: {
|
|
||||||
RecipeOrganizerSelector,
|
|
||||||
RecipeNutrition,
|
|
||||||
RecipeChips,
|
|
||||||
RecipeAssets,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
recipe: {
|
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const { user } = usePageUser();
|
|
||||||
const { isEditForm } = usePageState(props.recipe.slug);
|
|
||||||
|
|
||||||
|
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||||
|
const { isEditForm } = usePageState(recipe.value.slug);
|
||||||
return {
|
|
||||||
isEditForm,
|
|
||||||
user,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="d-flex justify-space-between align-center pt-2 pb-3">
|
<div class="d-flex justify-space-between align-center pt-2 pb-3">
|
||||||
<v-tooltip v-if="!isEditMode" small top color="secondary darken-1">
|
<RecipeScaleEditButton
|
||||||
<template #activator="{ on, attrs }">
|
v-if="!isEditMode"
|
||||||
<RecipeScaleEditButton
|
v-model.number="scaleValue"
|
||||||
v-model.number="scaleValue"
|
:recipe-servings="recipeServings"
|
||||||
v-bind="attrs"
|
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
|
||||||
:recipe-servings="recipeServings"
|
/>
|
||||||
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
|
|
||||||
v-on="on"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<span> {{ $t("recipe.edit-scale") }} </span>
|
|
||||||
</v-tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
|
||||||
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
|
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeScaleEditButton,
|
RecipeScaleEditButton,
|
||||||
},
|
},
|
||||||
@@ -36,6 +29,7 @@ export default defineComponent({
|
|||||||
default: 1,
|
default: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:scale"],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const { isEditMode } = usePageState(props.recipe.slug);
|
const { isEditMode } = usePageState(props.recipe.slug);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import RecipePage from "./RecipePage.vue";
|
|
||||||
|
|
||||||
export default RecipePage;
|
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="print-container">
|
<div class="print-container">
|
||||||
<RecipePrintView :recipe="recipe" :scale="scale" dense />
|
<RecipePrintView
|
||||||
</div>
|
:recipe="recipe"
|
||||||
|
:scale="scale"
|
||||||
|
:density="'compact'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
|
||||||
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipePrintView,
|
RecipePrintView,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,47 +1,54 @@
|
|||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<template>
|
<template>
|
||||||
<div :class="dense ? 'wrapper' : 'wrapper pa-3'">
|
<div :class="dense ? 'wrapper' : 'wrapper pa-3'">
|
||||||
<section>
|
<section>
|
||||||
<v-container class="ma-0 pa-0">
|
<v-container class="ma-0 pa-0">
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col
|
<v-col v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
|
||||||
v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
|
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
|
||||||
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
|
cols="4"
|
||||||
cols="4"
|
align-self="center"
|
||||||
align-self="center"
|
|
||||||
>
|
>
|
||||||
<img :key="imageKey" :src="recipeImageUrl" style="min-height: 50; max-width: 100%;" />
|
<img :key="imageKey"
|
||||||
|
:src="recipeImageUrl"
|
||||||
|
style="min-height: 50; max-width: 100%;"
|
||||||
|
>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col order=0>
|
<v-col order="0">
|
||||||
<v-card-title class="headline pl-0">
|
<v-card-title class="headline pl-0">
|
||||||
<v-icon left color="primary">
|
<v-icon start
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
{{ $globals.icons.primary }}
|
{{ $globals.icons.primary }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ recipe.name }}
|
{{ recipe.name }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<div v-if="recipeYield" class="d-flex justify-space-between align-center px-4 pb-2">
|
<div v-if="recipeYield"
|
||||||
<v-chip
|
class="d-flex justify-space-between align-center px-4 pb-2"
|
||||||
:small="$vuetify.breakpoint.smAndDown"
|
>
|
||||||
label
|
<v-chip :size="$vuetify.display.smAndDown ? 'small' : undefined"
|
||||||
|
label
|
||||||
>
|
>
|
||||||
<v-icon left>
|
<v-icon start>
|
||||||
{{ $globals.icons.potSteam }}
|
{{ $globals.icons.potSteam }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<span v-html="recipeYield"></span>
|
<span v-html="recipeYield" />
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</div>
|
</div>
|
||||||
<v-row class="d-flex justify-start">
|
<v-row class="d-flex justify-start">
|
||||||
<RecipeTimeCard
|
<RecipeTimeCard :prep-time="recipe.prepTime"
|
||||||
:prep-time="recipe.prepTime"
|
:total-time="recipe.totalTime"
|
||||||
:total-time="recipe.totalTime"
|
:perform-time="recipe.performTime"
|
||||||
:perform-time="recipe.performTime"
|
small
|
||||||
small
|
color="white"
|
||||||
color="white"
|
class="ml-4"
|
||||||
class="ml-4"
|
|
||||||
/>
|
/>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<v-card-text v-if="preferences.showDescription" class="px-0">
|
<v-card-text v-if="preferences.showDescription"
|
||||||
|
class="px-0"
|
||||||
|
>
|
||||||
<SafeMarkdown :source="recipe.description" />
|
<SafeMarkdown :source="recipe.description" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-col>
|
</v-col>
|
||||||
@@ -51,22 +58,28 @@
|
|||||||
|
|
||||||
<!-- Ingredients -->
|
<!-- Ingredients -->
|
||||||
<section>
|
<section>
|
||||||
<v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title>
|
<v-card-title class="headline pl-0">
|
||||||
<div
|
{{ $t("recipe.ingredients") }}
|
||||||
v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
</v-card-title>
|
||||||
:key="`ingredient-section-${sectionIndex}`"
|
<div v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
||||||
class="print-section"
|
:key="`ingredient-section-${sectionIndex}`"
|
||||||
|
class="print-section"
|
||||||
>
|
>
|
||||||
<h4 v-if="ingredientSection.ingredients[0].title" class="ingredient-title mt-2">
|
<h4 v-if="ingredientSection.ingredients[0].title"
|
||||||
|
class="ingredient-title mt-2"
|
||||||
|
>
|
||||||
{{ ingredientSection.ingredients[0].title }}
|
{{ ingredientSection.ingredients[0].title }}
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
<div class="ingredient-grid"
|
||||||
class="ingredient-grid"
|
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
||||||
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
|
||||||
>
|
>
|
||||||
<template v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients">
|
<template v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients"
|
||||||
|
:key="`ingredient-${ingredientIndex}`"
|
||||||
|
>
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<p :key="`ingredient-${ingredientIndex}`" class="ingredient-body" v-html="parseText(ingredient)" />
|
<p class="ingredient-body"
|
||||||
|
v-html="parseText(ingredient)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,19 +87,35 @@
|
|||||||
|
|
||||||
<!-- Instructions -->
|
<!-- Instructions -->
|
||||||
<section>
|
<section>
|
||||||
<div
|
<div v-for="(instructionSection, sectionIndex) in instructionSections"
|
||||||
v-for="(instructionSection, sectionIndex) in instructionSections"
|
:key="`instruction-section-${sectionIndex}`"
|
||||||
:key="`instruction-section-${sectionIndex}`"
|
:class="{ 'print-section': instructionSection.sectionName }"
|
||||||
:class="{ 'print-section': instructionSection.sectionName }"
|
|
||||||
>
|
>
|
||||||
<v-card-title v-if="!sectionIndex" class="headline pl-0">{{ $t("recipe.instructions") }}</v-card-title>
|
<v-card-title v-if="!sectionIndex"
|
||||||
<div v-for="(step, stepIndex) in instructionSection.instructions" :key="`instruction-${stepIndex}`">
|
class="headline pl-0"
|
||||||
|
>
|
||||||
|
{{ $t("recipe.instructions") }}
|
||||||
|
</v-card-title>
|
||||||
|
<div v-for="(step, stepIndex) in instructionSection.instructions"
|
||||||
|
:key="`instruction-${stepIndex}`"
|
||||||
|
>
|
||||||
<div class="print-section">
|
<div class="print-section">
|
||||||
<h4 v-if="step.title" :key="`instruction-title-${stepIndex}`" class="instruction-title mb-2">
|
<h4 v-if="step.title"
|
||||||
|
:key="`instruction-title-${stepIndex}`"
|
||||||
|
class="instruction-title mb-2"
|
||||||
|
>
|
||||||
{{ step.title }}
|
{{ step.title }}
|
||||||
</h4>
|
</h4>
|
||||||
<h5>{{ step.summary ? step.summary : $t("recipe.step-index", { step: stepIndex + instructionSection.stepOffset + 1 }) }}</h5>
|
<h5>
|
||||||
<SafeMarkdown :source="step.text" class="recipe-step-body" />
|
{{ step.summary ? step.summary : $t("recipe.step-index", {
|
||||||
|
step: stepIndex
|
||||||
|
+ instructionSection.stepOffset
|
||||||
|
+ 1,
|
||||||
|
}) }}
|
||||||
|
</h5>
|
||||||
|
<SafeMarkdown :source="step.text"
|
||||||
|
class="recipe-step-body"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,13 +123,19 @@
|
|||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
<div v-if="preferences.showNotes">
|
<div v-if="preferences.showNotes">
|
||||||
<v-divider v-if="hasNotes" class="grey my-4"></v-divider>
|
<v-divider v-if="hasNotes"
|
||||||
|
class="grey my-4"
|
||||||
|
/>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div v-for="(note, index) in recipe.notes" :key="index + 'note'">
|
<div v-for="(note, index) in recipe.notes"
|
||||||
|
:key="index + 'note'"
|
||||||
|
>
|
||||||
<div class="print-section">
|
<div class="print-section">
|
||||||
<h4>{{ note.title }}</h4>
|
<h4>{{ note.title }}</h4>
|
||||||
<SafeMarkdown :source="note.text" class="note-body" />
|
<SafeMarkdown :source="note.text"
|
||||||
|
class="note-body"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -108,13 +143,17 @@
|
|||||||
|
|
||||||
<!-- Nutrition -->
|
<!-- Nutrition -->
|
||||||
<div v-if="preferences.showNutrition">
|
<div v-if="preferences.showNutrition">
|
||||||
<v-card-title class="headline pl-0"> {{ $t("recipe.nutrition") }} </v-card-title>
|
<v-card-title class="headline pl-0">
|
||||||
|
{{ $t("recipe.nutrition") }}
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="print-section">
|
<div class="print-section">
|
||||||
<table class="nutrition-table">
|
<table class="nutrition-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(value, key) in recipe.nutrition" :key="key">
|
<tr v-for="(value, key) in recipe.nutrition"
|
||||||
|
:key="key"
|
||||||
|
>
|
||||||
<template v-if="value">
|
<template v-if="value">
|
||||||
<td>{{ labels[key].label }}</td>
|
<td>{{ labels[key].label }}</td>
|
||||||
<td>{{ value ? (labels[key].suffix ? `${value} ${labels[key].suffix}` : value) : '-' }}</td>
|
<td>{{ value ? (labels[key].suffix ? `${value} ${labels[key].suffix}` : value) : '-' }}</td>
|
||||||
@@ -122,26 +161,23 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||||
import { useStaticRoutes } from "~/composables/api";
|
import { useStaticRoutes } from "~/composables/api";
|
||||||
import { Recipe, RecipeIngredient, RecipeStep} from "~/lib/api/types/recipe";
|
import type { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||||
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
|
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
|
||||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||||
|
|
||||||
|
|
||||||
type IngredientSection = {
|
type IngredientSection = {
|
||||||
sectionName: string;
|
sectionName: string;
|
||||||
ingredients: RecipeIngredient[];
|
ingredients: RecipeIngredient[];
|
||||||
@@ -153,7 +189,7 @@ type InstructionSection = {
|
|||||||
instructions: RecipeStep[];
|
instructions: RecipeStep[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeTimeCard,
|
RecipeTimeCard,
|
||||||
},
|
},
|
||||||
@@ -168,15 +204,15 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
dense: {
|
dense: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
const preferences = useUserPrintPreferences();
|
const preferences = useUserPrintPreferences();
|
||||||
const { recipeImage } = useStaticRoutes();
|
const { recipeImage } = useStaticRoutes();
|
||||||
const { imageKey } = usePageState(props.recipe.slug);
|
const { imageKey } = usePageState(props.recipe.slug);
|
||||||
const {labels} = useNutritionLabels();
|
const { labels } = useNutritionLabels();
|
||||||
|
|
||||||
function sanitizeHTML(rawHtml: string) {
|
function sanitizeHTML(rawHtml: string) {
|
||||||
return DOMPurify.sanitize(rawHtml, {
|
return DOMPurify.sanitize(rawHtml, {
|
||||||
@@ -187,11 +223,13 @@ export default defineComponent({
|
|||||||
|
|
||||||
const servingsDisplay = computed(() => {
|
const servingsDisplay = computed(() => {
|
||||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
|
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
|
||||||
return scaledAmountDisplay ? i18n.t("recipe.yields-amount-with-text", {
|
return scaledAmountDisplay
|
||||||
amount: scaledAmountDisplay,
|
? i18n.t("recipe.yields-amount-with-text", {
|
||||||
text: props.recipe.recipeYield,
|
amount: scaledAmountDisplay,
|
||||||
}) as string : "";
|
text: props.recipe.recipeYield,
|
||||||
})
|
}) as string
|
||||||
|
: "";
|
||||||
|
});
|
||||||
|
|
||||||
const yieldDisplay = computed(() => {
|
const yieldDisplay = computed(() => {
|
||||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
|
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
|
||||||
@@ -201,10 +239,11 @@ export default defineComponent({
|
|||||||
const recipeYield = computed(() => {
|
const recipeYield = computed(() => {
|
||||||
if (servingsDisplay.value && yieldDisplay.value) {
|
if (servingsDisplay.value && yieldDisplay.value) {
|
||||||
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
|
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
|
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const recipeImageUrl = computed(() => {
|
const recipeImageUrl = computed(() => {
|
||||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||||
@@ -320,7 +359,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wrapper,
|
.wrapper,
|
||||||
.wrapper >>> * {
|
.wrapper :deep(*) {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
color: black !important;
|
color: black !important;
|
||||||
}
|
}
|
||||||
@@ -396,10 +435,10 @@ li {
|
|||||||
width: 30%;
|
width: 30%;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nutrition-table td {
|
.nutrition-table td {
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div @click.prevent>
|
<div @click.prevent>
|
||||||
<!-- User Rating -->
|
<!-- User Rating -->
|
||||||
<v-hover v-slot="{ hover }">
|
<v-hover v-slot="{ isHovering, props }">
|
||||||
<v-rating
|
<v-rating v-if="isOwnGroup && (userRating || isHovering || !ratingsLoaded)"
|
||||||
v-if="isOwnGroup && (userRating || hover || !ratingsLoaded)"
|
v-bind="props"
|
||||||
:value="userRating"
|
:model-value="userRating"
|
||||||
color="secondary"
|
active-color="secondary"
|
||||||
background-color="secondary lighten-3"
|
color="secondary-lighten-3"
|
||||||
length="5"
|
length="5"
|
||||||
:dense="small ? true : undefined"
|
:density="small ? 'compact' : 'default'"
|
||||||
:size="small ? 15 : undefined"
|
:size="small ? 'x-small' : undefined"
|
||||||
hover
|
hover
|
||||||
clearable
|
clearable
|
||||||
@input="updateRating"
|
@update:model-value="updateRating(+$event)"
|
||||||
@click="updateRating"
|
@click="updateRating"
|
||||||
/>
|
/>
|
||||||
<!-- Group Rating -->
|
<!-- Group Rating -->
|
||||||
<v-rating
|
<v-rating v-else
|
||||||
v-else
|
v-bind="props"
|
||||||
:value="groupRating"
|
:model-value="groupRating"
|
||||||
:half-increments="true"
|
:half-increments="true"
|
||||||
:readonly="true"
|
active-color="grey-darken-1"
|
||||||
color="grey darken-1"
|
color="secondary-lighten-3"
|
||||||
background-color="secondary lighten-3"
|
|
||||||
length="5"
|
length="5"
|
||||||
:dense="small ? true : undefined"
|
:density="small ? 'compact' : 'default'"
|
||||||
:size="small ? 15 : undefined"
|
:size="small ? 'x-small' : undefined"
|
||||||
hover
|
hover
|
||||||
/>
|
/>
|
||||||
</v-hover>
|
</v-hover>
|
||||||
@@ -33,10 +32,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, ref, watch } from "@nuxtjs/composition-api";
|
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { useUserSelfRatings } from "~/composables/use-users";
|
import { useUserSelfRatings } from "~/composables/use-users";
|
||||||
export default defineComponent({
|
|
||||||
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
emitOnly: {
|
emitOnly: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -50,7 +49,7 @@ export default defineComponent({
|
|||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
value: {
|
modelValue: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
@@ -59,12 +58,13 @@ export default defineComponent({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
const { userRatings, setRating, ready: ratingsLoaded } = useUserSelfRatings();
|
const { userRatings, setRating, ready: ratingsLoaded } = useUserSelfRatings();
|
||||||
|
|
||||||
const userRating = computed(() => {
|
const userRating = computed(() => {
|
||||||
return userRatings.value.find((r) => r.recipeId === props.recipeId)?.rating;
|
return userRatings.value.find(r => r.recipeId === props.recipeId)?.rating ?? undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
// if a user unsets their rating, we don't want to fall back to the group rating since it's out of sync
|
// if a user unsets their rating, we don't want to fall back to the group rating since it's out of sync
|
||||||
@@ -76,13 +76,13 @@ export default defineComponent({
|
|||||||
hideGroupRating.value = true;
|
hideGroupRating.value = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const groupRating = computed(() => {
|
const groupRating = computed(() => {
|
||||||
return hideGroupRating.value ? 0 : props.value;
|
return hideGroupRating.value ? 0 : props.modelValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateRating(val: number | null) {
|
function updateRating(val?: number) {
|
||||||
if (!isOwnGroup.value) {
|
if (!isOwnGroup.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ export default defineComponent({
|
|||||||
if (!props.emitOnly) {
|
if (!props.emitOnly) {
|
||||||
setRating(props.slug, val || 0, null);
|
setRating(props.slug, val || 0, null);
|
||||||
}
|
}
|
||||||
context.emit("input", val);
|
context.emit("update:modelValue", val);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,20 +2,61 @@
|
|||||||
<div v-if="yieldDisplay">
|
<div v-if="yieldDisplay">
|
||||||
<div class="text-center d-flex align-center">
|
<div class="text-center d-flex align-center">
|
||||||
<div>
|
<div>
|
||||||
<v-menu v-model="menu" :disabled="!canEditScale" offset-y top nudge-top="6" :close-on-content-click="false">
|
<v-menu
|
||||||
<template #activator="{ on, attrs }">
|
v-model="menu"
|
||||||
|
:disabled="!canEditScale"
|
||||||
|
offset-y
|
||||||
|
top
|
||||||
|
nudge-top="6"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-tooltip
|
||||||
|
v-if="canEditScale"
|
||||||
|
size="small"
|
||||||
|
top
|
||||||
|
color="secondary-darken-1"
|
||||||
|
>
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<v-card
|
||||||
|
class="pa-1 px-2"
|
||||||
|
dark
|
||||||
|
color="secondary-darken-1"
|
||||||
|
size="small"
|
||||||
|
v-bind="{ ...props, ...tooltipProps }"
|
||||||
|
:style="{ cursor: canEditScale ? '' : 'default' }"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
v-if="canEditScale"
|
||||||
|
size="small"
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
{{ $globals.icons.edit }}
|
||||||
|
</v-icon>
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<span v-html="yieldDisplay" />
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
<span> {{ $t("recipe.edit-scale") }} </span>
|
||||||
|
</v-tooltip>
|
||||||
<v-card
|
<v-card
|
||||||
|
v-else
|
||||||
class="pa-1 px-2"
|
class="pa-1 px-2"
|
||||||
dark
|
dark
|
||||||
color="secondary darken-1"
|
color="secondary-darken-1"
|
||||||
small
|
size="small"
|
||||||
v-bind="attrs"
|
v-bind="props"
|
||||||
:style="{ cursor: canEditScale ? '' : 'default' }"
|
:style="{ cursor: canEditScale ? '' : 'default' }"
|
||||||
v-on="on"
|
|
||||||
>
|
>
|
||||||
<v-icon v-if="canEditScale" small class="mr-2">{{ $globals.icons.edit }}</v-icon>
|
<v-icon
|
||||||
|
v-if="canEditScale"
|
||||||
|
size="small"
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
{{ $globals.icons.edit }}
|
||||||
|
</v-icon>
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<span v-html="yieldDisplay"></span>
|
<span v-html="yieldDisplay" />
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
<v-card min-width="300px">
|
<v-card min-width="300px">
|
||||||
@@ -24,10 +65,26 @@
|
|||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text class="mt-n5">
|
<v-card-text class="mt-n5">
|
||||||
<div class="mt-4 d-flex align-center">
|
<div class="mt-4 d-flex align-center">
|
||||||
<v-text-field v-model="yieldQuantityEditorValue" type="number" :min="0" hide-spin-buttons @input="recalculateScale(yieldQuantityEditorValue)" />
|
<v-text-field
|
||||||
<v-tooltip right color="secondary darken-1">
|
:model-value="yieldQuantityEditorValue"
|
||||||
<template #activator="{ on, attrs }">
|
type="number"
|
||||||
<v-btn v-bind="attrs" icon class="mx-1" small v-on="on" @click="scale = 1">
|
:min="0"
|
||||||
|
variant="underlined"
|
||||||
|
hide-spin-buttons
|
||||||
|
@update:model-value="recalculateScale(yieldQuantityEditorValue)"
|
||||||
|
/>
|
||||||
|
<v-tooltip
|
||||||
|
end
|
||||||
|
color="secondary-darken-1"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="props"
|
||||||
|
icon
|
||||||
|
class="mx-1"
|
||||||
|
size="small"
|
||||||
|
@click="scale = 1"
|
||||||
|
>
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ $globals.icons.undo }}
|
{{ $globals.icons.undo }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
@@ -47,13 +104,13 @@
|
|||||||
:buttons="[
|
:buttons="[
|
||||||
{
|
{
|
||||||
icon: $globals.icons.minus,
|
icon: $globals.icons.minus,
|
||||||
text: $tc('recipe.decrease-scale-label'),
|
text: $t('recipe.decrease-scale-label'),
|
||||||
event: 'decrement',
|
event: 'decrement',
|
||||||
disabled: disableDecrement,
|
disabled: disableDecrement,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.createAlt,
|
icon: $globals.icons.createAlt,
|
||||||
text: $tc('recipe.increase-scale-label'),
|
text: $t('recipe.increase-scale-label'),
|
||||||
event: 'increment',
|
event: 'increment',
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
@@ -65,12 +122,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
|
|
||||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
@@ -83,16 +139,17 @@ export default defineComponent({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
const menu = ref<boolean>(false);
|
const menu = ref<boolean>(false);
|
||||||
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
|
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
|
||||||
|
|
||||||
const scale = computed({
|
const scale = computed({
|
||||||
get: () => props.value,
|
get: () => props.modelValue,
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
const newScaleNumber = parseFloat(`${value}`);
|
const newScaleNumber = parseFloat(`${value}`);
|
||||||
emit("input", isNaN(newScaleNumber) ? 0 : newScaleNumber);
|
emit("update:modelValue", isNaN(newScaleNumber) ? 0 : newScaleNumber);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,7 +160,8 @@ export default defineComponent({
|
|||||||
|
|
||||||
if (props.recipeServings <= 0) {
|
if (props.recipeServings <= 0) {
|
||||||
scale.value = 1;
|
scale.value = 1;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
scale.value = newYield / props.recipeServings;
|
scale.value = newYield / props.recipeServings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,9 +171,11 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
|
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
|
||||||
const yieldDisplay = computed(() => {
|
const yieldDisplay = computed(() => {
|
||||||
return yieldQuantity.value ? i18n.t(
|
return yieldQuantity.value
|
||||||
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay }
|
? i18n.t(
|
||||||
) as string : "";
|
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay },
|
||||||
|
) as string
|
||||||
|
: "";
|
||||||
});
|
});
|
||||||
|
|
||||||
// only update yield quantity when the menu opens, so we don't override the user's input
|
// only update yield quantity when the menu opens, so we don't override the user's input
|
||||||
@@ -128,8 +188,8 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
yieldQuantityEditorValue.value = recipeYieldAmount.value.scaledAmount;
|
yieldQuantityEditorValue.value = recipeYieldAmount.value.scaledAmount;
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const disableDecrement = computed(() => {
|
const disableDecrement = computed(() => {
|
||||||
return recipeYieldAmount.value.scaledAmount <= 1;
|
return recipeYieldAmount.value.scaledAmount <= 1;
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="d-flex justify-center align-center">
|
<div class="d-flex justify-center align-center">
|
||||||
<v-btn-toggle v-model="selected" tile group color="primary accent-3" mandatory @change="emitMulti">
|
<v-btn-toggle v-model="selected" tile group color="primary accent-3" mandatory="force" @change="emitMulti">
|
||||||
<v-btn small :value="false">
|
<v-btn size="small" :value="false">
|
||||||
{{ $t("search.include") }}
|
{{ $t("search.include") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn small :value="true">
|
<v-btn size="small" :value="true">
|
||||||
{{ $t("search.exclude") }}
|
{{ $t("search.exclude") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-btn-toggle>
|
</v-btn-toggle>
|
||||||
<v-btn-toggle v-model="match" tile group color="primary accent-3" mandatory @change="emitMulti">
|
<v-btn-toggle v-model="match" tile group color="primary accent-3" mandatory="force" @change="emitMulti">
|
||||||
<v-btn small :value="false" class="text-uppercase">
|
<v-btn size="small" :value="false" class="text-uppercase">
|
||||||
{{ $t("search.and") }}
|
{{ $t("search.and") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn small :value="true" class="text-uppercase">
|
<v-btn size="small" :value="true" class="text-uppercase">
|
||||||
{{ $t("search.or") }}
|
{{ $t("search.or") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-btn-toggle>
|
</v-btn-toggle>
|
||||||
@@ -20,17 +20,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
|
||||||
|
|
||||||
type SelectionValue = "include" | "exclude" | "any";
|
type SelectionValue = "include" | "exclude" | "any";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: String as () => SelectionValue,
|
type: String as () => SelectionValue,
|
||||||
default: "include",
|
default: "include",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue", "update"],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selected: false,
|
selected: false,
|
||||||
@@ -39,7 +38,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
emitChange() {
|
emitChange() {
|
||||||
this.$emit("input", this.selected);
|
this.$emit("update:modelValue", this.selected);
|
||||||
},
|
},
|
||||||
emitMulti() {
|
emitMulti() {
|
||||||
const updateData = {
|
const updateData = {
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<v-menu offset-y top nudge-top="6" :close-on-content-click="false">
|
<v-menu
|
||||||
<template #activator="{ on, attrs }">
|
offset-y
|
||||||
<v-btn color="accent" dark v-bind="attrs" v-on="on">
|
top
|
||||||
<v-icon left>
|
nudge-top="6"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
color="accent"
|
||||||
|
dark
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
{{ $globals.icons.cog }}
|
{{ $globals.icons.cog }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $t("general.settings") }}
|
{{ $t("general.settings") }}
|
||||||
@@ -15,32 +24,24 @@
|
|||||||
{{ $t("recipe.recipe-settings") }}
|
{{ $t("recipe.recipe-settings") }}
|
||||||
</div>
|
</div>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider class="mx-2"></v-divider>
|
<v-divider class="mx-2" />
|
||||||
<v-card-text class="mt-n5 pt-6 pb-2">
|
<v-card-text class="mt-n5 pt-6 pb-2">
|
||||||
<RecipeSettingsSwitches v-model="value" :is-owner="isOwner" />
|
<RecipeSettingsSwitches
|
||||||
|
v-model="value"
|
||||||
|
:is-owner="isOwner"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
|
||||||
import RecipeSettingsSwitches from "./RecipeSettingsSwitches.vue";
|
import RecipeSettingsSwitches from "./RecipeSettingsSwitches.vue";
|
||||||
|
|
||||||
export default defineComponent({
|
const value = defineModel<object>({ required: true });
|
||||||
components: { RecipeSettingsSwitches },
|
|
||||||
props: {
|
defineProps<{ isOwner?: boolean }>();
|
||||||
value: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isOwner: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
<style lang="scss" scoped></style>
|
||||||
|
|||||||
@@ -1,51 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-switch
|
<v-switch
|
||||||
v-for="(_, key) in value"
|
v-for="(_, key) in model"
|
||||||
:key="key"
|
:key="key"
|
||||||
v-model="value[key]"
|
v-model="model[key]"
|
||||||
|
color="primary"
|
||||||
xs
|
xs
|
||||||
dense
|
density="compact"
|
||||||
:disabled="key == 'locked' && !isOwner"
|
:disabled="key == 'locked' && !isOwner"
|
||||||
class="my-1"
|
class="my-1"
|
||||||
:label="labels[key]"
|
:label="labels[key]"
|
||||||
hide-details
|
hide-details
|
||||||
></v-switch>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
import { defineModel, defineProps } from "vue";
|
||||||
import { RecipeSettings } from "~/lib/api/types/recipe";
|
import type { RecipeSettings } from "~/lib/api/types/recipe";
|
||||||
|
import { useI18n } from "#imports";
|
||||||
|
|
||||||
export default defineComponent({
|
defineProps<{ isOwner?: boolean }>();
|
||||||
props: {
|
|
||||||
value: {
|
|
||||||
type: Object as () => RecipeSettings,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isOwner: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const { i18n } = useContext();
|
|
||||||
const labels: Record<keyof RecipeSettings, string> = {
|
|
||||||
public: i18n.tc("recipe.public-recipe"),
|
|
||||||
showNutrition: i18n.tc("recipe.show-nutrition-values"),
|
|
||||||
showAssets: i18n.tc("asset.show-assets"),
|
|
||||||
landscapeView: i18n.tc("recipe.landscape-view-coming-soon"),
|
|
||||||
disableComments: i18n.tc("recipe.disable-comments"),
|
|
||||||
disableAmount: i18n.tc("recipe.disable-amount"),
|
|
||||||
locked: i18n.tc("recipe.locked"),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
const model = defineModel<RecipeSettings>({ required: true });
|
||||||
labels,
|
|
||||||
};
|
const i18n = useI18n();
|
||||||
},
|
const labels: Record<keyof RecipeSettings, string> = {
|
||||||
});
|
public: i18n.t("recipe.public-recipe"),
|
||||||
|
showNutrition: i18n.t("recipe.show-nutrition-values"),
|
||||||
|
showAssets: i18n.t("asset.show-assets"),
|
||||||
|
landscapeView: i18n.t("recipe.landscape-view-coming-soon"),
|
||||||
|
disableComments: i18n.t("recipe.disable-comments"),
|
||||||
|
disableAmount: i18n.t("recipe.disable-amount"),
|
||||||
|
locked: i18n.t("recipe.locked"),
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
<style lang="scss" scoped></style>
|
||||||
|
|||||||
@@ -12,23 +12,23 @@
|
|||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<div v-for="(organizer, idx) in missingOrganizers" :key="idx">
|
<div v-for="(organizer, idx) in missingOrganizers" :key="idx">
|
||||||
<v-col
|
<v-col v-if="organizer.show" cols="12">
|
||||||
v-if="organizer.show"
|
|
||||||
cols="12"
|
|
||||||
>
|
|
||||||
<div class="d-flex flex-row flex-wrap align-center pt-2">
|
<div class="d-flex flex-row flex-wrap align-center pt-2">
|
||||||
<v-icon class="ma-0 pa-0">{{ organizer.icon }}</v-icon>
|
<v-icon class="ma-0 pa-0">
|
||||||
<v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content;">
|
{{ organizer.icon }}
|
||||||
{{ $tc("recipe-finder.missing") }}:
|
</v-icon>
|
||||||
|
<v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content">
|
||||||
|
{{ $t("recipe-finder.missing") }}:
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-chip
|
<v-chip
|
||||||
v-for="item in organizer.items"
|
v-for="item in organizer.items"
|
||||||
:key="item.item.id"
|
:key="item.item.id"
|
||||||
label
|
label
|
||||||
color="secondary custom-transparent"
|
color="secondary custom-transparent"
|
||||||
class="mr-2 my-1"
|
class="mr-2 my-1 pl-1"
|
||||||
|
variant="flat"
|
||||||
>
|
>
|
||||||
<v-checkbox dark :ripple="false" @click="handleCheckbox(item)">
|
<v-checkbox dark :ripple="false" hide-details @click="handleCheckbox(item)">
|
||||||
<template #label>
|
<template #label>
|
||||||
{{ organizer.getLabel(item.item) }}
|
{{ organizer.getLabel(item.item) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -42,9 +42,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, reactive, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||||
import { IngredientFood, RecipeSummary, RecipeTool } from "~/lib/api/types/recipe";
|
import type { IngredientFood, RecipeSummary, RecipeTool } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
interface Organizer {
|
interface Organizer {
|
||||||
type: "food" | "tool";
|
type: "food" | "tool";
|
||||||
@@ -52,7 +51,7 @@ interface Organizer {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: { RecipeCardMobile },
|
components: { RecipeCardMobile },
|
||||||
props: {
|
props: {
|
||||||
recipe: {
|
recipe: {
|
||||||
@@ -73,27 +72,31 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { $globals } = useContext();
|
const { $globals } = useNuxtApp();
|
||||||
const missingOrganizers = computed(() => [
|
const missingOrganizers = computed(() => [
|
||||||
{
|
{
|
||||||
type: "food",
|
type: "food",
|
||||||
show: props.missingFoods?.length,
|
show: props.missingFoods?.length,
|
||||||
icon: $globals.icons.foods,
|
icon: $globals.icons.foods,
|
||||||
items: props.missingFoods ? props.missingFoods.map((food) => {
|
items: props.missingFoods
|
||||||
return reactive({type: "food", item: food, selected: false} as Organizer);
|
? props.missingFoods.map((food) => {
|
||||||
}) : [],
|
return reactive({ type: "food", item: food, selected: false } as Organizer);
|
||||||
|
})
|
||||||
|
: [],
|
||||||
getLabel: (item: IngredientFood) => item.pluralName || item.name,
|
getLabel: (item: IngredientFood) => item.pluralName || item.name,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool",
|
type: "tool",
|
||||||
show: props.missingTools?.length,
|
show: props.missingTools?.length,
|
||||||
icon: $globals.icons.tools,
|
icon: $globals.icons.tools,
|
||||||
items: props.missingTools ? props.missingTools.map((tool) => {
|
items: props.missingTools
|
||||||
return reactive({type: "tool", item: tool, selected: false} as Organizer);
|
? props.missingTools.map((tool) => {
|
||||||
}) : [],
|
return reactive({ type: "tool", item: tool, selected: false } as Organizer);
|
||||||
|
})
|
||||||
|
: [],
|
||||||
getLabel: (item: RecipeTool) => item.name,
|
getLabel: (item: RecipeTool) => item.name,
|
||||||
}
|
},
|
||||||
])
|
]);
|
||||||
|
|
||||||
function handleCheckbox(organizer: Organizer) {
|
function handleCheckbox(organizer: Organizer) {
|
||||||
if (props.disableCheckbox) {
|
if (props.disableCheckbox) {
|
||||||
@@ -113,6 +116,6 @@ export default defineComponent({
|
|||||||
missingOrganizers,
|
missingOrganizers,
|
||||||
handleCheckbox,
|
handleCheckbox,
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,34 +1,77 @@
|
|||||||
<template v-if="showCards">
|
<template v-if="showCards">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<!-- Total Time -->
|
<!-- Total Time -->
|
||||||
<div v-if="validateTotalTime" class="time-card-flex mx-auto">
|
<div
|
||||||
<v-row no-gutters class="d-flex flex-no-wrap align-center " :style="fontSize">
|
v-if="validateTotalTime"
|
||||||
<v-icon :x-large="!small" left color="primary">
|
class="time-card-flex mx-auto"
|
||||||
{{ $globals.icons.clockOutline }}
|
>
|
||||||
</v-icon>
|
|
||||||
<p class="my-0"><span class="font-weight-bold">{{ validateTotalTime.name }}</span><br>{{ validateTotalTime.value }}</p>
|
|
||||||
</v-row>
|
|
||||||
</div>
|
|
||||||
<v-divider v-if="validateTotalTime && (validatePrepTime || validatePerformTime)" class="my-2" />
|
|
||||||
<!-- Prep Time & Perform Time -->
|
|
||||||
<div v-if="validatePrepTime || validatePerformTime" class="time-card-flex mx-auto">
|
|
||||||
<v-row
|
<v-row
|
||||||
no-gutters
|
no-gutters
|
||||||
class="d-flex justify-center align-center" :class="{'flex-column': $vuetify.breakpoint.smAndDown}"
|
class="d-flex flex-no-wrap align-center"
|
||||||
style="width: 100%;" :style="fontSize"
|
:style="fontSize"
|
||||||
>
|
>
|
||||||
<div v-if="validatePrepTime" class="d-flex flex-no-wrap my-1">
|
<v-icon
|
||||||
<v-icon :large="!small" :dense="small" left color="primary">
|
:x-large="!small"
|
||||||
|
start
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{{ $globals.icons.clockOutline }}
|
||||||
|
</v-icon>
|
||||||
|
<p class="my-0">
|
||||||
|
<span class="font-weight-bold opacity-80">{{ validateTotalTime.name }}</span><br>{{ validateTotalTime.value }}
|
||||||
|
</p>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
<v-divider
|
||||||
|
v-if="validateTotalTime && (validatePrepTime || validatePerformTime)"
|
||||||
|
class="my-2"
|
||||||
|
/>
|
||||||
|
<!-- Prep Time & Perform Time -->
|
||||||
|
<div
|
||||||
|
v-if="validatePrepTime || validatePerformTime"
|
||||||
|
class="time-card-flex mx-auto"
|
||||||
|
>
|
||||||
|
<v-row
|
||||||
|
no-gutters
|
||||||
|
class="d-flex justify-center align-center"
|
||||||
|
:class="{ 'flex-column': $vuetify.display.smAndDown }"
|
||||||
|
style="width: 100%;"
|
||||||
|
:style="fontSize"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="validatePrepTime"
|
||||||
|
class="d-flex flex-no-wrap my-1 align-center"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
:size="small ? 'small' : 'large'"
|
||||||
|
left
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
{{ $globals.icons.knfife }}
|
{{ $globals.icons.knfife }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<p class="my-0"><span class="font-weight-bold">{{ validatePrepTime.name }}</span><br>{{ validatePrepTime.value }}</p>
|
<p class="my-0">
|
||||||
|
<span class="font-weight-bold opacity-80">{{ validatePrepTime.name }}</span><br>{{ validatePrepTime.value }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<v-divider v-if="validatePrepTime && validatePerformTime" vertical class="mx-4" />
|
<v-divider
|
||||||
<div v-if="validatePerformTime" class="d-flex flex-no-wrap my-1">
|
v-if="validatePrepTime && validatePerformTime"
|
||||||
<v-icon :large="!small" :dense="small" left color="primary">
|
vertical
|
||||||
|
class="mx-4"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="validatePerformTime"
|
||||||
|
class="d-flex flex-no-wrap my-1 align-center"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
:size="small ? 'small' : 'large'"
|
||||||
|
left
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
{{ $globals.icons.potSteam }}
|
{{ $globals.icons.potSteam }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<p class="my-0"><span class="font-weight-bold">{{ validatePerformTime.name }}</span><br>{{ validatePerformTime.value }}</p>
|
<p class="my-0">
|
||||||
|
<span class="font-weight-bold opacity-80">{{ validatePerformTime.name }}</span><br>{{ validatePerformTime.value }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</v-row>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,9 +79,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
export default defineNuxtComponent({
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
props: {
|
||||||
prepTime: {
|
prepTime: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -54,7 +95,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
color: {
|
color: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "accent custom-transparent"
|
default: "accent custom-transparent",
|
||||||
},
|
},
|
||||||
small: {
|
small: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -62,14 +103,14 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
|
||||||
function isEmpty(str: string | null) {
|
function isEmpty(str: string | null) {
|
||||||
return !str || str.length === 0;
|
return !str || str.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showCards = computed(() => {
|
const showCards = computed(() => {
|
||||||
return [props.prepTime, props.totalTime, props.performTime].some((x) => !isEmpty(x));
|
return [props.prepTime, props.totalTime, props.performTime].some(x => !isEmpty(x));
|
||||||
});
|
});
|
||||||
|
|
||||||
const validateTotalTime = computed(() => {
|
const validateTotalTime = computed(() => {
|
||||||
|
|||||||
@@ -4,55 +4,62 @@
|
|||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-col class="text-right">
|
<v-col class="text-right">
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<v-menu offset-y bottom left nudge-bottom="3" :close-on-content-click="false">
|
<v-menu
|
||||||
<template #activator="{ on, attrs }">
|
offset-y
|
||||||
<v-badge :content="filterBadgeCount" :value="filterBadgeCount" bordered overlap>
|
bottom
|
||||||
<v-btn fab small color="info" v-bind="attrs" v-on="on">
|
start
|
||||||
|
nudge-bottom="3"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-badge
|
||||||
|
:content="filterBadgeCount"
|
||||||
|
:model-value="filterBadgeCount > 0"
|
||||||
|
bordered
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
class="rounded-circle"
|
||||||
|
size="small"
|
||||||
|
color="info"
|
||||||
|
v-bind="props"
|
||||||
|
icon
|
||||||
|
>
|
||||||
<v-icon> {{ $globals.icons.filter }} </v-icon>
|
<v-icon> {{ $globals.icons.filter }} </v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-badge>
|
</v-badge>
|
||||||
</template>
|
</template>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-list>
|
<v-list>
|
||||||
<v-list-item @click="reverseSort">
|
<v-list-item
|
||||||
<v-icon left>
|
:prepend-icon="preferences.orderDirection === 'asc' ? $globals.icons.sortCalendarDescending : $globals.icons.sortCalendarAscending"
|
||||||
{{
|
:title="preferences.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
||||||
preferences.orderDirection === "asc" ?
|
@click="reverseSort"
|
||||||
$globals.icons.sortCalendarDescending : $globals.icons.sortCalendarAscending
|
/>
|
||||||
}}
|
|
||||||
</v-icon>
|
|
||||||
<v-list-item-title>
|
|
||||||
{{ preferences.orderDirection === "asc" ? $tc("general.sort-descending") : $tc("general.sort-ascending") }}
|
|
||||||
</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<v-list-item class="pa-0">
|
<v-list-item
|
||||||
<v-list class="py-0" style="width: 100%;">
|
v-for="option, idx in eventTypeFilterState"
|
||||||
<v-list-item
|
:key="idx"
|
||||||
v-for="option, idx in eventTypeFilterState"
|
>
|
||||||
:key="idx"
|
<v-checkbox
|
||||||
>
|
:model-value="option.checked"
|
||||||
<v-checkbox
|
color="primary"
|
||||||
:input-value="option.checked"
|
readonly
|
||||||
readonly
|
@click="toggleEventTypeOption(option.value)"
|
||||||
@click="toggleEventTypeOption(option.value)"
|
>
|
||||||
>
|
<template #label>
|
||||||
<template #label>
|
<v-icon start>
|
||||||
<v-icon left>
|
{{ option.icon }}
|
||||||
{{ option.icon }}
|
</v-icon>
|
||||||
</v-icon>
|
{{ option.label }}
|
||||||
{{ option.label }}
|
</template>
|
||||||
</template>
|
</v-checkbox>
|
||||||
</v-checkbox>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-divider class="mx-2"/>
|
<v-divider class="mx-2" />
|
||||||
<div
|
<div
|
||||||
v-if="timelineEvents.length"
|
v-if="timelineEvents.length"
|
||||||
id="timeline-container"
|
id="timeline-container"
|
||||||
@@ -61,7 +68,10 @@
|
|||||||
class="px-1"
|
class="px-1"
|
||||||
:style="maxHeight ? `max-height: ${maxHeight}; overflow-y: auto;` : ''"
|
:style="maxHeight ? `max-height: ${maxHeight}; overflow-y: auto;` : ''"
|
||||||
>
|
>
|
||||||
<v-timeline :dense="$vuetify.breakpoint.smAndDown" class="timeline">
|
<v-timeline
|
||||||
|
:dense="$vuetify.display.smAndDown"
|
||||||
|
class="timeline"
|
||||||
|
>
|
||||||
<RecipeTimelineItem
|
<RecipeTimelineItem
|
||||||
v-for="(event, index) in timelineEvents"
|
v-for="(event, index) in timelineEvents"
|
||||||
:key="event.id"
|
:key="event.id"
|
||||||
@@ -73,33 +83,41 @@
|
|||||||
/>
|
/>
|
||||||
</v-timeline>
|
</v-timeline>
|
||||||
</div>
|
</div>
|
||||||
<v-card v-else-if="!loading" class="mt-2">
|
<v-card
|
||||||
|
v-else-if="!loading"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
<v-card-title class="justify-center pa-9">
|
<v-card-title class="justify-center pa-9">
|
||||||
{{ $t("recipe.timeline-no-events-found-try-adjusting-filters") }}
|
{{ $t("recipe.timeline-no-events-found-try-adjusting-filters") }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
</v-card>
|
</v-card>
|
||||||
<div v-if="loading" class="mb-3 text-center">
|
<div
|
||||||
<AppLoader :loading="loading" :waiting-text="$tc('general.loading-events')" />
|
v-if="loading"
|
||||||
|
class="mb-3 text-center"
|
||||||
|
>
|
||||||
|
<AppLoader
|
||||||
|
:loading="loading"
|
||||||
|
:waiting-text="$t('general.loading-events')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, onMounted, ref, useAsync, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import { useThrottleFn, whenever } from "@vueuse/core";
|
import { useThrottleFn, whenever } from "@vueuse/core";
|
||||||
import RecipeTimelineItem from "./RecipeTimelineItem.vue"
|
import RecipeTimelineItem from "./RecipeTimelineItem.vue";
|
||||||
import { useTimelinePreferences } from "~/composables/use-users/preferences";
|
import { useTimelinePreferences } from "~/composables/use-users/preferences";
|
||||||
import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events";
|
import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events";
|
||||||
import { useAsyncKey } from "~/composables/use-utils";
|
import { useAsyncKey } from "~/composables/use-utils";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe";
|
import type { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: { RecipeTimelineItem },
|
components: { RecipeTimelineItem },
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
@@ -114,12 +132,12 @@ export default defineComponent({
|
|||||||
showRecipeCards: {
|
showRecipeCards: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
const preferences = useTimelinePreferences();
|
const preferences = useTimelinePreferences();
|
||||||
const { eventTypeOptions } = useTimelineEventTypes();
|
const { eventTypeOptions } = useTimelineEventTypes();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -133,16 +151,16 @@ export default defineComponent({
|
|||||||
const recipes = new Map<string, Recipe>();
|
const recipes = new Map<string, Recipe>();
|
||||||
const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length);
|
const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length);
|
||||||
const eventTypeFilterState = computed(() => {
|
const eventTypeFilterState = computed(() => {
|
||||||
return eventTypeOptions.value.map(option => {
|
return eventTypeOptions.value.map((option) => {
|
||||||
return {
|
return {
|
||||||
...option,
|
...option,
|
||||||
checked: preferences.value.types.includes(option.value),
|
checked: preferences.value.types.includes(option.value),
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ScrollEvent extends Event {
|
interface ScrollEvent extends Event {
|
||||||
target: HTMLInputElement;
|
target: HTMLInputElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const screenBuffer = 4;
|
const screenBuffer = 4;
|
||||||
@@ -154,17 +172,17 @@ export default defineComponent({
|
|||||||
const { scrollTop, offsetHeight, scrollHeight } = event.target;
|
const { scrollTop, offsetHeight, scrollHeight } = event.target;
|
||||||
|
|
||||||
// trigger when the user is getting close to the bottom
|
// trigger when the user is getting close to the bottom
|
||||||
const bottomOfElement = scrollTop + offsetHeight >= scrollHeight - (offsetHeight*screenBuffer);
|
const bottomOfElement = scrollTop + offsetHeight >= scrollHeight - (offsetHeight * screenBuffer);
|
||||||
if (bottomOfElement) {
|
if (bottomOfElement) {
|
||||||
infiniteScroll();
|
infiniteScroll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
whenever(
|
whenever(
|
||||||
() => props.value,
|
() => props.modelValue,
|
||||||
() => {
|
() => {
|
||||||
initializeTimelineEvents();
|
initializeTimelineEvents();
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
@@ -173,7 +191,7 @@ export default defineComponent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||||
initializeTimelineEvents();
|
initializeTimelineEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +203,8 @@ export default defineComponent({
|
|||||||
const index = preferences.value.types.indexOf(option);
|
const index = preferences.value.types.indexOf(option);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
preferences.value.types.push(option);
|
preferences.value.types.push(option);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
preferences.value.types.splice(index, 1);
|
preferences.value.types.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,21 +213,21 @@ export default defineComponent({
|
|||||||
|
|
||||||
// Timeline Actions
|
// Timeline Actions
|
||||||
async function updateTimelineEvent(index: number) {
|
async function updateTimelineEvent(index: number) {
|
||||||
const event = timelineEvents.value[index]
|
const event = timelineEvents.value[index];
|
||||||
const payload: RecipeTimelineEventUpdate = {
|
const payload: RecipeTimelineEventUpdate = {
|
||||||
subject: event.subject,
|
subject: event.subject,
|
||||||
eventMessage: event.eventMessage,
|
eventMessage: event.eventMessage,
|
||||||
image: event.image,
|
image: event.image,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { response } = await api.recipes.updateTimelineEvent(event.id, payload);
|
const { response } = await api.recipes.updateTimelineEvent(event.id, payload);
|
||||||
if (response?.status !== 200) {
|
if (response?.status !== 200) {
|
||||||
alert.error(i18n.t("events.something-went-wrong") as string);
|
alert.error(i18n.t("events.something-went-wrong") as string);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
alert.success(i18n.t("events.event-updated") as string);
|
alert.success(i18n.t("events.event-updated") as string);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function deleteTimelineEvent(index: number) {
|
async function deleteTimelineEvent(index: number) {
|
||||||
const { response } = await api.recipes.deleteTimelineEvent(timelineEvents.value[index].id);
|
const { response } = await api.recipes.deleteTimelineEvent(timelineEvents.value[index].id);
|
||||||
@@ -223,35 +242,35 @@ export default defineComponent({
|
|||||||
|
|
||||||
async function getRecipe(recipeId: string): Promise<Recipe | null> {
|
async function getRecipe(recipeId: string): Promise<Recipe | null> {
|
||||||
const { data } = await api.recipes.getOne(recipeId);
|
const { data } = await api.recipes.getOne(recipeId);
|
||||||
return data
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function updateRecipes(events: RecipeTimelineEventOut[]) {
|
async function updateRecipes(events: RecipeTimelineEventOut[]) {
|
||||||
const recipePromises: Promise<Recipe | null>[] = [];
|
const recipePromises: Promise<Recipe | null>[] = [];
|
||||||
const seenRecipeIds: string[] = [];
|
const seenRecipeIds: string[] = [];
|
||||||
events.forEach(event => {
|
events.forEach((event) => {
|
||||||
if (seenRecipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
|
if (seenRecipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
seenRecipeIds.push(event.recipeId);
|
seenRecipeIds.push(event.recipeId);
|
||||||
recipePromises.push(getRecipe(event.recipeId));
|
recipePromises.push(getRecipe(event.recipeId));
|
||||||
})
|
});
|
||||||
|
|
||||||
const results = await Promise.all(recipePromises);
|
const results = await Promise.all(recipePromises);
|
||||||
results.forEach(result => {
|
results.forEach((result) => {
|
||||||
if (result && result.id) {
|
if (result && result.id) {
|
||||||
recipes.set(result.id, result);
|
recipes.set(result.id, result);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrollTimelineEvents() {
|
async function scrollTimelineEvents() {
|
||||||
const orderBy = "timestamp";
|
const orderBy = "timestamp";
|
||||||
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
|
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
|
||||||
// eslint-disable-next-line quotes
|
|
||||||
const eventTypeValue = `["${preferences.value.types.join('", "')}"]`;
|
const eventTypeValue = `["${preferences.value.types.join("\", \"")}"]`;
|
||||||
const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`
|
const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`;
|
||||||
|
|
||||||
const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter });
|
const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter });
|
||||||
page.value += 1;
|
page.value += 1;
|
||||||
@@ -290,7 +309,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const infiniteScroll = useThrottleFn(() => {
|
const infiniteScroll = useThrottleFn(() => {
|
||||||
useAsync(async () => {
|
useAsyncData(useAsyncKey(), async () => {
|
||||||
if (!hasMore.value || loading.value) {
|
if (!hasMore.value || loading.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -298,7 +317,7 @@ export default defineComponent({
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
await scrollTimelineEvents();
|
await scrollTimelineEvents();
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}, useAsyncKey());
|
});
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
// preload events
|
// preload events
|
||||||
@@ -310,7 +329,7 @@ export default defineComponent({
|
|||||||
// if the inner element is scrollable, let its scroll event handle the infiniteScroll
|
// if the inner element is scrollable, let its scroll event handle the infiniteScroll
|
||||||
const timelineContainerElement = document.getElementById("timeline-container");
|
const timelineContainerElement = document.getElementById("timeline-container");
|
||||||
if (timelineContainerElement) {
|
if (timelineContainerElement) {
|
||||||
const { clientHeight, scrollHeight } = timelineContainerElement
|
const { clientHeight, scrollHeight } = timelineContainerElement;
|
||||||
|
|
||||||
// if scrollHeight == clientHeight, the element is not scrollable, so we need to look at the global position
|
// if scrollHeight == clientHeight, the element is not scrollable, so we need to look at the global position
|
||||||
// if scrollHeight > clientHeight, it is scrollable and we don't need to do anything here
|
// if scrollHeight > clientHeight, it is scrollable and we don't need to do anything here
|
||||||
@@ -319,13 +338,13 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight*screenBuffer);
|
const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight * screenBuffer);
|
||||||
if (bottomOfWindow) {
|
if (bottomOfWindow) {
|
||||||
infiniteScroll();
|
infiniteScroll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deleteTimelineEvent,
|
deleteTimelineEvent,
|
||||||
|
|||||||
@@ -1,32 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-tooltip bottom nudge-right="50" :color="buttonStyle ? 'info' : 'secondary'">
|
<v-tooltip
|
||||||
<template #activator="{ on, attrs }">
|
bottom
|
||||||
|
nudge-right="50"
|
||||||
|
:color="buttonStyle ? 'info' : 'secondary'"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
<v-btn
|
<v-btn
|
||||||
small
|
icon
|
||||||
|
:variant="buttonStyle ? 'flat' : undefined"
|
||||||
|
:rounded="buttonStyle ? 'circle' : undefined"
|
||||||
|
size="small"
|
||||||
:color="buttonStyle ? 'info' : 'secondary'"
|
:color="buttonStyle ? 'info' : 'secondary'"
|
||||||
:fab="buttonStyle"
|
:fab="buttonStyle"
|
||||||
class="ml-1"
|
v-bind="{ ...props, ...$attrs }"
|
||||||
v-bind="attrs"
|
|
||||||
v-on="on"
|
|
||||||
@click.prevent="toggleTimeline"
|
@click.prevent="toggleTimeline"
|
||||||
>
|
>
|
||||||
<v-icon :small="!buttonStyle" :color="buttonStyle ? 'white' : 'secondary'">
|
<v-icon
|
||||||
|
:size="!buttonStyle ? undefined : 'x-large'"
|
||||||
|
:color="buttonStyle ? 'white' : 'secondary'"
|
||||||
|
>
|
||||||
{{ $globals.icons.timelineText }}
|
{{ $globals.icons.timelineText }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<BaseDialog v-model="showTimeline" :title="timelineAttrs.title" :icon="$globals.icons.timelineText" width="70%">
|
<BaseDialog
|
||||||
<RecipeTimeline v-model="showTimeline" :query-filter="timelineAttrs.queryFilter" max-height="60vh" />
|
v-model="showTimeline"
|
||||||
|
:title="timelineAttrs.title"
|
||||||
|
:icon="$globals.icons.timelineText"
|
||||||
|
width="70%"
|
||||||
|
>
|
||||||
|
<RecipeTimeline
|
||||||
|
v-model="showTimeline"
|
||||||
|
:query-filter="timelineAttrs.queryFilter"
|
||||||
|
max-height="60vh"
|
||||||
|
/>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
<span>{{ $t('recipe.open-timeline') }}</span>
|
<span>{{ $t('recipe.open-timeline') }}</span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import RecipeTimeline from "./RecipeTimeline.vue";
|
import RecipeTimeline from "./RecipeTimeline.vue";
|
||||||
export default defineComponent({
|
|
||||||
|
export default defineNuxtComponent({
|
||||||
components: { RecipeTimeline },
|
components: { RecipeTimeline },
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
@@ -45,23 +61,24 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { $vuetify, i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
const { smAndDown } = useDisplay();
|
||||||
const showTimeline = ref(false);
|
const showTimeline = ref(false);
|
||||||
function toggleTimeline() {
|
function toggleTimeline() {
|
||||||
showTimeline.value = !showTimeline.value;
|
showTimeline.value = !showTimeline.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineAttrs = computed(() => {
|
const timelineAttrs = computed(() => {
|
||||||
let title = i18n.tc("recipe.timeline")
|
let title = i18n.t("recipe.timeline");
|
||||||
if ($vuetify.breakpoint.smAndDown) {
|
if (smAndDown.value) {
|
||||||
title += ` – ${props.recipeName}`
|
title += ` – ${props.recipeName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
queryFilter: `recipe.slug="${props.slug}"`,
|
queryFilter: `recipe.slug="${props.slug}"`,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
return { showTimeline, timelineAttrs, toggleTimeline };
|
return { showTimeline, timelineAttrs, toggleTimeline };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,58 +2,69 @@
|
|||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-model="recipeEventEditDialog"
|
v-model="recipeEventEditDialog"
|
||||||
:title="$tc('recipe.edit-timeline-event')"
|
:title="$t('recipe.edit-timeline-event')"
|
||||||
:icon="$globals.icons.edit"
|
:icon="$globals.icons.edit"
|
||||||
:submit-text="$tc('general.save')"
|
can-submit
|
||||||
@submit="$emit('update')"
|
:submit-text="$t('general.save')"
|
||||||
|
@submit="submitEdit"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-form ref="domMadeThisForm">
|
<v-form ref="domEditEventForm">
|
||||||
<v-text-field
|
<v-text-field v-model="localEvent.subject" :label="$t('general.subject')" />
|
||||||
v-model="event.subject"
|
<v-textarea v-model="localEvent.eventMessage" :label="$t('general.message')" rows="4" />
|
||||||
:label="$tc('general.subject')"
|
</v-form>
|
||||||
/>
|
</v-card-text>
|
||||||
<v-textarea
|
|
||||||
v-model="event.eventMessage"
|
|
||||||
:label="$tc('general.message')"
|
|
||||||
rows="4"
|
|
||||||
/>
|
|
||||||
</v-form>
|
|
||||||
</v-card-text>
|
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-model="recipeEventDeleteDialog"
|
v-model="recipeEventDeleteDialog"
|
||||||
:title="$tc('events.delete-event')"
|
:title="$t('events.delete-event')"
|
||||||
color="error"
|
color="error"
|
||||||
:icon="$globals.icons.alertCircle"
|
:icon="$globals.icons.alertCircle"
|
||||||
|
can-confirm
|
||||||
@confirm="$emit('delete')"
|
@confirm="$emit('delete')"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
{{ $t("events.event-delete-confirmation") }}
|
{{ $t('events.event-delete-confirmation') }}
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
<v-menu
|
<v-menu
|
||||||
offset-y
|
offset-y
|
||||||
left
|
start
|
||||||
:bottom="!menuTop"
|
:bottom="!props.menuTop"
|
||||||
:nudge-bottom="!menuTop ? '5' : '0'"
|
:nudge-bottom="!props.menuTop ? '5' : '0'"
|
||||||
:top="menuTop"
|
:top="props.menuTop"
|
||||||
:nudge-top="menuTop ? '5' : '0'"
|
:nudge-top="props.menuTop ? '5' : '0'"
|
||||||
allow-overflow
|
allow-overflow
|
||||||
close-delay="125"
|
close-delay="125"
|
||||||
:open-on-hover="!useMobileFormat"
|
:open-on-hover="!props.useMobileFormat"
|
||||||
content-class="d-print-none"
|
content-class="d-print-none"
|
||||||
>
|
>
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ props: btnProps }">
|
||||||
<v-btn :fab="fab" :x-small="fab" :elevation="elevation" :color="color" :icon="!fab" v-bind="attrs" v-on="on" @click.prevent>
|
<v-btn
|
||||||
|
:class="{ 'rounded-circle': props.fab }"
|
||||||
|
:x-small="props.fab"
|
||||||
|
:elevation="props.elevation ?? undefined"
|
||||||
|
:color="props.color"
|
||||||
|
:icon="!props.fab"
|
||||||
|
v-bind="btnProps"
|
||||||
|
@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>
|
||||||
@@ -61,10 +72,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
import { useI18n, useNuxtApp } from "#imports";
|
||||||
import { VForm } from "~/types/vuetify";
|
import type { RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
||||||
import { RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
|
||||||
|
|
||||||
export interface TimelineContextMenuIncludes {
|
export interface TimelineContextMenuIncludes {
|
||||||
edit: boolean;
|
edit: boolean;
|
||||||
@@ -78,129 +88,90 @@ export interface ContextMenuItem {
|
|||||||
event: string;
|
event: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: {
|
useItems?: TimelineContextMenuIncludes;
|
||||||
useItems: {
|
appendItems?: ContextMenuItem[];
|
||||||
type: Object as () => TimelineContextMenuIncludes,
|
leadingItems?: ContextMenuItem[];
|
||||||
default: () => ({
|
menuTop?: boolean;
|
||||||
edit: true,
|
fab?: boolean;
|
||||||
delete: true,
|
elevation?: number | null;
|
||||||
}),
|
color?: string;
|
||||||
},
|
event: RecipeTimelineEventOut;
|
||||||
// Append items are added at the end of the useItems list
|
menuIcon?: string | null;
|
||||||
appendItems: {
|
useMobileFormat?: boolean;
|
||||||
type: Array as () => ContextMenuItem[],
|
}>();
|
||||||
default: () => [],
|
|
||||||
},
|
const emit = defineEmits(["delete", "update"]);
|
||||||
// Append items are added at the beginning of the useItems list
|
|
||||||
leadingItems: {
|
const domEditEventForm = ref();
|
||||||
type: Array as () => ContextMenuItem[],
|
const recipeEventEditDialog = ref(false);
|
||||||
default: () => [],
|
const recipeEventDeleteDialog = ref(false);
|
||||||
},
|
const loading = ref(false);
|
||||||
menuTop: {
|
|
||||||
type: Boolean,
|
const i18n = useI18n();
|
||||||
default: true,
|
const { $globals } = useNuxtApp();
|
||||||
},
|
|
||||||
fab: {
|
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||||
type: Boolean,
|
edit: {
|
||||||
default: false,
|
title: i18n.t("general.edit"),
|
||||||
},
|
icon: $globals.icons.edit,
|
||||||
elevation: {
|
color: undefined,
|
||||||
type: Number,
|
event: "edit",
|
||||||
default: null
|
|
||||||
},
|
|
||||||
color: {
|
|
||||||
type: String,
|
|
||||||
default: "primary",
|
|
||||||
},
|
|
||||||
event: {
|
|
||||||
type: Object as () => RecipeTimelineEventOut,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
menuIcon: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
useMobileFormat: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
setup(props, context) {
|
delete: {
|
||||||
const domEditEventForm = ref<VForm>();
|
title: i18n.t("general.delete"),
|
||||||
const state = reactive({
|
icon: $globals.icons.delete,
|
||||||
recipeEventEditDialog: false,
|
color: "error",
|
||||||
recipeEventDeleteDialog: false,
|
event: "delete",
|
||||||
loading: false,
|
|
||||||
menuItems: [] as ContextMenuItem[],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { i18n, $globals } = useContext();
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Context Menu Setup
|
|
||||||
|
|
||||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
|
||||||
edit: {
|
|
||||||
title: i18n.tc("general.edit"),
|
|
||||||
icon: $globals.icons.edit,
|
|
||||||
color: undefined,
|
|
||||||
event: "edit",
|
|
||||||
},
|
|
||||||
delete: {
|
|
||||||
title: i18n.tc("general.delete"),
|
|
||||||
icon: $globals.icons.delete,
|
|
||||||
color: "error",
|
|
||||||
event: "delete",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get Default Menu Items Specified in Props
|
|
||||||
for (const [key, value] of Object.entries(props.useItems)) {
|
|
||||||
if (value) {
|
|
||||||
const item = defaultItems[key];
|
|
||||||
if (item) {
|
|
||||||
state.menuItems.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Leading and Appending Items
|
|
||||||
state.menuItems = [...state.menuItems, ...props.leadingItems, ...props.appendItems];
|
|
||||||
|
|
||||||
const icon = props.menuIcon || $globals.icons.dotsVertical;
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Context Menu Event Handler
|
|
||||||
|
|
||||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
|
||||||
edit: () => {
|
|
||||||
state.recipeEventEditDialog = true;
|
|
||||||
},
|
|
||||||
delete: () => {
|
|
||||||
state.recipeEventDeleteDialog = true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function contextMenuEventHandler(eventKey: string) {
|
|
||||||
const handler = eventHandlers[eventKey];
|
|
||||||
|
|
||||||
if (handler && typeof handler === "function") {
|
|
||||||
handler();
|
|
||||||
state.loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.emit(eventKey);
|
|
||||||
state.loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...toRefs(state),
|
|
||||||
contextMenuEventHandler,
|
|
||||||
domEditEventForm,
|
|
||||||
icon,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuItems = computed(() => {
|
||||||
|
const items: ContextMenuItem[] = [];
|
||||||
|
const useItems = props.useItems ?? { edit: true, delete: true };
|
||||||
|
for (const [key, value] of Object.entries(useItems)) {
|
||||||
|
if (value) {
|
||||||
|
const item = defaultItems[key];
|
||||||
|
if (item) items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...items,
|
||||||
|
...(props.leadingItems ?? []),
|
||||||
|
...(props.appendItems ?? []),
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const icon = computed(() => props.menuIcon || $globals.icons.dotsVertical);
|
||||||
|
|
||||||
|
const localEvent = ref({ ...props.event });
|
||||||
|
watch(() => props.event, (val) => {
|
||||||
|
localEvent.value = { ...val };
|
||||||
|
});
|
||||||
|
|
||||||
|
function openEditDialog() {
|
||||||
|
localEvent.value = { ...props.event };
|
||||||
|
recipeEventEditDialog.value = true;
|
||||||
|
}
|
||||||
|
function openDeleteDialog() {
|
||||||
|
recipeEventDeleteDialog.value = true;
|
||||||
|
}
|
||||||
|
function contextMenuEventHandler(eventKey: string) {
|
||||||
|
if (eventKey === "edit") {
|
||||||
|
openEditDialog();
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (eventKey === "delete") {
|
||||||
|
openDeleteDialog();
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(eventKey as "delete" | "update");
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
function submitEdit() {
|
||||||
|
emit("update", { ...localEvent.value });
|
||||||
|
recipeEventEditDialog.value = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,61 +1,57 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-timeline-item
|
<v-timeline-item :class="attrs.class" fill-dot :small="attrs.small" :icon="icon" dot-color="primary">
|
||||||
:class="attrs.class"
|
|
||||||
fill-dot
|
|
||||||
:small="attrs.small"
|
|
||||||
:icon="icon"
|
|
||||||
>
|
|
||||||
<template v-if="!useMobileFormat" #opposite>
|
<template v-if="!useMobileFormat" #opposite>
|
||||||
<v-chip v-if="event.timestamp" label large>
|
<v-chip v-if="event.timestamp" label large>
|
||||||
<v-icon class="mr-1"> {{ $globals.icons.calendar }} </v-icon>
|
<v-icon class="mr-1">
|
||||||
|
{{ $globals.icons.calendar }}
|
||||||
|
</v-icon>
|
||||||
{{ new Date(event.timestamp).toLocaleDateString($i18n.locale) }}
|
{{ new Date(event.timestamp).toLocaleDateString($i18n.locale) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
<v-card
|
<v-card
|
||||||
hover
|
hover
|
||||||
:to="$listeners.selected || !recipe ? undefined : `/g/${groupSlug}/r/${recipe.slug}`"
|
:to="$attrs.selected || !recipe ? undefined : `/g/${groupSlug}/r/${recipe.slug}`"
|
||||||
class="elevation-12"
|
class="elevation-12"
|
||||||
@click="$emit('selected')"
|
@click="$emit('selected')"
|
||||||
>
|
>
|
||||||
<v-card-title class="background">
|
<v-card-title class="background">
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col align-self="center" :cols="useMobileFormat ? 'auto' : '2'" :class="attrs.avatar.class">
|
<v-col align-self="center" :cols="useMobileFormat ? 'auto' : '2'" :class="attrs.avatar.class">
|
||||||
<UserAvatar :user-id="event.userId" :size="attrs.avatar.size" />
|
<UserAvatar :user-id="event.userId" :size="attrs.avatar.size" />
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col v-if="useMobileFormat" align-self="center" class="pr-0">
|
<v-col v-if="useMobileFormat" align-self="center" class="pr-0">
|
||||||
<v-chip label>
|
<v-chip label>
|
||||||
<v-icon> {{ $globals.icons.calendar }} </v-icon>
|
<v-icon> {{ $globals.icons.calendar }} </v-icon>
|
||||||
{{ new Date(event.timestamp || "").toLocaleDateString($i18n.locale) }}
|
{{ new Date(event.timestamp || "").toLocaleDateString($i18n.locale) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col v-else cols="9" style="margin: auto; text-align: center;">
|
<v-col v-else cols="9" style="margin: auto; text-align: center">
|
||||||
{{ event.subject }}
|
{{ event.subject }}
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0 pt-0">
|
<v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0 pt-0">
|
||||||
<RecipeTimelineContextMenu
|
<RecipeTimelineContextMenu
|
||||||
v-if="$auth.user && $auth.user.id == event.userId && event.eventType != 'system'"
|
v-if="currentUser && currentUser.id == event.userId && event.eventType != 'system'"
|
||||||
:menu-top="false"
|
:menu-top="false"
|
||||||
:event="event"
|
:event="event"
|
||||||
:menu-icon="$globals.icons.dotsVertical"
|
:menu-icon="$globals.icons.dotsVertical"
|
||||||
:use-mobile-format="useMobileFormat"
|
:use-mobile-format="useMobileFormat"
|
||||||
fab
|
color="transparent"
|
||||||
color="transparent"
|
:elevation="0"
|
||||||
:elevation="0"
|
:card-menu="false"
|
||||||
:card-menu="false"
|
:use-items="{
|
||||||
:use-items="{
|
edit: true,
|
||||||
edit: true,
|
delete: true,
|
||||||
delete: true,
|
}"
|
||||||
}"
|
@update="$emit('update')"
|
||||||
@update="$emit('update')"
|
@delete="$emit('delete')"
|
||||||
@delete="$emit('delete')"
|
/>
|
||||||
/>
|
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text v-if="showRecipeCards && recipe" class="background">
|
<v-card-text v-if="showRecipeCards && recipe" class="background">
|
||||||
<v-row :class="useMobileFormat ? 'py-3 mx-0' : 'py-3 mx-0'" style="max-width: 100%;">
|
<v-row :class="useMobileFormat ? 'py-3 mx-0' : 'py-3 mx-0'" style="max-width: 100%">
|
||||||
<v-col align-self="center" class="pa-0">
|
<v-col align-self="center" class="pa-0">
|
||||||
<RecipeCardMobile
|
<RecipeCardMobile
|
||||||
:vertical="useMobileFormat"
|
:vertical="useMobileFormat"
|
||||||
:name="recipe.name"
|
:name="recipe.name"
|
||||||
@@ -67,26 +63,26 @@
|
|||||||
:is-flat="true"
|
:is-flat="true"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-divider v-if="showRecipeCards && recipe && (useMobileFormat || event.eventMessage)" />
|
<v-divider v-if="showRecipeCards && recipe && (useMobileFormat || event.eventMessage)" />
|
||||||
<v-card-text class="background">
|
<v-card-text class="background">
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col>
|
<v-col>
|
||||||
<strong v-if="useMobileFormat">{{ event.subject }}</strong>
|
<strong v-if="useMobileFormat">{{ event.subject }}</strong>
|
||||||
<v-img
|
<v-img
|
||||||
v-if="eventImageUrl"
|
v-if="eventImageUrl"
|
||||||
:src="eventImageUrl"
|
:src="eventImageUrl"
|
||||||
min-height="50"
|
min-height="50"
|
||||||
:height="hideImage ? undefined : 'auto'"
|
:height="hideImage ? undefined : 'auto'"
|
||||||
:max-height="attrs.image.maxHeight"
|
:max-height="attrs.image.maxHeight"
|
||||||
contain
|
contain
|
||||||
:class=attrs.image.class
|
:class="attrs.image.class"
|
||||||
@error="hideImage = true"
|
@error="hideImage = true"
|
||||||
/>
|
/>
|
||||||
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
|
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
|
||||||
<SafeMarkdown :source="event.eventMessage" />
|
<SafeMarkdown :source="event.eventMessage" />
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
@@ -95,16 +91,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, ref, useContext, useRoute } from "@nuxtjs/composition-api";
|
|
||||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||||
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
|
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
|
||||||
import { useStaticRoutes } from "~/composables/api";
|
import { useStaticRoutes } from "~/composables/api";
|
||||||
import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events";
|
import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events";
|
||||||
import { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe"
|
import type { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
||||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||||
import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
|
import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: { RecipeCardMobile, RecipeTimelineContextMenu, UserAvatar, SafeMarkdown },
|
components: { RecipeCardMobile, RecipeTimelineContextMenu, UserAvatar, SafeMarkdown },
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
@@ -119,20 +114,23 @@ export default defineComponent({
|
|||||||
showRecipeCards: {
|
showRecipeCards: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["selected", "update", "delete"],
|
||||||
|
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { $auth, $globals, $vuetify } = useContext();
|
const { $vuetify, $globals } = useNuxtApp();
|
||||||
const { recipeTimelineEventImage } = useStaticRoutes();
|
const { recipeTimelineEventImage } = useStaticRoutes();
|
||||||
const { eventTypeOptions } = useTimelineEventTypes();
|
const { eventTypeOptions } = useTimelineEventTypes();
|
||||||
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
|
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
|
||||||
|
|
||||||
|
const { user: currentUser } = useMealieAuth();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
|
||||||
|
|
||||||
const useMobileFormat = computed(() => {
|
const useMobileFormat = computed(() => {
|
||||||
return $vuetify.breakpoint.smAndDown;
|
return $vuetify.display.smAndDown.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const attrs = computed(() => {
|
const attrs = computed(() => {
|
||||||
@@ -146,9 +144,9 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
maxHeight: "250",
|
maxHeight: "250",
|
||||||
class: "my-3"
|
class: "my-3",
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return {
|
return {
|
||||||
@@ -160,25 +158,25 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
maxHeight: "300",
|
maxHeight: "300",
|
||||||
class: "mb-5"
|
class: "mb-5",
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const icon = computed(() => {
|
const icon = computed(() => {
|
||||||
const option = eventTypeOptions.value.find((option) => option.value === props.event.eventType);
|
const option = eventTypeOptions.value.find(option => option.value === props.event.eventType);
|
||||||
return option ? option.icon : $globals.icons.informationVariant;
|
return option ? option.icon : $globals.icons.informationVariant;
|
||||||
});
|
});
|
||||||
|
|
||||||
const hideImage = ref(false);
|
const hideImage = ref(false);
|
||||||
const eventImageUrl = computed<string>( () => {
|
const eventImageUrl = computed<string>(() => {
|
||||||
if (props.event.image !== "has image") {
|
if (props.event.image !== "has image") {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
|
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
attrs,
|
attrs,
|
||||||
@@ -188,6 +186,7 @@ export default defineComponent({
|
|||||||
hideImage,
|
hideImage,
|
||||||
timelineEvents,
|
timelineEvents,
|
||||||
useMobileFormat,
|
useMobileFormat,
|
||||||
|
currentUser,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="scaledAmount" class="d-flex align-center">
|
<div
|
||||||
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger;">
|
v-if="scaledAmount"
|
||||||
<v-icon x-large left color="primary">
|
class="d-flex align-center"
|
||||||
|
>
|
||||||
|
<v-row
|
||||||
|
no-gutters
|
||||||
|
class="d-flex flex-wrap align-center"
|
||||||
|
style="font-size: larger;"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
size="x-large"
|
||||||
|
start
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
{{ $globals.icons.bread }}
|
{{ $globals.icons.bread }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<p class="my-0">
|
<p class="my-0 opacity-80">
|
||||||
<span class="font-weight-bold">{{ $i18n.tc("recipe.yield") }}</span><br>
|
<span class="font-weight-bold">{{ $t("recipe.yield") }}</span><br>
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<span v-html="scaledAmount"></span> {{ text }}
|
<span v-html="scaledAmount" /> {{ text }}
|
||||||
</p>
|
</p>
|
||||||
</v-row>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed } from "@nuxtjs/composition-api";
|
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
yieldQuantity: {
|
yieldQuantity: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@@ -34,11 +44,10 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
color: {
|
color: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "accent custom-transparent"
|
default: "accent custom-transparent",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
|
|
||||||
function sanitizeHTML(rawHtml: string) {
|
function sanitizeHTML(rawHtml: string) {
|
||||||
return DOMPurify.sanitize(rawHtml, {
|
return DOMPurify.sanitize(rawHtml, {
|
||||||
USE_PROFILES: { html: true },
|
USE_PROFILES: { html: true },
|
||||||
@@ -47,7 +56,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scaledAmount = computed(() => {
|
const scaledAmount = computed(() => {
|
||||||
const {scaledAmountDisplay} = useScaledAmount(props.yieldQuantity, props.scale);
|
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
|
||||||
return scaledAmountDisplay;
|
return scaledAmountDisplay;
|
||||||
});
|
});
|
||||||
const text = sanitizeHTML(props.yield);
|
const text = sanitizeHTML(props.yield);
|
||||||
|
|||||||
@@ -1,56 +1,131 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-menu v-model="state.menu" offset-y bottom nudge-bottom="3" :close-on-content-click="false">
|
<v-menu
|
||||||
<template #activator="{ on, attrs }">
|
v-model="state.menu"
|
||||||
<v-badge :value="selected.length > 0" small overlap color="primary" :content="selected.length">
|
offset-y
|
||||||
<v-btn small color="accent" dark v-bind="attrs" v-on="on">
|
bottom
|
||||||
<slot></slot>
|
nudge-bottom="3"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-badge
|
||||||
|
:model-value="selected.length > 0"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
:content="selected.length"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
color="accent"
|
||||||
|
dark
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-badge>
|
</v-badge>
|
||||||
</template>
|
</template>
|
||||||
<v-card width="400">
|
<v-card width="400">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-text-field v-model="state.search" class="mb-2" hide-details dense :label="$tc('search.search')" clearable />
|
<v-text-field
|
||||||
<div class="d-flex py-4">
|
v-model="state.search"
|
||||||
|
class="mb-2"
|
||||||
|
hide-details
|
||||||
|
density="comfortable"
|
||||||
|
:variant="'underlined'"
|
||||||
|
:label="$t('search.search')"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<div class="d-flex py-4 px-1">
|
||||||
<v-switch
|
<v-switch
|
||||||
v-if="requireAll != undefined"
|
v-if="requireAll != undefined"
|
||||||
v-model="requireAllValue"
|
v-model="requireAllValue"
|
||||||
dense
|
density="compact"
|
||||||
small
|
size="small"
|
||||||
hide-details
|
hide-details
|
||||||
class="my-auto"
|
class="my-auto"
|
||||||
:label="`${requireAll ? $tc('search.has-all') : $tc('search.has-any')}`"
|
color="primary"
|
||||||
|
:label="`${requireAll ? $t('search.has-all') : $t('search.has-any')}`"
|
||||||
/>
|
/>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
small
|
size="small"
|
||||||
color="accent"
|
color="accent"
|
||||||
class="mr-2 my-auto"
|
class="mr-2 my-auto"
|
||||||
@click="clearSelection"
|
@click="clearSelection"
|
||||||
>
|
>
|
||||||
{{ $tc("search.clear-selection") }}
|
{{ $t("search.clear-selection") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
<v-card v-if="filtered.length > 0" flat outlined>
|
<v-card
|
||||||
<v-radio-group v-model="selectedRadio" class="ma-0 pa-0">
|
v-if="filtered.length > 0"
|
||||||
<v-virtual-scroll :items="filtered" height="300" item-height="51">
|
flat
|
||||||
<template #default="{ item }">
|
variant="text"
|
||||||
<v-list-item :key="item.id" dense :value="item">
|
>
|
||||||
<v-list-item-action>
|
<!-- radio filters -->
|
||||||
<v-radio v-if="radio" :value="item" @click="handleRadioClick(item)" />
|
<v-radio-group
|
||||||
<v-checkbox v-else v-model="selected" :value="item" />
|
v-if="radio"
|
||||||
</v-list-item-action>
|
v-model="selectedRadio"
|
||||||
<v-list-item-content>
|
class="ma-0 pa-0"
|
||||||
<v-list-item-title> {{ item.name }} </v-list-item-title>
|
>
|
||||||
</v-list-item-content>
|
<v-virtual-scroll
|
||||||
|
:items="filtered"
|
||||||
|
height="300"
|
||||||
|
item-height="51"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<v-list-item
|
||||||
|
:key="item.id"
|
||||||
|
:value="item"
|
||||||
|
:title="item.name"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-list-item-action start>
|
||||||
|
<v-radio
|
||||||
|
v-if="radio"
|
||||||
|
:value="item"
|
||||||
|
color="primary"
|
||||||
|
@click="handleRadioClick(item)"
|
||||||
|
/>
|
||||||
|
</v-list-item-action>
|
||||||
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-divider></v-divider>
|
<v-divider />
|
||||||
</template>
|
</template>
|
||||||
</v-virtual-scroll>
|
</v-virtual-scroll>
|
||||||
</v-radio-group>
|
</v-radio-group>
|
||||||
|
<!-- checkbox filters -->
|
||||||
|
<v-row v-else class="mt-1">
|
||||||
|
<v-virtual-scroll
|
||||||
|
:items="filtered"
|
||||||
|
height="300"
|
||||||
|
item-height="51"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<v-list-item
|
||||||
|
:key="item.id"
|
||||||
|
:value="item"
|
||||||
|
:title="item.name"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-list-item-action start>
|
||||||
|
<v-checkbox-btn
|
||||||
|
v-model="selected"
|
||||||
|
:value="item"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</v-list-item-action>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
<v-divider />
|
||||||
|
</template>
|
||||||
|
</v-virtual-scroll>
|
||||||
|
</v-row>
|
||||||
</v-card>
|
</v-card>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<v-alert type="info" text> {{ $tc('search.no-results') }} </v-alert>
|
<v-alert
|
||||||
|
type="info"
|
||||||
|
:text="$t('search.no-results')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -59,20 +134,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, reactive, computed } from "@nuxtjs/composition-api";
|
|
||||||
|
|
||||||
export interface SelectableItem {
|
export interface SelectableItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
items: {
|
items: {
|
||||||
type: Array as () => SelectableItem[],
|
type: Array as () => SelectableItem[],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
value: {
|
modelValue: {
|
||||||
type: Array as () => any[],
|
type: Array as () => any[],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
@@ -85,6 +158,7 @@ export default defineComponent({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:requireAll", "update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
search: "",
|
search: "",
|
||||||
@@ -99,16 +173,16 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selected = computed({
|
const selected = computed({
|
||||||
get: () => props.value as SelectableItem[],
|
get: () => props.modelValue as SelectableItem[],
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
context.emit("input", value);
|
context.emit("update:modelValue", value);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedRadio = computed({
|
const selectedRadio = computed({
|
||||||
get: () => (selected.value.length > 0 ? selected.value[0] : null),
|
get: () => (selected.value.length > 0 ? selected.value[0] : null),
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
context.emit("input", value ? [value] : []);
|
context.emit("update:modelValue", value ? [value] : []);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,9 +191,19 @@ export default defineComponent({
|
|||||||
return props.items;
|
return props.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
return props.items.filter((item) => item.name.toLowerCase().includes(state.search.toLowerCase()));
|
return props.items.filter(item => item.name.toLowerCase().includes(state.search.toLowerCase()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleCheckboxClick = (item: SelectableItem) => {
|
||||||
|
console.log(selected.value, item);
|
||||||
|
if (selected.value.includes(item)) {
|
||||||
|
selected.value = selected.value.filter(i => i !== item);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
selected.value.push(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRadioClick = (item: SelectableItem) => {
|
const handleRadioClick = (item: SelectableItem) => {
|
||||||
if (selectedRadio.value === item) {
|
if (selectedRadio.value === item) {
|
||||||
selectedRadio.value = null;
|
selectedRadio.value = null;
|
||||||
@@ -138,6 +222,7 @@ export default defineComponent({
|
|||||||
selected,
|
selected,
|
||||||
selectedRadio,
|
selectedRadio,
|
||||||
filtered,
|
filtered,
|
||||||
|
handleCheckboxClick,
|
||||||
handleRadioClick,
|
handleRadioClick,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-chip v-bind="$attrs" label :color="label.color || undefined" :text-color="textColor">
|
<v-chip
|
||||||
|
v-bind="$attrs"
|
||||||
|
label
|
||||||
|
variant="flat"
|
||||||
|
:color="label.color || undefined"
|
||||||
|
:text-color="textColor"
|
||||||
|
>
|
||||||
<span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis;">
|
<span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis;">
|
||||||
{{ label.name }}
|
{{ label.name }}
|
||||||
</span>
|
</span>
|
||||||
@@ -7,12 +13,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
|
||||||
import { getTextColor } from "~/composables/use-text-color";
|
import { getTextColor } from "~/composables/use-text-color";
|
||||||
import { MultiPurposeLabelSummary } from "~/lib/api/types/recipe";
|
import type { MultiPurposeLabelSummary } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
|
export default defineNuxtComponent({
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
props: {
|
||||||
label: {
|
label: {
|
||||||
type: Object as () => MultiPurposeLabelSummary,
|
type: Object as () => MultiPurposeLabelSummary,
|
||||||
|
|||||||
@@ -6,12 +6,24 @@
|
|||||||
{{ $globals.icons.tags }}
|
{{ $globals.icons.tags }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</span>
|
</span>
|
||||||
{{ value.label.name }}
|
{{ modelValue.label.name }}
|
||||||
</div>
|
</div>
|
||||||
<div style="min-width: 72px" class="ml-auto text-right">
|
<div
|
||||||
<v-menu offset-x left min-width="125px">
|
style="min-width: 72px"
|
||||||
<template #activator="{ on, attrs }">
|
class="ml-auto text-right"
|
||||||
<v-btn small class="ml-2 handle" icon v-bind="attrs" v-on="on">
|
>
|
||||||
|
<v-menu
|
||||||
|
offset-x
|
||||||
|
start
|
||||||
|
min-width="125px"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
class="ml-2 handle"
|
||||||
|
icon
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ $globals.icons.arrowUpDown }}
|
{{ $globals.icons.arrowUpDown }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
@@ -23,22 +35,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
import type { ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/household";
|
||||||
import { ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/household";
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Object as () => ShoppingListMultiPurposeLabelOut,
|
type: Object as () => ShoppingListMultiPurposeLabelOut,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
useColor: {
|
useColor: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const labelColor = ref<string | undefined>(props.useColor ? props.value.label.color : undefined);
|
const labelColor = ref<string | undefined>(props.useColor ? props.modelValue.label.color : undefined);
|
||||||
|
|
||||||
function contextHandler(event: string) {
|
function contextHandler(event: string) {
|
||||||
context.emit(event);
|
context.emit(event);
|
||||||
|
|||||||
@@ -1,41 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container v-if="!edit" class="pa-0">
|
<v-container
|
||||||
<v-row no-gutters class="flex-nowrap align-center">
|
v-if="!edit"
|
||||||
|
class="pa-0"
|
||||||
|
>
|
||||||
|
<v-row
|
||||||
|
no-gutters
|
||||||
|
class="flex-nowrap align-center"
|
||||||
|
>
|
||||||
<v-col :cols="itemLabelCols">
|
<v-col :cols="itemLabelCols">
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-model="listItem.checked"
|
v-model="listItem.checked"
|
||||||
class="mt-0"
|
class="mt-0"
|
||||||
color="null"
|
color="null"
|
||||||
hide-details
|
hide-details
|
||||||
dense
|
density="compact"
|
||||||
:label="listItem.note"
|
:label="listItem.note!"
|
||||||
@change="$emit('checked', listItem)"
|
@change="$emit('checked', listItem)"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
<div :class="listItem.checked ? 'strike-through' : ''">
|
<div :class="listItem.checked ? 'strike-through' : ''">
|
||||||
<RecipeIngredientListItem :ingredient="listItem" :disable-amount="!(listItem.isFood || listItem.quantity !== 1)" />
|
<RecipeIngredientListItem
|
||||||
|
:ingredient="listItem"
|
||||||
|
:disable-amount="!(listItem.isFood || listItem.quantity !== 1)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox>
|
</v-checkbox>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-col v-if="label && showLabel" cols="3" class="text-right">
|
<v-col
|
||||||
<MultiPurposeLabel :label="label" small />
|
v-if="label && showLabel"
|
||||||
|
cols="3"
|
||||||
|
class="text-right"
|
||||||
|
>
|
||||||
|
<MultiPurposeLabel
|
||||||
|
:label="label"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="auto" class="text-right">
|
<v-col
|
||||||
<div v-if="!listItem.checked" style="min-width: 72px">
|
cols="auto"
|
||||||
<v-menu offset-x left min-width="125px">
|
class="text-right"
|
||||||
<template #activator="{ on, attrs }">
|
>
|
||||||
|
<div
|
||||||
|
v-if="!listItem.checked"
|
||||||
|
style="min-width: 72px"
|
||||||
|
>
|
||||||
|
<v-menu
|
||||||
|
offset-x
|
||||||
|
start
|
||||||
|
min-width="125px"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
<v-tooltip
|
<v-tooltip
|
||||||
v-if="recipeList && recipeList.length"
|
v-if="recipeList && recipeList.length"
|
||||||
open-delay="200"
|
open-delay="200"
|
||||||
transition="slide-x-reverse-transition"
|
transition="slide-x-reverse-transition"
|
||||||
dense
|
density="compact"
|
||||||
right
|
right
|
||||||
content-class="text-caption"
|
content-class="text-caption"
|
||||||
>
|
>
|
||||||
<template #activator="{ on: onBtn, attrs: attrsBtn }">
|
<template #activator="{ props: tooltipProps }">
|
||||||
<v-btn small class="ml-2" icon v-bind="attrsBtn" v-on="onBtn" @click="displayRecipeRefs = !displayRecipeRefs">
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
class="ml-2"
|
||||||
|
icon
|
||||||
|
v-bind="tooltipProps"
|
||||||
|
@click="displayRecipeRefs = !displayRecipeRefs"
|
||||||
|
>
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ $globals.icons.potSteam }}
|
{{ $globals.icons.potSteam }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
@@ -44,43 +77,91 @@
|
|||||||
<span>Toggle Recipes</span>
|
<span>Toggle Recipes</span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<!-- Dummy button so the spacing is consistent when labels are enabled -->
|
<!-- Dummy button so the spacing is consistent when labels are enabled -->
|
||||||
<v-btn v-else small class="ml-2" icon disabled>
|
<v-btn
|
||||||
</v-btn>
|
v-else
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
class="ml-2"
|
||||||
|
icon
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
|
||||||
<v-btn small class="ml-2 handle" icon v-bind="attrs" v-on="on">
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
class="ml-2 handle"
|
||||||
|
icon
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ $globals.icons.arrowUpDown }}
|
{{ $globals.icons.arrowUpDown }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn small class="ml-2" icon @click="toggleEdit(true)">
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
class="ml-2"
|
||||||
|
icon
|
||||||
|
@click="toggleEdit(true)"
|
||||||
|
>
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ $globals.icons.edit }}
|
{{ $globals.icons.edit }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<v-list dense>
|
<v-list density="compact">
|
||||||
<v-list-item v-for="action in contextMenu" :key="action.event" dense @click="contextHandler(action.event)">
|
<v-list-item
|
||||||
<v-list-item-title>{{ action.text }}</v-list-item-title>
|
v-for="action in contextMenu"
|
||||||
|
:key="action.event"
|
||||||
|
density="compact"
|
||||||
|
@click="contextHandler(action.event)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>
|
||||||
|
{{ action.text }}
|
||||||
|
</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs" no-gutters class="mb-2">
|
<v-row
|
||||||
<v-col cols="auto" style="width: 100%;">
|
v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs"
|
||||||
<RecipeList :recipes="recipeList" :list-item="listItem" :disabled="$nuxt.isOffline" small tile />
|
no-gutters
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
<v-col
|
||||||
|
cols="auto"
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
<RecipeList
|
||||||
|
:recipes="recipeList"
|
||||||
|
:list-item="listItem"
|
||||||
|
:disabled="isOffline"
|
||||||
|
size="small"
|
||||||
|
tile
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row v-if="listItem.checked" no-gutters class="mb-2">
|
<v-row
|
||||||
|
v-if="listItem.checked"
|
||||||
|
no-gutters
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
<v-col cols="auto">
|
<v-col cols="auto">
|
||||||
<div class="text-caption font-weight-light font-italic">
|
<div class="text-caption font-weight-light font-italic">
|
||||||
{{ $t("shopping-list.completed-on", {date: new Date(listItem.updatedAt || "").toLocaleDateString($i18n.locale)}) }}
|
{{ $t("shopping-list.completed-on", {
|
||||||
|
date: new Date(listItem.updatedAt
|
||||||
|
|| "").toLocaleDateString($i18n.locale) })
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-container>
|
</v-container>
|
||||||
<div v-else class="mb-1 mt-6">
|
<div
|
||||||
|
v-else
|
||||||
|
class="mb-1 mt-6"
|
||||||
|
>
|
||||||
<ShoppingListItemEditor
|
<ShoppingListItemEditor
|
||||||
v-model="localListItem"
|
v-model="localListItem"
|
||||||
:labels="labels"
|
:labels="labels"
|
||||||
@@ -95,13 +176,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition-api";
|
import { useOnline } from "@vueuse/core";
|
||||||
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
|
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
|
||||||
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
|
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
|
||||||
import MultiPurposeLabel from "./MultiPurposeLabel.vue";
|
import MultiPurposeLabel from "./MultiPurposeLabel.vue";
|
||||||
import { ShoppingListItemOut } from "~/lib/api/types/household";
|
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||||
import { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||||
import { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe";
|
import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe";
|
||||||
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
|
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
|
||||||
|
|
||||||
interface actions {
|
interface actions {
|
||||||
@@ -109,10 +190,10 @@ interface actions {
|
|||||||
event: string;
|
event: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeList, RecipeIngredientListItem },
|
components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeList, RecipeIngredientListItem },
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Object as () => ShoppingListItemOut,
|
type: Object as () => ShoppingListItemOut,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
@@ -137,10 +218,12 @@ export default defineComponent({
|
|||||||
default: undefined,
|
default: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["checked", "update:modelValue", "save", "delete"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
const displayRecipeRefs = ref(false);
|
const displayRecipeRefs = ref(false);
|
||||||
const itemLabelCols = ref<string>(props.value.checked ? "auto" : props.showLabel ? "4" : "6");
|
const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : props.showLabel ? "4" : "6");
|
||||||
|
const isOffline = computed(() => useOnline().value === false);
|
||||||
|
|
||||||
const contextMenu: actions[] = [
|
const contextMenu: actions[] = [
|
||||||
{
|
{
|
||||||
@@ -154,15 +237,15 @@ export default defineComponent({
|
|||||||
];
|
];
|
||||||
|
|
||||||
// copy prop value so a refresh doesn't interrupt the user
|
// copy prop value so a refresh doesn't interrupt the user
|
||||||
const localListItem = ref(Object.assign({}, props.value));
|
const localListItem = ref(Object.assign({}, props.modelValue));
|
||||||
const listItem = computed({
|
const listItem = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return props.value;
|
return props.modelValue;
|
||||||
},
|
},
|
||||||
set: (val) => {
|
set: (val) => {
|
||||||
// keep local copy in sync
|
// keep local copy in sync
|
||||||
localListItem.value = val;
|
localListItem.value = val;
|
||||||
context.emit("input", val);
|
context.emit("update:modelValue", val);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const edit = ref(false);
|
const edit = ref(false);
|
||||||
@@ -173,7 +256,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
if (val) {
|
if (val) {
|
||||||
// update local copy of item with the current value
|
// update local copy of item with the current value
|
||||||
localListItem.value = props.value;
|
localListItem.value = props.modelValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
edit.value = val;
|
edit.value = val;
|
||||||
@@ -182,7 +265,8 @@ export default defineComponent({
|
|||||||
function contextHandler(event: string) {
|
function contextHandler(event: string) {
|
||||||
if (event === "edit") {
|
if (event === "edit") {
|
||||||
toggleEdit(true);
|
toggleEdit(true);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
context.emit(event);
|
context.emit(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,9 +289,7 @@ export default defineComponent({
|
|||||||
* or the label of the food applied.
|
* or the label of the food applied.
|
||||||
*/
|
*/
|
||||||
const label = computed<MultiPurposeLabelSummary | undefined>(() => {
|
const label = computed<MultiPurposeLabelSummary | undefined>(() => {
|
||||||
// @ts-ignore - it _might_ exists
|
|
||||||
if (listItem.value.label) {
|
if (listItem.value.label) {
|
||||||
// @ts-ignore - it _might_ exists
|
|
||||||
return listItem.value.label as MultiPurposeLabelSummary;
|
return listItem.value.label as MultiPurposeLabelSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +307,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
listItem.value.recipeReferences.forEach((ref) => {
|
listItem.value.recipeReferences.forEach((ref) => {
|
||||||
const recipe = props.recipes.get(ref.recipeId)
|
const recipe = props.recipes.get(ref.recipeId);
|
||||||
if (recipe) {
|
if (recipe) {
|
||||||
recipeList.push(recipe);
|
recipeList.push(recipe);
|
||||||
}
|
}
|
||||||
@@ -247,6 +329,7 @@ export default defineComponent({
|
|||||||
label,
|
label,
|
||||||
recipeList,
|
recipeList,
|
||||||
toggleEdit,
|
toggleEdit,
|
||||||
|
isOffline,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-card outlined>
|
<v-card variant="outlined">
|
||||||
<v-card-text class="pb-3 pt-1">
|
<v-card-text class="pb-3 pt-1">
|
||||||
<div v-if="listItem.isFood" class="d-md-flex align-center mb-2" style="gap: 20px">
|
<div v-if="listItem.isFood" class="d-md-flex align-center mb-2" style="gap: 20px">
|
||||||
<div>
|
<div>
|
||||||
@@ -8,26 +8,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<InputLabelType
|
<InputLabelType
|
||||||
v-model="listItem.unit"
|
v-model="listItem.unit"
|
||||||
|
v-model:item-id="listItem.unitId!"
|
||||||
:items="units"
|
:items="units"
|
||||||
:item-id.sync="listItem.unitId"
|
|
||||||
:label="$t('general.units')"
|
:label="$t('general.units')"
|
||||||
:icon="$globals.icons.units"
|
:icon="$globals.icons.units"
|
||||||
|
create
|
||||||
@create="createAssignUnit"
|
@create="createAssignUnit"
|
||||||
/>
|
/>
|
||||||
<InputLabelType
|
<InputLabelType
|
||||||
v-model="listItem.food"
|
v-model="listItem.food"
|
||||||
|
v-model:item-id="listItem.foodId!"
|
||||||
:items="foods"
|
:items="foods"
|
||||||
:item-id.sync="listItem.foodId"
|
|
||||||
:label="$t('shopping-list.food')"
|
:label="$t('shopping-list.food')"
|
||||||
:icon="$globals.icons.foods"
|
:icon="$globals.icons.foods"
|
||||||
|
create
|
||||||
@create="createAssignFood"
|
@create="createAssignFood"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="d-md-flex align-center" style="gap: 20px">
|
<div class="d-md-flex align-center" style="gap: 20px">
|
||||||
<div v-if="!listItem.isFood">
|
<div v-if="!listItem.isFood">
|
||||||
<InputQuantity v-model="listItem.quantity" />
|
<InputQuantity v-model="listItem.quantity" />
|
||||||
</div>
|
</div>
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-model="listItem.note"
|
v-model="listItem.note"
|
||||||
hide-details
|
hide-details
|
||||||
@@ -36,17 +37,17 @@
|
|||||||
auto-grow
|
auto-grow
|
||||||
autofocus
|
autofocus
|
||||||
@keypress="handleNoteKeyPress"
|
@keypress="handleNoteKeyPress"
|
||||||
></v-textarea>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap align-end" style="gap: 20px">
|
<div class="d-flex flex-wrap align-end" style="gap: 20px">
|
||||||
<div class="d-flex align-end">
|
<div class="d-flex align-end">
|
||||||
|
|
||||||
<div style="max-width: 300px" class="mt-3 mr-auto">
|
<div style="max-width: 300px" class="mt-3 mr-auto">
|
||||||
<InputLabelType
|
<InputLabelType
|
||||||
v-model="listItem.label"
|
v-model="listItem.label"
|
||||||
|
v-model:item-id="listItem.labelId!"
|
||||||
:items="labels"
|
:items="labels"
|
||||||
:item-id.sync="listItem.labelId"
|
|
||||||
:label="$t('shopping-list.label')"
|
:label="$t('shopping-list.label')"
|
||||||
|
width="250"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,11 +55,11 @@
|
|||||||
v-if="listItem.recipeReferences && listItem.recipeReferences.length > 0"
|
v-if="listItem.recipeReferences && listItem.recipeReferences.length > 0"
|
||||||
open-on-hover
|
open-on-hover
|
||||||
offset-y
|
offset-y
|
||||||
left
|
start
|
||||||
top
|
top
|
||||||
>
|
>
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ props }">
|
||||||
<v-icon class="mt-auto" icon v-bind="attrs" color="warning" v-on="on">
|
<v-icon class="mt-auto" :icon="$globals.icons.alert" v-bind="props" color="warning">
|
||||||
{{ $globals.icons.alert }}
|
{{ $globals.icons.alert }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</template>
|
</template>
|
||||||
@@ -71,10 +72,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
|
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
|
||||||
small
|
size="small"
|
||||||
color="info"
|
color="info"
|
||||||
:icon="$globals.icons.tagArrowRight"
|
:icon="$globals.icons.tagArrowRight"
|
||||||
:text="$tc('shopping-list.save-label')"
|
:text="$t('shopping-list.save-label')"
|
||||||
class="mt-2 align-items-flex-start"
|
class="mt-2 align-items-flex-start"
|
||||||
@click="assignLabelToFood"
|
@click="assignLabelToFood"
|
||||||
/>
|
/>
|
||||||
@@ -84,11 +85,15 @@
|
|||||||
<v-card-actions class="ma-0 pt-0 pb-1 justify-end">
|
<v-card-actions class="ma-0 pt-0 pb-1 justify-end">
|
||||||
<BaseButtonGroup
|
<BaseButtonGroup
|
||||||
:buttons="[
|
:buttons="[
|
||||||
...(allowDelete ? [{
|
...(allowDelete
|
||||||
icon: $globals.icons.delete,
|
? [
|
||||||
text: $t('general.delete'),
|
{
|
||||||
event: 'delete',
|
icon: $globals.icons.delete,
|
||||||
}] : []),
|
text: $t('general.delete'),
|
||||||
|
event: 'delete',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
icon: $globals.icons.close,
|
icon: $globals.icons.close,
|
||||||
text: $t('general.cancel'),
|
text: $t('general.cancel'),
|
||||||
@@ -116,15 +121,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, watch } from "@nuxtjs/composition-api";
|
import type { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||||
import { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/household";
|
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
|
||||||
import { MultiPurposeLabelOut } from "~/lib/api/types/labels";
|
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||||
import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
|
||||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Object as () => ShoppingListItemCreate | ShoppingListItemOut,
|
type: Object as () => ShoppingListItemCreate | ShoppingListItemOut,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
@@ -146,6 +150,7 @@ export default defineComponent({
|
|||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue", "save", "cancel", "delete"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const foodStore = useFoodStore();
|
const foodStore = useFoodStore();
|
||||||
const foodData = useFoodData();
|
const foodData = useFoodData();
|
||||||
@@ -155,25 +160,25 @@ export default defineComponent({
|
|||||||
|
|
||||||
const listItem = computed({
|
const listItem = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return props.value;
|
return props.modelValue;
|
||||||
},
|
},
|
||||||
set: (val) => {
|
set: (val) => {
|
||||||
context.emit("input", val);
|
context.emit("update:modelValue", val);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.value.food,
|
() => props.modelValue.food,
|
||||||
(newFood) => {
|
(newFood) => {
|
||||||
// @ts-ignore our logic already assumes there's a label attribute, even if TS doesn't think there is
|
|
||||||
listItem.value.label = newFood?.label || null;
|
listItem.value.label = newFood?.label || null;
|
||||||
listItem.value.labelId = listItem.value.label?.id || null;
|
listItem.value.labelId = listItem.value.label?.id || null;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
async function createAssignFood(val: string) {
|
async function createAssignFood(val: string) {
|
||||||
// keep UI reactive
|
// keep UI reactive
|
||||||
listItem.value.food ? listItem.value.food.name = val : listItem.value.food = { name: val };
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
listItem.value.food ? (listItem.value.food.name = val) : (listItem.value.food = { name: val });
|
||||||
|
|
||||||
foodData.data.name = val;
|
foodData.data.name = val;
|
||||||
const newFood = await foodStore.actions.createOne(foodData.data);
|
const newFood = await foodStore.actions.createOne(foodData.data);
|
||||||
@@ -186,7 +191,8 @@ export default defineComponent({
|
|||||||
|
|
||||||
async function createAssignUnit(val: string) {
|
async function createAssignUnit(val: string) {
|
||||||
// keep UI reactive
|
// keep UI reactive
|
||||||
listItem.value.unit ? listItem.value.unit.name = val : listItem.value.unit = { name: val };
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
listItem.value.unit ? (listItem.value.unit.name = val) : (listItem.value.unit = { name: val });
|
||||||
|
|
||||||
unitData.data.name = val;
|
unitData.data.name = val;
|
||||||
const newUnit = await unitStore.actions.createOne(unitData.data);
|
const newUnit = await unitStore.actions.createOne(unitData.data);
|
||||||
@@ -203,7 +209,6 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
listItem.value.food.labelId = listItem.value.labelId;
|
listItem.value.food.labelId = listItem.value.labelId;
|
||||||
// @ts-ignore the food will have an id, even though TS says it might not
|
|
||||||
await foodStore.actions.updateOne(listItem.value.food);
|
await foodStore.actions.updateOne(listItem.value.food);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +227,6 @@ export default defineComponent({
|
|||||||
this.$emit("save");
|
this.$emit("save");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,12 +4,29 @@
|
|||||||
:disabled="!user || !tooltip"
|
:disabled="!user || !tooltip"
|
||||||
right
|
right
|
||||||
>
|
>
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ props }">
|
||||||
<v-list-item-avatar v-if="list" v-bind="attrs" v-on="on">
|
<v-avatar
|
||||||
<v-img :src="imageURL" :alt="userId" @load="error = false" @error="error = true"> </v-img>
|
v-if="list"
|
||||||
</v-list-item-avatar>
|
v-bind="props"
|
||||||
<v-avatar v-else :size="size" v-bind="attrs" v-on="on">
|
>
|
||||||
<v-img :src="imageURL" :alt="userId" @load="error = false" @error="error = true"> </v-img>
|
<v-img
|
||||||
|
:src="imageURL"
|
||||||
|
:alt="userId"
|
||||||
|
@load="error = false"
|
||||||
|
@error="error = true"
|
||||||
|
/>
|
||||||
|
</v-avatar>
|
||||||
|
<v-avatar
|
||||||
|
v-else
|
||||||
|
:size="size"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-img
|
||||||
|
:src="imageURL"
|
||||||
|
:alt="userId"
|
||||||
|
@load="error = false"
|
||||||
|
@error="error = true"
|
||||||
|
/>
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
</template>
|
</template>
|
||||||
<span v-if="user">
|
<span v-if="user">
|
||||||
@@ -19,11 +36,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, toRefs, reactive, useContext, computed } from "@nuxtjs/composition-api";
|
|
||||||
import { useUserStore } from "~/composables/store/use-user-store";
|
import { useUserStore } from "~/composables/store/use-user-store";
|
||||||
import { UserOut } from "~/lib/api/types/user";
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
userId: {
|
userId: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -40,22 +55,22 @@ export default defineComponent({
|
|||||||
tooltip: {
|
tooltip: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
error: false,
|
error: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { $auth } = useContext();
|
const $auth = useMealieAuth();
|
||||||
const { store: users } = useUserStore();
|
const { store: users } = useUserStore();
|
||||||
const user = computed(() => {
|
const user = computed(() => {
|
||||||
return users.value.find((user) => user.id === props.userId);
|
return users.value.find(user => user.id === props.userId);
|
||||||
})
|
});
|
||||||
|
|
||||||
const imageURL = computed(() => {
|
const imageURL = computed(() => {
|
||||||
// TODO Setup correct user type for $auth.user
|
// Note: $auth.user is a ref now
|
||||||
const authUser = $auth.user as unknown as UserOut | null;
|
const authUser = $auth.user.value;
|
||||||
const key = authUser?.cacheKey ?? "";
|
const key = authUser?.cacheKey ?? "";
|
||||||
return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`;
|
return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,50 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-model="inviteDialog"
|
v-model="inviteDialog"
|
||||||
:title="$tc('profile.get-invite-link')"
|
:title="$t('profile.get-invite-link')"
|
||||||
:icon="$globals.icons.accountPlusOutline"
|
:icon="$globals.icons.accountPlusOutline"
|
||||||
color="primary">
|
color="primary"
|
||||||
|
>
|
||||||
<v-container>
|
<v-container>
|
||||||
<v-form class="mt-5">
|
<v-form class="mt-5">
|
||||||
<v-select
|
<v-select
|
||||||
v-if="groups && groups.length"
|
v-if="groups && groups.length"
|
||||||
v-model="selectedGroup"
|
v-model="selectedGroup"
|
||||||
:items="groups"
|
:items="groups"
|
||||||
item-text="name"
|
item-title="name"
|
||||||
item-value="id"
|
item-value="id"
|
||||||
:return-object="false"
|
:return-object="false"
|
||||||
filled
|
variant="filled"
|
||||||
:label="$tc('group.user-group')"
|
:label="$t('group.user-group')"
|
||||||
:rules="[validators.required]" />
|
:rules="[validators.required]"
|
||||||
|
/>
|
||||||
<v-select
|
<v-select
|
||||||
v-if="households && households.length"
|
v-if="households && households.length"
|
||||||
v-model="selectedHousehold"
|
v-model="selectedHousehold"
|
||||||
:items="filteredHouseholds"
|
:items="filteredHouseholds"
|
||||||
item-text="name" item-value="id"
|
item-title="name"
|
||||||
:return-object="false" filled
|
item-value="id"
|
||||||
:label="$tc('household.user-household')"
|
:return-object="false"
|
||||||
:rules="[validators.required]" />
|
variant="filled"
|
||||||
|
:label="$t('household.user-household')"
|
||||||
|
:rules="[validators.required]"
|
||||||
|
/>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="9">
|
<v-col cols="9">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
:label="$tc('profile.invite-link')"
|
:label="$t('profile.invite-link')"
|
||||||
type="text" readonly filled
|
type="text"
|
||||||
:value="generatedSignupLink" />
|
readonly
|
||||||
|
variant="filled"
|
||||||
|
:value="generatedSignupLink"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="3" class="pl-1 mt-3">
|
<v-col
|
||||||
|
cols="3"
|
||||||
|
class="pl-1 mt-3"
|
||||||
|
>
|
||||||
<AppButtonCopy
|
<AppButtonCopy
|
||||||
:icon="false"
|
:icon="false"
|
||||||
color="info"
|
color="info"
|
||||||
:copy-text="generatedSignupLink"
|
:copy-text="generatedSignupLink"
|
||||||
:disabled="generatedSignupLink" />
|
:disabled="generatedSignupLink"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="sendTo"
|
v-model="sendTo"
|
||||||
:label="$t('user.email')"
|
:label="$t('user.email')"
|
||||||
:rules="[validators.email]"
|
:rules="[validators.email]"
|
||||||
outlined
|
variant="outlined"
|
||||||
@keydown.enter="sendInvite" />
|
@keydown.enter="sendInvite"
|
||||||
|
/>
|
||||||
</v-form>
|
</v-form>
|
||||||
</v-container>
|
</v-container>
|
||||||
<template #custom-card-action>
|
<template #custom-card-action>
|
||||||
@@ -52,15 +65,15 @@
|
|||||||
:disabled="!validEmail"
|
:disabled="!validEmail"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:icon="$globals.icons.email"
|
:icon="$globals.icons.email"
|
||||||
@click="sendInvite">
|
@click="sendInvite"
|
||||||
{{ $t("group.invite") }}
|
>
|
||||||
|
{{ $t("group.invite") }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</template>
|
</template>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext, ref, toRefs, reactive } from "@nuxtjs/composition-api";
|
|
||||||
import { watchEffect } from "vue";
|
import { watchEffect } from "vue";
|
||||||
import { useUserApi } from "@/composables/api";
|
import { useUserApi } from "@/composables/api";
|
||||||
import BaseDialog from "~/components/global/BaseDialog.vue";
|
import BaseDialog from "~/components/global/BaseDialog.vue";
|
||||||
@@ -68,12 +81,12 @@ import AppButtonCopy from "~/components/global/AppButtonCopy.vue";
|
|||||||
import BaseButton from "~/components/global/BaseButton.vue";
|
import BaseButton from "~/components/global/BaseButton.vue";
|
||||||
import { validators } from "~/composables/use-validators";
|
import { validators } from "~/composables/use-validators";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
import { GroupInDB } from "~/lib/api/types/user";
|
import type { GroupInDB } from "~/lib/api/types/user";
|
||||||
import { HouseholdInDB } from "~/lib/api/types/household";
|
import type { HouseholdInDB } from "~/lib/api/types/household";
|
||||||
import { useGroups } from "~/composables/use-groups";
|
import { useGroups } from "~/composables/use-groups";
|
||||||
import { useAdminHouseholds } from "~/composables/use-households";
|
import { useAdminHouseholds } from "~/composables/use-households";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
name: "UserInviteDialog",
|
name: "UserInviteDialog",
|
||||||
components: {
|
components: {
|
||||||
BaseDialog,
|
BaseDialog,
|
||||||
@@ -81,15 +94,17 @@ export default defineComponent({
|
|||||||
BaseButton,
|
BaseButton,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { $auth, i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
|
||||||
const isAdmin = computed(() => $auth.user?.admin);
|
const isAdmin = computed(() => $auth.user.value?.admin);
|
||||||
const token = ref("");
|
const token = ref("");
|
||||||
const selectedGroup = ref<string | null>(null);
|
const selectedGroup = ref<string | null>(null);
|
||||||
const selectedHousehold = ref<string | null>(null);
|
const selectedHousehold = ref<string | null>(null);
|
||||||
@@ -98,7 +113,7 @@ export default defineComponent({
|
|||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
|
|
||||||
const fetchGroupsAndHouseholds = () => {
|
const fetchGroupsAndHouseholds = () => {
|
||||||
if (isAdmin) {
|
if (isAdmin.value) {
|
||||||
const groupsResponse = useGroups();
|
const groupsResponse = useGroups();
|
||||||
const householdsResponse = useAdminHouseholds();
|
const householdsResponse = useAdminHouseholds();
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
@@ -110,10 +125,10 @@ export default defineComponent({
|
|||||||
|
|
||||||
const inviteDialog = computed<boolean>({
|
const inviteDialog = computed<boolean>({
|
||||||
get() {
|
get() {
|
||||||
return props.value;
|
return props.modelValue;
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
context.emit("input", val);
|
context.emit("update:modelValue", val);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,9 +171,10 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (data && data.success) {
|
if (data && data.success) {
|
||||||
alert.success(i18n.tc("profile.email-sent"));
|
alert.success(i18n.t("profile.email-sent"));
|
||||||
} else {
|
}
|
||||||
alert.error(i18n.tc("profile.error-sending-email"));
|
else {
|
||||||
|
alert.error(i18n.t("profile.error-sending-email"));
|
||||||
}
|
}
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
inviteDialog.value = false;
|
inviteDialog.value = false;
|
||||||
@@ -191,10 +207,11 @@ export default defineComponent({
|
|||||||
households,
|
households,
|
||||||
fetchGroupsAndHouseholds,
|
fetchGroupsAndHouseholds,
|
||||||
...toRefs(state),
|
...toRefs(state),
|
||||||
|
isAdmin,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value: {
|
modelValue: {
|
||||||
immediate: false,
|
immediate: false,
|
||||||
handler(val) {
|
handler(val) {
|
||||||
if (val && !this.isAdmin) {
|
if (val && !this.isAdmin) {
|
||||||
|
|||||||
@@ -13,19 +13,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, toRef, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import { usePasswordStrength } from "~/composables/use-passwords";
|
import { usePasswordStrength } from "~/composables/use-passwords";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const asRef = toRef(props, "value");
|
const asRef = toRef(props, "modelValue");
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const pwStrength = usePasswordStrength(asRef, i18n);
|
const pwStrength = usePasswordStrength(asRef, i18n);
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +1,75 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card outlined nuxt :to="link.to" height="100%" class="d-flex flex-column">
|
<v-card
|
||||||
<div v-if="$vuetify.breakpoint.smAndDown" class="pa-2 mx-auto">
|
variant="outlined"
|
||||||
<v-img max-width="150px" max-height="125" :src="image" />
|
style="border-color: lightgrey;"
|
||||||
|
:to="link.to"
|
||||||
|
height="100%"
|
||||||
|
class="d-flex flex-column mt-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="$vuetify.display.smAndDown"
|
||||||
|
class="pa-2 mx-auto"
|
||||||
|
>
|
||||||
|
<v-img
|
||||||
|
width="150px"
|
||||||
|
height="125"
|
||||||
|
:src="image"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-space-between">
|
<div class="d-flex justify-space-between">
|
||||||
<div>
|
<div>
|
||||||
<v-card-title class="headline pb-0">
|
<v-card-title class="text-subtitle-1 pb-0">
|
||||||
<slot name="title"> </slot>
|
<slot name="title" />
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<div class="d-flex justify-center align-center">
|
<div class="d-flex justify-center align-center">
|
||||||
<v-card-text class="d-flex flex-row mb-auto">
|
<v-card-text class="d-flex flex-row mb-auto">
|
||||||
<slot name="default"></slot>
|
<slot name="default" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="$vuetify.breakpoint.mdAndUp" class="py-2 px-10 my-auto">
|
<div
|
||||||
<v-img max-width="150px" max-height="125" :src="image"></v-img>
|
v-if="$vuetify.display.mdAndUp"
|
||||||
|
class="py-2 px-10 my-auto"
|
||||||
|
>
|
||||||
|
<v-img
|
||||||
|
width="150px"
|
||||||
|
height="125"
|
||||||
|
:src="image"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<v-divider class="mt-auto"></v-divider>
|
<v-spacer />
|
||||||
|
<v-divider />
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-btn text color="info" :to="link.to">
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
color="info"
|
||||||
|
:to="link.to"
|
||||||
|
>
|
||||||
{{ link.text }}
|
{{ link.text }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
|
||||||
|
|
||||||
interface LinkProp {
|
interface LinkProp {
|
||||||
text: string;
|
text: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps({
|
||||||
props: {
|
link: {
|
||||||
link: {
|
type: Object as () => LinkProp,
|
||||||
type: Object as () => LinkProp,
|
required: true,
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
setup() {
|
image: {
|
||||||
return {};
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("Props", props);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-card-title>
|
<v-card-title>
|
||||||
<v-icon large class="mr-3"> {{ $globals.icons.user }}</v-icon>
|
<v-icon
|
||||||
|
size="large"
|
||||||
|
class="mr-3"
|
||||||
|
>
|
||||||
|
{{ $globals.icons.user }}
|
||||||
|
</v-icon>
|
||||||
<span class="headline"> {{ $t("user-registration.account-details") }}</span>
|
<span class="headline"> {{ $t("user-registration.account-details") }}</span>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-form ref="domAccountForm" @submit.prevent>
|
<v-form
|
||||||
|
ref="domAccountForm"
|
||||||
|
@submit.prevent
|
||||||
|
>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="accountDetails.username.value"
|
v-model="accountDetails.username.value"
|
||||||
autofocus
|
autofocus
|
||||||
v-bind="inputAttrs"
|
v-bind="inputAttrs"
|
||||||
:label="$tc('user.username')"
|
:label="$t('user.username')"
|
||||||
:prepend-icon="$globals.icons.user"
|
:prepend-icon="$globals.icons.user"
|
||||||
:rules="[validators.required]"
|
:rules="[validators.required]"
|
||||||
:error-messages="usernameErrorMessages"
|
:error-messages="usernameErrorMessages"
|
||||||
@@ -20,7 +28,7 @@
|
|||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="accountDetails.fullName.value"
|
v-model="accountDetails.fullName.value"
|
||||||
v-bind="inputAttrs"
|
v-bind="inputAttrs"
|
||||||
:label="$tc('user.full-name')"
|
:label="$t('user.full-name')"
|
||||||
:prepend-icon="$globals.icons.user"
|
:prepend-icon="$globals.icons.user"
|
||||||
:rules="[validators.required]"
|
:rules="[validators.required]"
|
||||||
/>
|
/>
|
||||||
@@ -28,7 +36,7 @@
|
|||||||
v-model="accountDetails.email.value"
|
v-model="accountDetails.email.value"
|
||||||
v-bind="inputAttrs"
|
v-bind="inputAttrs"
|
||||||
:prepend-icon="$globals.icons.email"
|
:prepend-icon="$globals.icons.email"
|
||||||
:label="$tc('user.email')"
|
:label="$t('user.email')"
|
||||||
:rules="[validators.required, validators.email]"
|
:rules="[validators.required, validators.email]"
|
||||||
:error-messages="emailErrorMessages"
|
:error-messages="emailErrorMessages"
|
||||||
@blur="validateEmail"
|
@blur="validateEmail"
|
||||||
@@ -37,11 +45,11 @@
|
|||||||
v-model="credentials.password1.value"
|
v-model="credentials.password1.value"
|
||||||
v-bind="inputAttrs"
|
v-bind="inputAttrs"
|
||||||
:type="pwFields.inputType.value"
|
:type="pwFields.inputType.value"
|
||||||
:append-icon="pwFields.passwordIcon.value"
|
:append-inner-icon="pwFields.passwordIcon.value"
|
||||||
:prepend-icon="$globals.icons.lock"
|
:prepend-icon="$globals.icons.lock"
|
||||||
:label="$tc('user.password')"
|
:label="$t('user.password')"
|
||||||
:rules="[validators.required, validators.minLength(8), validators.maxLength(258)]"
|
:rules="[validators.required, validators.minLength(8), validators.maxLength(258)]"
|
||||||
@click:append="pwFields.togglePasswordShow"
|
@click:append-inner="pwFields.togglePasswordShow"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UserPasswordStrength :value="credentials.password1.value" />
|
<UserPasswordStrength :value="credentials.password1.value" />
|
||||||
@@ -50,19 +58,19 @@
|
|||||||
v-model="credentials.password2.value"
|
v-model="credentials.password2.value"
|
||||||
v-bind="inputAttrs"
|
v-bind="inputAttrs"
|
||||||
:type="pwFields.inputType.value"
|
:type="pwFields.inputType.value"
|
||||||
:append-icon="pwFields.passwordIcon.value"
|
:append-inner-icon="pwFields.passwordIcon.value"
|
||||||
:prepend-icon="$globals.icons.lock"
|
:prepend-icon="$globals.icons.lock"
|
||||||
:label="$tc('user.confirm-password')"
|
:label="$t('user.confirm-password')"
|
||||||
:rules="[validators.required, credentials.passwordMatch]"
|
:rules="[validators.required, credentials.passwordMatch]"
|
||||||
@click:append="pwFields.togglePasswordShow"
|
@click:append-inner="pwFields.togglePasswordShow"
|
||||||
/>
|
/>
|
||||||
<div class="px-2">
|
<div class="px-2">
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-model="accountDetails.advancedOptions.value"
|
v-model="accountDetails.advancedOptions.value"
|
||||||
:label="$tc('user.enable-advanced-content')"
|
:label="$t('user.enable-advanced-content')"
|
||||||
/>
|
/>
|
||||||
<p class="text-caption mt-n4">
|
<p class="text-caption mt-n4">
|
||||||
{{ $tc("user.enable-advanced-content-description") }}
|
{{ $t("user.enable-advanced-content-description") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</v-form>
|
</v-form>
|
||||||
@@ -71,7 +79,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
|
||||||
import { useDark } from "@vueuse/core";
|
import { useDark } from "@vueuse/core";
|
||||||
import { validators } from "~/composables/use-validators";
|
import { validators } from "~/composables/use-validators";
|
||||||
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
|
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
|
||||||
@@ -79,16 +86,19 @@ import { usePasswordField } from "~/composables/use-passwords";
|
|||||||
import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue";
|
import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue";
|
||||||
|
|
||||||
const inputAttrs = {
|
const inputAttrs = {
|
||||||
filled: true,
|
|
||||||
rounded: true,
|
rounded: true,
|
||||||
validateOnBlur: true,
|
validateOnBlur: true,
|
||||||
class: "rounded-lg",
|
class: "rounded-lg pb-1",
|
||||||
|
variant: "solo-filled" as any,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: { UserPasswordStrength },
|
components: { UserPasswordStrength },
|
||||||
layout: "blank",
|
|
||||||
setup() {
|
setup() {
|
||||||
|
definePageMeta({
|
||||||
|
layout: "blank",
|
||||||
|
});
|
||||||
|
|
||||||
const isDark = useDark();
|
const isDark = useDark();
|
||||||
const langDialog = ref(false);
|
const langDialog = ref(false);
|
||||||
|
|
||||||
|
|||||||
@@ -2,108 +2,146 @@
|
|||||||
<v-app dark>
|
<v-app dark>
|
||||||
<TheSnackbar />
|
<TheSnackbar />
|
||||||
|
|
||||||
|
<AppHeader>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
@click.stop="sidebar = !sidebar"
|
||||||
|
>
|
||||||
|
<v-icon> {{ $globals.icons.menu }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</AppHeader>
|
||||||
|
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
v-model="sidebar"
|
v-model="sidebar"
|
||||||
absolute
|
absolute
|
||||||
:top-link="topLinks"
|
:top-link="topLinks"
|
||||||
:secondary-links="cookbookLinks || []"
|
:secondary-links="cookbookLinks || []"
|
||||||
:bottom-links="isAdmin ? bottomLinks : []"
|
:bottom-links="bottomLinks"
|
||||||
>
|
>
|
||||||
<v-menu offset-y nudge-bottom="5" close-delay="50" nudge-right="15">
|
<v-menu
|
||||||
<template #activator="{ on, attrs }">
|
offset-y
|
||||||
<v-btn v-if="isOwnGroup" rounded large class="ml-2 mt-3" v-bind="attrs" v-on="on">
|
nudge-bottom="5"
|
||||||
<v-icon left large color="primary">
|
close-delay="50"
|
||||||
|
nudge-right="15"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-if="isOwnGroup"
|
||||||
|
rounded
|
||||||
|
size="large"
|
||||||
|
class="ml-2 mt-3"
|
||||||
|
v-bind="props"
|
||||||
|
variant="elevated"
|
||||||
|
elevation="2"
|
||||||
|
:color="$vuetify.theme.current.dark ? 'background-lighten-1' : 'background-darken-1'"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
start
|
||||||
|
size="large"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
{{ $globals.icons.createAlt }}
|
{{ $globals.icons.createAlt }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $t("general.create") }}
|
{{ $t("general.create") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<v-list dense class="my-0 py-0">
|
<v-list
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-0 mt-1 py-0"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
<template v-for="(item, index) in createLinks">
|
<template v-for="(item, index) in createLinks">
|
||||||
<div v-if="!item.hide" :key="item.title">
|
<div
|
||||||
<v-divider v-if="item.insertDivider" :key="index" class="mx-2"></v-divider>
|
v-if="!item.hide"
|
||||||
<v-list-item v-if="!item.restricted || isOwnGroup" :key="item.title" :to="item.to" exact>
|
:key="item.title"
|
||||||
<v-list-item-avatar>
|
>
|
||||||
<v-icon>
|
<v-divider
|
||||||
{{ item.icon }}
|
v-if="item.insertDivider"
|
||||||
</v-icon>
|
:key="index"
|
||||||
</v-list-item-avatar>
|
class="mx-2"
|
||||||
<v-list-item-content>
|
/>
|
||||||
<v-list-item-title>
|
<v-list-item
|
||||||
{{ item.title }}
|
v-if="!item.restricted || isOwnGroup"
|
||||||
</v-list-item-title>
|
:key="item.title"
|
||||||
<v-list-item-subtitle v-if="item.subtitle">
|
:to="item.to"
|
||||||
{{ item.subtitle }}
|
exact
|
||||||
</v-list-item-subtitle>
|
class="my-1"
|
||||||
</v-list-item-content>
|
>
|
||||||
</v-list-item>
|
<template #prepend>
|
||||||
|
<v-icon
|
||||||
|
size="40"
|
||||||
|
:icon="item.icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title class="font-weight-medium" style="font-size: small;">
|
||||||
|
{{ item.title }}
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="font-weight-medium" style="font-size: small;">
|
||||||
|
{{ item.subtitle }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
<template #bottom>
|
<template #bottom>
|
||||||
<v-list-item @click.stop="languageDialog = true">
|
<v-list-item @click.stop="languageDialog = true">
|
||||||
<v-list-item-icon>
|
<template #prepend>
|
||||||
<v-icon>{{ $globals.icons.translate }}</v-icon>
|
<v-icon>{{ $globals.icons.translate }}</v-icon>
|
||||||
</v-list-item-icon>
|
</template>
|
||||||
<v-list-item-content>
|
<v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
|
||||||
<v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
|
<LanguageDialog v-model="languageDialog" />
|
||||||
<LanguageDialog v-model="languageDialog" />
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item @click="toggleDark">
|
<v-list-item @click="toggleDark">
|
||||||
<v-list-item-icon>
|
<template #prepend>
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ $vuetify.theme.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
|
{{ $vuetify.theme.current.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-list-item-icon>
|
</template>
|
||||||
<v-list-item-title>
|
<v-list-item-title>
|
||||||
{{ $vuetify.theme.dark ? $t("settings.theme.light-mode") : $t("settings.theme.dark-mode") }}
|
{{ $vuetify.theme.current.dark ? $t("settings.theme.light-mode") : $t("settings.theme.dark-mode") }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
</AppSidebar>
|
</AppSidebar>
|
||||||
|
<v-main class="pt-16">
|
||||||
<AppHeader>
|
|
||||||
<v-btn icon @click.stop="sidebar = !sidebar">
|
|
||||||
<v-icon> {{ $globals.icons.menu }}</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</AppHeader>
|
|
||||||
<v-main>
|
|
||||||
<v-scroll-x-transition>
|
<v-scroll-x-transition>
|
||||||
<Nuxt />
|
<div>
|
||||||
|
<NuxtPage />
|
||||||
|
</div>
|
||||||
</v-scroll-x-transition>
|
</v-scroll-x-transition>
|
||||||
</v-main>
|
</v-main>
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, onMounted, ref, useContext, useRoute } from "@nuxtjs/composition-api";
|
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
|
import type { SideBarLink } from "~/types/application-types";
|
||||||
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
|
|
||||||
import { SideBarLink } from "~/types/application-types";
|
|
||||||
import LanguageDialog from "~/components/global/LanguageDialog.vue";
|
|
||||||
import TheSnackbar from "@/components/Layout/LayoutParts/TheSnackbar.vue";
|
|
||||||
import { useAppInfo } from "~/composables/api";
|
import { useAppInfo } from "~/composables/api";
|
||||||
import { useCookbooks, usePublicCookbooks } from "~/composables/use-group-cookbooks";
|
import { useCookbooks, usePublicCookbooks } from "~/composables/use-group-cookbooks";
|
||||||
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
||||||
import { useHouseholdStore, usePublicHouseholdStore } from "~/composables/store/use-household-store";
|
import { useHouseholdStore, usePublicHouseholdStore } from "~/composables/store/use-household-store";
|
||||||
import { useToggleDarkMode } from "~/composables/use-utils";
|
import { useToggleDarkMode } from "~/composables/use-utils";
|
||||||
import { ReadCookBook } from "~/lib/api/types/cookbook";
|
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
|
|
||||||
|
export default defineNuxtComponent({
|
||||||
export default defineComponent({
|
|
||||||
components: { AppHeader, AppSidebar, LanguageDialog, TheSnackbar },
|
|
||||||
setup() {
|
setup() {
|
||||||
const { $globals, $auth, $vuetify, i18n } = useContext();
|
const i18n = useI18n();
|
||||||
|
const { $globals, $vuetify } = useNuxtApp();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const isAdmin = computed(() => $auth.user?.admin);
|
const isAdmin = computed(() => $auth.user.value?.admin);
|
||||||
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 { cookbooks } = isOwnGroup.value ? useCookbooks() : usePublicCookbooks(groupSlug.value || "");
|
|
||||||
|
const loggedInCookbooks = useCookbooks();
|
||||||
|
const publicCookbooks = usePublicCookbooks(groupSlug.value || "");
|
||||||
|
const cookbooks = computed(() =>
|
||||||
|
isOwnGroup.value ? loggedInCookbooks.cookbooks.value : publicCookbooks.cookbooks.value,
|
||||||
|
);
|
||||||
|
|
||||||
const cookbookPreferences = useCookbookPreferences();
|
const cookbookPreferences = useCookbookPreferences();
|
||||||
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value || "");
|
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value || "");
|
||||||
|
|
||||||
@@ -121,10 +159,9 @@ export default defineComponent({
|
|||||||
|
|
||||||
const languageDialog = ref<boolean>(false);
|
const languageDialog = ref<boolean>(false);
|
||||||
|
|
||||||
const sidebar = ref<boolean | null>(null);
|
const sidebar = ref<boolean>(false);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
sidebar.value = !$vuetify.breakpoint.md;
|
sidebar.value = $vuetify.display.mdAndUp.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
function cookbookAsLink(cookbook: ReadCookBook): SideBarLink {
|
function cookbookAsLink(cookbook: ReadCookBook): SideBarLink {
|
||||||
@@ -137,16 +174,17 @@ export default defineComponent({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUserHouseholdId = computed(() => $auth.user?.householdId);
|
const currentUserHouseholdId = computed(() => $auth.user.value?.householdId);
|
||||||
const cookbookLinks = computed<SideBarLink[]>(() => {
|
const cookbookLinks = computed<SideBarLink[]>(() => {
|
||||||
if (!cookbooks.value) {
|
if (!cookbooks.value || !households.value) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
cookbooks.value.sort((a, b) => (a.position || 0) - (b.position || 0));
|
|
||||||
|
const sortedCookbooks = [...cookbooks.value].sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||||
|
|
||||||
const ownLinks: SideBarLink[] = [];
|
const ownLinks: SideBarLink[] = [];
|
||||||
const links: SideBarLink[] = [];
|
const links: SideBarLink[] = [];
|
||||||
const cookbooksByHousehold = cookbooks.value.reduce((acc, cookbook) => {
|
const cookbooksByHousehold = sortedCookbooks.reduce((acc, cookbook) => {
|
||||||
const householdName = householdsById.value[cookbook.householdId]?.name || "";
|
const householdName = householdsById.value[cookbook.householdId]?.name || "";
|
||||||
if (!acc[householdName]) {
|
if (!acc[householdName]) {
|
||||||
acc[householdName] = [];
|
acc[householdName] = [];
|
||||||
@@ -156,9 +194,13 @@ export default defineComponent({
|
|||||||
}, {} as Record<string, ReadCookBook[]>);
|
}, {} as Record<string, ReadCookBook[]>);
|
||||||
|
|
||||||
Object.entries(cookbooksByHousehold).forEach(([householdName, cookbooks]) => {
|
Object.entries(cookbooksByHousehold).forEach(([householdName, cookbooks]) => {
|
||||||
|
if (!cookbooks.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (cookbooks[0].householdId === currentUserHouseholdId.value) {
|
if (cookbooks[0].householdId === currentUserHouseholdId.value) {
|
||||||
ownLinks.push(...cookbooks.map(cookbookAsLink));
|
ownLinks.push(...cookbooks.map(cookbookAsLink));
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
links.push({
|
links.push({
|
||||||
key: householdName,
|
key: householdName,
|
||||||
icon: $globals.icons.book,
|
icon: $globals.icons.book,
|
||||||
@@ -170,19 +212,20 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
links.sort((a, b) => a.title.localeCompare(b.title));
|
links.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
if ($auth.user && cookbookPreferences.value.hideOtherHouseholds) {
|
if ($auth.user.value && cookbookPreferences.value.hideOtherHouseholds) {
|
||||||
return ownLinks;
|
return ownLinks;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
return [...ownLinks, ...links];
|
return [...ownLinks, ...links];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const createLinks = computed<SideBarLink[]>(() => [
|
const createLinks = computed(() => [
|
||||||
{
|
{
|
||||||
insertDivider: false,
|
insertDivider: false,
|
||||||
icon: $globals.icons.link,
|
icon: $globals.icons.link,
|
||||||
title: i18n.tc("general.import"),
|
title: i18n.t("general.import"),
|
||||||
subtitle: i18n.tc("new-recipe.import-by-url"),
|
subtitle: i18n.t("new-recipe.import-by-url"),
|
||||||
to: `/g/${groupSlug.value}/r/create/url`,
|
to: `/g/${groupSlug.value}/r/create/url`,
|
||||||
restricted: true,
|
restricted: true,
|
||||||
hide: false,
|
hide: false,
|
||||||
@@ -190,8 +233,8 @@ export default defineComponent({
|
|||||||
{
|
{
|
||||||
insertDivider: false,
|
insertDivider: false,
|
||||||
icon: $globals.icons.fileImage,
|
icon: $globals.icons.fileImage,
|
||||||
title: i18n.tc("recipe.create-from-image"),
|
title: i18n.t("recipe.create-from-image"),
|
||||||
subtitle: i18n.tc("recipe.create-recipe-from-an-image"),
|
subtitle: i18n.t("recipe.create-recipe-from-an-image"),
|
||||||
to: `/g/${groupSlug.value}/r/create/image`,
|
to: `/g/${groupSlug.value}/r/create/image`,
|
||||||
restricted: true,
|
restricted: true,
|
||||||
hide: !showImageImport.value,
|
hide: !showImageImport.value,
|
||||||
@@ -199,81 +242,85 @@ export default defineComponent({
|
|||||||
{
|
{
|
||||||
insertDivider: true,
|
insertDivider: true,
|
||||||
icon: $globals.icons.edit,
|
icon: $globals.icons.edit,
|
||||||
title: i18n.tc("general.create"),
|
title: i18n.t("general.create"),
|
||||||
subtitle: i18n.tc("new-recipe.create-manually"),
|
subtitle: i18n.t("new-recipe.create-manually"),
|
||||||
to: `/g/${groupSlug.value}/r/create/new`,
|
to: `/g/${groupSlug.value}/r/create/new`,
|
||||||
restricted: true,
|
restricted: true,
|
||||||
hide: false,
|
hide: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bottomLinks = computed<SideBarLink[]>(() => [
|
const bottomLinks = computed<SideBarLink[]>(() =>
|
||||||
{
|
isAdmin.value
|
||||||
icon: $globals.icons.cog,
|
? [
|
||||||
title: i18n.tc("general.settings"),
|
{
|
||||||
to: "/admin/site-settings",
|
icon: $globals.icons.cog,
|
||||||
restricted: true,
|
title: i18n.t("general.settings"),
|
||||||
},
|
to: "/admin/site-settings",
|
||||||
]);
|
restricted: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
const topLinks = computed<SideBarLink[]>(() => [
|
const topLinks = computed<SideBarLink[]>(() => [
|
||||||
{
|
{
|
||||||
icon: $globals.icons.silverwareForkKnife,
|
icon: $globals.icons.silverwareForkKnife,
|
||||||
to: `/g/${groupSlug.value}`,
|
to: `/g/${groupSlug.value}`,
|
||||||
title: i18n.tc("general.recipes"),
|
title: i18n.t("general.recipes"),
|
||||||
restricted: false,
|
restricted: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.search,
|
icon: $globals.icons.search,
|
||||||
to: `/g/${groupSlug.value}/recipes/finder`,
|
to: `/g/${groupSlug.value}/recipes/finder`,
|
||||||
title: i18n.tc("recipe-finder.recipe-finder"),
|
title: i18n.t("recipe-finder.recipe-finder"),
|
||||||
restricted: false,
|
restricted: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.calendarMultiselect,
|
icon: $globals.icons.calendarMultiselect,
|
||||||
title: i18n.tc("meal-plan.meal-planner"),
|
title: i18n.t("meal-plan.meal-planner"),
|
||||||
to: "/household/mealplan/planner/view",
|
to: "/household/mealplan/planner/view",
|
||||||
restricted: true,
|
restricted: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.formatListCheck,
|
icon: $globals.icons.formatListCheck,
|
||||||
title: i18n.tc("shopping-list.shopping-lists"),
|
title: i18n.t("shopping-list.shopping-lists"),
|
||||||
to: "/shopping-lists",
|
to: "/shopping-lists",
|
||||||
restricted: true,
|
restricted: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.timelineText,
|
icon: $globals.icons.timelineText,
|
||||||
title: i18n.tc("recipe.timeline"),
|
title: i18n.t("recipe.timeline"),
|
||||||
to: `/g/${groupSlug.value}/recipes/timeline`,
|
to: `/g/${groupSlug.value}/recipes/timeline`,
|
||||||
restricted: true,
|
restricted: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.book,
|
icon: $globals.icons.book,
|
||||||
to: `/g/${groupSlug.value}/cookbooks`,
|
to: `/g/${groupSlug.value}/cookbooks`,
|
||||||
title: i18n.tc("cookbook.cookbooks"),
|
title: i18n.t("cookbook.cookbooks"),
|
||||||
restricted: true,
|
restricted: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.organizers,
|
icon: $globals.icons.organizers,
|
||||||
title: i18n.tc("general.organizers"),
|
title: i18n.t("general.organizers"),
|
||||||
restricted: true,
|
restricted: true,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
icon: $globals.icons.categories,
|
icon: $globals.icons.categories,
|
||||||
to: `/g/${groupSlug.value}/recipes/categories`,
|
to: `/g/${groupSlug.value}/recipes/categories`,
|
||||||
title: i18n.tc("sidebar.categories"),
|
title: i18n.t("sidebar.categories"),
|
||||||
restricted: true,
|
restricted: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.tags,
|
icon: $globals.icons.tags,
|
||||||
to: `/g/${groupSlug.value}/recipes/tags`,
|
to: `/g/${groupSlug.value}/recipes/tags`,
|
||||||
title: i18n.tc("sidebar.tags"),
|
title: i18n.t("sidebar.tags"),
|
||||||
restricted: true,
|
restricted: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.potSteam,
|
icon: $globals.icons.potSteam,
|
||||||
to: `/g/${groupSlug.value}/recipes/tools`,
|
to: `/g/${groupSlug.value}/recipes/tools`,
|
||||||
title: i18n.tc("tool.tools"),
|
title: i18n.t("tool.tools"),
|
||||||
restricted: true,
|
restricted: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -286,7 +333,6 @@ export default defineComponent({
|
|||||||
createLinks,
|
createLinks,
|
||||||
bottomLinks,
|
bottomLinks,
|
||||||
topLinks,
|
topLinks,
|
||||||
isAdmin,
|
|
||||||
isOwnGroup,
|
isOwnGroup,
|
||||||
languageDialog,
|
languageDialog,
|
||||||
toggleDark,
|
toggleDark,
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-footer color="primary" padless app>
|
<v-footer
|
||||||
<v-row justify="center" align="center" dense no-gutters>
|
color="primary"
|
||||||
<v-col class="py-2 text-center white--text" cols="12">
|
padless
|
||||||
<v-btn color="white" icon href="https://github.com/hay-kot/mealie" target="_blank">
|
app
|
||||||
|
>
|
||||||
|
<v-row
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
dense
|
||||||
|
no-gutters
|
||||||
|
>
|
||||||
|
<v-col
|
||||||
|
class="py-2 text-center white--text"
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
color="white"
|
||||||
|
icon
|
||||||
|
href="https://github.com/hay-kot/mealie"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ $globals.icons.github }}
|
{{ $globals.icons.github }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
@@ -14,9 +31,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
export default defineNuxtComponent({
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
setup() {
|
setup() {
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,46 +1,83 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-app-bar clipped-left dense app color="primary" dark class="d-print-none">
|
<v-app-bar
|
||||||
|
clipped-left
|
||||||
|
density="compact"
|
||||||
|
app
|
||||||
|
color="primary"
|
||||||
|
dark
|
||||||
|
class="d-print-none"
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
<router-link :to="routerLink">
|
<router-link :to="routerLink">
|
||||||
<v-btn icon>
|
<v-btn
|
||||||
|
icon
|
||||||
|
color="white"
|
||||||
|
>
|
||||||
<v-icon size="40"> {{ $globals.icons.primary }} </v-icon>
|
<v-icon size="40"> {{ $globals.icons.primary }} </v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<div btn class="pl-2">
|
<div
|
||||||
<v-toolbar-title style="cursor: pointer" @click="$router.push(routerLink)"> Mealie </v-toolbar-title>
|
btn
|
||||||
|
class="pl-2"
|
||||||
|
>
|
||||||
|
<v-toolbar-title
|
||||||
|
style="cursor: pointer"
|
||||||
|
@click="$router.push(routerLink)"
|
||||||
|
>
|
||||||
|
Mealie
|
||||||
|
</v-toolbar-title>
|
||||||
</div>
|
</div>
|
||||||
<RecipeDialogSearch ref="domSearchDialog" />
|
<RecipeDialogSearch ref="domSearchDialog" />
|
||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer />
|
||||||
|
|
||||||
<!-- Navigation Menu -->
|
<!-- Navigation Menu -->
|
||||||
<template v-if="menu">
|
<template v-if="menu">
|
||||||
<div v-if="!$vuetify.breakpoint.xs" style="max-width: 500px" @click="activateSearch">
|
<v-responsive
|
||||||
|
v-if="!xs"
|
||||||
|
max-width="250"
|
||||||
|
@click="activateSearch"
|
||||||
|
>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
readonly
|
readonly
|
||||||
class="mt-6 rounded-xl"
|
class="mt-1"
|
||||||
rounded
|
rounded
|
||||||
dark
|
variant="solo-filled"
|
||||||
solo
|
density="compact"
|
||||||
dense
|
|
||||||
flat
|
flat
|
||||||
:prepend-inner-icon="$globals.icons.search"
|
:prepend-inner-icon="$globals.icons.search"
|
||||||
background-color="primary darken-1"
|
bg-color="primary-darken-1"
|
||||||
color="white"
|
|
||||||
:placeholder="$t('search.search-hint')"
|
:placeholder="$t('search.search-hint')"
|
||||||
>
|
/>
|
||||||
</v-text-field>
|
</v-responsive>
|
||||||
</div>
|
<v-btn
|
||||||
<v-btn v-else icon @click="activateSearch">
|
v-else
|
||||||
|
icon
|
||||||
|
@click="activateSearch"
|
||||||
|
>
|
||||||
<v-icon> {{ $globals.icons.search }}</v-icon>
|
<v-icon> {{ $globals.icons.search }}</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn v-if="loggedIn" :text="$vuetify.breakpoint.smAndUp" :icon="$vuetify.breakpoint.xs" @click="logout()">
|
<v-btn
|
||||||
<v-icon :left="$vuetify.breakpoint.smAndUp">{{ $globals.icons.logout }}</v-icon>
|
v-if="loggedIn"
|
||||||
{{ $vuetify.breakpoint.smAndUp ? $t("user.logout") : "" }}
|
:variant="smAndUp ? 'text' : undefined"
|
||||||
|
:icon="xs"
|
||||||
|
@click="logout()"
|
||||||
|
>
|
||||||
|
<v-icon :start="smAndUp">
|
||||||
|
{{ $globals.icons.logout }}
|
||||||
|
</v-icon>
|
||||||
|
{{ smAndUp ? $t("user.logout") : "" }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn v-else text nuxt to="/login">
|
<v-btn
|
||||||
<v-icon left>{{ $globals.icons.user }}</v-icon>
|
v-else
|
||||||
|
variant="text"
|
||||||
|
nuxt
|
||||||
|
to="/login"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
|
{{ $globals.icons.user }}
|
||||||
|
</v-icon>
|
||||||
{{ $t("user.login") }}
|
{{ $t("user.login") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
@@ -48,11 +85,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, useContext, useRoute, useRouter } from "@nuxtjs/composition-api";
|
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue";
|
import RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: { RecipeDialogSearch },
|
components: { RecipeDialogSearch },
|
||||||
props: {
|
props: {
|
||||||
menu: {
|
menu: {
|
||||||
@@ -61,11 +97,11 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { $auth } = useContext();
|
const $auth = useMealieAuth();
|
||||||
const { loggedIn } = useLoggedInState();
|
const { loggedIn } = useLoggedInState();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
const { xs, smAndUp } = useDisplay();
|
||||||
|
|
||||||
const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/");
|
const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/");
|
||||||
const domSearchDialog = ref<InstanceType<typeof RecipeDialogSearch> | null>(null);
|
const domSearchDialog = ref<InstanceType<typeof RecipeDialogSearch> | null>(null);
|
||||||
@@ -91,7 +127,12 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await $auth.logout().then(() => router.push("/login?direct=1"))
|
try {
|
||||||
|
await $auth.signOut({ callbackUrl: "/login?direct=1" });
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -100,7 +141,14 @@ export default defineComponent({
|
|||||||
routerLink,
|
routerLink,
|
||||||
loggedIn,
|
loggedIn,
|
||||||
logout,
|
logout,
|
||||||
|
xs, smAndUp,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-toolbar {
|
||||||
|
z-index: 1010 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,66 +1,52 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-navigation-drawer v-model="drawer" class="d-flex flex-column d-print-none" clipped app width="240px">
|
<v-navigation-drawer v-model="showDrawer" class="d-flex flex-column d-print-none position-fixed">
|
||||||
<!-- User Profile -->
|
<!-- User Profile -->
|
||||||
<template v-if="loggedIn">
|
<template v-if="loggedIn">
|
||||||
<v-list-item two-line :to="userProfileLink" exact>
|
<v-list-item lines="two" :to="userProfileLink" exact>
|
||||||
<UserAvatar list :user-id="$auth.user.id" :tooltip="false" />
|
<div class="d-flex align-center ga-2">
|
||||||
|
<UserAvatar list :user-id="sessionUser.id" :tooltip="false" />
|
||||||
|
|
||||||
<v-list-item-content>
|
<div class="d-flex flex-column justify-start">
|
||||||
<v-list-item-title class="pr-2"> {{ $auth.user.fullName }}</v-list-item-title>
|
<v-list-item-title class="pr-2 pl-1">
|
||||||
<v-list-item-subtitle>
|
{{ sessionUser.fullName }}
|
||||||
<v-btn v-if="isOwnGroup" class="px-2 pa-0" text :to="userFavoritesLink" small>
|
</v-list-item-title>
|
||||||
<v-icon left small>
|
<v-list-item-subtitle class="opacity-100">
|
||||||
{{ $globals.icons.heart }}
|
<v-btn v-if="isOwnGroup" class="px-2 pa-0" variant="text" :to="userFavoritesLink" size="small">
|
||||||
</v-icon>
|
<v-icon start size="small">
|
||||||
{{ $t("user.favorite-recipes") }}
|
{{ $globals.icons.heart }}
|
||||||
</v-btn>
|
</v-icon>
|
||||||
</v-list-item-subtitle>
|
{{ $t("user.favorite-recipes") }}
|
||||||
</v-list-item-content>
|
</v-btn>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-divider></v-divider>
|
<v-divider />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<slot></slot>
|
<slot />
|
||||||
|
|
||||||
<!-- Primary Links -->
|
<!-- Primary Links -->
|
||||||
<template v-if="topLink">
|
<template v-if="topLink">
|
||||||
<v-list nav dense>
|
<v-list v-model:selected="secondarySelected" nav density="comfortable" color="primary">
|
||||||
<template v-for="nav in topLink">
|
<template v-for="nav in topLink">
|
||||||
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
||||||
<!-- Multi Items -->
|
<!-- Multi Items -->
|
||||||
<v-list-group
|
<v-list-group v-if="nav.children" :key="(nav.key || nav.title) + 'multi-item'"
|
||||||
v-if="nav.children"
|
v-model="dropDowns[nav.title]" color="primary" :prepend-icon="nav.icon" :fluid="true">
|
||||||
:key="(nav.key || nav.title) + 'multi-item'"
|
<template #activator="{ props }">
|
||||||
v-model="dropDowns[nav.title]"
|
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
|
||||||
color="primary"
|
|
||||||
:prepend-icon="nav.icon"
|
|
||||||
>
|
|
||||||
<template #activator>
|
|
||||||
<v-list-item-title>{{ nav.title }}</v-list-item-title>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to" class="ml-2">
|
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to"
|
||||||
<v-list-item-icon>
|
:prepend-icon="child.icon" :title="child.title" class="ml-4" />
|
||||||
<v-icon>{{ child.icon }}</v-icon>
|
|
||||||
</v-list-item-icon>
|
|
||||||
<v-list-item-title>{{ child.title }}</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list-group>
|
</v-list-group>
|
||||||
|
|
||||||
<!-- Single Item -->
|
<!-- Single Item -->
|
||||||
<v-list-item-group
|
<template v-else>
|
||||||
v-else
|
<v-list-item :key="(nav.key || nav.title) + 'single-item'" exact link :to="nav.to"
|
||||||
:key="(nav.key || nav.title) + 'single-item'"
|
:prepend-icon="nav.icon" :title="nav.title" />
|
||||||
v-model="secondarySelected"
|
</template>
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
<v-list-item exact link :to="nav.to">
|
|
||||||
<v-list-item-icon>
|
|
||||||
<v-icon>{{ nav.icon }}</v-icon>
|
|
||||||
</v-list-item-icon>
|
|
||||||
<v-list-item-title>{{ nav.title }}</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list-item-group>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-list>
|
</v-list>
|
||||||
@@ -68,39 +54,28 @@
|
|||||||
|
|
||||||
<!-- Secondary Links -->
|
<!-- Secondary Links -->
|
||||||
<template v-if="secondaryLinks.length > 0">
|
<template v-if="secondaryLinks.length > 0">
|
||||||
<v-divider class="mt-2"></v-divider>
|
<v-divider class="mt-2" />
|
||||||
<v-list nav dense exact>
|
<v-list v-model:selected="secondarySelected" nav density="compact" exact>
|
||||||
<template v-for="nav in secondaryLinks">
|
<template v-for="nav in secondaryLinks">
|
||||||
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
||||||
<!-- Multi Items -->
|
<!-- Multi Items -->
|
||||||
<v-list-group
|
<v-list-group v-if="nav.children" :key="(nav.key || nav.title) + 'multi-item'"
|
||||||
v-if="nav.children"
|
v-model="dropDowns[nav.title]" color="primary" :prepend-icon="nav.icon" fluid>
|
||||||
:key="(nav.key || nav.title) + 'multi-item'"
|
<template #activator="{ props }">
|
||||||
v-model="dropDowns[nav.title]"
|
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
|
||||||
color="primary"
|
|
||||||
:prepend-icon="nav.icon"
|
|
||||||
>
|
|
||||||
<template #activator>
|
|
||||||
<v-list-item-title>{{ nav.title }}</v-list-item-title>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to" class="ml-2">
|
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to"
|
||||||
<v-list-item-icon>
|
class="ml-2" :prepend-icon="child.icon" :title="child.title" />
|
||||||
<v-icon>{{ child.icon }}</v-icon>
|
|
||||||
</v-list-item-icon>
|
|
||||||
<v-list-item-title>{{ child.title }}</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list-group>
|
</v-list-group>
|
||||||
|
|
||||||
<!-- Single Item -->
|
<!-- Single Item -->
|
||||||
<v-list-item-group v-else :key="(nav.key || nav.title) + 'single-item'" v-model="secondarySelected" color="primary">
|
<v-list-item v-else :key="(nav.key || nav.title) + 'single-item'" exact link :to="nav.to">
|
||||||
<v-list-item exact link :to="nav.to">
|
<template #prepend>
|
||||||
<v-list-item-icon>
|
<v-icon>{{ nav.icon }}</v-icon>
|
||||||
<v-icon>{{ nav.icon }}</v-icon>
|
</template>
|
||||||
</v-list-item-icon>
|
<v-list-item-title>{{ nav.title }}</v-list-item-title>
|
||||||
<v-list-item-title>{{ nav.title }}</v-list-item-title>
|
</v-list-item>
|
||||||
</v-list-item>
|
|
||||||
</v-list-item-group>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-list>
|
</v-list>
|
||||||
@@ -108,46 +83,39 @@
|
|||||||
|
|
||||||
<!-- Bottom Navigation Links -->
|
<!-- Bottom Navigation Links -->
|
||||||
<template v-if="bottomLinks" #append>
|
<template v-if="bottomLinks" #append>
|
||||||
<v-list nav dense>
|
<v-list v-model:selected="bottomSelected" nav density="compact">
|
||||||
<v-list-item-group v-model="bottomSelected" color="primary">
|
<template v-for="nav in bottomLinks">
|
||||||
<template v-for="nav in bottomLinks">
|
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
||||||
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
<v-list-item :key="nav.key || nav.title" exact link :to="nav.to" :href="nav.href"
|
||||||
<v-list-item
|
:target="nav.href ? '_blank' : null">
|
||||||
:key="nav.key || nav.title"
|
<template #prepend>
|
||||||
exact
|
<v-icon>{{ nav.icon }}</v-icon>
|
||||||
link
|
</template>
|
||||||
:to="nav.to || null"
|
<v-list-item-title>{{ nav.title }}</v-list-item-title>
|
||||||
:href="nav.href || null"
|
</v-list-item>
|
||||||
:target="nav.href ? '_blank' : null"
|
</div>
|
||||||
>
|
</template>
|
||||||
<v-list-item-icon>
|
<slot name="bottom" />
|
||||||
<v-icon>{{ nav.icon }}</v-icon>
|
|
||||||
</v-list-item-icon>
|
|
||||||
<v-list-item-title>{{ nav.title }}</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-list-item-group>
|
|
||||||
<slot name="bottom"></slot>
|
|
||||||
</v-list>
|
</v-list>
|
||||||
</template>
|
</template>
|
||||||
</v-navigation-drawer>
|
</v-navigation-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, reactive, toRefs, useContext, watch } from "@nuxtjs/composition-api";
|
import { useWindowSize } from "@vueuse/core";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { SidebarLinks } from "~/types/application-types";
|
import type { SidebarLinks } from "~/types/application-types";
|
||||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
UserAvatar,
|
UserAvatar,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: null,
|
required: false,
|
||||||
|
default: false,
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -165,31 +133,16 @@ export default defineComponent({
|
|||||||
bottomLinks: {
|
bottomLinks: {
|
||||||
type: Array as () => SidebarLinks,
|
type: Array as () => SidebarLinks,
|
||||||
required: false,
|
required: false,
|
||||||
default: null,
|
default: () => ([]),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
// V-Model Support
|
const $auth = useMealieAuth();
|
||||||
const drawer = computed({
|
|
||||||
get: () => {
|
|
||||||
return props.value;
|
|
||||||
},
|
|
||||||
set: (val) => {
|
|
||||||
if (window.innerWidth < 760 && state.hasOpenedBefore === false) {
|
|
||||||
state.hasOpenedBefore = true;
|
|
||||||
val = false;
|
|
||||||
context.emit("input", val);
|
|
||||||
} else {
|
|
||||||
context.emit("input", val);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { $auth } = useContext();
|
|
||||||
const { loggedIn, isOwnGroup } = useLoggedInState();
|
const { loggedIn, isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const userFavoritesLink = computed(() => $auth.user ? `/user/${$auth.user.id}/favorites` : undefined);
|
const userFavoritesLink = computed(() => $auth.user.value ? `/user/${$auth.user.value.id}/favorites` : undefined);
|
||||||
const userProfileLink = computed(() => $auth.user ? "/user/profile" : undefined);
|
const userProfileLink = computed(() => $auth.user.value ? "/user/profile" : undefined);
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
dropDowns: {} as Record<string, boolean>,
|
dropDowns: {} as Record<string, boolean>,
|
||||||
@@ -198,12 +151,31 @@ export default defineComponent({
|
|||||||
bottomSelected: null as string[] | null,
|
bottomSelected: null as string[] | null,
|
||||||
hasOpenedBefore: false as boolean,
|
hasOpenedBefore: false as boolean,
|
||||||
});
|
});
|
||||||
|
// model to control the drawer
|
||||||
|
const showDrawer = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: value => context.emit("update:modelValue", value),
|
||||||
|
});
|
||||||
|
watch(showDrawer, () => {
|
||||||
|
if (window.innerWidth < 760 && state.hasOpenedBefore === false) {
|
||||||
|
state.hasOpenedBefore = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { width: wWidth } = useWindowSize();
|
||||||
|
watch(wWidth, (w) => {
|
||||||
|
if (w > 760) {
|
||||||
|
showDrawer.value = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
showDrawer.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || []), ...(props.bottomLinks || [])]);
|
const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || []), ...(props.bottomLinks || [])]);
|
||||||
function initDropdowns() {
|
function initDropdowns() {
|
||||||
allLinks.value.forEach((link) => {
|
allLinks.value.forEach((link) => {
|
||||||
state.dropDowns[link.title] = link.childrenStartExpanded || false;
|
state.dropDowns[link.title] = link.childrenStartExpanded || false;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
watch(
|
watch(
|
||||||
() => allLinks,
|
() => allLinks,
|
||||||
@@ -212,22 +184,23 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
deep: true,
|
deep: true,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...toRefs(state),
|
...toRefs(state),
|
||||||
userFavoritesLink,
|
userFavoritesLink,
|
||||||
userProfileLink,
|
userProfileLink,
|
||||||
drawer,
|
showDrawer,
|
||||||
loggedIn,
|
loggedIn,
|
||||||
isOwnGroup,
|
isOwnGroup,
|
||||||
|
sessionUser: $auth.user,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
@media print {
|
@media print {
|
||||||
.no-print {
|
.no-print {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -1,57 +1,75 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<v-snackbar v-model="toastAlert.open" top :color="toastAlert.color" timeout="2000" @input="toastAlert.open = false">
|
<v-snackbar
|
||||||
<v-icon dark left>
|
v-model="toastAlert.open"
|
||||||
{{ icon }}
|
location="top"
|
||||||
</v-icon>
|
:color="toastAlert.color"
|
||||||
|
timeout="2000"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
v-if="icon"
|
||||||
|
dark
|
||||||
|
start
|
||||||
|
:icon="icon"
|
||||||
|
/>
|
||||||
|
|
||||||
{{ toastAlert.title }}
|
{{ toastAlert.title }}
|
||||||
{{ toastAlert.text }}
|
{{ toastAlert.text }}
|
||||||
|
|
||||||
<template #action="{ attrs }">
|
<template #actions>
|
||||||
<v-btn text v-bind="attrs" @click="toastAlert.open = false"> {{ $t('general.close') }} </v-btn>
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
@click="toastAlert.open = false"
|
||||||
|
>
|
||||||
|
{{ $t('general.close') }}
|
||||||
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
</v-snackbar>
|
</v-snackbar>
|
||||||
<v-snackbar
|
<v-snackbar
|
||||||
|
v-model="toastLoading.open"
|
||||||
content-class="py-2"
|
content-class="py-2"
|
||||||
dense
|
density="compact"
|
||||||
bottom
|
location="bottom"
|
||||||
right
|
|
||||||
:value="toastLoading.open"
|
|
||||||
:timeout="-1"
|
:timeout="-1"
|
||||||
:color="toastLoading.color"
|
:color="toastLoading.color"
|
||||||
@input="toastLoading.open = false"
|
|
||||||
>
|
>
|
||||||
<div class="d-flex flex-column align-center justify-start" @click="toastLoading.open = false">
|
<div
|
||||||
|
class="d-flex flex-column align-center justify-start"
|
||||||
|
@click="toastLoading.open = false"
|
||||||
|
>
|
||||||
<div class="mb-2 mt-0 text-subtitle-1 text-center">
|
<div class="mb-2 mt-0 text-subtitle-1 text-center">
|
||||||
{{ toastLoading.text }}
|
{{ toastLoading.text }}
|
||||||
</div>
|
</div>
|
||||||
<v-progress-linear indeterminate color="white darken-2"></v-progress-linear>
|
<v-progress-linear
|
||||||
|
indeterminate
|
||||||
|
color="white-darken-2"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</v-snackbar>
|
</v-snackbar>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
import { useNuxtApp } from "#app";
|
||||||
import { toastAlert, toastLoading } from "~/composables/use-toast";
|
import { toastAlert, toastLoading } from "~/composables/use-toast";
|
||||||
|
|
||||||
export default defineComponent({
|
export default {
|
||||||
setup() {
|
setup() {
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
const icon = computed(() => {
|
const icon = computed(() => {
|
||||||
switch (toastAlert.color) {
|
switch (toastAlert.color) {
|
||||||
case "error":
|
case "error":
|
||||||
return "mdi-alert";
|
return $globals.icons.alertOutline;
|
||||||
case "success":
|
case "success":
|
||||||
return "mdi-check-bold";
|
return $globals.icons.checkBold;
|
||||||
case "info":
|
case "info":
|
||||||
return "mdi-information-outline";
|
return $globals.icons.informationOutline;
|
||||||
default:
|
default:
|
||||||
return "mdi-alert";
|
return $globals.icons.alertOutline;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { icon, toastAlert, toastLoading };
|
return { icon, toastAlert, toastLoading };
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div scoped-slot></div>
|
<div scoped-slot />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renderless component that only renders if the user is logged in.
|
* Renderless component that only renders if the user is logged in.
|
||||||
* and has advanced options toggled.
|
* and has advanced options toggled.
|
||||||
*/
|
*/
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
setup(_, ctx) {
|
setup(_, ctx) {
|
||||||
const { $auth } = useContext();
|
const $auth = useMealieAuth();
|
||||||
|
|
||||||
const r = $auth?.user?.advanced || false;
|
const r = $auth.user.value?.advanced || false;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
return r ? ctx.slots.default?.() : null;
|
return r ? ctx.slots.default?.() : null;
|
||||||
|
|||||||
@@ -2,32 +2,33 @@
|
|||||||
<v-tooltip
|
<v-tooltip
|
||||||
ref="copyToolTip"
|
ref="copyToolTip"
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:color="copied? 'success lighten-1' : 'red lighten-1'"
|
:color="copied? 'success-lighten-1' : 'red-lighten-1'"
|
||||||
top
|
top
|
||||||
:open-on-hover="false"
|
:open-on-hover="false"
|
||||||
:open-on-click="true"
|
:open-on-click="true"
|
||||||
close-delay="500"
|
close-delay="500"
|
||||||
transition="slide-y-transition"
|
transition="slide-y-transition"
|
||||||
>
|
>
|
||||||
<template #activator="{ on }">
|
<template #activator="{ props }">
|
||||||
<v-btn
|
<v-btn
|
||||||
|
variant="flat"
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
:color="color"
|
:color="color"
|
||||||
retain-focus-on-click
|
retain-focus-on-click
|
||||||
:class="btnClass"
|
:class="btnClass"
|
||||||
:disabled="copyText !== '' ? false : true"
|
:disabled="copyText !== '' ? false : true"
|
||||||
@click="
|
v-bind="props"
|
||||||
on.click;
|
@click="textToClipboard()"
|
||||||
textToClipboard();
|
|
||||||
"
|
|
||||||
@blur="on.blur"
|
|
||||||
>
|
>
|
||||||
<v-icon>{{ $globals.icons.contentCopy }}</v-icon>
|
<v-icon>{{ $globals.icons.contentCopy }}</v-icon>
|
||||||
{{ icon ? "" : $t("general.copy") }}
|
{{ icon ? "" : $t("general.copy") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<span>
|
<span>
|
||||||
<v-icon left dark>
|
<v-icon
|
||||||
|
start
|
||||||
|
dark
|
||||||
|
>
|
||||||
{{ $globals.icons.clipboardCheck }}
|
{{ $globals.icons.clipboardCheck }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<slot v-if="!isSupported"> {{ $t("general.your-browser-does-not-support-clipboard") }} </slot>
|
<slot v-if="!isSupported"> {{ $t("general.your-browser-does-not-support-clipboard") }} </slot>
|
||||||
@@ -37,11 +38,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useClipboard } from "@vueuse/core"
|
import { useClipboard } from "@vueuse/core";
|
||||||
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
|
||||||
import { VTooltip } from "~/types/vuetify";
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
copyText: {
|
copyText: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -61,7 +60,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { copy, copied, isSupported } = useClipboard()
|
const { copy, copied, isSupported } = useClipboard();
|
||||||
const show = ref(false);
|
const show = ref(false);
|
||||||
const copyToolTip = ref<VTooltip | null>(null);
|
const copyToolTip = ref<VTooltip | null>(null);
|
||||||
|
|
||||||
@@ -73,7 +72,7 @@ export default defineComponent({
|
|||||||
if (isSupported.value) {
|
if (isSupported.value) {
|
||||||
await copy(props.copyText);
|
await copy(props.copyText);
|
||||||
if (copied.value) {
|
if (copied.value) {
|
||||||
console.log(`Copied\n${props.copyText}`)
|
console.log(`Copied\n${props.copyText}`);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
console.warn("Copy failed: ", copied.value);
|
console.warn("Copy failed: ", copied.value);
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-form ref="file">
|
<v-form ref="file">
|
||||||
<input ref="uploader" class="d-none" type="file" :accept="accept" @change="onFileChanged" />
|
<input
|
||||||
|
ref="uploader"
|
||||||
|
class="d-none"
|
||||||
|
type="file"
|
||||||
|
:accept="accept"
|
||||||
|
@change="onFileChanged"
|
||||||
|
>
|
||||||
<slot v-bind="{ isSelecting, onButtonClick }">
|
<slot v-bind="{ isSelecting, onButtonClick }">
|
||||||
<v-btn :loading="isSelecting" :small="small" :color="color" :text="textBtn" :disabled="disabled" @click="onButtonClick">
|
<v-btn
|
||||||
<v-icon left> {{ effIcon }}</v-icon>
|
:loading="isSelecting"
|
||||||
|
:small="small"
|
||||||
|
:color="color"
|
||||||
|
:variant="textBtn ? 'text' : 'elevated'"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="onButtonClick"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
|
{{ effIcon }}
|
||||||
|
</v-icon>
|
||||||
{{ text ? text : defaultText }}
|
{{ text ? text : defaultText }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</slot>
|
</slot>
|
||||||
@@ -11,12 +26,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
|
||||||
const UPLOAD_EVENT = "uploaded";
|
const UPLOAD_EVENT = "uploaded";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
small: {
|
small: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -57,14 +71,15 @@ export default defineComponent({
|
|||||||
disabled: {
|
disabled: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const file = ref<File | null>(null);
|
const file = ref<File | null>(null);
|
||||||
const uploader = ref<HTMLInputElement | null>(null);
|
const uploader = ref<HTMLInputElement | null>(null);
|
||||||
const isSelecting = ref(false);
|
const isSelecting = ref(false);
|
||||||
|
|
||||||
const { i18n, $globals } = useContext();
|
const i18n = useI18n();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
const effIcon = props.icon ? props.icon : $globals.icons.upload;
|
const effIcon = props.icon ? props.icon : $globals.icons.upload;
|
||||||
|
|
||||||
const defaultText = i18n.t("general.upload");
|
const defaultText = i18n.t("general.upload");
|
||||||
@@ -82,11 +97,15 @@ export default defineComponent({
|
|||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append(props.fileName, file.value);
|
formData.append(props.fileName, file.value);
|
||||||
|
try {
|
||||||
const response = await api.upload.file(props.url, formData);
|
const response = await api.upload.file(props.url, formData);
|
||||||
|
if (response) {
|
||||||
if (response) {
|
context.emit(UPLOAD_EVENT, response);
|
||||||
context.emit(UPLOAD_EVENT, response);
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
context.emit(UPLOAD_EVENT, null);
|
||||||
}
|
}
|
||||||
isSelecting.value = false;
|
isSelecting.value = false;
|
||||||
}
|
}
|
||||||
@@ -107,7 +126,7 @@ export default defineComponent({
|
|||||||
() => {
|
() => {
|
||||||
isSelecting.value = false;
|
isSelecting.value = false;
|
||||||
},
|
},
|
||||||
{ once: true }
|
{ once: true },
|
||||||
);
|
);
|
||||||
uploader.value?.click();
|
uploader.value?.click();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mx-auto my-3 justify-center" style="display: flex;">
|
<div
|
||||||
|
class="mx-auto my-3 justify-center"
|
||||||
|
style="display: flex;"
|
||||||
|
>
|
||||||
<div style="display: inline;">
|
<div style="display: inline;">
|
||||||
<v-progress-circular :width="size.width" :size="size.size" color="primary lighten-2" indeterminate>
|
<v-progress-circular
|
||||||
|
:width="size.width"
|
||||||
|
:size="size.size"
|
||||||
|
color="primary-lighten-2"
|
||||||
|
indeterminate
|
||||||
|
>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<v-icon :size="size.icon" color="primary lighten-2">
|
<v-icon
|
||||||
|
:size="size.icon"
|
||||||
|
color="primary-lighten-2"
|
||||||
|
>
|
||||||
{{ $globals.icons.primary }}
|
{{ $globals.icons.primary }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<div v-if="large" class="text-small">
|
<div
|
||||||
|
v-if="large"
|
||||||
|
class="text-small"
|
||||||
|
>
|
||||||
<slot>
|
<slot>
|
||||||
{{ (small || tiny) ? "" : waitingText }}
|
{{ (small || tiny) ? "" : waitingText }}
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-progress-circular>
|
</v-progress-circular>
|
||||||
<div v-if="!large" class="text-small">
|
<div
|
||||||
|
v-if="!large"
|
||||||
|
class="text-small"
|
||||||
|
>
|
||||||
<slot>
|
<slot>
|
||||||
{{ (small || tiny) ? "" : waitingTextCalculated }}
|
{{ (small || tiny) ? "" : waitingTextCalculated }}
|
||||||
</slot>
|
</slot>
|
||||||
@@ -23,9 +40,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
export default defineNuxtComponent({
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
props: {
|
||||||
loading: {
|
loading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -50,7 +65,7 @@ export default defineComponent({
|
|||||||
waitingText: {
|
waitingText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: undefined,
|
default: undefined,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const size = computed(() => {
|
const size = computed(() => {
|
||||||
@@ -67,7 +82,8 @@ export default defineComponent({
|
|||||||
icon: 30,
|
icon: 30,
|
||||||
size: 50,
|
size: 50,
|
||||||
};
|
};
|
||||||
} else if (props.large) {
|
}
|
||||||
|
else if (props.large) {
|
||||||
return {
|
return {
|
||||||
width: 4,
|
width: 4,
|
||||||
icon: 120,
|
icon: 120,
|
||||||
@@ -81,7 +97,7 @@ export default defineComponent({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { i18n } = useContext();
|
const i18n = useI18n();
|
||||||
const waitingTextCalculated = props.waitingText == null ? i18n.t("general.loading-recipes") : props.waitingText;
|
const waitingTextCalculated = props.waitingText == null ? i18n.t("general.loading-recipes") : props.waitingText;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-toolbar color="transparent" flat>
|
<v-toolbar
|
||||||
<BaseButton color="null" rounded secondary @click="$router.go(-1)">
|
color="transparent"
|
||||||
<template #icon> {{ $globals.icons.arrowLeftBold }}</template>
|
flat
|
||||||
|
>
|
||||||
|
<BaseButton
|
||||||
|
color="null"
|
||||||
|
rounded
|
||||||
|
secondary
|
||||||
|
@click="$router.go(-1)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
{{ $globals.icons.arrowLeftBold }}
|
||||||
|
</template>
|
||||||
{{ $t('general.back') }}
|
{{ $t('general.back') }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
<slot></slot>
|
<slot />
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
export default defineNuxtComponent({
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
props: {
|
||||||
back: {
|
back: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|||||||
@@ -1,49 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card :color="color" :dark="dark" flat :width="width" class="my-2">
|
<v-card
|
||||||
|
:color="color"
|
||||||
|
:dark="dark"
|
||||||
|
flat
|
||||||
|
:width="width"
|
||||||
|
class="my-2"
|
||||||
|
>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col v-for="(inputField, index) in items" :key="index" class="py-0" cols="12" sm="12">
|
<v-col
|
||||||
<v-divider v-if="inputField.section" class="my-2" />
|
v-for="(inputField, index) in items"
|
||||||
<v-card-title v-if="inputField.section" class="pl-0">
|
:key="index"
|
||||||
|
class="py-0"
|
||||||
|
cols="12"
|
||||||
|
sm="12"
|
||||||
|
>
|
||||||
|
<v-divider
|
||||||
|
v-if="inputField.section"
|
||||||
|
class="my-2"
|
||||||
|
/>
|
||||||
|
<v-card-title
|
||||||
|
v-if="inputField.section"
|
||||||
|
class="pl-0"
|
||||||
|
>
|
||||||
{{ inputField.section }}
|
{{ inputField.section }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text v-if="inputField.sectionDetails" class="pl-0 mt-0 pt-0">
|
<v-card-text
|
||||||
|
v-if="inputField.sectionDetails"
|
||||||
|
class="pl-0 mt-0 pt-0"
|
||||||
|
>
|
||||||
{{ inputField.sectionDetails }}
|
{{ inputField.sectionDetails }}
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
<!-- Check Box -->
|
<!-- Check Box -->
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-if="inputField.type === fieldTypes.BOOLEAN"
|
v-if="inputField.type === fieldTypes.BOOLEAN"
|
||||||
v-model="value[inputField.varName]"
|
v-model="modelValue[inputField.varName]"
|
||||||
class="my-0 py-0"
|
|
||||||
:name="inputField.varName"
|
:name="inputField.varName"
|
||||||
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
||||||
@change="emitBlur"
|
:hint="inputField.hint"
|
||||||
>
|
hide-details="auto"
|
||||||
|
density="comfortable"
|
||||||
|
@change="emitBlur">
|
||||||
<template #label>
|
<template #label>
|
||||||
<div>
|
<span class="ml-4">
|
||||||
<v-card-text class="text-body-1 my-0 py-0">
|
{{ inputField.label }}
|
||||||
{{ inputField.label }}
|
</span>
|
||||||
</v-card-text>
|
</template>
|
||||||
<v-card-text v-if="inputField.hint" class="text-caption my-0 py-0">
|
</v-checkbox>
|
||||||
{{ inputField.hint }}
|
|
||||||
</v-card-text>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-checkbox>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Text Field -->
|
<!-- Text Field -->
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
|
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
|
||||||
v-model="value[inputField.varName]"
|
v-model="modelValue[inputField.varName]"
|
||||||
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
|
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
|
||||||
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
||||||
filled
|
|
||||||
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
|
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
|
||||||
rounded
|
variant="solo-filled"
|
||||||
class="rounded-lg"
|
flat
|
||||||
:autofocus="index === 0"
|
:autofocus="index === 0"
|
||||||
dense
|
density="comfortable"
|
||||||
:label="inputField.label"
|
:label="inputField.label"
|
||||||
:name="inputField.varName"
|
:name="inputField.varName"
|
||||||
:hint="inputField.hint || ''"
|
:hint="inputField.hint || ''"
|
||||||
@@ -55,15 +70,15 @@
|
|||||||
<!-- Text Area -->
|
<!-- Text Area -->
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
|
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
|
||||||
v-model="value[inputField.varName]"
|
v-model="modelValue[inputField.varName]"
|
||||||
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
|
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
|
||||||
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
||||||
filled
|
variant="solo-filled"
|
||||||
rounded
|
flat
|
||||||
class="rounded-lg"
|
class="rounded-lg"
|
||||||
rows="3"
|
rows="3"
|
||||||
auto-grow
|
auto-grow
|
||||||
dense
|
density="comfortable"
|
||||||
:label="inputField.label"
|
:label="inputField.label"
|
||||||
:name="inputField.varName"
|
:name="inputField.varName"
|
||||||
:hint="inputField.hint || ''"
|
:hint="inputField.hint || ''"
|
||||||
@@ -75,42 +90,53 @@
|
|||||||
<!-- Option Select -->
|
<!-- Option Select -->
|
||||||
<v-select
|
<v-select
|
||||||
v-else-if="inputField.type === fieldTypes.SELECT"
|
v-else-if="inputField.type === fieldTypes.SELECT"
|
||||||
v-model="value[inputField.varName]"
|
v-model="modelValue[inputField.varName]"
|
||||||
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
|
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
|
||||||
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
||||||
filled
|
variant="solo-filled"
|
||||||
rounded
|
flat
|
||||||
class="rounded-lg"
|
class="rounded-lg"
|
||||||
:prepend-icon="inputField.icons ? value[inputField.varName] : null"
|
:prepend-icon="inputField.icons ? modelValue[inputField.varName] : null"
|
||||||
:label="inputField.label"
|
:label="inputField.label"
|
||||||
:name="inputField.varName"
|
:name="inputField.varName"
|
||||||
:items="inputField.options"
|
:items="inputField.options"
|
||||||
:item-text="inputField.itemText"
|
:item-title="inputField.itemText"
|
||||||
:item-value="inputField.itemValue"
|
:item-value="inputField.itemValue"
|
||||||
:return-object="false"
|
:return-object="false"
|
||||||
:hint="inputField.hint"
|
:hint="inputField.hint"
|
||||||
|
density="comfortable"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
lazy-validation
|
lazy-validation
|
||||||
@blur="emitBlur"
|
@blur="emitBlur"
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<v-list-item-content>
|
<div>
|
||||||
<v-list-item-title>{{ item.text }}</v-list-item-title>
|
<v-list-item-title>{{ item.raw.text }}</v-list-item-title>
|
||||||
<v-list-item-subtitle>{{ item.description }}</v-list-item-subtitle>
|
<v-list-item-subtitle>{{ item.raw.description }}</v-list-item-subtitle>
|
||||||
</v-list-item-content>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
|
|
||||||
<!-- Color Picker -->
|
<!-- Color Picker -->
|
||||||
<div v-else-if="inputField.type === fieldTypes.COLOR" class="d-flex" style="width: 100%">
|
<div
|
||||||
|
v-else-if="inputField.type === fieldTypes.COLOR"
|
||||||
|
class="d-flex"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
<v-menu offset-y>
|
<v-menu offset-y>
|
||||||
<template #activator="{ on }">
|
<template #activator="{ props: templateProps }">
|
||||||
<v-btn class="my-2 ml-auto" style="min-width: 200px" :color="value[inputField.varName]" dark v-on="on">
|
<v-btn
|
||||||
|
class="my-2 ml-auto"
|
||||||
|
style="min-width: 200px"
|
||||||
|
:color="modelValue[inputField.varName]"
|
||||||
|
dark
|
||||||
|
v-bind="templateProps"
|
||||||
|
>
|
||||||
{{ inputField.label }}
|
{{ inputField.label }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<v-color-picker
|
<v-color-picker
|
||||||
v-model="value[inputField.varName]"
|
v-model="modelValue[inputField.varName]"
|
||||||
value="#7417BE"
|
value="#7417BE"
|
||||||
hide-canvas
|
hide-canvas
|
||||||
hide-inputs
|
hide-inputs
|
||||||
@@ -122,21 +148,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="inputField.type === fieldTypes.OBJECT">
|
<div v-else-if="inputField.type === fieldTypes.OBJECT">
|
||||||
<auto-form v-model="value[inputField.varName]" :color="color" :items="inputField.items" @blur="emitBlur" />
|
<auto-form
|
||||||
|
v-model="modelValue[inputField.varName]"
|
||||||
|
:color="color"
|
||||||
|
:items="inputField.items"
|
||||||
|
@blur="emitBlur"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- List Type -->
|
<!-- List Type -->
|
||||||
<div v-else-if="inputField.type === fieldTypes.LIST">
|
<div v-else-if="inputField.type === fieldTypes.LIST">
|
||||||
<div v-for="(item, idx) in value[inputField.varName]" :key="idx">
|
<div
|
||||||
|
v-for="(item, idx) in modelValue[inputField.varName]"
|
||||||
|
:key="idx"
|
||||||
|
>
|
||||||
<p>
|
<p>
|
||||||
{{ inputField.label }} {{ idx + 1 }}
|
{{ inputField.label }} {{ idx + 1 }}
|
||||||
<span>
|
<span>
|
||||||
<BaseButton class="ml-5" x-small delete @click="removeByIndex(value[inputField.varName], idx)" />
|
<BaseButton
|
||||||
|
class="ml-5"
|
||||||
|
x-small
|
||||||
|
delete
|
||||||
|
@click="removeByIndex(modelValue[inputField.varName], idx)"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<v-divider class="mb-5 mx-2" />
|
<v-divider class="mb-5 mx-2" />
|
||||||
<auto-form
|
<auto-form
|
||||||
v-model="value[inputField.varName][idx]"
|
v-model="modelValue[inputField.varName][idx]"
|
||||||
:color="color"
|
:color="color"
|
||||||
:items="inputField.items"
|
:items="inputField.items"
|
||||||
@blur="emitBlur"
|
@blur="emitBlur"
|
||||||
@@ -144,7 +183,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<BaseButton small @click="value[inputField.varName].push(getTemplate(inputField.items))">
|
<BaseButton
|
||||||
|
small
|
||||||
|
@click="modelValue[inputField.varName].push(getTemplate(inputField.items))"
|
||||||
|
>
|
||||||
{{ $t("general.new") }}
|
{{ $t("general.new") }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
@@ -154,111 +196,96 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
|
||||||
import { validators } from "@/composables/use-validators";
|
import { validators } from "@/composables/use-validators";
|
||||||
import { fieldTypes } from "@/composables/forms";
|
import { fieldTypes } from "@/composables/forms";
|
||||||
import { AutoFormItems } from "~/types/auto-forms";
|
import type { AutoFormItems } from "~/types/auto-forms";
|
||||||
|
|
||||||
const BLUR_EVENT = "blur";
|
const BLUR_EVENT = "blur";
|
||||||
|
|
||||||
type ValidatorKey = keyof typeof validators;
|
type ValidatorKey = keyof typeof validators;
|
||||||
|
|
||||||
export default defineComponent({
|
// Use defineModel for v-model
|
||||||
name: "AutoForm",
|
const modelValue = defineModel<[object, Array<any>]>();
|
||||||
props: {
|
|
||||||
value: {
|
const props = defineProps({
|
||||||
default: null,
|
updateMode: {
|
||||||
type: [Object, Array],
|
default: false,
|
||||||
},
|
type: Boolean,
|
||||||
updateMode: {
|
|
||||||
default: false,
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
default: null,
|
|
||||||
type: Array as () => AutoFormItems,
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
type: [Number, String],
|
|
||||||
default: "max",
|
|
||||||
},
|
|
||||||
globalRules: {
|
|
||||||
default: null,
|
|
||||||
type: Array as () => string[],
|
|
||||||
},
|
|
||||||
color: {
|
|
||||||
default: null,
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
default: false,
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
disabledFields: {
|
|
||||||
default: null,
|
|
||||||
type: Array as () => string[],
|
|
||||||
},
|
|
||||||
readonlyFields: {
|
|
||||||
default: null,
|
|
||||||
type: Array as () => string[],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
setup(props, context) {
|
items: {
|
||||||
function rulesByKey(keys?: ValidatorKey[] | null) {
|
default: null,
|
||||||
if (keys === undefined || keys === null) {
|
type: Array as () => AutoFormItems,
|
||||||
return [];
|
},
|
||||||
}
|
width: {
|
||||||
|
type: [Number, String],
|
||||||
const list = [] as ((v: string) => boolean | string)[];
|
default: "max",
|
||||||
keys.forEach((key) => {
|
},
|
||||||
const split = key.split(":");
|
globalRules: {
|
||||||
const validatorKey = split[0] as ValidatorKey;
|
default: null,
|
||||||
if (validatorKey in validators) {
|
type: Array as () => string[],
|
||||||
if (split.length === 1) {
|
},
|
||||||
// @ts-ignore- validators[validatorKey] is a function
|
color: {
|
||||||
list.push(validators[validatorKey]);
|
default: null,
|
||||||
} else {
|
type: String,
|
||||||
// @ts-ignore - validators[validatorKey] is a function
|
},
|
||||||
list.push(validators[validatorKey](split[1]));
|
dark: {
|
||||||
}
|
default: false,
|
||||||
}
|
type: Boolean,
|
||||||
});
|
},
|
||||||
return list;
|
disabledFields: {
|
||||||
}
|
default: null,
|
||||||
|
type: Array as () => string[],
|
||||||
const defaultRules = computed(() => rulesByKey(props.globalRules as ValidatorKey[]));
|
},
|
||||||
|
readonlyFields: {
|
||||||
function removeByIndex(list: never[], index: number) {
|
default: null,
|
||||||
// Removes the item at the index
|
type: Array as () => string[],
|
||||||
list.splice(index, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTemplate(item: AutoFormItems) {
|
|
||||||
const obj = {} as { [key: string]: string };
|
|
||||||
|
|
||||||
item.forEach((field) => {
|
|
||||||
obj[field.varName] = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitBlur() {
|
|
||||||
context.emit(BLUR_EVENT, props.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
rulesByKey,
|
|
||||||
defaultRules,
|
|
||||||
removeByIndex,
|
|
||||||
getTemplate,
|
|
||||||
emitBlur,
|
|
||||||
fieldTypes,
|
|
||||||
validators,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["blur", "update:modelValue"]);
|
||||||
|
|
||||||
|
function rulesByKey(keys?: ValidatorKey[] | null) {
|
||||||
|
if (keys === undefined || keys === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = [] as ((v: string) => boolean | string)[];
|
||||||
|
keys.forEach((key) => {
|
||||||
|
const split = key.split(":");
|
||||||
|
const validatorKey = split[0] as ValidatorKey;
|
||||||
|
if (validatorKey in validators) {
|
||||||
|
if (split.length === 1) {
|
||||||
|
list.push(validators[validatorKey]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
list.push(validators[validatorKey](split[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRules = computed(() => rulesByKey(props.globalRules as ValidatorKey[]));
|
||||||
|
|
||||||
|
function removeByIndex(list: never[], index: number) {
|
||||||
|
// Removes the item at the index
|
||||||
|
list.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemplate(item: AutoFormItems) {
|
||||||
|
const obj = {} as { [key: string]: string };
|
||||||
|
|
||||||
|
item.forEach((field) => {
|
||||||
|
obj[field.varName] = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitBlur() {
|
||||||
|
emit(BLUR_EVENT, modelValue.value);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
<style lang="scss" scoped></style>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user