mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-03-03 00:37:00 +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.
|
||||
"VARIANT": "3.12-bullseye",
|
||||
// Options
|
||||
"NODE_VERSION": "16"
|
||||
"NODE_VERSION": "20"
|
||||
}
|
||||
},
|
||||
"mounts": [
|
||||
@@ -55,5 +55,6 @@
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"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 🏗
|
||||
uses: actions/setup-node@v4.0.0
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
|
||||
- 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/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: ./tests/e2e/yarn.lock
|
||||
- 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 🏗
|
||||
uses: actions/setup-node@v4.0.0
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
|
||||
- name: Get yarn cache directory path 🛠
|
||||
@@ -34,6 +34,10 @@ jobs:
|
||||
run: yarn
|
||||
working-directory: "frontend"
|
||||
|
||||
- name: Prepare nuxt 🚀
|
||||
run: yarn nuxt prepare
|
||||
working-directory: "frontend"
|
||||
|
||||
- name: Run linter 👀
|
||||
run: yarn lint
|
||||
working-directory: "frontend"
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,6 +10,9 @@ docs/site/
|
||||
*temp/*
|
||||
.secret
|
||||
frontend/dist/
|
||||
frontend/.output/*
|
||||
frontend/.yarn/*
|
||||
frontend/.yarnrc.yml
|
||||
|
||||
dev/code-generation/generated/*
|
||||
dev/data/mealie.db-journal
|
||||
@@ -164,3 +167,5 @@ dev/code-generation/openapi.json
|
||||
|
||||
.run/
|
||||
.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"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.useFlatConfig": true,
|
||||
"eslint.workingDirectories": [
|
||||
"./frontend"
|
||||
],
|
||||
|
||||
@@ -243,7 +243,7 @@ tasks:
|
||||
desc: runs the frontend server
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn run dev
|
||||
- yarn run dev --no-fork
|
||||
|
||||
docker:build-from-package:
|
||||
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"
|
||||
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"
|
||||
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -173,12 +174,12 @@ def inject_nuxt_values():
|
||||
|
||||
all_langs = []
|
||||
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)
|
||||
|
||||
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_dates, all_date_locales)
|
||||
inject_inline(i18n_config, CodeKeys.nuxt_local_dates, all_date_locales)
|
||||
|
||||
|
||||
def inject_registration_validation_values():
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###############################################
|
||||
# Frontend Build
|
||||
###############################################
|
||||
FROM node:16 AS frontend-builder
|
||||
FROM node:20 AS frontend-builder
|
||||
|
||||
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 */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
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;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
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;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
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;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
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;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
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;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
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;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url('~assets/fonts/Roboto-100-latin7.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
src: url("~assets/fonts/Roboto-100-latin7.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
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;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
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;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
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;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
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;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
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;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
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;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('~assets/fonts/Roboto-300-latin14.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
src: url("~assets/fonts/Roboto-300-latin14.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
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;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
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;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
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;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
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;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
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;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
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;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('~assets/fonts/Roboto-400-latin21.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
src: url("~assets/fonts/Roboto-400-latin21.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
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;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
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;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
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;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
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;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
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;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
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;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('~assets/fonts/Roboto-500-latin28.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
src: url("~assets/fonts/Roboto-500-latin28.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
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;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
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;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
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;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
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;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
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;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
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;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('~assets/fonts/Roboto-700-latin35.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
src: url("~assets/fonts/Roboto-700-latin35.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
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;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
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;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
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;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
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;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
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;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
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;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url('~assets/fonts/Roboto-900-latin42.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
background-color: var(--v-background-base, #1e1e1e) !important;
|
||||
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
|
||||
}
|
||||
|
||||
.theme--dark.v-card {
|
||||
@@ -29,11 +29,11 @@
|
||||
}
|
||||
|
||||
.left-border {
|
||||
border-left: 5px solid var(--v-primary-base) !important;
|
||||
border-left: 5px solid rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
|
||||
.left-warning-border {
|
||||
border-left: 5px solid var(--v-warning-base) !important;
|
||||
border-left: 5px solid rgb(var(--v-theme-warning)) !important;
|
||||
}
|
||||
|
||||
.handle {
|
||||
@@ -56,3 +56,11 @@
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.fill-height {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card-text v-if="cookbook" class="px-1">
|
||||
<v-text-field v-model="cookbook.name" :label="$t('cookbook.cookbook-name')"></v-text-field>
|
||||
<v-textarea v-model="cookbook.description" auto-grow :rows="2" :label="$t('recipe.description')"></v-textarea>
|
||||
<v-card-text
|
||||
v-if="cookbook"
|
||||
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
|
||||
:field-defs="fieldDefs"
|
||||
:initial-query-filter="cookbook.queryFilter"
|
||||
@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>
|
||||
{{ $t('cookbook.public-cookbook') }}
|
||||
<HelpIcon small right class="ml-2">
|
||||
<HelpIcon
|
||||
size="small"
|
||||
right
|
||||
class="ml-2"
|
||||
>
|
||||
{{ $t('cookbook.public-cookbook-description') }}
|
||||
</HelpIcon>
|
||||
</template>
|
||||
@@ -21,16 +45,15 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
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 },
|
||||
props: {
|
||||
cookbook: {
|
||||
modelValue: {
|
||||
type: Object as () => ReadCookBook,
|
||||
required: true,
|
||||
},
|
||||
@@ -39,52 +62,57 @@ export default defineComponent({
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n } = useContext();
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
const i18n = useI18n();
|
||||
|
||||
const cookbook = toRef(() => props.modelValue);
|
||||
|
||||
function handleInput(value: string | undefined) {
|
||||
props.cookbook.queryFilterString = value || "";
|
||||
cookbook.value.queryFilterString = value || "";
|
||||
emit("update:modelValue", cookbook.value);
|
||||
}
|
||||
|
||||
const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "recipe_category.id",
|
||||
label: i18n.tc("category.categories"),
|
||||
label: i18n.t("category.categories"),
|
||||
type: Organizer.Category,
|
||||
},
|
||||
{
|
||||
name: "tags.id",
|
||||
label: i18n.tc("tag.tags"),
|
||||
label: i18n.t("tag.tags"),
|
||||
type: Organizer.Tag,
|
||||
},
|
||||
{
|
||||
name: "recipe_ingredient.food.id",
|
||||
label: i18n.tc("recipe.ingredients"),
|
||||
label: i18n.t("recipe.ingredients"),
|
||||
type: Organizer.Food,
|
||||
},
|
||||
{
|
||||
name: "tools.id",
|
||||
label: i18n.tc("tool.tools"),
|
||||
label: i18n.t("tool.tools"),
|
||||
type: Organizer.Tool,
|
||||
},
|
||||
{
|
||||
name: "household_id",
|
||||
label: i18n.tc("household.households"),
|
||||
label: i18n.t("household.households"),
|
||||
type: Organizer.Household,
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
label: i18n.tc("general.date-created"),
|
||||
label: i18n.t("general.date-created"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "updated_at",
|
||||
label: i18n.tc("general.date-updated"),
|
||||
label: i18n.t("general.date-updated"),
|
||||
type: "date",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
cookbook,
|
||||
handleInput,
|
||||
fieldDefs,
|
||||
};
|
||||
|
||||
@@ -7,44 +7,59 @@
|
||||
width="100%"
|
||||
max-width="1100px"
|
||||
:icon="$globals.icons.pages"
|
||||
:title="$tc('general.edit')"
|
||||
:title="$t('general.edit')"
|
||||
:submit-icon="$globals.icons.save"
|
||||
:submit-text="$tc('general.save')"
|
||||
:submit-text="$t('general.save')"
|
||||
:submit-disabled="!editTarget.queryFilterString"
|
||||
can-submit
|
||||
@submit="editCookbook"
|
||||
>
|
||||
<v-card-text>
|
||||
<CookbookEditor :cookbook="editTarget" :actions="actions" />
|
||||
<CookbookEditor
|
||||
v-model="editTarget"
|
||||
:actions="actions"
|
||||
/>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Page -->
|
||||
<v-container v-if="book" fluid>
|
||||
<v-app-bar color="transparent" flat class="mt-n1">
|
||||
<v-icon large left> {{ $globals.icons.pages }} </v-icon>
|
||||
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton
|
||||
v-if="canEdit"
|
||||
class="mx-1"
|
||||
:edit="true"
|
||||
@click="handleEditCookbook"
|
||||
/>
|
||||
</v-app-bar>
|
||||
<v-card flat>
|
||||
<v-card-text class="py-0">
|
||||
<v-container
|
||||
v-if="book"
|
||||
fluid
|
||||
class="py-0 my-0"
|
||||
>
|
||||
<v-sheet
|
||||
color="transparent"
|
||||
class="d-flex flex-column w-100 pa-0 ma-0"
|
||||
elevation="0"
|
||||
>
|
||||
<div class="d-flex align-center w-100 mb-2">
|
||||
<v-icon size="large" class="mr-3">
|
||||
{{ $globals.icons.pages }}
|
||||
</v-icon>
|
||||
<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 }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-sheet>
|
||||
|
||||
<v-container class="pa-0">
|
||||
<RecipeCardSection
|
||||
class="mb-5 mx-1"
|
||||
:recipes="recipes"
|
||||
:query="{ cookbook: slug }"
|
||||
@sortRecipes="assignSorted"
|
||||
@replaceRecipes="replaceRecipes"
|
||||
@appendRecipes="appendRecipes"
|
||||
@sort-recipes="assignSorted"
|
||||
@replace-recipes="replaceRecipes"
|
||||
@append-recipes="appendRecipes"
|
||||
@delete="removeRecipe"
|
||||
/>
|
||||
</v-container>
|
||||
@@ -52,92 +67,89 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useRoute, ref, useContext, useMeta, reactive, useRouter } from "@nuxtjs/composition-api";
|
||||
import { useLazyRecipes } from "~/composables/recipes";
|
||||
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
|
||||
import { useCookbook, useCookbooks } from "~/composables/use-group-cookbooks";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { RecipeCookBook } from "~/lib/api/types/cookbook";
|
||||
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||
<script lang="ts">
|
||||
import { useLazyRecipes } from "~/composables/recipes";
|
||||
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
|
||||
import { useCookbook, useCookbooks } from "~/composables/use-group-cookbooks";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import type { RecipeCookBook } from "~/lib/api/types/cookbook";
|
||||
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: { RecipeCardSection, CookbookEditor },
|
||||
setup() {
|
||||
const { $auth } = useContext();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeCardSection, CookbookEditor },
|
||||
setup() {
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const slug = route.value.params.slug;
|
||||
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
|
||||
const { actions } = useCookbooks();
|
||||
const router = useRouter();
|
||||
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const slug = route.params.slug as string;
|
||||
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
|
||||
const { actions } = useCookbooks();
|
||||
const router = useRouter();
|
||||
|
||||
const tab = ref(null);
|
||||
const book = getOne(slug);
|
||||
const tab = ref(null);
|
||||
const book = getOne(slug);
|
||||
|
||||
const isOwnHousehold = computed(() => {
|
||||
if (!($auth.user && book.value?.householdId)) {
|
||||
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;
|
||||
const isOwnHousehold = computed(() => {
|
||||
if (!($auth.user.value && book.value?.householdId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function editCookbook() {
|
||||
if (!editTarget.value) {
|
||||
return;
|
||||
}
|
||||
const response = await actions.updateOne(editTarget.value);
|
||||
return $auth.user.value.householdId === book.value.householdId;
|
||||
});
|
||||
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
||||
|
||||
if (response?.slug && book.value?.slug !== response?.slug) {
|
||||
// if name changed, redirect to new slug
|
||||
router.push(`/g/${route.value.params.groupSlug}/cookbooks/${response?.slug}`);
|
||||
} else {
|
||||
// otherwise reload the page, since the recipe criteria changed
|
||||
router.go(0);
|
||||
}
|
||||
dialogStates.edit = false;
|
||||
editTarget.value = null;
|
||||
const dialogStates = reactive({
|
||||
edit: false,
|
||||
});
|
||||
|
||||
const editTarget = ref<RecipeCookBook | null>(null);
|
||||
function handleEditCookbook() {
|
||||
dialogStates.edit = true;
|
||||
editTarget.value = book.value;
|
||||
}
|
||||
|
||||
async function editCookbook() {
|
||||
if (!editTarget.value) {
|
||||
return;
|
||||
}
|
||||
const response = await actions.updateOne(editTarget.value);
|
||||
|
||||
useMeta(() => {
|
||||
return {
|
||||
title: book?.value?.name || "Cookbook",
|
||||
};
|
||||
});
|
||||
if (response?.slug && book.value?.slug !== response?.slug) {
|
||||
// if name changed, redirect to new slug
|
||||
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 {
|
||||
book,
|
||||
slug,
|
||||
tab,
|
||||
appendRecipes,
|
||||
assignSorted,
|
||||
recipes,
|
||||
removeRecipe,
|
||||
replaceRecipes,
|
||||
canEdit,
|
||||
dialogStates,
|
||||
editTarget,
|
||||
handleEditCookbook,
|
||||
editCookbook,
|
||||
actions,
|
||||
};
|
||||
},
|
||||
head: {}, // Must include for useMeta
|
||||
});
|
||||
</script>
|
||||
useSeoMeta({
|
||||
title: book?.value?.name || "Cookbook",
|
||||
});
|
||||
|
||||
return {
|
||||
book,
|
||||
slug,
|
||||
tab,
|
||||
appendRecipes,
|
||||
assignSorted,
|
||||
recipes,
|
||||
removeRecipe,
|
||||
replaceRecipes,
|
||||
canEdit,
|
||||
dialogStates,
|
||||
editTarget,
|
||||
handleEditCookbook,
|
||||
editCookbook,
|
||||
actions,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -7,21 +7,24 @@
|
||||
class="elevation-0"
|
||||
@click:row="downloadData"
|
||||
>
|
||||
<template #item.expires="{ item }">
|
||||
<template #[`item.expires`]="{ item }">
|
||||
{{ getTimeToExpire(item.expires) }}
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<BaseButton download small :download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`">
|
||||
</BaseButton>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<BaseButton
|
||||
download
|
||||
size="small"
|
||||
:download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { parseISO, formatDistanceToNow } from "date-fns";
|
||||
import { GroupDataExport } from "~/lib/api/types/group";
|
||||
export default defineComponent({
|
||||
import type { GroupDataExport } from "~/lib/api/types/group";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
exports: {
|
||||
type: Array as () => GroupDataExport[],
|
||||
@@ -29,14 +32,14 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
|
||||
const headers = [
|
||||
{ text: i18n.t("export.export"), value: "name" },
|
||||
{ text: i18n.t("export.file-name"), value: "filename" },
|
||||
{ text: i18n.t("export.size"), value: "size" },
|
||||
{ text: i18n.t("export.link-expires"), value: "expires" },
|
||||
{ text: "", value: "actions" },
|
||||
{ title: i18n.t("export.export"), value: "name" },
|
||||
{ title: i18n.t("export.file-name"), value: "filename" },
|
||||
{ title: i18n.t("export.size"), value: "size" },
|
||||
{ title: i18n.t("export.link-expires"), value: "expires" },
|
||||
{ title: "", value: "actions" },
|
||||
];
|
||||
|
||||
function getTimeToExpire(timeString: string) {
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
<template>
|
||||
<div v-if="preferences">
|
||||
<BaseCardSectionTitle :title="$tc('group.general-preferences')"></BaseCardSectionTitle>
|
||||
<v-checkbox v-model="preferences.privateGroup" class="mt-n4" :label="$t('group.private-group')"></v-checkbox>
|
||||
<BaseCardSectionTitle :title="$t('group.general-preferences')" />
|
||||
<v-checkbox
|
||||
v-model="preferences.privateGroup"
|
||||
class="mt-n4"
|
||||
:label="$t('group.private-group')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const preferences = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set(val) {
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,5 +35,4 @@ export default defineComponent({
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -5,31 +5,30 @@
|
||||
:label="label"
|
||||
:hint="description"
|
||||
:persistent-hint="!!description"
|
||||
item-text="name"
|
||||
item-title="name"
|
||||
:multiple="multiselect"
|
||||
:prepend-inner-icon="$globals.icons.household"
|
||||
return-object
|
||||
>
|
||||
<template #selection="data">
|
||||
<template #chip="data">
|
||||
<v-chip
|
||||
:key="data.index"
|
||||
class="ma-1"
|
||||
:input-value="data.selected"
|
||||
small
|
||||
close
|
||||
:input-value="data.item"
|
||||
size="small"
|
||||
closable
|
||||
label
|
||||
color="accent"
|
||||
dark
|
||||
@click:close="removeByIndex(data.index)"
|
||||
>
|
||||
{{ data.item.name || data.item }}
|
||||
{{ data.item.raw.name || data.item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, useContext } from "@nuxtjs/composition-api";
|
||||
import { useHouseholdStore } from "~/composables/store/use-household-store";
|
||||
|
||||
interface HouseholdLike {
|
||||
@@ -37,9 +36,9 @@ interface HouseholdLike {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Array as () => HouseholdLike[],
|
||||
required: true,
|
||||
},
|
||||
@@ -52,11 +51,12 @@ export default defineComponent({
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const selected = computed({
|
||||
get: () => props.value,
|
||||
get: () => props.modelValue,
|
||||
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(
|
||||
() => props.multiselect ? i18n.tc("household.households") : i18n.tc("household.household")
|
||||
() => props.multiselect ? i18n.t("household.households") : i18n.t("household.household"),
|
||||
);
|
||||
|
||||
const { store: households } = useHouseholdStore();
|
||||
|
||||
@@ -8,26 +8,41 @@
|
||||
/>
|
||||
<v-menu
|
||||
offset-y
|
||||
left
|
||||
start
|
||||
:bottom="!menuTop"
|
||||
:nudge-bottom="!menuTop ? '5' : '0'"
|
||||
:top="menuTop"
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="$vuetify.breakpoint.mdAndUp"
|
||||
:open-on-hover="mdAndUp"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent>
|
||||
<template #activator="{ props }">
|
||||
<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-btn>
|
||||
</template>
|
||||
<v-list dense>
|
||||
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
|
||||
<v-list-item-icon>
|
||||
<v-icon :color="item.color"> {{ item.icon }} </v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="(item, index) in menuItems"
|
||||
:key="index"
|
||||
@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>
|
||||
</v-list>
|
||||
@@ -36,10 +51,9 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
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";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
@@ -50,7 +64,7 @@ export interface ContextMenuItem {
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeDialogAddToShoppingList,
|
||||
},
|
||||
@@ -77,7 +91,10 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { $globals, i18n } = useContext();
|
||||
const { mdAndUp } = useDisplay();
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const api = useUserApi();
|
||||
|
||||
const state = reactive({
|
||||
@@ -85,7 +102,7 @@ export default defineComponent({
|
||||
shoppingListDialog: false,
|
||||
menuItems: [
|
||||
{
|
||||
title: i18n.tc("recipe.add-to-list"),
|
||||
title: i18n.t("recipe.add-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
@@ -103,16 +120,17 @@ export default defineComponent({
|
||||
scale: 1,
|
||||
...recipe,
|
||||
};
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
async function getShoppingLists() {
|
||||
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
||||
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> } = {
|
||||
shoppingList: () => {
|
||||
getShoppingLists();
|
||||
@@ -139,7 +157,8 @@ export default defineComponent({
|
||||
icon,
|
||||
recipesWithScales,
|
||||
shoppingLists,
|
||||
}
|
||||
mdAndUp,
|
||||
};
|
||||
},
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-md-flex" style="gap: 10px">
|
||||
<v-select v-model="inputDay" :items="MEAL_DAY_OPTIONS" :label="$t('meal-plan.rule-day')"></v-select>
|
||||
<v-select v-model="inputEntryType" :items="MEAL_TYPE_OPTIONS" :label="$t('meal-plan.meal-type')"></v-select>
|
||||
<div
|
||||
class="d-md-flex"
|
||||
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 class="mb-5">
|
||||
@@ -15,20 +26,19 @@
|
||||
|
||||
<!-- TODO: proper pluralization of inputDay -->
|
||||
{{ $t('meal-plan.this-rule-will-apply', {
|
||||
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])
|
||||
}) }}
|
||||
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]),
|
||||
}) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
||||
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 { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
QueryFilterBuilder,
|
||||
},
|
||||
@@ -54,8 +64,9 @@ export default defineComponent({
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:day", "update:entry-type", "update:query-filter-string"],
|
||||
setup(props, context) {
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
|
||||
const MEAL_TYPE_OPTIONS = [
|
||||
{ text: i18n.t("meal-plan.breakfast"), value: "breakfast" },
|
||||
@@ -110,42 +121,42 @@ export default defineComponent({
|
||||
const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "recipe_category.id",
|
||||
label: i18n.tc("category.categories"),
|
||||
label: i18n.t("category.categories"),
|
||||
type: Organizer.Category,
|
||||
},
|
||||
{
|
||||
name: "tags.id",
|
||||
label: i18n.tc("tag.tags"),
|
||||
label: i18n.t("tag.tags"),
|
||||
type: Organizer.Tag,
|
||||
},
|
||||
{
|
||||
name: "recipe_ingredient.food.id",
|
||||
label: i18n.tc("recipe.ingredients"),
|
||||
label: i18n.t("recipe.ingredients"),
|
||||
type: Organizer.Food,
|
||||
},
|
||||
{
|
||||
name: "tools.id",
|
||||
label: i18n.tc("tool.tools"),
|
||||
label: i18n.t("tool.tools"),
|
||||
type: Organizer.Tool,
|
||||
},
|
||||
{
|
||||
name: "household_id",
|
||||
label: i18n.tc("household.households"),
|
||||
label: i18n.t("household.households"),
|
||||
type: Organizer.Household,
|
||||
},
|
||||
{
|
||||
name: "last_made",
|
||||
label: i18n.tc("general.last-made"),
|
||||
label: i18n.t("general.last-made"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
label: i18n.tc("general.date-created"),
|
||||
label: i18n.t("general.date-created"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "updated_at",
|
||||
label: i18n.tc("general.date-updated"),
|
||||
label: i18n.t("general.date-updated"),
|
||||
type: "date",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card-text>
|
||||
<v-switch v-model="webhookCopy.enabled" :label="$t('general.enabled')"></v-switch>
|
||||
<v-text-field v-model="webhookCopy.name" :label="$t('settings.webhooks.webhook-name')"></v-text-field>
|
||||
<v-text-field v-model="webhookCopy.url" :label="$t('settings.webhooks.webhook-url')"></v-text-field>
|
||||
<v-time-picker v-model="scheduledTime" class="elevation-2" ampm-in-title format="ampm"></v-time-picker>
|
||||
<v-switch
|
||||
v-model="webhookCopy.enabled"
|
||||
color="primary"
|
||||
: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-actions class="py-0 justify-end">
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $tc('general.delete'),
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.testTube,
|
||||
text: $tc('general.test'),
|
||||
text: $t('general.test'),
|
||||
event: 'test',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.save,
|
||||
text: $tc('general.save'),
|
||||
text: $t('general.save'),
|
||||
event: 'save',
|
||||
},
|
||||
]"
|
||||
@@ -34,11 +51,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
|
||||
import { ReadWebhook } from "~/lib/api/types/household";
|
||||
import type { ReadWebhook } from "~/lib/api/types/household";
|
||||
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
webhook: {
|
||||
type: Object as () => ReadWebhook,
|
||||
@@ -47,6 +63,7 @@ export default defineComponent({
|
||||
},
|
||||
emits: ["delete", "save", "test"],
|
||||
setup(props, { emit }) {
|
||||
const i18n = useI18n();
|
||||
const itemUTC = ref<string>(props.webhook.scheduledTime);
|
||||
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
|
||||
|
||||
@@ -67,6 +84,11 @@ export default defineComponent({
|
||||
emit("save", webhookCopy.value);
|
||||
}
|
||||
|
||||
// Set page title using useSeoMeta
|
||||
useSeoMeta({
|
||||
title: i18n.t("settings.webhooks.webhooks"),
|
||||
});
|
||||
|
||||
return {
|
||||
webhookCopy,
|
||||
scheduledTime,
|
||||
@@ -75,10 +97,5 @@ export default defineComponent({
|
||||
itemLocal,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("settings.webhooks.webhooks") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,157 +1,144 @@
|
||||
<template>
|
||||
<div v-if="preferences">
|
||||
<BaseCardSectionTitle class="mt-10" :title="$tc('household.household-preferences')"></BaseCardSectionTitle>
|
||||
<div class="mb-6">
|
||||
<v-checkbox
|
||||
v-model="preferences.privateHousehold"
|
||||
hide-details
|
||||
dense
|
||||
:label="$t('household.private-household')"
|
||||
/>
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.private-household-description") }}
|
||||
</p>
|
||||
<DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<v-checkbox
|
||||
v-model="preferences.lockRecipeEditsFromOtherHouseholds"
|
||||
hide-details
|
||||
dense
|
||||
:label="$t('household.lock-recipe-edits-from-other-households')"
|
||||
/>
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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')"
|
||||
/>
|
||||
<div v-if="preferences">
|
||||
<BaseCardSectionTitle :title="$t('household.household-preferences')" />
|
||||
<div class="mb-6">
|
||||
<v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.private-household-description") }}
|
||||
</p>
|
||||
<DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<v-select
|
||||
v-model="preferences.firstDayOfWeek"
|
||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||
:items="allDays"
|
||||
item-title="name"
|
||||
item-value="value"
|
||||
:label="$t('settings.first-day-of-week')"
|
||||
variant="underlined"
|
||||
flat
|
||||
/>
|
||||
|
||||
<BaseCardSectionTitle class="mt-5" :title="$tc('household.household-recipe-preferences')"></BaseCardSectionTitle>
|
||||
<div class="preference-container">
|
||||
<div v-for="p in recipePreferences" :key="p.key">
|
||||
<v-checkbox
|
||||
v-model="preferences[p.key]"
|
||||
hide-details
|
||||
dense
|
||||
:label="p.label"
|
||||
/>
|
||||
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
||||
{{ p.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')" />
|
||||
<div class="preference-container">
|
||||
<div v-for="p in recipePreferences" :key="p.key">
|
||||
<v-checkbox v-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
|
||||
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
||||
{{ p.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
||||
import { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
||||
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
|
||||
type Preference = {
|
||||
key: keyof ReadHouseholdPreferences;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
type Preference = {
|
||||
key: keyof ReadHouseholdPreferences;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const recipePreferences: Preference[] = [
|
||||
{
|
||||
key: "recipePublic",
|
||||
label: i18n.tc("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"),
|
||||
},
|
||||
{
|
||||
key: "recipeShowNutrition",
|
||||
label: i18n.tc("group.show-nutrition-information"),
|
||||
description: i18n.tc("group.show-nutrition-information-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeShowAssets",
|
||||
label: i18n.tc("group.show-recipe-assets"),
|
||||
description: i18n.tc("group.show-recipe-assets-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeLandscapeView",
|
||||
label: i18n.tc("group.default-to-landscape-view"),
|
||||
description: i18n.tc("group.default-to-landscape-view-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeDisableComments",
|
||||
label: i18n.tc("group.disable-users-from-commenting-on-recipes"),
|
||||
description: i18n.tc("group.disable-users-from-commenting-on-recipes-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeDisableAmount",
|
||||
label: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food"),
|
||||
description: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food-description"),
|
||||
},
|
||||
];
|
||||
const recipePreferences: Preference[] = [
|
||||
{
|
||||
key: "recipePublic",
|
||||
label: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes"),
|
||||
description: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeShowNutrition",
|
||||
label: i18n.t("group.show-nutrition-information"),
|
||||
description: i18n.t("group.show-nutrition-information-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeShowAssets",
|
||||
label: i18n.t("group.show-recipe-assets"),
|
||||
description: i18n.t("group.show-recipe-assets-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeLandscapeView",
|
||||
label: i18n.t("group.default-to-landscape-view"),
|
||||
description: i18n.t("group.default-to-landscape-view-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeDisableComments",
|
||||
label: i18n.t("group.disable-users-from-commenting-on-recipes"),
|
||||
description: i18n.t("group.disable-users-from-commenting-on-recipes-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeDisableAmount",
|
||||
label: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food"),
|
||||
description: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food-description"),
|
||||
},
|
||||
];
|
||||
|
||||
const allDays = [
|
||||
{
|
||||
name: i18n.t("general.sunday"),
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.monday"),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.tuesday"),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.wednesday"),
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.thursday"),
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.friday"),
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.saturday"),
|
||||
value: 6,
|
||||
},
|
||||
];
|
||||
const allDays = [
|
||||
{
|
||||
name: i18n.t("general.sunday"),
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.monday"),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.tuesday"),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.wednesday"),
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.thursday"),
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.friday"),
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.saturday"),
|
||||
value: 6,
|
||||
},
|
||||
];
|
||||
|
||||
const preferences = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
},
|
||||
set(val) {
|
||||
context.emit("input", val);
|
||||
},
|
||||
});
|
||||
const preferences = computed({
|
||||
get() {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(val) {
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
allDays,
|
||||
preferences,
|
||||
recipePreferences,
|
||||
};
|
||||
return {
|
||||
allDays,
|
||||
preferences,
|
||||
recipePreferences,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +1,37 @@
|
||||
<template>
|
||||
<v-toolbar
|
||||
rounded
|
||||
height="0"
|
||||
class="fixed-bar mt-0"
|
||||
color="rgb(255, 0, 0, 0.0)"
|
||||
flat
|
||||
style="z-index: 2; position: sticky"
|
||||
style="z-index: 2; position: sticky; background: transparent; box-shadow: none;"
|
||||
density="compact"
|
||||
elevation="0"
|
||||
>
|
||||
<BaseDialog
|
||||
v-model="deleteDialog"
|
||||
:title="$tc('recipe.delete-recipe')"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
@confirm="emitDelete()"
|
||||
>
|
||||
<BaseDialog v-model="deleteDialog" :title="$t('recipe.delete-recipe')" color="error"
|
||||
:icon="$globals.icons.alertCircle" can-confirm @confirm="emitDelete()">
|
||||
<v-card-text>
|
||||
{{ $t("recipe.delete-confirmation") }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-spacer />
|
||||
<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 />
|
||||
<RecipeTimelineBadge v-if="loggedIn" button-style class="ml-1" :slug="recipe.slug" :recipe-name="recipe.name" />
|
||||
<RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always />
|
||||
<RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" />
|
||||
<div v-if="loggedIn">
|
||||
<v-tooltip v-if="canEdit" bottom color="info">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn fab small class="ml-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
|
||||
<v-icon> {{ $globals.icons.edit }} </v-icon>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
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>
|
||||
</template>
|
||||
<span>{{ $t("general.edit") }}</span>
|
||||
@@ -37,14 +41,14 @@
|
||||
<RecipeContextMenu
|
||||
show-print
|
||||
:menu-top="false"
|
||||
:name="recipe.name"
|
||||
:slug="recipe.slug"
|
||||
:name="recipe.name!"
|
||||
:slug="recipe.slug!"
|
||||
:menu-icon="$globals.icons.dotsVertical"
|
||||
fab
|
||||
color="info"
|
||||
:card-menu="false"
|
||||
:recipe="recipe"
|
||||
:recipe-id="recipe.id"
|
||||
:recipe-id="recipe.id!"
|
||||
:recipe-scale="recipeScale"
|
||||
:use-items="{
|
||||
edit: false,
|
||||
@@ -66,31 +70,33 @@
|
||||
<v-btn
|
||||
v-for="(btn, index) in editorButtons"
|
||||
:key="index"
|
||||
:fab="$vuetify.breakpoint.xs"
|
||||
:small="$vuetify.breakpoint.xs"
|
||||
:class="{ 'rounded-circle': $vuetify.display.xs }"
|
||||
:size="$vuetify.display.xs ? 'small' : undefined"
|
||||
:color="btn.color"
|
||||
variant="elevated"
|
||||
@click="emitHandler(btn.event)"
|
||||
>
|
||||
<v-icon :left="!$vuetify.breakpoint.xs">{{ btn.icon }}</v-icon>
|
||||
{{ $vuetify.breakpoint.xs ? "" : btn.text }}
|
||||
<v-icon :left="!$vuetify.display.xs">
|
||||
{{ btn.icon }}
|
||||
</v-icon>
|
||||
{{ $vuetify.display.xs ? "" : btn.text }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.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 DELETE_EVENT = "delete";
|
||||
const CLOSE_EVENT = "close";
|
||||
const JSON_EVENT = "json";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
|
||||
props: {
|
||||
recipe: {
|
||||
@@ -126,10 +132,12 @@ export default defineComponent({
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["print", "input", "delete", "close", "edit"],
|
||||
setup(_, context) {
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
const { i18n, $globals } = useContext();
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const editorButtons = [
|
||||
{
|
||||
text: i18n.t("general.delete"),
|
||||
@@ -209,9 +217,13 @@ export default defineComponent({
|
||||
|
||||
.fixed-bar {
|
||||
position: sticky;
|
||||
position: -webkit-sticky; /* for Safari */
|
||||
top: 4.5em;
|
||||
z-index: 2;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
min-height: 0 !important;
|
||||
height: 48px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.fixed-bar-mobile {
|
||||
|
||||
@@ -4,71 +4,107 @@
|
||||
<v-card-title class="py-2">
|
||||
{{ $t("asset.assets") }}
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2"></v-divider>
|
||||
<v-list v-if="value.length > 0" :flat="!edit">
|
||||
<v-list-item v-for="(item, i) in value" :key="i">
|
||||
<v-list-item-icon class="ma-auto">
|
||||
<v-tooltip bottom>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-icon v-bind="attrs" v-on="on">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ getIconDefinition(item.icon).title }}</span>
|
||||
</v-tooltip>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title class="pl-2">
|
||||
{{ item.name }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
<v-divider class="mx-2" />
|
||||
<v-list
|
||||
v-if="value.length > 0"
|
||||
:flat="!edit"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(item, i) in value"
|
||||
:key="i"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="ma-auto">
|
||||
<v-tooltip bottom>
|
||||
<template #activator="{ props }">
|
||||
<v-icon v-bind="props">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<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-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-btn>
|
||||
<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-btn>
|
||||
<AppButtonCopy color="" :copy-text="assetEmbed(item.fileName)" />
|
||||
<AppButtonCopy
|
||||
color=""
|
||||
:copy-text="assetEmbed(item.fileName ?? '')"
|
||||
/>
|
||||
</div>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
<div class="d-flex ml-auto mt-2">
|
||||
<v-spacer></v-spacer>
|
||||
<v-spacer />
|
||||
<BaseDialog
|
||||
v-model="state.newAssetDialog"
|
||||
:title="$tc('asset.new-asset')"
|
||||
:title="$t('asset.new-asset')"
|
||||
:icon="getIconDefinition(state.newAsset.icon).icon"
|
||||
can-submit
|
||||
@submit="addAsset"
|
||||
>
|
||||
<template #activator>
|
||||
<BaseButton v-if="edit" small create @click="state.newAssetDialog = true" />
|
||||
<BaseButton
|
||||
v-if="edit"
|
||||
size="small"
|
||||
create
|
||||
@click="state.newAssetDialog = true"
|
||||
/>
|
||||
</template>
|
||||
<v-card-text class="pt-4">
|
||||
<v-text-field v-model="state.newAsset.name" 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">
|
||||
<v-select
|
||||
v-model="state.newAsset.icon"
|
||||
dense
|
||||
density="compact"
|
||||
:prepend-icon="getIconDefinition(state.newAsset.icon).icon"
|
||||
:items="iconOptions"
|
||||
item-text="title"
|
||||
item-title="title"
|
||||
item-value="name"
|
||||
class="mr-2"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<v-list-item-avatar>
|
||||
<v-avatar>
|
||||
<v-icon class="mr-auto">
|
||||
{{ item.icon }}
|
||||
{{ item.raw.icon }}
|
||||
</v-icon>
|
||||
</v-list-item-avatar>
|
||||
</v-avatar>
|
||||
{{ item.title }}
|
||||
</template>
|
||||
</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>
|
||||
{{ state.fileObject.name }}
|
||||
</v-card-text>
|
||||
@@ -78,13 +114,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, useContext } from "@nuxtjs/composition-api";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { detectServerBaseUrl } from "~/composables/use-utils";
|
||||
import { RecipeAsset } from "~/lib/api/types/recipe";
|
||||
import type { RecipeAsset } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
@@ -94,7 +128,7 @@ export default defineComponent({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Array as () => RecipeAsset[],
|
||||
required: true,
|
||||
},
|
||||
@@ -103,6 +137,7 @@ export default defineComponent({
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const api = useUserApi();
|
||||
|
||||
@@ -115,7 +150,8 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
const { $globals, i18n, req } = useContext();
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const iconOptions = [
|
||||
{
|
||||
@@ -145,10 +181,10 @@ export default defineComponent({
|
||||
},
|
||||
];
|
||||
|
||||
const serverBase = detectServerBaseUrl(req);
|
||||
const serverBase = useRequestURL().origin;
|
||||
|
||||
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();
|
||||
@@ -181,7 +217,7 @@ export default defineComponent({
|
||||
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.fileObject = {} as File;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<v-lazy>
|
||||
<v-hover v-slot="{ hover }" :open-delay="50">
|
||||
<v-hover
|
||||
v-slot="{ isHovering, props }"
|
||||
:open-delay="50"
|
||||
>
|
||||
<v-card
|
||||
:class="{ 'on-hover': hover }"
|
||||
v-bind="props"
|
||||
:class="{ 'on-hover': isHovering }"
|
||||
:style="{ cursor }"
|
||||
:elevation="hover ? 12 : 2"
|
||||
:elevation="isHovering ? 12 : 2"
|
||||
:to="recipeRoute"
|
||||
:min-height="imageHeight + 75"
|
||||
@click.self="$emit('click')"
|
||||
@@ -14,11 +18,15 @@
|
||||
:height="imageHeight"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
small
|
||||
size="small"
|
||||
:image-version="image"
|
||||
>
|
||||
<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">
|
||||
<div class="descriptionWrapper">
|
||||
<SafeMarkdown :source="description" />
|
||||
@@ -27,24 +35,47 @@
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</RecipeCardImage>
|
||||
<v-card-title class="my-n3 px-2 mb-n6">
|
||||
<v-card-title class="mb-n3 px-4">
|
||||
<div class="headerClass">
|
||||
{{ name }}
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<slot name="actions">
|
||||
<v-card-actions v-if="showRecipeContent" class="px-1">
|
||||
<RecipeFavoriteBadge v-if="isOwnGroup" class="absolute" :recipe-id="recipeId" show-always />
|
||||
<v-card-actions
|
||||
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" />
|
||||
<v-spacer></v-spacer>
|
||||
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" v-on="$listeners" />
|
||||
<RecipeRating
|
||||
class="ml-n2"
|
||||
: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 -->
|
||||
<RecipeContextMenu
|
||||
v-if="isOwnGroup"
|
||||
color="grey darken-2"
|
||||
color="grey-darken-2"
|
||||
:slug="slug"
|
||||
:name="name"
|
||||
:recipe-id="recipeId"
|
||||
@@ -62,14 +93,13 @@
|
||||
/>
|
||||
</v-card-actions>
|
||||
</slot>
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</v-lazy>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeChips from "./RecipeChips.vue";
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
@@ -77,7 +107,7 @@ import RecipeCardImage from "./RecipeCardImage.vue";
|
||||
import RecipeRating from "./RecipeRating.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage },
|
||||
props: {
|
||||
name: {
|
||||
@@ -119,12 +149,13 @@ export default defineComponent({
|
||||
default: 200,
|
||||
},
|
||||
},
|
||||
emits: ["click", "delete"],
|
||||
setup(props) {
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
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 recipeRoute = computed<string>(() => {
|
||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||
@@ -159,7 +190,7 @@ export default defineComponent({
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.descriptionWrapper{
|
||||
.descriptionWrapper {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 8;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<v-img
|
||||
v-if="!fallBackImage"
|
||||
:height="height"
|
||||
cover
|
||||
min-height="125"
|
||||
max-height="fill-height"
|
||||
:src="getImage(recipeId)"
|
||||
@@ -9,21 +10,28 @@
|
||||
@load="fallBackImage = false"
|
||||
@error="fallBackImage = true"
|
||||
>
|
||||
<slot> </slot>
|
||||
<slot />
|
||||
</v-img>
|
||||
<div v-else class="icon-slot" @click="$emit('click')">
|
||||
<v-icon color="primary" class="icon-position" :size="iconSize">
|
||||
<div
|
||||
v-else
|
||||
class="icon-slot"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<v-icon
|
||||
color="primary"
|
||||
class="icon-position"
|
||||
:size="iconSize"
|
||||
>
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
<slot> </slot>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, watch } from "@nuxtjs/composition-api";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
tiny: {
|
||||
type: Boolean,
|
||||
@@ -55,9 +63,10 @@ export default defineComponent({
|
||||
},
|
||||
height: {
|
||||
type: [Number, String],
|
||||
default: "fill-height",
|
||||
default: "100%",
|
||||
},
|
||||
},
|
||||
emits: ["click"],
|
||||
setup(props) {
|
||||
const api = useUserApi();
|
||||
|
||||
@@ -75,7 +84,7 @@ export default defineComponent({
|
||||
() => props.recipeId,
|
||||
() => {
|
||||
fallBackImage.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function getImage(recipeId: string) {
|
||||
|
||||
@@ -1,81 +1,121 @@
|
||||
<template>
|
||||
<div :style="`height: ${height}`">
|
||||
<div :style="`height: ${height}px;`">
|
||||
<v-expand-transition>
|
||||
<v-card
|
||||
:ripple="false"
|
||||
:class="isFlat ? 'mx-auto flat' : 'mx-auto'"
|
||||
:style="{ cursor }"
|
||||
hover
|
||||
:to="$listeners.selected ? undefined : recipeRoute"
|
||||
height="100%"
|
||||
:to="$attrs.selected ? undefined : recipeRoute"
|
||||
@click="$emit('selected')"
|
||||
>
|
||||
<v-img v-if="vertical" class="rounded-sm">
|
||||
<v-img
|
||||
v-if="vertical"
|
||||
class="rounded-sm"
|
||||
cover
|
||||
>
|
||||
<RecipeCardImage
|
||||
:icon-size="100"
|
||||
:height="height"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
small
|
||||
size="small"
|
||||
:image-version="image"
|
||||
:height="height"
|
||||
/>
|
||||
</v-img>
|
||||
<v-list-item three-line :class="vertical ? 'px-2' : 'px-0'">
|
||||
<slot v-if="!vertical" name="avatar">
|
||||
<v-list-item-avatar tile :height="height" width="125" class="v-mobile-img rounded-sm my-0">
|
||||
<v-list-item
|
||||
lines="two"
|
||||
class="py-0"
|
||||
:class="vertical ? 'px-2' : 'px-0'"
|
||||
item-props
|
||||
height="100%"
|
||||
density="compact"
|
||||
>
|
||||
<template #prepend>
|
||||
<slot
|
||||
v-if="!vertical"
|
||||
name="avatar"
|
||||
>
|
||||
<RecipeCardImage
|
||||
:icon-size="100"
|
||||
:height="height"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
: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
|
||||
/>
|
||||
</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 -->
|
||||
<!-- We also add padding to the v-rating above to compensate -->
|
||||
<RecipeContextMenu
|
||||
v-if="isOwnGroup && showRecipeContent"
|
||||
:slug="slug"
|
||||
:menu-icon="$globals.icons.dotsHorizontal"
|
||||
:name="name"
|
||||
:recipe-id="recipeId"
|
||||
:use-items="{
|
||||
delete: false,
|
||||
edit: false,
|
||||
download: true,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: false,
|
||||
printPreferences: false,
|
||||
share: true,
|
||||
}"
|
||||
@deleted="$emit('delete', slug)"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</v-list-item-content>
|
||||
<!-- 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 -->
|
||||
<RecipeContextMenu
|
||||
v-if="isOwnGroup && showRecipeContent"
|
||||
:slug="slug"
|
||||
:menu-icon="$globals.icons.dotsHorizontal"
|
||||
:name="name"
|
||||
:recipe-id="recipeId"
|
||||
class="ml-auto"
|
||||
:use-items="{
|
||||
delete: false,
|
||||
edit: false,
|
||||
download: true,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: false,
|
||||
printPreferences: false,
|
||||
share: true,
|
||||
}"
|
||||
@deleted="$emit('delete', slug)"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</slot>
|
||||
</v-list-item>
|
||||
<slot />
|
||||
</v-card>
|
||||
@@ -84,7 +124,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
import RecipeCardImage from "./RecipeCardImage.vue";
|
||||
@@ -92,7 +131,7 @@ import RecipeRating from "./RecipeRating.vue";
|
||||
import RecipeChips from "./RecipeChips.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeFavoriteBadge,
|
||||
RecipeContextMenu,
|
||||
@@ -139,27 +178,23 @@ export default defineComponent({
|
||||
default: false,
|
||||
},
|
||||
height: {
|
||||
type: [Number, String],
|
||||
type: [Number],
|
||||
default: 150,
|
||||
},
|
||||
imageHeight: {
|
||||
type: [Number, String],
|
||||
default: "fill-height",
|
||||
},
|
||||
},
|
||||
emits: ["selected", "delete"],
|
||||
setup(props) {
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
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 recipeRoute = computed<string>(() => {
|
||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||
});
|
||||
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
|
||||
|
||||
|
||||
return {
|
||||
isOwnGroup,
|
||||
recipeRoute,
|
||||
@@ -170,7 +205,10 @@ export default defineComponent({
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
:deep(.v-list-item__prepend) {
|
||||
height: 100%;
|
||||
}
|
||||
.v-mobile-img {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
@@ -198,8 +236,9 @@ export default defineComponent({
|
||||
align-self: start !important;
|
||||
}
|
||||
|
||||
.flat, .theme--dark .flat {
|
||||
box-shadow: none!important;
|
||||
background-color: transparent!important;
|
||||
.flat,
|
||||
.theme--dark .flat {
|
||||
box-shadow: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,67 +1,102 @@
|
||||
<template>
|
||||
<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">
|
||||
<v-icon v-if="title" large left>
|
||||
<v-icon
|
||||
v-if="title"
|
||||
size="large"
|
||||
start
|
||||
>
|
||||
{{ displayTitleIcon }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
|
||||
<v-toolbar-title class="headline">
|
||||
{{ title }}
|
||||
</v-toolbar-title>
|
||||
</slot>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn :icon="$vuetify.breakpoint.xsOnly" text :disabled="recipes.length === 0" @click="navigateRandom">
|
||||
<v-icon :left="!$vuetify.breakpoint.xsOnly">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
:icon="$vuetify.display.xs"
|
||||
variant="text"
|
||||
:disabled="recipes.length === 0"
|
||||
@click="navigateRandom"
|
||||
>
|
||||
<v-icon :start="!$vuetify.display.xs">
|
||||
{{ $globals.icons.diceMultiple }}
|
||||
</v-icon>
|
||||
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.random") }}
|
||||
{{ $vuetify.display.xs ? null : $t("general.random") }}
|
||||
</v-btn>
|
||||
|
||||
<v-menu v-if="$listeners.sortRecipes" offset-y left>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn text :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" :loading="sortLoading" v-on="on">
|
||||
<v-icon :left="!$vuetify.breakpoint.xsOnly">
|
||||
<v-menu
|
||||
v-if="!disableSort"
|
||||
offset-y
|
||||
start
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
variant="text"
|
||||
:icon="$vuetify.display.xs"
|
||||
v-bind="props"
|
||||
:loading="sortLoading"
|
||||
>
|
||||
<v-icon :start="!$vuetify.display.xs">
|
||||
{{ preferences.sortIcon }}
|
||||
</v-icon>
|
||||
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.sort") }}
|
||||
{{ $vuetify.display.xs ? null : $t("general.sort") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item @click="sortRecipes(EVENTS.az)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.orderAlphabeticalAscending }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-icon class="mr-2" inline>
|
||||
{{ $globals.icons.orderAlphabeticalAscending }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
|
||||
</div>
|
||||
</v-list-item>
|
||||
<v-list-item @click="sortRecipes(EVENTS.rating)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.star }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title>
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-icon class="mr-2" inline>
|
||||
{{ $globals.icons.star }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title>
|
||||
</div>
|
||||
</v-list-item>
|
||||
<v-list-item @click="sortRecipes(EVENTS.created)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.newBox }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title>
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-icon class="mr-2" inline>
|
||||
{{ $globals.icons.newBox }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title>
|
||||
</div>
|
||||
</v-list-item>
|
||||
<v-list-item @click="sortRecipes(EVENTS.updated)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.update }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-icon class="mr-2" inline>
|
||||
{{ $globals.icons.update }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
|
||||
</div>
|
||||
</v-list-item>
|
||||
<v-list-item @click="sortRecipes(EVENTS.lastMade)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.chefHat }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-icon class="mr-2" inline>
|
||||
{{ $globals.icons.chefHat }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<ContextMenu
|
||||
v-if="!$vuetify.breakpoint.smAndDown"
|
||||
v-if="!$vuetify.display.smAndDown"
|
||||
:items="[
|
||||
{
|
||||
title: $tc('general.toggle-view'),
|
||||
title: $t('general.toggle-view'),
|
||||
icon: $globals.icons.eye,
|
||||
event: 'toggle-dense-view',
|
||||
},
|
||||
@@ -72,84 +107,75 @@
|
||||
<div v-if="recipes && ready">
|
||||
<div class="mt-2">
|
||||
<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-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"
|
||||
:sm="singleColumn ? '12' : '12'"
|
||||
:md="singleColumn ? '12' : '6'"
|
||||
:lg="singleColumn ? '12' : '4'"
|
||||
:xl="singleColumn ? '12' : '3'"
|
||||
>
|
||||
<v-lazy>
|
||||
<RecipeCardMobile
|
||||
: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>
|
||||
<RecipeCardMobile
|
||||
: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>
|
||||
</div>
|
||||
<v-card v-intersect="infiniteScroll"></v-card>
|
||||
<v-card v-intersect="infiniteScroll" />
|
||||
<v-fade-transition>
|
||||
<AppLoader v-if="loading" :loading="loading" />
|
||||
<AppLoader
|
||||
v-if="loading"
|
||||
:loading="loading"
|
||||
/>
|
||||
</v-fade-transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 RecipeCard from "./RecipeCard.vue";
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useAsyncKey } from "~/composables/use-utils";
|
||||
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 { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||
|
||||
const REPLACE_RECIPES_EVENT = "replaceRecipes";
|
||||
const APPEND_RECIPES_EVENT = "appendRecipes";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeCard,
|
||||
RecipeCardMobile,
|
||||
@@ -159,6 +185,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableSort: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
@@ -181,6 +211,7 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const preferences = useUserSortPreferences();
|
||||
|
||||
const EVENTS = {
|
||||
@@ -192,10 +223,11 @@ export default defineComponent({
|
||||
shuffle: "shuffle",
|
||||
};
|
||||
|
||||
const { $auth, $globals, $vuetify } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const useMobileCards = computed(() => {
|
||||
return $vuetify.breakpoint.smAndDown || preferences.value.useMobileCards;
|
||||
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
|
||||
});
|
||||
|
||||
const displayTitleIcon = computed(() => {
|
||||
@@ -207,7 +239,7 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
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 perPage = 32;
|
||||
@@ -259,14 +291,14 @@ export default defineComponent({
|
||||
watch(
|
||||
() => props.query,
|
||||
async (newValue: RecipeSearchQuery | undefined) => {
|
||||
const newValueString = JSON.stringify(newValue)
|
||||
const newValueString = JSON.stringify(newValue);
|
||||
if (lastQuery !== newValueString) {
|
||||
lastQuery = newValueString;
|
||||
ready.value = false;
|
||||
await initRecipes();
|
||||
ready.value = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function initRecipes() {
|
||||
@@ -286,29 +318,26 @@ export default defineComponent({
|
||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
|
||||
const infiniteScroll = useThrottleFn(() => {
|
||||
useAsync(async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
const infiniteScroll = useThrottleFn(async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
page.value = page.value + 1;
|
||||
loading.value = true;
|
||||
page.value = page.value + 1;
|
||||
|
||||
const newRecipes = await fetchRecipes();
|
||||
if (newRecipes.length < perPage) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
if (newRecipes.length) {
|
||||
context.emit(APPEND_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
const newRecipes = await fetchRecipes();
|
||||
if (newRecipes.length < perPage) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
if (newRecipes.length) {
|
||||
context.emit(APPEND_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}, useAsyncKey());
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
|
||||
|
||||
function sortRecipes(sortType: string) {
|
||||
async function sortRecipes(sortType: string) {
|
||||
if (state.sortLoading || loading.value) {
|
||||
return;
|
||||
}
|
||||
@@ -318,13 +347,14 @@ export default defineComponent({
|
||||
ascIcon: string,
|
||||
descIcon: string,
|
||||
defaultOrderDirection = "asc",
|
||||
filterNull = false
|
||||
filterNull = false,
|
||||
) {
|
||||
if (preferences.value.orderBy !== orderBy) {
|
||||
preferences.value.orderBy = orderBy;
|
||||
preferences.value.orderDirection = defaultOrderDirection;
|
||||
preferences.value.filterNull = filterNull;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
}
|
||||
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
|
||||
@@ -337,7 +367,7 @@ export default defineComponent({
|
||||
$globals.icons.sortAlphabeticalAscending,
|
||||
$globals.icons.sortAlphabeticalDescending,
|
||||
"asc",
|
||||
false
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case EVENTS.rating:
|
||||
@@ -349,7 +379,7 @@ export default defineComponent({
|
||||
$globals.icons.sortCalendarAscending,
|
||||
$globals.icons.sortCalendarDescending,
|
||||
"desc",
|
||||
false
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case EVENTS.updated:
|
||||
@@ -361,7 +391,7 @@ export default defineComponent({
|
||||
$globals.icons.sortCalendarAscending,
|
||||
$globals.icons.sortCalendarDescending,
|
||||
"desc",
|
||||
true
|
||||
true,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
@@ -369,21 +399,19 @@ export default defineComponent({
|
||||
return;
|
||||
}
|
||||
|
||||
useAsync(async () => {
|
||||
// reset pagination
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
// reset pagination
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
|
||||
state.sortLoading = true;
|
||||
loading.value = true;
|
||||
state.sortLoading = true;
|
||||
loading.value = true;
|
||||
|
||||
// fetch new recipes
|
||||
const newRecipes = await fetchRecipes();
|
||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
// fetch new recipes
|
||||
const newRecipes = await fetchRecipes();
|
||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
|
||||
state.sortLoading = false;
|
||||
loading.value = false;
|
||||
}, useAsyncKey());
|
||||
state.sortLoading = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function navigateRandom() {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<template>
|
||||
<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-for="category in items.slice(0, limit)"
|
||||
:key="category.name"
|
||||
label
|
||||
class="ma-1"
|
||||
color="accent"
|
||||
:small="small"
|
||||
variant="flat"
|
||||
:size="small ? 'small' : 'default'"
|
||||
dark
|
||||
|
||||
@click.prevent="() => $emit('item-selected', category, urlPrefix)"
|
||||
@@ -18,12 +24,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
|
||||
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
truncate: {
|
||||
type: Boolean,
|
||||
@@ -54,13 +59,14 @@ export default defineComponent({
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ["item-selected"],
|
||||
setup(props) {
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
|
||||
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>(() => {
|
||||
return `/g/${groupSlug.value}`
|
||||
return `/g/${groupSlug.value}`;
|
||||
});
|
||||
|
||||
function truncateText(text: string, length = 20, clamp = "...") {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
:title="$t('recipe.delete-recipe')"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
can-confirm
|
||||
@confirm="deleteRecipe()"
|
||||
>
|
||||
<v-card-text>
|
||||
@@ -19,16 +20,17 @@
|
||||
:title="$t('recipe.duplicate')"
|
||||
color="primary"
|
||||
:icon="$globals.icons.duplicate"
|
||||
can-confirm
|
||||
@confirm="duplicateRecipe()"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="recipeName"
|
||||
dense
|
||||
density="compact"
|
||||
:label="$t('recipe.recipe-name')"
|
||||
autofocus
|
||||
@keyup.enter="duplicateRecipe()"
|
||||
></v-text-field>
|
||||
/>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<BaseDialog
|
||||
@@ -36,6 +38,7 @@
|
||||
:title="$t('recipe.add-recipe-to-mealplan')"
|
||||
color="primary"
|
||||
:icon="$globals.icons.calendar"
|
||||
can-confirm
|
||||
@confirm="addRecipeToPlan()"
|
||||
>
|
||||
<v-card-text>
|
||||
@@ -47,22 +50,21 @@
|
||||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<template #activator="{ props }">
|
||||
<v-text-field
|
||||
v-model="newMealdate"
|
||||
v-model="newMealdateString"
|
||||
:label="$t('general.date')"
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="attrs"
|
||||
v-bind="props"
|
||||
readonly
|
||||
v-on="on"
|
||||
></v-text-field>
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
v-model="newMealdate"
|
||||
no-title
|
||||
hide-header
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@input="pickerMenu = false"
|
||||
@update:model-value="pickerMenu = false"
|
||||
/>
|
||||
</v-menu>
|
||||
<v-select
|
||||
@@ -70,7 +72,9 @@
|
||||
:return-object="false"
|
||||
:items="planTypeOptions"
|
||||
:label="$t('recipe.entry-type')"
|
||||
></v-select>
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
/>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<RecipeDialogAddToShoppingList
|
||||
@@ -81,35 +85,53 @@
|
||||
/>
|
||||
<v-menu
|
||||
offset-y
|
||||
left
|
||||
start
|
||||
:bottom="!menuTop"
|
||||
:nudge-bottom="!menuTop ? '5' : '0'"
|
||||
:top="menuTop"
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="$vuetify.breakpoint.mdAndUp"
|
||||
:open-on-hover="$vuetify.display.mdAndUp"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent>
|
||||
<v-icon>{{ icon }}</v-icon>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
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>
|
||||
</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-icon>
|
||||
<v-icon :color="item.color"> {{ item.icon }} </v-icon>
|
||||
</v-list-item-icon>
|
||||
<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>
|
||||
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
|
||||
<v-divider />
|
||||
<v-list-group @click.stop>
|
||||
<template #activator>
|
||||
<v-list-item-title>{{ $tc("recipe.recipe-actions") }}</v-list-item-title>
|
||||
<template #activator="{ props }">
|
||||
<v-list-item-title v-bind="props">
|
||||
{{ $t("recipe.recipe-actions") }}
|
||||
</v-list-item-title>
|
||||
</template>
|
||||
<v-list dense class="ma-0 pa-0">
|
||||
<v-list density="compact" class="ma-0 pa-0">
|
||||
<v-list-item
|
||||
v-for="(action, index) in recipeActions"
|
||||
:key="index"
|
||||
@@ -129,7 +151,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, toRefs, useContext, useRoute, useRouter, ref } from "@nuxtjs/composition-api";
|
||||
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
|
||||
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.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 { alert } from "~/composables/use-toast";
|
||||
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
|
||||
import { PlanEntryType } from "~/lib/api/types/meal-plan";
|
||||
import { useAxiosDownloader } from "~/composables/api/use-axios-download";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
|
||||
import type { PlanEntryType } from "~/lib/api/types/meal-plan";
|
||||
import { useDownloader } from "~/composables/api/use-downloader";
|
||||
|
||||
export interface ContextMenuIncludes {
|
||||
delete: boolean;
|
||||
edit: boolean;
|
||||
download: boolean;
|
||||
duplicate: boolean;
|
||||
mealplanner: boolean;
|
||||
shoppingList: boolean;
|
||||
print: boolean;
|
||||
@@ -164,12 +186,12 @@ export interface ContextMenuItem {
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeDialogAddToShoppingList,
|
||||
RecipeDialogPrintPreferences,
|
||||
RecipeDialogShare,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
useItems: {
|
||||
type: Object as () => ContextMenuIncludes,
|
||||
@@ -233,6 +255,7 @@ export default defineComponent({
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
emits: ["delete"],
|
||||
setup(props, context) {
|
||||
const api = useUserApi();
|
||||
|
||||
@@ -246,17 +269,23 @@ export default defineComponent({
|
||||
recipeName: props.name,
|
||||
loading: false,
|
||||
menuItems: [] as ContextMenuItem[],
|
||||
newMealdate: "",
|
||||
newMealdate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
|
||||
newMealType: "dinner" as PlanEntryType,
|
||||
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 { isOwnGroup } = useLoggedInState();
|
||||
|
||||
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(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
@@ -267,63 +296,63 @@ export default defineComponent({
|
||||
|
||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||
edit: {
|
||||
title: i18n.tc("general.edit"),
|
||||
title: i18n.t("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
color: undefined,
|
||||
event: "edit",
|
||||
isPublic: false,
|
||||
},
|
||||
delete: {
|
||||
title: i18n.tc("general.delete"),
|
||||
title: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
color: undefined,
|
||||
event: "delete",
|
||||
isPublic: false,
|
||||
},
|
||||
download: {
|
||||
title: i18n.tc("general.download"),
|
||||
title: i18n.t("general.download"),
|
||||
icon: $globals.icons.download,
|
||||
color: undefined,
|
||||
event: "download",
|
||||
isPublic: false,
|
||||
},
|
||||
duplicate: {
|
||||
title: i18n.tc("general.duplicate"),
|
||||
title: i18n.t("general.duplicate"),
|
||||
icon: $globals.icons.duplicate,
|
||||
color: undefined,
|
||||
event: "duplicate",
|
||||
isPublic: false,
|
||||
},
|
||||
mealplanner: {
|
||||
title: i18n.tc("recipe.add-to-plan"),
|
||||
title: i18n.t("recipe.add-to-plan"),
|
||||
icon: $globals.icons.calendar,
|
||||
color: undefined,
|
||||
event: "mealplanner",
|
||||
isPublic: false,
|
||||
},
|
||||
shoppingList: {
|
||||
title: i18n.tc("recipe.add-to-list"),
|
||||
title: i18n.t("recipe.add-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
isPublic: false,
|
||||
},
|
||||
print: {
|
||||
title: i18n.tc("general.print"),
|
||||
title: i18n.t("general.print"),
|
||||
icon: $globals.icons.printer,
|
||||
color: undefined,
|
||||
event: "print",
|
||||
isPublic: true,
|
||||
},
|
||||
printPreferences: {
|
||||
title: i18n.tc("general.print-preferences"),
|
||||
title: i18n.t("general.print-preferences"),
|
||||
icon: $globals.icons.printerSettings,
|
||||
color: undefined,
|
||||
event: "printPreferences",
|
||||
isPublic: true,
|
||||
},
|
||||
share: {
|
||||
title: i18n.tc("general.share"),
|
||||
title: i18n.t("general.share"),
|
||||
icon: $globals.icons.shareVariant,
|
||||
color: undefined,
|
||||
event: "share",
|
||||
@@ -350,8 +379,10 @@ export default defineComponent({
|
||||
// Context Menu Event Handler
|
||||
|
||||
const shoppingLists = ref<ShoppingListSummary[]>();
|
||||
const recipeRef = ref<Recipe>(props.recipe);
|
||||
const recipeRefWithScale = computed(() => recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined);
|
||||
const recipeRef = ref<Recipe | undefined>(props.recipe);
|
||||
const recipeRefWithScale = computed(() =>
|
||||
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
|
||||
);
|
||||
|
||||
async function getShoppingLists() {
|
||||
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
||||
@@ -371,13 +402,15 @@ export default defineComponent({
|
||||
const groupRecipeActionsStore = useGroupRecipeActions();
|
||||
|
||||
async function executeRecipeAction(action: GroupRecipeActionOut) {
|
||||
if (!props.recipe) return;
|
||||
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
|
||||
|
||||
if (action.actionType === "post") {
|
||||
if (!response?.error) {
|
||||
alert.success(i18n.tc("events.message-sent"));
|
||||
} else {
|
||||
alert.error(i18n.tc("events.something-went-wrong"));
|
||||
alert.success(i18n.t("events.message-sent"));
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("events.something-went-wrong"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -390,7 +423,7 @@ export default defineComponent({
|
||||
context.emit("delete", props.slug);
|
||||
}
|
||||
|
||||
const download = useAxiosDownloader();
|
||||
const download = useDownloader();
|
||||
|
||||
async function handleDownloadEvent() {
|
||||
const { data } = await api.recipes.getZipToken(props.slug);
|
||||
@@ -402,7 +435,7 @@ export default defineComponent({
|
||||
|
||||
async function addRecipeToPlan() {
|
||||
const { response } = await api.mealplans.createOne({
|
||||
date: state.newMealdate,
|
||||
date: newMealdateString.value,
|
||||
entryType: state.newMealType,
|
||||
title: "",
|
||||
text: "",
|
||||
@@ -411,7 +444,8 @@ export default defineComponent({
|
||||
|
||||
if (response?.status === 201) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -424,6 +458,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
// 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> } = {
|
||||
delete: () => {
|
||||
state.recipeDeleteDialog = true;
|
||||
@@ -448,7 +483,9 @@ export default defineComponent({
|
||||
promises.push(refreshRecipe());
|
||||
}
|
||||
|
||||
Promise.allSettled(promises).then(() => { state.shoppingListDialog = true });
|
||||
Promise.allSettled(promises).then(() => {
|
||||
state.shoppingListDialog = true;
|
||||
});
|
||||
},
|
||||
share: () => {
|
||||
state.shareDialog = true;
|
||||
@@ -472,6 +509,7 @@ export default defineComponent({
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
newMealdateString,
|
||||
recipeRef,
|
||||
recipeRefWithScale,
|
||||
executeRecipeAction,
|
||||
|
||||
@@ -1,41 +1,29 @@
|
||||
<template>
|
||||
<div>
|
||||
<BaseDialog
|
||||
v-model="dialog"
|
||||
:title="$t('data-pages.manage-aliases')"
|
||||
:icon="$globals.icons.edit"
|
||||
:submit-icon="$globals.icons.check"
|
||||
:submit-text="$tc('general.confirm')"
|
||||
@submit="saveAliases"
|
||||
@cancel="$emit('cancel')"
|
||||
>
|
||||
<BaseDialog v-model="dialog" :title="$t('data-pages.manage-aliases')" :icon="$globals.icons.edit"
|
||||
:submit-icon="$globals.icons.check" :submit-text="$t('general.confirm')" can-submit @submit="saveAliases"
|
||||
@cancel="$emit('cancel')">
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-row v-for="alias, i in aliases" :key="i">
|
||||
<v-col cols="10">
|
||||
<v-text-field
|
||||
v-model="alias.name"
|
||||
:label="$t('general.name')"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<v-text-field v-model="alias.name" :label="$t('general.name')" :rules="[validators.required]" />
|
||||
</v-col>
|
||||
<v-col cols="2">
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $tc('general.delete'),
|
||||
event: 'delete'
|
||||
}
|
||||
]"
|
||||
@delete="deleteAlias(i)"
|
||||
/>
|
||||
<BaseButtonGroup :buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
]" @delete="deleteAlias(i)" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<template #custom-card-action>
|
||||
<BaseButton edit @click="createAlias">{{ $t('data-pages.create-alias') }}
|
||||
<BaseButton edit @click="createAlias">
|
||||
{{ $t('data-pages.create-alias') }}
|
||||
<template #icon>
|
||||
{{ $globals.icons.create }}
|
||||
</template>
|
||||
@@ -46,18 +34,17 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref } from "@nuxtjs/composition-api";
|
||||
import { whenever } from "@vueuse/core";
|
||||
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 {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@@ -66,21 +53,22 @@ export default defineComponent({
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["submit", "update:modelValue", "cancel"],
|
||||
setup(props, context) {
|
||||
// V-Model Support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
function createAlias() {
|
||||
aliases.value.push({
|
||||
"name": "",
|
||||
})
|
||||
name: "",
|
||||
});
|
||||
}
|
||||
|
||||
function deleteAlias(index: number) {
|
||||
@@ -97,11 +85,11 @@ export default defineComponent({
|
||||
|
||||
initAliases();
|
||||
whenever(
|
||||
() => props.value,
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
initAliases();
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
function saveAliases() {
|
||||
const seenAliasNames: string[] = [];
|
||||
@@ -111,9 +99,7 @@ export default defineComponent({
|
||||
!alias.name
|
||||
|| alias.name === props.data.name
|
||||
|| alias.name === props.data.pluralName
|
||||
// @ts-ignore only applies to units
|
||||
|| alias.name === props.data.abbreviation
|
||||
// @ts-ignore only applies to units
|
||||
|| alias.name === props.data.pluralAbbreviation
|
||||
|| seenAliasNames.includes(alias.name)
|
||||
) {
|
||||
@@ -122,7 +108,7 @@ export default defineComponent({
|
||||
|
||||
keepAliases.push(alias);
|
||||
seenAliasNames.push(alias.name);
|
||||
})
|
||||
});
|
||||
|
||||
aliases.value = keepAliases;
|
||||
context.emit("submit", keepAliases);
|
||||
@@ -135,7 +121,7 @@ export default defineComponent({
|
||||
deleteAlias,
|
||||
saveAliases,
|
||||
validators,
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -3,60 +3,73 @@
|
||||
v-model="selected"
|
||||
item-key="id"
|
||||
show-select
|
||||
sort-by="dateAdded"
|
||||
sort-desc
|
||||
:sort-by="[{ key: 'dateAdded', order: 'desc' }]"
|
||||
:headers="headers"
|
||||
:items="recipes"
|
||||
:items-per-page="15"
|
||||
class="elevation-0"
|
||||
:loading="loading"
|
||||
@input="setValue(selected)"
|
||||
>
|
||||
<template #body.preappend>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>Hello</td>
|
||||
<td colspan="4"></td>
|
||||
</tr>
|
||||
<template #[`item.name`]="{ item }">
|
||||
<a
|
||||
:href="`/g/${groupSlug}/r/${item.slug}`"
|
||||
style="color: inherit; text-decoration: inherit; "
|
||||
@click="$emit('click')"
|
||||
>{{ item.name }}</a>
|
||||
</template>
|
||||
<template #item.name="{ item }">
|
||||
<a :href="`/g/${groupSlug}/r/${item.slug}`" style="color: inherit; text-decoration: inherit; " @click="$emit('click')">{{ item.name }}</a>
|
||||
<template #[`item.tags`]="{ item }">
|
||||
<RecipeChip
|
||||
small
|
||||
:items="item.tags!"
|
||||
:is-category="false"
|
||||
url-prefix="tags"
|
||||
@item-selected="filterItems"
|
||||
/>
|
||||
</template>
|
||||
<template #item.tags="{ item }">
|
||||
<RecipeChip small :items="item.tags" :is-category="false" url-prefix="tags" @item-selected="filterItems" />
|
||||
<template #[`item.recipeCategory`]="{ item }">
|
||||
<RecipeChip
|
||||
small
|
||||
:items="item.recipeCategory!"
|
||||
@item-selected="filterItems"
|
||||
/>
|
||||
</template>
|
||||
<template #item.recipeCategory="{ item }">
|
||||
<RecipeChip small :items="item.recipeCategory" @item-selected="filterItems" />
|
||||
<template #[`item.tools`]="{ item }">
|
||||
<RecipeChip
|
||||
small
|
||||
:items="item.tools"
|
||||
url-prefix="tools"
|
||||
@item-selected="filterItems"
|
||||
/>
|
||||
</template>
|
||||
<template #item.tools="{ item }">
|
||||
<RecipeChip small :items="item.tools" url-prefix="tools" @item-selected="filterItems" />
|
||||
<template #[`item.userId`]="{ item }">
|
||||
<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 #item.userId="{ item }">
|
||||
<v-list-item class="justify-start">
|
||||
<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 #[`item.dateAdded`]="{ item }">
|
||||
{{ formatDate(item.dateAdded!) }}
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, ref, useContext, useRouter } from "@nuxtjs/composition-api";
|
||||
import UserAvatar from "../User/UserAvatar.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 { UserSummary } from "~/lib/api/types/user";
|
||||
import { RecipeTag } from "~/lib/api/types/household";
|
||||
import type { UserSummary } from "~/lib/api/types/user";
|
||||
import type { RecipeTag } from "~/lib/api/types/household";
|
||||
|
||||
const INPUT_EVENT = "input";
|
||||
const INPUT_EVENT = "update:modelValue";
|
||||
|
||||
interface ShowHeaders {
|
||||
id: boolean;
|
||||
@@ -70,11 +83,11 @@ interface ShowHeaders {
|
||||
dateAdded: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeChip, UserAvatar },
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
modelValue: {
|
||||
type: Array as PropType<Recipe[]>,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
@@ -104,45 +117,48 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
},
|
||||
emits: ["click"],
|
||||
setup(props, context) {
|
||||
const { $auth, i18n } = useContext();
|
||||
const groupSlug = $auth.user?.groupSlug;
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = $auth.user.value?.groupSlug;
|
||||
const router = useRouter();
|
||||
function setValue(value: Recipe[]) {
|
||||
context.emit(INPUT_EVENT, value);
|
||||
}
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => context.emit(INPUT_EVENT, value),
|
||||
});
|
||||
|
||||
const headers = computed(() => {
|
||||
const hdrs = [];
|
||||
const hdrs: Array<{ title: string; value: string; align?: string; sortable?: boolean }> = [];
|
||||
|
||||
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) {
|
||||
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) {
|
||||
hdrs.push({ text: i18n.t("recipe.categories"), value: "recipeCategory" });
|
||||
hdrs.push({ title: i18n.t("recipe.categories"), value: "recipeCategory", sortable: true });
|
||||
}
|
||||
|
||||
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) {
|
||||
hdrs.push({ text: i18n.t("tool.tools"), value: "tools" });
|
||||
hdrs.push({ title: i18n.t("tool.tools"), value: "tools", sortable: true });
|
||||
}
|
||||
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) {
|
||||
hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYieldQuantity" });
|
||||
hdrs.push({ title: i18n.t("recipe.yield"), value: "recipeYieldQuantity", sortable: true });
|
||||
}
|
||||
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) {
|
||||
hdrs.push({ text: i18n.t("general.date-added"), value: "dateAdded" });
|
||||
hdrs.push({ title: i18n.t("general.date-added"), value: "dateAdded", sortable: true });
|
||||
}
|
||||
|
||||
return hdrs;
|
||||
@@ -151,7 +167,8 @@ export default defineComponent({
|
||||
function formatDate(date: string) {
|
||||
try {
|
||||
return i18n.d(Date.parse(date), "medium");
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -181,15 +198,15 @@ export default defineComponent({
|
||||
|
||||
function getMember(id: string) {
|
||||
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 {
|
||||
selected,
|
||||
groupSlug,
|
||||
setValue,
|
||||
headers,
|
||||
formatDate,
|
||||
members,
|
||||
@@ -197,16 +214,5 @@ export default defineComponent({
|
||||
filterItems,
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selected: [],
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
this.selected = val;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<template>
|
||||
<div v-if="dialog">
|
||||
<BaseDialog v-if="shoppingListDialog && ready" v-model="dialog" :title="$t('recipe.add-to-list')" :icon="$globals.icons.cartCheck">
|
||||
<v-container v-if="!shoppingListChoices.length">
|
||||
<BasePageTitle>
|
||||
<template #title>{{ $t('shopping-list.no-shopping-lists-found') }}</template>
|
||||
</BasePageTitle>
|
||||
</v-container>
|
||||
<BaseDialog
|
||||
v-if="shoppingListDialog && ready"
|
||||
v-model="dialog"
|
||||
:title="$t('recipe.add-to-list')"
|
||||
:icon="$globals.icons.cartCheck"
|
||||
>
|
||||
<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
|
||||
v-for="list in shoppingListChoices"
|
||||
@@ -21,14 +28,23 @@
|
||||
</v-card-text>
|
||||
<template #card-actions>
|
||||
<v-btn
|
||||
text
|
||||
variant="text"
|
||||
color="grey"
|
||||
@click="dialog = false"
|
||||
>
|
||||
{{ $t("general.cancel") }}
|
||||
</v-btn>
|
||||
<div class="d-flex justify-end" style="width: 100%;">
|
||||
<v-checkbox v-model="preferences.viewAllLists" hide-details :label="$tc('general.show-all')" class="my-auto mr-4" @click="setShowAllToggled()" />
|
||||
<div
|
||||
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>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
@@ -38,32 +54,52 @@
|
||||
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
|
||||
:icon="$globals.icons.cartCheck"
|
||||
width="70%"
|
||||
:submit-text="$tc('recipe.add-to-list')"
|
||||
:submit-text="$t('recipe.add-to-list')"
|
||||
can-submit
|
||||
@submit="addRecipesToList()"
|
||||
>
|
||||
<div style="max-height: 70vh; overflow-y: auto">
|
||||
<v-card
|
||||
v-for="(recipeSection, recipeSectionIndex) in recipeIngredientSections" :key="recipeSection.recipeId + recipeSectionIndex"
|
||||
v-for="(recipeSection, recipeSectionIndex) in recipeIngredientSections"
|
||||
:key="recipeSection.recipeId + recipeSectionIndex"
|
||||
elevation="0"
|
||||
height="fit-content"
|
||||
width="100%"
|
||||
>
|
||||
<v-divider v-if="recipeSectionIndex > 0" class="mt-3" />
|
||||
<v-divider
|
||||
v-if="recipeSectionIndex > 0"
|
||||
class="mt-3"
|
||||
/>
|
||||
<v-card-title
|
||||
v-if="recipeIngredientSections.length > 1"
|
||||
class="justify-center text-h5"
|
||||
width="100%"
|
||||
>
|
||||
<v-container style="width: 100%;">
|
||||
<v-row no-gutters class="ma-0 pa-0">
|
||||
<v-col cols="12" align-self="center" class="text-center">
|
||||
<v-row
|
||||
no-gutters
|
||||
class="ma-0 pa-0"
|
||||
>
|
||||
<v-col
|
||||
cols="12"
|
||||
align-self="center"
|
||||
class="text-center"
|
||||
>
|
||||
{{ recipeSection.recipeName }}
|
||||
</v-col>
|
||||
</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 -->
|
||||
<v-col cols="12" align-self="center" class="text-center">
|
||||
({{ $tc("recipe.quantity") }}: {{ recipeSection.recipeScale }})
|
||||
<v-col
|
||||
cols="12"
|
||||
align-self="center"
|
||||
class="text-center"
|
||||
>
|
||||
({{ $t("recipe.quantity") }}: {{ recipeSection.recipeScale }})
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
@@ -73,36 +109,41 @@
|
||||
v-for="(ingredientSection, ingredientSectionIndex) in recipeSection.ingredientSections"
|
||||
: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 }}
|
||||
</v-card-title>
|
||||
<div
|
||||
:class="$vuetify.breakpoint.smAndDown ? '' : 'ingredient-grid'"
|
||||
:style="$vuetify.breakpoint.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
||||
:class="$vuetify.display.smAndDown ? '' : 'ingredient-grid'"
|
||||
:style="$vuetify.display.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(ingredientData, i) in ingredientSection.ingredients"
|
||||
:key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex + i"
|
||||
dense
|
||||
density="compact"
|
||||
@click="recipeIngredientSections[recipeSectionIndex]
|
||||
.ingredientSections[ingredientSectionIndex]
|
||||
.ingredients[i].checked = !recipeIngredientSections[recipeSectionIndex]
|
||||
.ingredientSections[ingredientSectionIndex]
|
||||
.ingredients[i]
|
||||
.checked"
|
||||
.ingredientSections[ingredientSectionIndex]
|
||||
.ingredients[i]
|
||||
.checked"
|
||||
>
|
||||
<v-checkbox
|
||||
hide-details
|
||||
:input-value="ingredientData.checked"
|
||||
:model-value="ingredientData.checked"
|
||||
class="pt-0 my-auto py-auto"
|
||||
color="secondary"
|
||||
density="compact"
|
||||
/>
|
||||
<v-list-item-content :key="ingredientData.ingredient.quantity">
|
||||
<div :key="ingredientData.ingredient.quantity">
|
||||
<RecipeIngredientListItem
|
||||
:ingredient="ingredientData.ingredient"
|
||||
:disable-amount="ingredientData.disableAmount"
|
||||
:scale="recipeSection.recipeScale" />
|
||||
</v-list-item-content>
|
||||
:scale="recipeSection.recipeScale"
|
||||
/>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,12 +155,12 @@
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.checkboxBlankOutline,
|
||||
text: $tc('shopping-list.uncheck-all-items'),
|
||||
text: $t('shopping-list.uncheck-all-items'),
|
||||
event: 'uncheck',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.checkboxOutline,
|
||||
text: $tc('shopping-list.check-all-items'),
|
||||
text: $t('shopping-list.check-all-items'),
|
||||
event: 'check',
|
||||
},
|
||||
]"
|
||||
@@ -132,14 +173,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, ref, useContext, watchEffect } from "@nuxtjs/composition-api";
|
||||
import { toRefs } from "@vueuse/core";
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { useShoppingListPreferences } from "~/composables/use-users/preferences";
|
||||
import { RecipeIngredient, ShoppingListAddRecipeParamsBulk, ShoppingListSummary } from "~/lib/api/types/household";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { RecipeIngredient, ShoppingListAddRecipeParamsBulk, ShoppingListSummary } from "~/lib/api/types/household";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
export interface RecipeWithScale extends Recipe {
|
||||
scale: number;
|
||||
@@ -163,12 +203,12 @@ export interface ShoppingListRecipeIngredientSection {
|
||||
ingredientSections: ShoppingListIngredientSection[];
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeIngredientListItem,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@@ -181,8 +221,10 @@ export default defineComponent({
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const { $auth, i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const api = useUserApi();
|
||||
const preferences = useShoppingListPreferences();
|
||||
const ready = ref(false);
|
||||
@@ -190,10 +232,10 @@ export default defineComponent({
|
||||
// v-model support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
initState();
|
||||
},
|
||||
});
|
||||
@@ -205,11 +247,11 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
const userHousehold = computed(() => {
|
||||
return $auth.user?.householdSlug || "";
|
||||
return $auth.user.value?.householdSlug || "";
|
||||
});
|
||||
|
||||
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[]>([]);
|
||||
@@ -220,7 +262,8 @@ export default defineComponent({
|
||||
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
selectedShoppingList.value = shoppingListChoices.value[0];
|
||||
openShoppingListIngredientDialog(selectedShoppingList.value);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
ready.value = true;
|
||||
}
|
||||
},
|
||||
@@ -234,7 +277,6 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
if (recipeSectionMap.has(recipe.slug)) {
|
||||
// @ts-ignore not undefined, see above
|
||||
recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
|
||||
continue;
|
||||
}
|
||||
@@ -247,7 +289,8 @@ export default defineComponent({
|
||||
recipe.id = data.id || "";
|
||||
recipe.name = data.name || "";
|
||||
recipe.recipeIngredient = data.recipeIngredient;
|
||||
} else if (!recipe.recipeIngredient.length) {
|
||||
}
|
||||
else if (!recipe.recipeIngredient.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -257,7 +300,7 @@ export default defineComponent({
|
||||
checked: !householdsWithFood.includes(userHousehold.value),
|
||||
ingredient: ing,
|
||||
disableAmount: recipe.settings?.disableAmount || false,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
let currentTitle = "";
|
||||
@@ -300,7 +343,7 @@ export default defineComponent({
|
||||
recipeName: recipe.name,
|
||||
recipeScale: recipe.scale,
|
||||
ingredientSections: shoppingListIngredientSections,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
|
||||
@@ -366,13 +409,13 @@ export default defineComponent({
|
||||
recipeId: section.recipeId,
|
||||
recipeIncrementQuantity: section.recipeScale,
|
||||
recipeIngredients: ingredients,
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
|
||||
error ? alert.error(i18n.tc("recipe.failed-to-add-recipes-to-list"))
|
||||
: alert.success(i18n.tc("recipe.successfully-added-to-list"));
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
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.shoppingListIngredientDialog = false;
|
||||
@@ -391,9 +434,9 @@ export default defineComponent({
|
||||
setShowAllToggled,
|
||||
recipeIngredientSections,
|
||||
selectedShoppingList,
|
||||
}
|
||||
};
|
||||
},
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="css">
|
||||
|
||||
@@ -1,54 +1,88 @@
|
||||
<template>
|
||||
<div class="text-center">
|
||||
<v-dialog v-model="dialog" width="800">
|
||||
<template #activator="{ on, attrs }">
|
||||
<BaseButton v-bind="attrs" v-on="on" @click="inputText = inputTextProp">
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
width="800"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<BaseButton
|
||||
v-bind="props"
|
||||
@click="inputText = inputTextProp"
|
||||
>
|
||||
{{ $t("new-recipe.bulk-add") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-app-bar dense dark color="primary" class="mb-2">
|
||||
<v-icon large left>
|
||||
<v-app-bar
|
||||
density="compact"
|
||||
dark
|
||||
color="primary"
|
||||
class="mb-2 position-relative left-0 top-0 w-100"
|
||||
>
|
||||
<v-icon
|
||||
size="large"
|
||||
start
|
||||
>
|
||||
{{ $globals.icons.createAlt }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline"> {{ $t("new-recipe.bulk-add") }}</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-toolbar-title class="headline">
|
||||
{{ $t("new-recipe.bulk-add") }}
|
||||
</v-toolbar-title>
|
||||
<v-spacer />
|
||||
</v-app-bar>
|
||||
|
||||
<v-card-text>
|
||||
<v-textarea
|
||||
v-model="inputText"
|
||||
outlined
|
||||
variant="outlined"
|
||||
rows="12"
|
||||
hide-details
|
||||
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
|
||||
>
|
||||
</v-textarea>
|
||||
/>
|
||||
|
||||
<v-divider></v-divider>
|
||||
<template v-for="(util, idx) in utilities">
|
||||
<v-list-item :key="util.id" dense class="py-1">
|
||||
<v-divider />
|
||||
<template
|
||||
v-for="(util) in utilities"
|
||||
:key="util.id"
|
||||
>
|
||||
<v-list-item
|
||||
density="compact"
|
||||
class="py-1"
|
||||
>
|
||||
<v-list-item-title>
|
||||
<v-list-item-subtitle class="wrap-word">
|
||||
{{ util.description }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-title>
|
||||
<BaseButton small color="info" @click="util.action">
|
||||
<template #icon> {{ $globals.icons.robot }}</template>
|
||||
<BaseButton
|
||||
size="small"
|
||||
color="info"
|
||||
@click="util.action"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.robot }}
|
||||
</template>
|
||||
{{ $t("general.run") }}
|
||||
</BaseButton>
|
||||
</v-list-item>
|
||||
<v-divider :key="`divider-${idx}`" class="mx-2"></v-divider>
|
||||
<v-divider class="mx-2" />
|
||||
</template>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions>
|
||||
<BaseButton cancel @click="dialog = false"> </BaseButton>
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton save color="success" @click="save"> </BaseButton>
|
||||
<BaseButton
|
||||
cancel
|
||||
@click="dialog = false"
|
||||
/>
|
||||
<v-spacer />
|
||||
<BaseButton
|
||||
save
|
||||
color="success"
|
||||
@click="save"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -56,8 +90,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { reactive, toRefs, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
inputTextProp: {
|
||||
type: String,
|
||||
@@ -65,6 +98,7 @@ export default defineComponent({
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["bulk-data"],
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
dialog: false,
|
||||
@@ -72,12 +106,12 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
function splitText() {
|
||||
return state.inputText.split("\n").filter((line) => !(line === "\n" || !line));
|
||||
return state.inputText.split("\n").filter(line => !(line === "\n" || !line));
|
||||
}
|
||||
|
||||
function removeFirstCharacter() {
|
||||
state.inputText = splitText()
|
||||
.map((line) => line.substring(1))
|
||||
.map(line => line.substring(1))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
@@ -108,22 +142,22 @@ export default defineComponent({
|
||||
state.dialog = false;
|
||||
}
|
||||
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
|
||||
const utilities = [
|
||||
{
|
||||
id: "trim-whitespace",
|
||||
description: i18n.tc("new-recipe.trim-whitespace-description"),
|
||||
description: i18n.t("new-recipe.trim-whitespace-description"),
|
||||
action: trimAllLines,
|
||||
},
|
||||
{
|
||||
id: "trim-prefix",
|
||||
description: i18n.tc("new-recipe.trim-prefix-description"),
|
||||
description: i18n.t("new-recipe.trim-prefix-description"),
|
||||
action: removeFirstCharacter,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,16 +2,29 @@
|
||||
<BaseDialog
|
||||
v-model="dialog"
|
||||
:icon="$globals.icons.printerSettings"
|
||||
:title="$tc('general.print-preferences')"
|
||||
:title="$t('general.print-preferences')"
|
||||
width="70%"
|
||||
max-width="816px"
|
||||
>
|
||||
<div class="pa-6">
|
||||
<v-container class="print-config mb-3 pa-0">
|
||||
<v-row>
|
||||
<v-col cols="auto" align-self="center" class="text-center">
|
||||
<div class="text-subtitle-2" style="text-align: center;">{{ $tc('recipe.recipe-image') }}</div>
|
||||
<v-btn-toggle v-model="preferences.imagePosition" mandatory style="width: fit-content;">
|
||||
<v-col
|
||||
cols="auto"
|
||||
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-icon>{{ $globals.icons.dockLeft }}</v-icon>
|
||||
</v-btn>
|
||||
@@ -23,20 +36,37 @@
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</v-col>
|
||||
<v-col cols="auto" align-self="start">
|
||||
<v-col
|
||||
cols="auto"
|
||||
align-self="start"
|
||||
>
|
||||
<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 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-col>
|
||||
<v-col cols="auto" align-self="start">
|
||||
<v-row no-gutters>
|
||||
<v-switch v-model="preferences.showNutrition" hide-details :label="$tc('recipe.nutrition')" />
|
||||
</v-row>
|
||||
<v-col
|
||||
cols="auto"
|
||||
align-self="start"
|
||||
>
|
||||
<v-row no-gutters>
|
||||
<v-switch
|
||||
v-model="preferences.showNutrition"
|
||||
hide-details
|
||||
:label="$t('recipe.nutrition')"
|
||||
/>
|
||||
</v-row>
|
||||
<v-row no-gutters />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
@@ -47,42 +77,43 @@
|
||||
class="print-preview"
|
||||
style="overflow-y: auto;"
|
||||
>
|
||||
<RecipePrintView :recipe="recipe"/>
|
||||
<RecipePrintView :recipe="recipe" />
|
||||
</v-card>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipePrintView,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
recipe: {
|
||||
type: Object as () => Recipe,
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const preferences = useUserPrintPreferences();
|
||||
|
||||
// V-Model Support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -90,7 +121,7 @@ export default defineComponent({
|
||||
dialog,
|
||||
ImagePosition,
|
||||
preferences,
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,37 +1,61 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot v-bind="{ open, close }"> </slot>
|
||||
<v-dialog v-model="dialog" max-width="988px" content-class="top-dialog" :scrollable="false">
|
||||
<v-app-bar sticky dark color="primary lighten-1" :rounded="!$vuetify.breakpoint.xs">
|
||||
<slot v-bind="{ open, close }" />
|
||||
<v-dialog
|
||||
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
|
||||
id="arrow-search"
|
||||
v-model="search.query.value"
|
||||
autofocus
|
||||
solo
|
||||
variant="solo-filled"
|
||||
flat
|
||||
autocomplete="off"
|
||||
background-color="primary lighten-1"
|
||||
bg-color="primary-lighten-1"
|
||||
color="white"
|
||||
dense
|
||||
density="compact"
|
||||
class="mx-2 arrow-search"
|
||||
hide-details
|
||||
single-line
|
||||
:placeholder="$t('search.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>
|
||||
{{ $globals.icons.close }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</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>
|
||||
<div class="mr-auto">
|
||||
{{ $t("search.results") }}
|
||||
</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>
|
||||
|
||||
<RecipeCardMobile
|
||||
@@ -39,13 +63,13 @@
|
||||
:key="index"
|
||||
:tabindex="index"
|
||||
class="ma-1 arrow-nav"
|
||||
:name="recipe.name"
|
||||
:description="recipe.description || ''"
|
||||
:slug="recipe.slug"
|
||||
:rating="recipe.rating"
|
||||
:name="recipe.name ?? ''"
|
||||
:description="recipe.description ?? ''"
|
||||
:slug="recipe.slug ?? ''"
|
||||
:rating="recipe.rating ?? 0"
|
||||
:image="recipe.image"
|
||||
:recipe-id="recipe.id"
|
||||
v-on="$listeners.selected ? { selected: () => handleSelect(recipe) } : {}"
|
||||
:recipe-id="recipe.id ?? ''"
|
||||
v-bind="$attrs.selected ? { selected: () => handleSelect(recipe) } : {}"
|
||||
/>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -53,21 +77,21 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, toRefs, reactive, ref, watch, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
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 { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
|
||||
import { usePublicExploreApi } from "~/composables/api/api-client";
|
||||
|
||||
const SELECTED_EVENT = "selected";
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeCardMobile,
|
||||
},
|
||||
|
||||
setup(_, context) {
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
selectedIndex: -1,
|
||||
@@ -110,13 +134,16 @@ export default defineComponent({
|
||||
if (e.key === "Enter") {
|
||||
console.log(document.activeElement);
|
||||
// (document.activeElement as HTMLElement).click();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
}
|
||||
else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
state.selectedIndex--;
|
||||
} else if (e.key === "ArrowDown") {
|
||||
}
|
||||
else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
state.selectedIndex++;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
return;
|
||||
}
|
||||
selectRecipe();
|
||||
@@ -125,14 +152,15 @@ export default defineComponent({
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
document.removeEventListener("keyup", onUpDown);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
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 advancedSearchUrl = computed(() => `/g/${groupSlug.value}`)
|
||||
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`);
|
||||
watch(route, close);
|
||||
|
||||
function open() {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<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-menu
|
||||
v-model="datePickerMenu"
|
||||
@@ -10,68 +14,94 @@
|
||||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<template #activator="{ props }">
|
||||
<v-text-field
|
||||
v-model="expirationDate"
|
||||
v-model="expirationDateString"
|
||||
:label="$t('recipe-share.expiration-date')"
|
||||
:hint="$t('recipe-share.default-30-days')"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="attrs"
|
||||
v-bind="props"
|
||||
readonly
|
||||
v-on="on"
|
||||
></v-text-field>
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
v-model="expirationDate"
|
||||
no-title
|
||||
hide-header
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@input="datePickerMenu = false"
|
||||
@update:model-value="datePickerMenu = false"
|
||||
/>
|
||||
</v-menu>
|
||||
</v-card-text>
|
||||
<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-list-item v-for="token in tokens" :key="token.id" @click="shareRecipe(token.id)">
|
||||
<v-list-item-avatar color="grey">
|
||||
<v-icon dark class="pa-2"> {{ $globals.icons.link }} </v-icon>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item
|
||||
v-for="token in tokens"
|
||||
:key="token.id"
|
||||
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>
|
||||
<v-list-item-title> {{ $t("recipe-share.expires-at") }} </v-list-item-title>
|
||||
<div class="pl-3 flex-grow-1">
|
||||
<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-list-item-content>
|
||||
|
||||
<v-list-item-action>
|
||||
<v-btn icon @click.stop="deleteToken(token.id)">
|
||||
<v-icon color="error lighten-1"> {{ $globals.icons.delete }} </v-icon>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
@click.stop="deleteToken(token.id)"
|
||||
>
|
||||
<v-icon color="error-lighten-1">
|
||||
{{ $globals.icons.delete }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-action>
|
||||
<v-list-item-action>
|
||||
<v-btn icon @click.stop="copyTokenLink(token.id)">
|
||||
<v-icon color="info lighten-1"> {{ $globals.icons.contentCopy }} </v-icon>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
@click.stop="copyTokenLink(token.id)"
|
||||
>
|
||||
<v-icon color="info-lighten-1">
|
||||
{{ $globals.icons.contentCopy }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-action>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, toRefs, reactive, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
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 { useHouseholdSelf } from "~/composables/use-households";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@@ -84,38 +114,43 @@ export default defineComponent({
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
// V-Model Support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
datePickerMenu: false,
|
||||
expirationDate: "",
|
||||
expirationDate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
|
||||
tokens: [] as RecipeShareToken[],
|
||||
});
|
||||
|
||||
const expirationDateString = computed(() => {
|
||||
return state.expirationDate.toISOString().substring(0, 10);
|
||||
});
|
||||
|
||||
whenever(
|
||||
() => props.value,
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
// Set expiration date to today + 30 Days
|
||||
const today = new Date();
|
||||
const expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
state.expirationDate = expirationDate.toISOString().substring(0, 10);
|
||||
state.expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
refreshTokens();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const { $auth, i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const { household } = useHouseholdSelf();
|
||||
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(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
@@ -128,11 +163,9 @@ export default defineComponent({
|
||||
|
||||
async function createNewToken() {
|
||||
// Convert expiration date to timestamp
|
||||
const expirationDate = new Date(state.expirationDate);
|
||||
|
||||
const { data } = await userApi.recipes.share.createOne({
|
||||
recipeId: props.recipeId,
|
||||
expiresAt: expirationDate.toISOString(),
|
||||
expiresAt: state.expirationDate.toISOString(),
|
||||
});
|
||||
|
||||
if (data) {
|
||||
@@ -142,7 +175,7 @@ export default defineComponent({
|
||||
|
||||
async function deleteToken(id: string) {
|
||||
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() {
|
||||
@@ -187,13 +220,15 @@ export default defineComponent({
|
||||
url: getTokenLink(token),
|
||||
text: getRecipeText() as string,
|
||||
});
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
await copyTokenLink(token);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
expirationDateString,
|
||||
dialog,
|
||||
createNewToken,
|
||||
deleteToken,
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-0">
|
||||
<div class="search-container py-8">
|
||||
<form class="search-box pa-2" @submit.prevent="search">
|
||||
<div class="d-flex justify-center my-2">
|
||||
<v-container
|
||||
fluid
|
||||
class="pa-0"
|
||||
>
|
||||
<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
|
||||
ref="input"
|
||||
v-model="state.search"
|
||||
outlined
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
color="primary"
|
||||
:placeholder="$tc('search.search-placeholder')"
|
||||
:placeholder="$t('search.search-placeholder')"
|
||||
:prepend-inner-icon="$globals.icons.search"
|
||||
@keyup.enter="hideKeyboard"
|
||||
/>
|
||||
@@ -20,134 +26,184 @@
|
||||
<SearchFilter
|
||||
v-if="categories"
|
||||
v-model="selectedCategories"
|
||||
:require-all.sync="state.requireAllCategories"
|
||||
v-model:require-all="state.requireAllCategories"
|
||||
:items="categories"
|
||||
>
|
||||
<v-icon left>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.categories }}
|
||||
</v-icon>
|
||||
{{ $t("category.categories") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Tag Filter -->
|
||||
<SearchFilter v-if="tags" v-model="selectedTags" :require-all.sync="state.requireAllTags" :items="tags">
|
||||
<v-icon left>
|
||||
<SearchFilter
|
||||
v-if="tags"
|
||||
v-model="selectedTags"
|
||||
v-model:require-all="state.requireAllTags"
|
||||
:items="tags"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.tags }}
|
||||
</v-icon>
|
||||
{{ $t("tag.tags") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Tool Filter -->
|
||||
<SearchFilter v-if="tools" v-model="selectedTools" :require-all.sync="state.requireAllTools" :items="tools">
|
||||
<v-icon left>
|
||||
<SearchFilter
|
||||
v-if="tools"
|
||||
v-model="selectedTools"
|
||||
v-model:require-all="state.requireAllTools"
|
||||
:items="tools"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
{{ $t("tool.tools") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Food Filter -->
|
||||
<SearchFilter v-if="foods" v-model="selectedFoods" :require-all.sync="state.requireAllFoods" :items="foods">
|
||||
<v-icon left>
|
||||
<SearchFilter
|
||||
v-if="foods"
|
||||
v-model="selectedFoods"
|
||||
v-model:require-all="state.requireAllFoods"
|
||||
:items="foods"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.foods }}
|
||||
</v-icon>
|
||||
{{ $t("general.foods") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Household Filter -->
|
||||
<SearchFilter v-if="households.length > 1" v-model="selectedHouseholds" :items="households" radio>
|
||||
<v-icon left>
|
||||
<SearchFilter
|
||||
v-if="households.length > 1"
|
||||
v-model="selectedHouseholds"
|
||||
:items="households"
|
||||
radio
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.household }}
|
||||
</v-icon>
|
||||
{{ $t("household.households") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Sort Options -->
|
||||
<v-menu offset-y nudge-bottom="3">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn class="ml-auto" small color="accent" v-bind="attrs" v-on="on">
|
||||
<v-icon :left="!$vuetify.breakpoint.xsOnly">
|
||||
<v-menu
|
||||
offset-y
|
||||
nudge-bottom="3"
|
||||
>
|
||||
<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 }}
|
||||
</v-icon>
|
||||
{{ $vuetify.breakpoint.xsOnly ? null : sortText }}
|
||||
{{ $vuetify.display.xs ? null : sortText }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item @click="toggleOrderDirection()">
|
||||
<v-icon left>
|
||||
{{
|
||||
state.orderDirection === "asc" ?
|
||||
$globals.icons.sortDescending : $globals.icons.sortAscending
|
||||
}}
|
||||
</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-list-item
|
||||
slim
|
||||
density="comfortable"
|
||||
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
|
||||
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
||||
@click="toggleOrderDirection()"
|
||||
/>
|
||||
<v-divider />
|
||||
<v-list-item
|
||||
v-for="v in sortable"
|
||||
: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"
|
||||
>
|
||||
<v-icon left>
|
||||
{{ v.icon }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ v.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
/>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- Settings -->
|
||||
<v-menu offset-y bottom left nudge-bottom="3" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn small color="accent" dark v-bind="attrs" v-on="on">
|
||||
<v-icon small>
|
||||
<v-menu
|
||||
offset-y
|
||||
bottom
|
||||
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 }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-switch v-model="state.auto" :label="$t('search.auto-search')" single-line></v-switch>
|
||||
<v-btn block color="primary" @click="reset">
|
||||
{{ $tc("general.reset") }}
|
||||
<v-switch
|
||||
v-model="state.auto"
|
||||
:label="$t('search.auto-search')"
|
||||
single-line
|
||||
/>
|
||||
<v-btn
|
||||
block
|
||||
color="primary"
|
||||
@click="reset"
|
||||
>
|
||||
{{ $t("general.reset") }}
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
<div v-if="!state.auto" class="search-button-container">
|
||||
<v-btn x-large color="primary" type="submit" block>
|
||||
<v-icon left>
|
||||
<div
|
||||
v-if="!state.auto"
|
||||
class="search-button-container"
|
||||
>
|
||||
<v-btn
|
||||
size="x-large"
|
||||
color="primary"
|
||||
type="submit"
|
||||
block
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.search }}
|
||||
</v-icon>
|
||||
{{ $tc("search.search") }}
|
||||
{{ $t("search.search") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<v-divider></v-divider>
|
||||
<v-divider />
|
||||
<v-container class="mt-6 px-md-6">
|
||||
<RecipeCardSection
|
||||
v-if="state.ready"
|
||||
class="mt-n5"
|
||||
:icon="$globals.icons.silverwareForkKnife"
|
||||
:title="$tc('general.recipes')"
|
||||
:title="$t('general.recipes')"
|
||||
:recipes="recipes"
|
||||
:query="passedQueryWithSeed"
|
||||
disable-sort
|
||||
@item-selected="filterItems"
|
||||
@replaceRecipes="replaceRecipes"
|
||||
@appendRecipes="appendRecipes"
|
||||
@replace-recipes="replaceRecipes"
|
||||
@append-recipes="appendRecipes"
|
||||
/>
|
||||
</v-container>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, defineComponent, useRouter, onMounted, useContext, computed, Ref, useRoute, watch } from "@nuxtjs/composition-api";
|
||||
import { watchDebounced } from "@vueuse/shared";
|
||||
import SearchFilter from "~/components/Domain/SearchFilter.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
@@ -165,17 +221,19 @@ import {
|
||||
} from "~/composables/store";
|
||||
import { useUserSearchQuerySession } from "~/composables/use-users/preferences";
|
||||
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
||||
import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { useLazyRecipes } from "~/composables/recipes";
|
||||
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { SearchFilter, RecipeCardSection },
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const { $auth, $globals, i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const state = ref({
|
||||
@@ -193,7 +251,7 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
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 { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
@@ -236,9 +294,9 @@ export default defineComponent({
|
||||
const passedQueryWithSeed = computed(() => {
|
||||
return {
|
||||
...passedQuery.value,
|
||||
_searchSeed: Date.now().toString()
|
||||
_searchSeed: Date.now().toString(),
|
||||
};
|
||||
})
|
||||
});
|
||||
|
||||
const queryDefaults = {
|
||||
search: "",
|
||||
@@ -248,7 +306,7 @@ export default defineComponent({
|
||||
requireAllTags: false,
|
||||
requireAllTools: false,
|
||||
requireAllFoods: false,
|
||||
}
|
||||
};
|
||||
|
||||
function reset() {
|
||||
state.value.search = queryDefaults.search;
|
||||
@@ -271,11 +329,11 @@ export default defineComponent({
|
||||
|
||||
function toIDArray(array: { id: string }[]) {
|
||||
// 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() {
|
||||
input.value.blur()
|
||||
input.value.blur();
|
||||
}
|
||||
|
||||
const input: Ref<any> = ref(null);
|
||||
@@ -306,7 +364,7 @@ export default defineComponent({
|
||||
requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
|
||||
requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined,
|
||||
},
|
||||
}
|
||||
};
|
||||
await router.push({ query });
|
||||
searchQuerySession.value.recipe = JSON.stringify(query);
|
||||
}
|
||||
@@ -314,7 +372,7 @@ export default defineComponent({
|
||||
function waitUntilAndExecute(
|
||||
condition: () => boolean,
|
||||
callback: () => void,
|
||||
opts = { timeout: 2000, interval: 500 }
|
||||
opts = { timeout: 2000, interval: 500 },
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = {
|
||||
@@ -341,7 +399,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
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 "";
|
||||
return `${sort.name}`;
|
||||
});
|
||||
@@ -349,103 +407,112 @@ export default defineComponent({
|
||||
const sortable = [
|
||||
{
|
||||
icon: $globals.icons.orderAlphabeticalAscending,
|
||||
name: i18n.tc("general.sort-alphabetically"),
|
||||
name: i18n.t("general.sort-alphabetically"),
|
||||
value: "name",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.newBox,
|
||||
name: i18n.tc("general.created"),
|
||||
name: i18n.t("general.created"),
|
||||
value: "created_at",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.chefHat,
|
||||
name: i18n.tc("general.last-made"),
|
||||
name: i18n.t("general.last-made"),
|
||||
value: "last_made",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.star,
|
||||
name: i18n.tc("general.rating"),
|
||||
name: i18n.t("general.rating"),
|
||||
value: "rating",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.update,
|
||||
name: i18n.tc("general.updated"),
|
||||
name: i18n.t("general.updated"),
|
||||
value: "updated_at",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.diceMultiple,
|
||||
name: i18n.tc("general.random"),
|
||||
name: i18n.t("general.random"),
|
||||
value: "random",
|
||||
},
|
||||
];
|
||||
|
||||
watch(
|
||||
() => route.value.query,
|
||||
() => route.query,
|
||||
() => {
|
||||
if (!Object.keys(route.value.query).length) {
|
||||
if (!Object.keys(route.query).length) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) {
|
||||
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>[];
|
||||
} 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>[];
|
||||
} 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>[];
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateSearch() {
|
||||
const query = router.currentRoute.query;
|
||||
const query = router.currentRoute.value.query;
|
||||
if (query.auto?.length) {
|
||||
state.value.auto = query.auto === "true";
|
||||
}
|
||||
|
||||
if (query.search?.length) {
|
||||
state.value.search = query.search as string;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.search = queryDefaults.search;
|
||||
}
|
||||
|
||||
if (query.orderBy?.length) {
|
||||
state.value.orderBy = query.orderBy as string;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.orderBy = queryDefaults.orderBy;
|
||||
}
|
||||
|
||||
if (query.orderDirection?.length) {
|
||||
state.value.orderDirection = query.orderDirection as "asc" | "desc";
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.orderDirection = queryDefaults.orderDirection;
|
||||
}
|
||||
|
||||
if (query.requireAllCategories?.length) {
|
||||
state.value.requireAllCategories = query.requireAllCategories === "true";
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.requireAllCategories = queryDefaults.requireAllCategories;
|
||||
}
|
||||
|
||||
if (query.requireAllTags?.length) {
|
||||
state.value.requireAllTags = query.requireAllTags === "true";
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.requireAllTags = queryDefaults.requireAllTags;
|
||||
}
|
||||
|
||||
if (query.requireAllTools?.length) {
|
||||
state.value.requireAllTools = query.requireAllTools === "true";
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.requireAllTools = queryDefaults.requireAllTools;
|
||||
}
|
||||
|
||||
if (query.requireAllFoods?.length) {
|
||||
state.value.requireAllFoods = query.requireAllFoods === "true";
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.requireAllFoods = queryDefaults.requireAllFoods;
|
||||
}
|
||||
|
||||
@@ -456,15 +523,16 @@ export default defineComponent({
|
||||
waitUntilAndExecute(
|
||||
() => categories.store.value.length > 0,
|
||||
() => {
|
||||
const result = categories.store.value.filter((item) =>
|
||||
(query.categories as string[]).includes(item.id as string)
|
||||
const result = categories.store.value.filter(item =>
|
||||
(query.categories as string[]).includes(item.id as string),
|
||||
);
|
||||
|
||||
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
selectedCategories.value = [];
|
||||
}
|
||||
|
||||
@@ -473,12 +541,13 @@ export default defineComponent({
|
||||
waitUntilAndExecute(
|
||||
() => 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>[];
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
selectedTags.value = [];
|
||||
}
|
||||
|
||||
@@ -487,12 +556,13 @@ export default defineComponent({
|
||||
waitUntilAndExecute(
|
||||
() => 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>[];
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
selectedTools.value = [];
|
||||
}
|
||||
|
||||
@@ -506,12 +576,13 @@ export default defineComponent({
|
||||
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 ?? [];
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
selectedFoods.value = [];
|
||||
}
|
||||
|
||||
@@ -525,12 +596,13 @@ export default defineComponent({
|
||||
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>[] ?? [];
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
selectedHouseholds.value = [];
|
||||
}
|
||||
|
||||
@@ -539,11 +611,12 @@ export default defineComponent({
|
||||
|
||||
onMounted(async () => {
|
||||
// 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 {
|
||||
const query = JSON.parse(searchQuerySession.value.recipe);
|
||||
await router.replace({ query });
|
||||
} catch (error) {
|
||||
}
|
||||
catch {
|
||||
searchQuerySession.value.recipe = "";
|
||||
router.replace({ query: {} });
|
||||
}
|
||||
@@ -576,7 +649,7 @@ export default defineComponent({
|
||||
},
|
||||
{
|
||||
debounce: 500,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -610,7 +683,6 @@ export default defineComponent({
|
||||
filterItems,
|
||||
};
|
||||
},
|
||||
head: {},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<template>
|
||||
<v-tooltip bottom nudge-right="50" :color="buttonStyle ? 'info' : 'secondary'">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-tooltip
|
||||
location="bottom"
|
||||
nudge-right="50"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-if="isFavorite || showAlways"
|
||||
small
|
||||
icon
|
||||
:variant="buttonStyle ? 'flat' : undefined"
|
||||
:rounded="buttonStyle ? 'circle' : undefined"
|
||||
size="small"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
:icon="!buttonStyle"
|
||||
:fab="buttonStyle"
|
||||
v-bind="attrs"
|
||||
v-bind="{ ...props, ...$attrs }"
|
||||
@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 }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
@@ -21,11 +29,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { useUserSelfRatings } from "~/composables/use-users";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { UserOut } from "~/lib/api/types/user";
|
||||
export default defineComponent({
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
recipeId: {
|
||||
type: String,
|
||||
@@ -42,22 +49,21 @@ export default defineComponent({
|
||||
},
|
||||
setup(props) {
|
||||
const api = useUserApi();
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
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 rating = userRatings.value.find((r) => r.recipeId === props.recipeId);
|
||||
const rating = userRatings.value.find(r => r.recipeId === props.recipeId);
|
||||
return rating?.isFavorite || false;
|
||||
});
|
||||
|
||||
async function toggleFavorite() {
|
||||
if (!$auth.user.value) return;
|
||||
if (!isFavorite.value) {
|
||||
await api.users.addFavorite(user.value?.id, props.recipeId);
|
||||
} else {
|
||||
await api.users.removeFavorite(user.value?.id, props.recipeId);
|
||||
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
else {
|
||||
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
await refreshUserRatings();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
<template>
|
||||
<div class="text-center">
|
||||
<v-menu v-model="menu" offset-y top nudge-top="6" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn color="accent" dark v-bind="attrs" v-on="on">
|
||||
<v-icon left>
|
||||
<v-menu
|
||||
v-model="menu"
|
||||
offset-y
|
||||
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 }}
|
||||
</v-icon>
|
||||
{{ $t("general.image") }}
|
||||
@@ -25,9 +35,21 @@
|
||||
</v-card-title>
|
||||
<v-card-text class="mt-n5">
|
||||
<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>
|
||||
<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") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -40,13 +62,12 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
const REFRESH_EVENT = "refresh";
|
||||
const UPLOAD_EVENT = "upload";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
@@ -58,7 +79,7 @@ export default defineComponent({
|
||||
url: "",
|
||||
loading: false,
|
||||
menu: false,
|
||||
})
|
||||
});
|
||||
|
||||
function uploadImage(fileObject: File) {
|
||||
context.emit(UPLOAD_EVENT, fileObject);
|
||||
@@ -75,7 +96,7 @@ export default defineComponent({
|
||||
state.menu = false;
|
||||
}
|
||||
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")];
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,101 +1,148 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-text-field
|
||||
v-if="value.title || showTitle"
|
||||
v-model="value.title"
|
||||
dense
|
||||
v-if="model.title || showTitle"
|
||||
v-model="model.title"
|
||||
density="compact"
|
||||
variant="underlined"
|
||||
hide-details
|
||||
class="mx-1 mt-3 mb-4"
|
||||
:placeholder="$t('recipe.section-title')"
|
||||
style="max-width: 500px"
|
||||
@click="$emit('clickIngredientField', 'title')"
|
||||
/>
|
||||
<v-row
|
||||
:no-gutters="mdAndUp"
|
||||
dense
|
||||
class="d-flex flex-wrap my-1"
|
||||
>
|
||||
</v-text-field>
|
||||
<v-row :no-gutters="$vuetify.breakpoint.mdAndUp" dense class="d-flex flex-wrap my-1">
|
||||
<v-col v-if="!disableAmount" sm="12" md="2" cols="12" class="flex-grow-0 flex-shrink-0">
|
||||
<v-col
|
||||
v-if="!disableAmount"
|
||||
sm="12"
|
||||
md="2"
|
||||
cols="12"
|
||||
class="flex-grow-0 flex-shrink-0"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="value.quantity"
|
||||
solo
|
||||
v-model="model.quantity"
|
||||
variant="solo"
|
||||
hide-details
|
||||
dense
|
||||
density="compact"
|
||||
type="number"
|
||||
:placeholder="$t('recipe.quantity')"
|
||||
@keypress="quantityFilter"
|
||||
>
|
||||
<v-icon v-if="$listeners && $listeners.delete" slot="prepend" class="mr-n1 handle">
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
<template #prepend>
|
||||
<v-icon
|
||||
class="mr-n1 handle"
|
||||
>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</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
|
||||
ref="unitAutocomplete"
|
||||
v-model="value.unit"
|
||||
:search-input.sync="unitSearch"
|
||||
v-model="model.unit"
|
||||
v-model:search="unitSearch"
|
||||
auto-select-first
|
||||
hide-details
|
||||
dense
|
||||
solo
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="units || []"
|
||||
item-text="name"
|
||||
item-title="name"
|
||||
class="mx-1"
|
||||
:placeholder="$t('recipe.choose-unit')"
|
||||
clearable
|
||||
@keyup.enter="handleUnitEnter"
|
||||
>
|
||||
<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 #append-item>
|
||||
<div class="px-2">
|
||||
<BaseButton block small @click="createAssignUnit()"></BaseButton>
|
||||
<BaseButton
|
||||
block
|
||||
size="small"
|
||||
@click="createAssignUnit()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</v-col>
|
||||
|
||||
<!-- 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
|
||||
ref="foodAutocomplete"
|
||||
v-model="value.food"
|
||||
:search-input.sync="foodSearch"
|
||||
v-model="model.food"
|
||||
v-model:search="foodSearch"
|
||||
auto-select-first
|
||||
hide-details
|
||||
dense
|
||||
solo
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="foods || []"
|
||||
item-text="name"
|
||||
item-title="name"
|
||||
class="mx-1 py-0"
|
||||
:placeholder="$t('recipe.choose-food')"
|
||||
clearable
|
||||
@keyup.enter="handleFoodEnter"
|
||||
>
|
||||
<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 #append-item>
|
||||
<div class="px-2">
|
||||
<BaseButton block small @click="createAssignFood()"></BaseButton>
|
||||
<BaseButton
|
||||
block
|
||||
size="small"
|
||||
@click="createAssignFood()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</v-col>
|
||||
<v-col sm="12" md="" cols="12">
|
||||
<v-col
|
||||
sm="12"
|
||||
md=""
|
||||
cols="12"
|
||||
>
|
||||
<div class="d-flex">
|
||||
<v-text-field
|
||||
v-model="value.note"
|
||||
v-model="model.note"
|
||||
hide-details
|
||||
dense
|
||||
solo
|
||||
density="compact"
|
||||
variant="solo"
|
||||
:placeholder="$t('recipe.notes')"
|
||||
class="mb-auto"
|
||||
@click="$emit('clickIngredientField', 'note')"
|
||||
>
|
||||
<v-icon v-if="disableAmount && $listeners && $listeners.delete" slot="prepend" class="mr-n1 handle">
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
<template #prepend>
|
||||
<v-icon
|
||||
v-if="disableAmount && $attrs && $attrs.delete"
|
||||
class="mr-n1 handle"
|
||||
>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
<BaseButtonGroup
|
||||
hover
|
||||
@@ -112,195 +159,181 @@
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<p v-if="showOriginalText" class="text-caption">
|
||||
{{ $t("recipe.original-text-with-value", { originalText: value.originalText }) }}
|
||||
<p
|
||||
v-if="showOriginalText"
|
||||
class="text-caption"
|
||||
>
|
||||
{{ $t("recipe.original-text-with-value", { originalText: model.originalText }) }}
|
||||
</p>
|
||||
|
||||
<v-divider v-if="!$vuetify.breakpoint.mdAndUp" class="my-4"></v-divider>
|
||||
<v-divider
|
||||
v-if="!mdAndUp"
|
||||
class="my-4"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||
<script setup lang="ts">
|
||||
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 { validators } from "~/composables/use-validators";
|
||||
import { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { useNuxtApp } from "#app";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Object as () => RecipeIngredient,
|
||||
required: true,
|
||||
},
|
||||
disableAmount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowInsertIngredient: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
// defineModel replaces modelValue prop
|
||||
const model = defineModel<RecipeIngredient>({ required: true });
|
||||
|
||||
const props = defineProps({
|
||||
disableAmount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
setup(props, { listeners }) {
|
||||
const { i18n, $globals } = useContext();
|
||||
|
||||
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,
|
||||
};
|
||||
allowInsertIngredient: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="safeMarkup"></div>
|
||||
<div v-html="safeMarkup" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
|
||||
export default defineComponent({
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
markup: {
|
||||
type: String,
|
||||
@@ -17,7 +17,7 @@ export default defineComponent({
|
||||
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
|
||||
return {
|
||||
safeMarkup,
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
<template>
|
||||
<div class="ma-0 pa-0 text-subtitle-1 dense-markdown ingredient-item">
|
||||
<SafeMarkdown v-if="parsedIng.quantity" 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" />
|
||||
<SafeMarkdown
|
||||
v-if="parsedIng.quantity"
|
||||
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>
|
||||
<SafeMarkdown v-if="parsedIng.name" class="text-bold d-inline" :source="parsedIng.name" />
|
||||
<SafeMarkdown v-if="parsedIng.note" class="note" :source="parsedIng.note" />
|
||||
<SafeMarkdown
|
||||
v-if="parsedIng.name"
|
||||
class="text-bold d-inline"
|
||||
:source="parsedIng.name"
|
||||
/>
|
||||
<SafeMarkdown
|
||||
v-if="parsedIng.note"
|
||||
class="note"
|
||||
:source="parsedIng.note"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { RecipeIngredient } from "~/lib/api/types/household";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/household";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
ingredient: {
|
||||
type: Object as () => RecipeIngredient,
|
||||
@@ -40,12 +58,20 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.ingredient-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
|
||||
.d-inline {
|
||||
& > p {
|
||||
display: inline;
|
||||
&:has(>sub)>sup {
|
||||
&:has(> sub) > sup {
|
||||
letter-spacing: -0.05rem;
|
||||
}
|
||||
}
|
||||
@@ -55,7 +81,7 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
sup {
|
||||
&+span{
|
||||
& + span {
|
||||
letter-spacing: -0.05rem;
|
||||
}
|
||||
&:before {
|
||||
@@ -66,12 +92,19 @@ export default defineComponent({
|
||||
|
||||
.text-bold {
|
||||
font-weight: bold;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.note {
|
||||
line-height: 1.25em;
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
display: block;
|
||||
line-height: 1.3em;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
<template>
|
||||
<div v-if="value && value.length > 0">
|
||||
<div v-if="!isCookMode" class="d-flex justify-start" >
|
||||
<h2 class="mb-2 mt-1">{{ $t("recipe.ingredients") }}</h2>
|
||||
<AppButtonCopy btn-class="ml-auto" :copy-text="ingredientCopyText" />
|
||||
<div
|
||||
v-if="!isCookMode"
|
||||
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 v-for="(ingredient, index) in value" :key="'ingredient' + index">
|
||||
<div
|
||||
v-for="(ingredient, index) in value"
|
||||
:key="'ingredient' + index"
|
||||
>
|
||||
<template v-if="!isCookMode">
|
||||
<h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3>
|
||||
<v-divider v-if="showTitleEditor[index]"></v-divider>
|
||||
<h3
|
||||
v-if="showTitleEditor[index]"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ ingredient.title }}
|
||||
</h3>
|
||||
<v-divider v-if="showTitleEditor[index]" />
|
||||
</template>
|
||||
<v-list-item dense @click.stop="toggleChecked(index)">
|
||||
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" />
|
||||
<v-list-item-content :key="ingredient.quantity">
|
||||
<RecipeIngredientListItem :ingredient="ingredient" :disable-amount="disableAmount" :scale="scale" />
|
||||
</v-list-item-content>
|
||||
<v-list-item
|
||||
density="compact"
|
||||
@click.stop="toggleChecked(index)"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -22,12 +53,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, toRefs } from "@nuxtjs/composition-api";
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
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 },
|
||||
props: {
|
||||
value: {
|
||||
@@ -45,7 +75,7 @@ export default defineComponent({
|
||||
isCookMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
function validateTitle(title?: string) {
|
||||
@@ -54,7 +84,7 @@ export default defineComponent({
|
||||
|
||||
const state = reactive({
|
||||
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(() => {
|
||||
|
||||
@@ -4,46 +4,45 @@
|
||||
<BaseDialog
|
||||
v-model="madeThisDialog"
|
||||
:icon="$globals.icons.chefHat"
|
||||
:title="$tc('recipe.made-this')"
|
||||
:submit-text="$tc('recipe.add-to-timeline')"
|
||||
:title="$t('recipe.made-this')"
|
||||
:submit-text="$t('recipe.add-to-timeline')"
|
||||
can-submit
|
||||
@submit="createTimelineEvent"
|
||||
>
|
||||
>
|
||||
<v-card-text>
|
||||
<v-form ref="domMadeThisForm">
|
||||
<v-textarea
|
||||
v-model="newTimelineEvent.eventMessage"
|
||||
autofocus
|
||||
:label="$tc('recipe.comment')"
|
||||
:hint="$tc('recipe.how-did-it-turn-out')"
|
||||
:label="$t('recipe.comment')"
|
||||
:hint="$t('recipe.how-did-it-turn-out')"
|
||||
persistent-hint
|
||||
rows="4"
|
||||
></v-textarea>
|
||||
/>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col cols="auto">
|
||||
<v-col cols="6">
|
||||
<v-menu
|
||||
v-model="datePickerMenu"
|
||||
:close-on-content-click="false"
|
||||
transition="scale-transition"
|
||||
offset-y
|
||||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<template #activator="{ props }">
|
||||
<v-text-field
|
||||
v-model="newTimelineEventTimestamp"
|
||||
v-model="newTimelineEventTimestampString"
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="attrs"
|
||||
v-bind="props"
|
||||
readonly
|
||||
v-on="on"
|
||||
></v-text-field>
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
v-model="newTimelineEventTimestamp"
|
||||
no-title
|
||||
hide-header
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@input="datePickerMenu = false"
|
||||
@update:model-value="datePickerMenu = false"
|
||||
/>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
@@ -55,18 +54,16 @@
|
||||
url="none"
|
||||
file-name="image"
|
||||
accept="image/*"
|
||||
:text="$i18n.tc('recipe.upload-image')"
|
||||
:text="$t('recipe.upload-image')"
|
||||
:text-btn="false"
|
||||
:post="false"
|
||||
@uploaded="uploadImage"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="!!newTimelineEventImage"
|
||||
color="error"
|
||||
@click="clearImage"
|
||||
>
|
||||
<v-icon left>{{ $globals.icons.close }}</v-icon>
|
||||
{{ $i18n.tc('recipe.remove-image') }}
|
||||
<v-btn v-if="!!newTimelineEventImage" color="error" @click="clearImage">
|
||||
<v-icon start>
|
||||
{{ $globals.icons.close }}
|
||||
</v-icon>
|
||||
{{ $t("recipe.remove-image") }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -87,24 +84,31 @@
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<template #activator="{ on, attrs }">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
rounded
|
||||
outlined
|
||||
x-large
|
||||
color="primary"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
variant="outlined"
|
||||
size="x-large"
|
||||
v-bind="props"
|
||||
style="border-color: rgb(var(--v-theme-primary));"
|
||||
@click="madeThisDialog = true"
|
||||
>
|
||||
<v-icon left large>{{ $globals.icons.calendar }}</v-icon>
|
||||
<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>
|
||||
<v-icon right large>{{ $globals.icons.createAlt }}</v-icon>
|
||||
<v-icon start size="large" color="primary">
|
||||
{{ $globals.icons.calendar }}
|
||||
</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>
|
||||
</template>
|
||||
<span>{{ $tc("recipe.made-this") }}</span>
|
||||
<span>{{ $t("recipe.made-this") }}</span>
|
||||
</v-tooltip>
|
||||
</v-row>
|
||||
</div>
|
||||
@@ -113,25 +117,26 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||
import { whenever } from "@vueuse/core";
|
||||
import { VForm } from "~/types/vuetify";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
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: {
|
||||
recipe: {
|
||||
type: Object as () => Recipe,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["eventCreated"],
|
||||
setup(props, context) {
|
||||
const madeThisDialog = ref(false);
|
||||
const userApi = useUserApi();
|
||||
const { household } = useHouseholdSelf();
|
||||
const { $auth, i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const domMadeThisForm = ref<VForm>();
|
||||
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
||||
subject: "",
|
||||
@@ -143,14 +148,18 @@ export default defineComponent({
|
||||
const newTimelineEventImage = ref<Blob | File>();
|
||||
const newTimelineEventImageName = 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 lastMadeReady = ref(false);
|
||||
onMounted(async () => {
|
||||
if (!$auth.user?.householdSlug) {
|
||||
if (!$auth.user?.value?.householdSlug) {
|
||||
lastMade.value = props.recipe.lastMade;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
|
||||
lastMade.value = data?.lastMade;
|
||||
}
|
||||
@@ -158,15 +167,12 @@ export default defineComponent({
|
||||
lastMadeReady.value = true;
|
||||
});
|
||||
|
||||
|
||||
whenever(
|
||||
() => madeThisDialog.value,
|
||||
() => {
|
||||
// Set timestamp to now
|
||||
newTimelineEventTimestamp.value = (
|
||||
new Date(Date.now() - (new Date()).getTimezoneOffset() * 60000)
|
||||
).toISOString().substring(0, 10);
|
||||
}
|
||||
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
|
||||
},
|
||||
);
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
@@ -190,19 +196,19 @@ export default defineComponent({
|
||||
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||
}
|
||||
|
||||
const state = reactive({datePickerMenu: false});
|
||||
const state = reactive({ datePickerMenu: false });
|
||||
async function createTimelineEvent() {
|
||||
if (!(newTimelineEventTimestamp.value && props.recipe?.id && props.recipe?.slug)) {
|
||||
if (!(newTimelineEventTimestampString.value && props.recipe?.id && props.recipe?.slug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
newTimelineEvent.value.recipeId = props.recipe.id
|
||||
// @ts-expect-error - TS doesn't like the $auth global user attribute
|
||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.fullName })
|
||||
newTimelineEvent.value.recipeId = props.recipe.id;
|
||||
// Note: $auth.user is now a ref
|
||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
|
||||
|
||||
// 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
|
||||
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 newEvent = eventResponse.data;
|
||||
@@ -210,7 +216,7 @@ export default defineComponent({
|
||||
// we also update the recipe's last made value
|
||||
if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
|
||||
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
|
||||
@@ -221,7 +227,6 @@ export default defineComponent({
|
||||
newTimelineEventImageName.value,
|
||||
);
|
||||
if (imageResponse.data) {
|
||||
// @ts-ignore the image response data will always match a value of TimelineEventImage
|
||||
newEvent.image = imageResponse.data.image;
|
||||
}
|
||||
}
|
||||
@@ -245,6 +250,7 @@ export default defineComponent({
|
||||
newTimelineEventImage,
|
||||
newTimelineEventImagePreviewUrl,
|
||||
newTimelineEventTimestamp,
|
||||
newTimelineEventTimestampString,
|
||||
lastMade,
|
||||
lastMadeReady,
|
||||
createTimelineEvent,
|
||||
|
||||
@@ -7,34 +7,57 @@
|
||||
:class="attrs.class.sheet"
|
||||
: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-avatar :class="attrs.class.avatar">
|
||||
<v-icon :class="attrs.class.icon" dark :small="small"> {{ $globals.icons.primary }} </v-icon>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content :class="attrs.class.text">
|
||||
<v-list-item-title :class="listItem && listItemDescriptions[index] ? '' : 'pr-4'" :style="attrs.style.text.title">
|
||||
<v-list-item
|
||||
:to="disabled ? '' : '/g/' + groupSlug + '/r/' + recipe.slug"
|
||||
:class="attrs.class.listItem"
|
||||
>
|
||||
<template #prepend>
|
||||
<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 }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="showDescription">{{ recipe.description }}</v-list-item-subtitle>
|
||||
<v-list-item-subtitle v-if="listItem && listItemDescriptions[index]" :style="attrs.style.text.subTitle">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="listItemDescriptions[index]"></div>
|
||||
<v-list-item-subtitle v-if="showDescription">
|
||||
{{ recipe.description }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<slot :name="'actions-' + recipe.id" :v-bind="{ item: recipe }"> </slot>
|
||||
<v-list-item-subtitle
|
||||
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-sheet>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useFraction } from "~/composables/recipes/use-fraction";
|
||||
import { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import { RecipeSummary } from "~/lib/api/types/recipe";
|
||||
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
recipes: {
|
||||
type: Array as () => RecipeSummary[],
|
||||
@@ -59,44 +82,46 @@ export default defineComponent({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const { frac } = useFraction();
|
||||
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(() => {
|
||||
return props.small ? {
|
||||
class: {
|
||||
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||
listItem: "px-0",
|
||||
avatar: "ma-0",
|
||||
icon: "ma-0 pa-0 primary",
|
||||
text: "pa-0",
|
||||
},
|
||||
style: {
|
||||
text: {
|
||||
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",
|
||||
avatar: "",
|
||||
icon: "pa-1 primary",
|
||||
text: "",
|
||||
},
|
||||
style: {
|
||||
text: {
|
||||
title: "",
|
||||
subTitle: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
return props.small
|
||||
? {
|
||||
class: {
|
||||
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||
listItem: "px-0",
|
||||
avatar: "ma-0",
|
||||
icon: "ma-0 pa-0 primary",
|
||||
text: "pa-0",
|
||||
},
|
||||
style: {
|
||||
text: {
|
||||
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",
|
||||
avatar: "",
|
||||
icon: "pa-1 primary",
|
||||
text: "",
|
||||
},
|
||||
style: {
|
||||
text: {
|
||||
title: "",
|
||||
subTitle: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
@@ -108,11 +133,11 @@ export default defineComponent({
|
||||
|
||||
const listItemDescriptions = computed<string[]>(() => {
|
||||
if (
|
||||
props.recipes.length === 1 // we don't need to specify details if there's only one recipe ref
|
||||
|| !props.listItem?.recipeReferences
|
||||
|| props.listItem.recipeReferences.length !== props.recipes.length
|
||||
) {
|
||||
return props.recipes.map((_) => "")
|
||||
props.recipes.length === 1 // we don't need to specify details if there's only one recipe ref
|
||||
|| !props.listItem?.recipeReferences
|
||||
|| props.listItem.recipeReferences.length !== props.recipes.length
|
||||
) {
|
||||
return props.recipes.map(_ => "");
|
||||
}
|
||||
|
||||
const listItemDescriptions: string[] = [];
|
||||
@@ -120,36 +145,37 @@ export default defineComponent({
|
||||
const itemRef = props.listItem?.recipeReferences[i];
|
||||
const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1);
|
||||
|
||||
let listItemDescription = ""
|
||||
let listItemDescription = "";
|
||||
if (props.listItem.unit?.fraction) {
|
||||
const fraction = frac(quantity, 10, true);
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
listItemDescription += fraction[0];
|
||||
}
|
||||
const fraction = frac(quantity, 10, true);
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
listItemDescription += fraction[0];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
listItemDescription += ` <sup>${fraction[1]}</sup>⁄<sub>${fraction[2]}</sub>`;
|
||||
}
|
||||
else {
|
||||
listItemDescription = (quantity).toString();
|
||||
}
|
||||
if (fraction[1] > 0) {
|
||||
listItemDescription += ` <sup>${fraction[1]}</sup>⁄<sub>${fraction[2]}</sub>`;
|
||||
}
|
||||
else {
|
||||
listItemDescription = (Math.round(quantity*100)/100).toString();
|
||||
listItemDescription = (quantity).toString();
|
||||
}
|
||||
}
|
||||
else {
|
||||
listItemDescription = (Math.round(quantity * 100) / 100).toString();
|
||||
}
|
||||
|
||||
if (props.listItem.unit) {
|
||||
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
|
||||
? props.listItem.unit.abbreviation : props.listItem.unit.name;
|
||||
if (props.listItem.unit) {
|
||||
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
|
||||
? props.listItem.unit.abbreviation
|
||||
: props.listItem.unit.name;
|
||||
|
||||
listItemDescription += ` ${unitDisplay}`
|
||||
}
|
||||
listItemDescription += ` ${unitDisplay}`;
|
||||
}
|
||||
|
||||
if (itemRef.recipeNote) {
|
||||
listItemDescription += `, ${itemRef.recipeNote}`
|
||||
}
|
||||
if (itemRef.recipeNote) {
|
||||
listItemDescription += `, ${itemRef.recipeNote}`;
|
||||
}
|
||||
|
||||
listItemDescriptions.push(sanitizeHTML(listItemDescription));
|
||||
listItemDescriptions.push(sanitizeHTML(listItemDescription));
|
||||
}
|
||||
|
||||
return listItemDescriptions;
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
<template>
|
||||
<div v-if="value.length > 0 || edit" class="mt-8">
|
||||
<h2 class="my-4">{{ $t("recipe.note") }}</h2>
|
||||
<div v-for="(note, index) in value" :id="'note' + index" :key="'note' + index" class="mt-1">
|
||||
<div
|
||||
v-if="model.length > 0 || edit"
|
||||
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-text>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field v-model="value[index]['title']" :label="$t('recipe.title')" />
|
||||
<v-btn icon class="mr-2" elevation="0" @click="removeByIndex(value, index)">
|
||||
<v-text-field
|
||||
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-btn>
|
||||
</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>
|
||||
<div v-else>
|
||||
@@ -23,44 +47,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="edit" class="d-flex justify-end">
|
||||
<BaseButton class="ml-auto my-2" @click="addNote"> {{ $t("general.add") }}</BaseButton>
|
||||
<div
|
||||
v-if="edit"
|
||||
class="d-flex justify-end"
|
||||
>
|
||||
<BaseButton
|
||||
class="ml-auto my-2"
|
||||
@click="addNote"
|
||||
>
|
||||
{{ $t("general.add") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import { RecipeNote } from "~/lib/api/types/recipe";
|
||||
<script setup lang="ts">
|
||||
import type { RecipeNote } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Array as () => RecipeNote[],
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
const model = defineModel<RecipeNote[]>({ default: () => [] });
|
||||
|
||||
edit: {
|
||||
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,
|
||||
};
|
||||
defineProps({
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
</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">
|
||||
{{ $t("recipe.nutrition") }}
|
||||
</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">
|
||||
<div v-for="(item, key, index) in value" :key="index">
|
||||
<div
|
||||
v-for="(item, key, index) in modelValue"
|
||||
:key="index"
|
||||
>
|
||||
<v-text-field
|
||||
dense :value="value[key]" :label="labels[key].label" :suffix="labels[key].suffix" type="number"
|
||||
autocomplete="off" @input="updateValue(key, $event)"></v-text-field>
|
||||
density="compact"
|
||||
:model-value="modelValue[key]"
|
||||
:label="labels[key].label"
|
||||
:suffix="labels[key].suffix"
|
||||
type="number"
|
||||
autocomplete="off"
|
||||
@update:model-value="updateValue(key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-list v-if="showViewer" dense class="mt-0 pt-0">
|
||||
<v-list-item v-for="(item, key, index) in renderedList" :key="index" style="min-height: 25px" dense>
|
||||
<v-list-item-content>
|
||||
<v-list
|
||||
v-if="showViewer"
|
||||
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">
|
||||
<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>
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
@@ -28,13 +47,13 @@ dense :value="value[key]" :label="labels[key].label" :suffix="labels[key].suffix
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { useNutritionLabels } from "~/composables/recipes";
|
||||
import { Nutrition } from "~/lib/api/types/recipe";
|
||||
import { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
|
||||
export default defineComponent({
|
||||
import type { Nutrition } from "~/lib/api/types/recipe";
|
||||
import type { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Object as () => Nutrition,
|
||||
required: true,
|
||||
},
|
||||
@@ -43,12 +62,13 @@ export default defineComponent({
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const { labels } = useNutritionLabels();
|
||||
const valueNotNull = computed(() => {
|
||||
let key: keyof Nutrition;
|
||||
for (key in props.value) {
|
||||
if (props.value[key] !== null) {
|
||||
for (key in props.modelValue) {
|
||||
if (props.modelValue[key] !== null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -58,16 +78,16 @@ export default defineComponent({
|
||||
const showViewer = computed(() => !props.edit && valueNotNull.value);
|
||||
|
||||
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
|
||||
const renderedList = computed(() => {
|
||||
return Object.entries(labels).reduce((item: NutritionLabelType, [key, label]) => {
|
||||
if (props.value[key]?.trim()) {
|
||||
if (props.modelValue[key]?.trim()) {
|
||||
item[key] = {
|
||||
...label,
|
||||
value: props.value[key],
|
||||
value: props.modelValue[key],
|
||||
};
|
||||
}
|
||||
return item;
|
||||
|
||||
@@ -1,36 +1,58 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-dialog v-model="dialog" width="500">
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
width="500"
|
||||
>
|
||||
<v-card>
|
||||
<v-app-bar dense dark color="primary mb-2">
|
||||
<v-icon large left class="mt-1">
|
||||
{{ itemType === Organizer.Tool ? $globals.icons.potSteam :
|
||||
itemType === Organizer.Category ? $globals.icons.categories :
|
||||
$globals.icons.tags }}
|
||||
<v-app-bar
|
||||
density="compact"
|
||||
dark
|
||||
color="primary mb-2 position-relative left-0 top-0 w-100 pl-3"
|
||||
>
|
||||
<v-icon
|
||||
size="large"
|
||||
start
|
||||
class="mt-1"
|
||||
>
|
||||
{{ itemType === Organizer.Tool ? $globals.icons.potSteam
|
||||
: itemType === Organizer.Category ? $globals.icons.categories
|
||||
: $globals.icons.tags }}
|
||||
</v-icon>
|
||||
|
||||
<v-toolbar-title class="headline">
|
||||
{{ properties.title }}
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-spacer />
|
||||
</v-app-bar>
|
||||
<v-card-title> </v-card-title>
|
||||
<v-card-title />
|
||||
<v-form @submit.prevent="select">
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="name"
|
||||
dense
|
||||
density="compact"
|
||||
:label="properties.label"
|
||||
:rules="[rules.required]"
|
||||
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-actions>
|
||||
<BaseButton cancel @click="dialog = false" />
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton type="submit" create :disabled="!name" />
|
||||
<BaseButton
|
||||
cancel
|
||||
@click="dialog = false"
|
||||
/>
|
||||
<v-spacer />
|
||||
<BaseButton
|
||||
type="submit"
|
||||
create
|
||||
:disabled="!name"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
@@ -39,16 +61,15 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, toRefs, useContext, watch } from "@nuxtjs/composition-api";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
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";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@@ -65,8 +86,9 @@ export default defineComponent({
|
||||
default: "category",
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
|
||||
const state = reactive({
|
||||
name: "",
|
||||
@@ -75,18 +97,18 @@ export default defineComponent({
|
||||
|
||||
const dialog = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
context.emit("input", value);
|
||||
context.emit("update:modelValue", value);
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
() => props.modelValue,
|
||||
(val: boolean) => {
|
||||
if (!val) state.name = "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const userApi = useUserApi();
|
||||
@@ -135,7 +157,7 @@ export default defineComponent({
|
||||
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);
|
||||
dialog.value = false;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div v-if="items">
|
||||
<RecipeOrganizerDialog v-model="dialogs.organizer" :item-type="itemType" />
|
||||
<RecipeOrganizerDialog
|
||||
v-model="dialogs.organizer"
|
||||
:item-type="itemType"
|
||||
/>
|
||||
|
||||
<BaseDialog
|
||||
v-if="deleteTarget"
|
||||
@@ -8,18 +11,34 @@
|
||||
:title="$t('general.delete-with-name', { name: $t(translationKey) })"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
can-confirm
|
||||
@confirm="deleteOne()"
|
||||
>
|
||||
<v-card-text>
|
||||
<p>{{ $t("general.confirm-delete-generic-with-name", { name: $t(translationKey) }) }}</p>
|
||||
<p class="mt-4 mb-0 ml-4">{{ deleteTarget.name }}</p>
|
||||
<p>{{ $t("general.confirm-delete-generic-with-name", { name: $t(translationKey) }) }}</p>
|
||||
<p class="mt-4 mb-0 ml-4">
|
||||
{{ deleteTarget.name }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
</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-text-field v-model="updateTarget.name" :label="$t('general.name')"> </v-text-field>
|
||||
<v-checkbox v-if="itemType === Organizer.Tool" v-model="updateTarget.onHand" :label="$t('tool.on-hand')"></v-checkbox>
|
||||
<v-text-field
|
||||
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>
|
||||
</BaseDialog>
|
||||
|
||||
@@ -27,32 +46,61 @@
|
||||
<v-col>
|
||||
<v-text-field
|
||||
v-model="searchString"
|
||||
outlined
|
||||
variant="outlined"
|
||||
autofocus
|
||||
color="primary accent-3"
|
||||
:placeholder="$t('search.search-placeholder')"
|
||||
:prepend-inner-icon="$globals.icons.search"
|
||||
clearable
|
||||
>
|
||||
</v-text-field>
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-app-bar color="transparent" flat class="mt-n1 rounded align-center">
|
||||
<v-icon large left>
|
||||
<v-app-bar
|
||||
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 }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline">
|
||||
<slot name="title"> </slot>
|
||||
<slot name="title" />
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton create @click="dialogs.organizer = true" />
|
||||
<v-spacer />
|
||||
<BaseButton
|
||||
create
|
||||
@click="dialogs.organizer = true"
|
||||
/>
|
||||
</v-app-bar>
|
||||
<section v-for="(itms, key, idx) in itemsSorted" :key="'header' + idx" :class="idx === 1 ? null : 'my-4'">
|
||||
<BaseCardSectionTitle v-if="isTitle(key)" :title="key" />
|
||||
<section
|
||||
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-col 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-col
|
||||
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-icon>
|
||||
{{ icon }}
|
||||
@@ -60,7 +108,7 @@
|
||||
<v-card-title class="py-1">
|
||||
{{ item.name }}
|
||||
</v-card-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-spacer />
|
||||
<ContextMenu
|
||||
:items="[presets.delete, presets.edit]"
|
||||
@delete="confirmDelete(item)"
|
||||
@@ -76,10 +124,10 @@
|
||||
|
||||
<script lang="ts">
|
||||
import Fuse from "fuse.js";
|
||||
import { defineComponent, computed, ref, reactive, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
|
||||
import { useContextPresets } from "~/composables/use-context-presents";
|
||||
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 { deepCopy } from "~/composables/use-utils";
|
||||
|
||||
@@ -90,7 +138,7 @@ interface GenericItem {
|
||||
onHand: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeOrganizerDialog,
|
||||
},
|
||||
@@ -108,6 +156,7 @@ export default defineComponent({
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update", "delete"],
|
||||
setup(props, { emit }) {
|
||||
const state = reactive({
|
||||
// Search Options
|
||||
@@ -124,9 +173,9 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
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
|
||||
@@ -141,11 +190,11 @@ export default defineComponent({
|
||||
|
||||
const translationKey = computed<string>(() => {
|
||||
const typeMap = {
|
||||
"categories": "category.category",
|
||||
"tags": "tag.tag",
|
||||
"tools": "tool.tool",
|
||||
"foods": "shopping-list.food",
|
||||
"households": "household.household",
|
||||
categories: "category.category",
|
||||
tags: "tag.tag",
|
||||
tools: "tool.tool",
|
||||
foods: "shopping-list.food",
|
||||
households: "household.household",
|
||||
};
|
||||
return typeMap[props.itemType] || "";
|
||||
});
|
||||
@@ -193,7 +242,7 @@ export default defineComponent({
|
||||
return props.items;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
fuzzyItems.value
|
||||
[...fuzzyItems.value]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach((item) => {
|
||||
const letter = item.name[0].toUpperCase();
|
||||
@@ -240,7 +289,5 @@ export default defineComponent({
|
||||
translationKey,
|
||||
};
|
||||
},
|
||||
// Needed for useMeta
|
||||
head: {},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,62 +1,61 @@
|
||||
<template>
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
v-bind="inputAttrs"
|
||||
v-model:search="searchInput"
|
||||
:items="storeItem"
|
||||
:value="value"
|
||||
:label="label"
|
||||
chips
|
||||
deletable-chips
|
||||
item-text="name"
|
||||
closable-chips
|
||||
item-title="name"
|
||||
multiple
|
||||
variant="underlined"
|
||||
:prepend-inner-icon="icon"
|
||||
:append-icon="$globals.icons.create"
|
||||
return-object
|
||||
v-bind="inputAttrs"
|
||||
auto-select-first
|
||||
:search-input.sync="searchInput"
|
||||
class="pa-0"
|
||||
@change="resetSearchInput"
|
||||
@update:model-value="resetSearchInput"
|
||||
@click:append="dialog = true"
|
||||
>
|
||||
<template #selection="data">
|
||||
<template #chip="{ item, index }">
|
||||
<v-chip
|
||||
:key="data.index"
|
||||
:key="index"
|
||||
class="ma-1"
|
||||
:input-value="data.selected"
|
||||
small
|
||||
close
|
||||
label
|
||||
color="accent"
|
||||
dark
|
||||
@click:close="removeByIndex(data.index)"
|
||||
variant="flat"
|
||||
label
|
||||
|
||||
closable
|
||||
@click:close="removeByIndex(index)"
|
||||
>
|
||||
{{ data.item.name || data.item }}
|
||||
{{ item.value }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template v-if="showAdd" #append-outer>
|
||||
<v-btn icon @click="dialog = true">
|
||||
<v-icon>
|
||||
{{ $globals.icons.create }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<RecipeOrganizerDialog v-model="dialog" :item-type="selectorType" @created-item="appendCreated" />
|
||||
|
||||
<template
|
||||
v-if="showAdd"
|
||||
#append
|
||||
>
|
||||
<RecipeOrganizerDialog
|
||||
v-model="dialog"
|
||||
:item-type="selectorType"
|
||||
@created-item="appendCreated"
|
||||
/>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
|
||||
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
|
||||
import { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
||||
import { RecipeTool } from "~/lib/api/types/admin";
|
||||
import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
||||
import type { RecipeTool } from "~/lib/api/types/admin";
|
||||
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeOrganizerDialog,
|
||||
},
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Array as () => (
|
||||
| HouseholdSummary
|
||||
| RecipeTag
|
||||
@@ -95,12 +94,13 @@ export default defineComponent({
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
|
||||
setup(props, context) {
|
||||
const selected = computed({
|
||||
get: () => props.value,
|
||||
get: () => props.modelValue,
|
||||
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(() => {
|
||||
if (!props.showLabel) {
|
||||
@@ -168,11 +169,11 @@ export default defineComponent({
|
||||
const store = computed(() => {
|
||||
const { store } = storeMap[props.selectorType];
|
||||
return store.value;
|
||||
})
|
||||
});
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.returnObject) {
|
||||
return store.value.map((item) => item.name);
|
||||
return store.value.map(item => item.name);
|
||||
}
|
||||
return store.value;
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-container v-show="!isCookMode" key="recipe-page" :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
|
||||
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
|
||||
<v-container v-show="!isCookMode" key="recipe-page" class="pt-0" :class="{ 'pa-0': $vuetify.display.smAndDown.value }">
|
||||
<v-card :flat="$vuetify.display.smAndDown.value" class="d-print-none">
|
||||
<RecipePageHeader
|
||||
:recipe="recipe"
|
||||
:recipe-scale="scale"
|
||||
@@ -9,7 +9,13 @@
|
||||
@save="saveRecipe"
|
||||
@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>
|
||||
<!--
|
||||
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
|
||||
data management and mutation system we're using.
|
||||
-->
|
||||
<RecipePageInfoEditor v-if="isEditMode" :recipe="recipe" :landscape="landscape" />
|
||||
<RecipePageEditorToolbar v-if="isEditForm" :recipe="recipe" />
|
||||
<RecipePageIngredientEditor v-if="isEditForm" :recipe="recipe" />
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
|
||||
<div>
|
||||
<RecipePageInfoEditor v-if="isEditMode" v-model="recipe" />
|
||||
</div>
|
||||
<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.
|
||||
@@ -35,9 +49,9 @@
|
||||
-->
|
||||
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4">
|
||||
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
|
||||
<RecipePageOrganizers v-if="$vuetify.breakpoint.mdAndUp" :recipe="recipe" @item-selected="chipClicked" />
|
||||
<RecipePageOrganizers v-if="$vuetify.display.mdAndUp" v-model="recipe" @item-selected="chipClicked" />
|
||||
</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
|
||||
@@ -46,104 +60,102 @@
|
||||
<v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4" :lg="8 + (isCookMode ? 1 : 0) * 4">
|
||||
<RecipePageInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
:assets.sync="recipe.assets"
|
||||
v-model:assets="recipe.assets"
|
||||
:recipe="recipe"
|
||||
:scale="scale"
|
||||
/>
|
||||
<div v-if="isEditForm" class="d-flex">
|
||||
<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 v-if="!$vuetify.breakpoint.mdAndUp">
|
||||
<RecipePageOrganizers :recipe="recipe" />
|
||||
<div v-if="!$vuetify.display.mdAndUp">
|
||||
<RecipePageOrganizers v-model="recipe" />
|
||||
</div>
|
||||
<RecipeNotes v-model="recipe.notes" :edit="isEditForm" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<RecipePageFooter :recipe="recipe" />
|
||||
<RecipePageFooter v-model="recipe" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<WakelockSwitch/>
|
||||
<WakelockSwitch />
|
||||
<RecipePageComments
|
||||
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
|
||||
:recipe="recipe"
|
||||
v-model="recipe"
|
||||
class="px-1 my-4 d-print-none"
|
||||
/>
|
||||
<RecipePrintContainer :recipe="recipe" :scale="scale" />
|
||||
</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 -->
|
||||
<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-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%;">
|
||||
<v-sheet
|
||||
v-show="isCookMode && !hasLinkedIngredients"
|
||||
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">
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
|
||||
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
||||
</div>
|
||||
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" :is-cook-mode="isCookMode" />
|
||||
<v-divider></v-divider>
|
||||
<RecipePageIngredientToolsView
|
||||
v-if="!isEditForm"
|
||||
:recipe="recipe"
|
||||
:scale="scale"
|
||||
:is-cook-mode="isCookMode"
|
||||
/>
|
||||
<v-divider />
|
||||
</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
|
||||
v-model="recipe.recipeInstructions"
|
||||
v-model:assets="recipe.assets"
|
||||
class="overflow-y-hidden px-4"
|
||||
:assets.sync="recipe.assets"
|
||||
:recipe="recipe"
|
||||
:scale="scale"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-sheet>
|
||||
<v-sheet v-show="isCookMode && hasLinkedIngredients">
|
||||
<div class="mt-2 px-2 px-md-4">
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale"/>
|
||||
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
||||
</div>
|
||||
<RecipePageInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
v-model:assets="recipe.assets"
|
||||
class="overflow-y-hidden mt-n5 px-2 px-md-4"
|
||||
:assets.sync="recipe.assets"
|
||||
:recipe="recipe"
|
||||
:scale="scale"
|
||||
/>
|
||||
|
||||
<div v-if="notLinkedIngredients.length > 0" class="px-2 px-md-4 pb-4 ">
|
||||
<v-divider></v-divider>
|
||||
<div v-if="notLinkedIngredients.length > 0" class="px-2 px-md-4 pb-4">
|
||||
<v-divider />
|
||||
<v-card flat>
|
||||
<v-card-title>{{ $t('recipe.not-linked-ingredients') }}</v-card-title>
|
||||
<RecipeIngredients
|
||||
:value="notLinkedIngredients"
|
||||
:scale="scale"
|
||||
:disable-amount="recipe.settings.disableAmount"
|
||||
:is-cook-mode="isCookMode">
|
||||
|
||||
</RecipeIngredients>
|
||||
<v-card-title>{{ $t("recipe.not-linked-ingredients") }}</v-card-title>
|
||||
<RecipeIngredients
|
||||
:value="notLinkedIngredients"
|
||||
:scale="scale"
|
||||
:disable-amount="recipe.settings.disableAmount"
|
||||
:is-cook-mode="isCookMode"
|
||||
/>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-sheet>
|
||||
<v-btn
|
||||
v-if="isCookMode"
|
||||
fab
|
||||
small
|
||||
icon
|
||||
color="primary"
|
||||
style="position: fixed; right: 12px; top: 60px;"
|
||||
style="position: fixed; right: 12px; top: 60px"
|
||||
@click="toggleCookMode()"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
>
|
||||
<v-icon>{{ $globals.icons.close }}</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent,
|
||||
useContext,
|
||||
useRouter,
|
||||
computed,
|
||||
ref,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
useRoute,
|
||||
} from "@nuxtjs/composition-api";
|
||||
<script setup lang="ts">
|
||||
import { invoke, until } from "@vueuse/core";
|
||||
import RecipeIngredients from "../RecipeIngredients.vue";
|
||||
import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue";
|
||||
@@ -156,17 +168,14 @@ import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
|
||||
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
|
||||
import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue";
|
||||
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue";
|
||||
import {
|
||||
clearPageState,
|
||||
EditorMode,
|
||||
PageMode,
|
||||
usePageState,
|
||||
usePageUser,
|
||||
} from "~/composables/recipe-page/shared-state";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import { useRouteQuery } from "~/composables/use-router";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
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 { useNavigationWarning } from "~/composables/use-navigation-warning";
|
||||
|
||||
const EDITOR_OPTIONS = {
|
||||
mode: "code",
|
||||
search: false,
|
||||
mainMenuBar: false,
|
||||
};
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipePageHeader,
|
||||
RecipePrintContainer,
|
||||
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 { $vuetify } = useNuxtApp();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
|
||||
const router = useRouter();
|
||||
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 groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
/** =============================================================
|
||||
* 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(props.recipe).not.toBeNull();
|
||||
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: {},
|
||||
const router = useRouter();
|
||||
const api = useUserApi();
|
||||
const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode }
|
||||
= usePageState(recipe.value.slug);
|
||||
const { deactivateNavigationWarning } = useNavigationWarning();
|
||||
const notLinkedIngredients = computed(() => {
|
||||
return recipe.value.recipeIngredient.filter((ingredient) => {
|
||||
return !recipe.value.recipeInstructions.some(step =>
|
||||
step.ingredientReferences?.map(ref => ref.referenceId).includes(ingredient.referenceId),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** =============================================================
|
||||
* 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>
|
||||
|
||||
<style lang="css">
|
||||
.flip-list-move {
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
|
||||
.no-move {
|
||||
transition: transform 0s;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.list-group {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.list-group-item i {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -6,44 +6,73 @@
|
||||
</v-icon>
|
||||
{{ $t("recipe.comments") }}
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2"></v-divider>
|
||||
<div v-if="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-divider class="mx-2" />
|
||||
<div
|
||||
v-if="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-model="comment"
|
||||
hide-details=""
|
||||
dense
|
||||
hide-details
|
||||
density="compact"
|
||||
single-line
|
||||
outlined
|
||||
variant="outlined"
|
||||
auto-grow
|
||||
rows="2"
|
||||
:placeholder="$t('recipe.join-the-conversation')"
|
||||
>
|
||||
</v-textarea>
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-auto mt-1">
|
||||
<BaseButton small :disabled="!comment" @click="submitComment">
|
||||
<template #icon>{{ $globals.icons.check }}</template>
|
||||
<BaseButton
|
||||
size="small"
|
||||
:disabled="!comment"
|
||||
@click="submitComment"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.check }}
|
||||
</template>
|
||||
{{ $t("general.submit") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="comment in recipe.comments" :key="comment.id" class="d-flex my-2" style="gap: 10px">
|
||||
<UserAvatar :tooltip="false" size="40" :user-id="comment.userId" />
|
||||
<v-card outlined class="flex-grow-1">
|
||||
<div
|
||||
v-for="recipeComment in recipe.comments"
|
||||
: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">
|
||||
<p class="">{{ comment.user.fullName }} • {{ $d(Date.parse(comment.createdAt), "medium") }}</p>
|
||||
<SafeMarkdown :source="comment.text" />
|
||||
<p class="">
|
||||
{{ recipeComment.user.fullName }} • {{ $d(Date.parse(recipeComment.createdAt), "medium") }}
|
||||
</p>
|
||||
<SafeMarkdown :source="recipeComment.text" />
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end mt-0 pt-0">
|
||||
<v-btn
|
||||
v-if="user.id == comment.user.id || user.admin"
|
||||
v-if="user.id == recipeComment.user.id || user.admin"
|
||||
color="error"
|
||||
text
|
||||
x-small
|
||||
@click="deleteComment(comment.id)"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
@click="deleteComment(recipeComment.id)"
|
||||
>
|
||||
{{ $t("general.delete") }}
|
||||
</v-btn>
|
||||
@@ -53,58 +82,37 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs, reactive } from "@nuxtjs/composition-api";
|
||||
<script lang="ts" setup>
|
||||
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 { 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 SafeMarkdown from "~/components/global/SafeMarkdown.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
UserAvatar,
|
||||
SafeMarkdown
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const api = useUserApi();
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
const api = useUserApi();
|
||||
const { user } = usePageUser();
|
||||
const comment = ref("");
|
||||
|
||||
const { user } = usePageUser();
|
||||
async function submitComment() {
|
||||
const { data } = await api.recipes.comments.createOne({
|
||||
recipeId: recipe.value.id,
|
||||
text: comment.value,
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
comment: "",
|
||||
});
|
||||
if (data) {
|
||||
recipe.value.comments.push(data);
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
const { data } = await api.recipes.comments.createOne({
|
||||
recipeId: props.recipe.id,
|
||||
text: state.comment,
|
||||
});
|
||||
comment.value = "";
|
||||
}
|
||||
|
||||
if (data) {
|
||||
// @ts-ignore username is always populated here
|
||||
props.recipe.comments.push(data);
|
||||
}
|
||||
async function deleteComment(id: string) {
|
||||
const { response } = await api.recipes.comments.deleteOne(id);
|
||||
|
||||
state.comment = "";
|
||||
}
|
||||
|
||||
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 };
|
||||
},
|
||||
});
|
||||
if (response?.status === 200) {
|
||||
recipe.value.comments = recipe.value.comments.filter(comment => comment.id !== id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
<template>
|
||||
<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
|
||||
v-model="recipe.settings"
|
||||
class="my-1 mx-1"
|
||||
:value="recipe.settings"
|
||||
:is-owner="recipe.userId == user.id"
|
||||
@upload="uploadImage"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-container class="py-0" style="width: 40%;">
|
||||
<v-container
|
||||
class="py-0"
|
||||
style="width: 40%;"
|
||||
>
|
||||
<v-select
|
||||
v-model="recipe.userId"
|
||||
:items="allUsers"
|
||||
item-text="fullName"
|
||||
item-title="fullName"
|
||||
item-value="id"
|
||||
:label="$tc('general.owner')"
|
||||
:label="$t('general.owner')"
|
||||
hide-details
|
||||
:disabled="!canEditOwner"
|
||||
variant="underlined"
|
||||
>
|
||||
<template #prepend>
|
||||
<UserAvatar :user-id="recipe.userId" :tooltip="false" />
|
||||
<UserAvatar
|
||||
:user-id="recipe.userId"
|
||||
:tooltip="false"
|
||||
/>
|
||||
</template>
|
||||
</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-icon>{{ $globals.icons.household }}</v-icon>
|
||||
<span class="pl-1">{{ ownerHousehold.name }}</span>
|
||||
@@ -31,11 +47,11 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBtn.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 { useHouseholdStore } from "~/composables/store";
|
||||
|
||||
export default defineComponent({
|
||||
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 recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
const canEditOwner = computed(() => {
|
||||
return user.id === props.recipe.userId || user.admin;
|
||||
})
|
||||
const { user } = usePageUser();
|
||||
const api = useUserApi();
|
||||
const { imageKey } = usePageState(recipe.value.slug);
|
||||
|
||||
const { store: allUsers } = useUserStore();
|
||||
const { store: households } = useHouseholdStore();
|
||||
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 canEditOwner = computed(() => {
|
||||
return user.id === recipe.value.userId || user.admin;
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -5,35 +5,54 @@
|
||||
v-if="isEditForm"
|
||||
v-model="recipe.orgURL"
|
||||
class="mt-10"
|
||||
variant="underlined"
|
||||
:label="$t('recipe.original-url')"
|
||||
></v-text-field>
|
||||
/>
|
||||
<v-btn
|
||||
v-else-if="recipe.orgURL && !isCookMode"
|
||||
dense
|
||||
small
|
||||
:hover="false"
|
||||
type="label"
|
||||
:ripple="false"
|
||||
elevation="0"
|
||||
variant="flat"
|
||||
:href="recipe.orgURL"
|
||||
color="secondary darken-1"
|
||||
color="secondary-darken-1"
|
||||
target="_blank"
|
||||
class="rounded-sm mr-n2"
|
||||
class="mr-n2"
|
||||
size="small"
|
||||
>
|
||||
{{ $t("recipe.original-url") }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
<AdvancedOnly>
|
||||
<v-card v-if="isEditForm" flat class="mb-2 mx-n2">
|
||||
<v-card-title> {{ $t('recipe.api-extras') }} </v-card-title>
|
||||
<v-divider class="ml-4"></v-divider>
|
||||
<v-card
|
||||
v-if="isEditForm"
|
||||
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>
|
||||
{{ $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-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>
|
||||
<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-btn>
|
||||
</template>
|
||||
@@ -43,69 +62,58 @@
|
||||
</v-card-text>
|
||||
<v-card-actions class="d-flex ml-2 mt-n3">
|
||||
<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>
|
||||
<BaseButton create small class="ml-5" @click="createApiExtra" />
|
||||
<BaseButton
|
||||
create
|
||||
size="small"
|
||||
class="ml-5"
|
||||
@click="createApiExtra"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</AdvancedOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
||||
<script setup lang="ts">
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { 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("");
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
function createApiExtra() {
|
||||
if (!props.recipe) {
|
||||
return;
|
||||
}
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
const { isEditForm, isCookMode } = usePageState(recipe.value.slug);
|
||||
const apiNewKey = ref("");
|
||||
|
||||
if (!props.recipe.extras) {
|
||||
props.recipe.extras = {};
|
||||
}
|
||||
function createApiExtra() {
|
||||
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
|
||||
if (Object.keys(props.recipe.extras).includes(apiNewKey.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.recipe.extras[apiNewKey.value] = "";
|
||||
|
||||
apiNewKey.value = "";
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
function removeApiExtra(key: string | number) {
|
||||
if (!recipe.value) {
|
||||
return;
|
||||
}
|
||||
if (!recipe.value.extras) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete recipe.value.extras[key];
|
||||
recipe.value.extras = { ...recipe.value.extras };
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<RecipePageInfoCard :recipe="recipe" :recipe-scale="recipeScale" :landscape="landscape" />
|
||||
<RecipePageInfoCard
|
||||
:recipe="recipe"
|
||||
:recipe-scale="recipeScale"
|
||||
:landscape="landscape"
|
||||
/>
|
||||
<v-divider />
|
||||
<RecipeActionMenu
|
||||
:recipe="recipe"
|
||||
@@ -11,7 +15,7 @@
|
||||
:logged-in="isOwnGroup"
|
||||
:open="isEditMode"
|
||||
:recipe-id="recipe.id"
|
||||
class="ml-auto mt-n2 pb-4"
|
||||
class="ml-auto mt-n7 pb-4"
|
||||
@close="setMode(PageMode.VIEW)"
|
||||
@json="toggleEditMode()"
|
||||
@edit="setMode(PageMode.EDIT)"
|
||||
@@ -23,17 +27,17 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useRecipePermissions } from "~/composables/recipes";
|
||||
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
|
||||
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
|
||||
export default defineComponent({
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipePageInfoCard,
|
||||
RecipeActionMenu,
|
||||
@@ -52,8 +56,9 @@ export default defineComponent({
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["save", "delete"],
|
||||
setup(props) {
|
||||
const { $vuetify } = useContext();
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
||||
const { user } = usePageUser();
|
||||
@@ -74,7 +79,7 @@ export default defineComponent({
|
||||
|
||||
const hideImage = ref(false);
|
||||
const imageHeight = computed(() => {
|
||||
return $vuetify.breakpoint.xs ? "200" : "400";
|
||||
return $vuetify.display.xs.value ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
@@ -85,7 +90,7 @@ export default defineComponent({
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-end flex-wrap align-stretch">
|
||||
<RecipePageInfoCardImage v-if="landscape" :recipe="recipe" />
|
||||
<RecipePageInfoCardImage
|
||||
v-if="landscape"
|
||||
:recipe="recipe"
|
||||
/>
|
||||
<v-card
|
||||
:width="landscape ? '100%' : '50%'"
|
||||
flat
|
||||
class="d-flex flex-column justify-center align-center"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-card-title class="headline pa-0 flex-column align-center">
|
||||
<v-card-text class="w-100">
|
||||
<v-card-title class="text-h5 font-weight-regular pa-0 d-flex flex-column align-center justify-center opacity-80">
|
||||
{{ 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-divider class="my-2" />
|
||||
<SafeMarkdown :source="recipe.description" />
|
||||
<SafeMarkdown :source="recipe.description" class="my-3" />
|
||||
<v-divider v-if="recipe.description" />
|
||||
<v-container class="d-flex flex-row flex-wrap justify-center">
|
||||
<div class="mx-6">
|
||||
<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
|
||||
:yield-quantity="recipe.recipeYieldQuantity"
|
||||
:yield="recipe.recipeYield"
|
||||
@@ -28,7 +40,10 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
<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
|
||||
v-if="isOwnGroup"
|
||||
:recipe="recipe"
|
||||
@@ -49,22 +64,27 @@
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import RecipeYield from "~/components/Domain/Recipe/RecipeYield.vue";
|
||||
import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
export default defineComponent({
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeRating,
|
||||
RecipeLastMade,
|
||||
@@ -87,15 +107,11 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { $vuetify } = useContext();
|
||||
const useMobile = computed(() => $vuetify.breakpoint.smAndDown);
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
return {
|
||||
isOwnGroup,
|
||||
useMobile,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
:key="imageKey"
|
||||
:max-width="maxWidth"
|
||||
min-height="50"
|
||||
cover
|
||||
width="100%"
|
||||
:height="hideImage ? undefined : imageHeight"
|
||||
:src="recipeImageUrl"
|
||||
class="d-print-none"
|
||||
@@ -11,13 +13,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
export default defineComponent({
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
@@ -29,7 +31,7 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { $vuetify } = useContext();
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const { user } = usePageUser();
|
||||
@@ -44,7 +46,7 @@ export default defineComponent({
|
||||
|
||||
const hideImage = ref(false);
|
||||
const imageHeight = computed(() => {
|
||||
return $vuetify.breakpoint.xs ? "200" : "400";
|
||||
return $vuetify.display.xs.value ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
@@ -55,7 +57,7 @@ export default defineComponent({
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -64,6 +66,6 @@ export default defineComponent({
|
||||
hideImage,
|
||||
imageHeight,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,103 +5,117 @@
|
||||
class="my-3"
|
||||
:label="$t('recipe.recipe-name')"
|
||||
:rules="[validators.required]"
|
||||
density="compact"
|
||||
variant="underlined"
|
||||
/>
|
||||
<v-container class="ma-0 pa-0">
|
||||
<v-row>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
v-model="recipeServings"
|
||||
:model-value="recipeServings"
|
||||
type="number"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
dense
|
||||
density="compact"
|
||||
:label="$t('recipe.servings')"
|
||||
@input="validateInput($event, 'recipeServings')"
|
||||
variant="underlined"
|
||||
@update:model-value="validateInput($event, 'recipeServings')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
v-model="recipeYieldQuantity"
|
||||
:model-value="recipeYieldQuantity"
|
||||
type="number"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
dense
|
||||
density="compact"
|
||||
:label="$t('recipe.yield')"
|
||||
@input="validateInput($event, 'recipeYieldQuantity')"
|
||||
variant="underlined"
|
||||
@update:model-value="validateInput($event, 'recipeYieldQuantity')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="recipe.recipeYield"
|
||||
dense
|
||||
:label="$t('recipe.yield-text')"
|
||||
/>
|
||||
v-model="recipe.recipeYield"
|
||||
density="compact"
|
||||
:label="$t('recipe.yield-text')"
|
||||
variant="underlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<div class="d-flex flex-wrap" style="gap: 1rem">
|
||||
<v-text-field v-model="recipe.totalTime" :label="$t('recipe.total-time')" />
|
||||
<v-text-field v-model="recipe.prepTime" :label="$t('recipe.prep-time')" />
|
||||
<v-text-field v-model="recipe.performTime" :label="$t('recipe.perform-time')" />
|
||||
<div
|
||||
class="d-flex flex-wrap"
|
||||
style="gap: 1rem"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
<script setup lang="ts">
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
const recipeServings = computed<number>({
|
||||
get() {
|
||||
return recipe.value.recipeServings;
|
||||
},
|
||||
setup(props) {
|
||||
const recipeServings = computed<number>({
|
||||
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,
|
||||
};
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeServings");
|
||||
},
|
||||
});
|
||||
|
||||
const recipeYieldQuantity = computed<number>({
|
||||
get() {
|
||||
return recipe.value.recipeYieldQuantity;
|
||||
},
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeYieldQuantity");
|
||||
},
|
||||
});
|
||||
|
||||
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
|
||||
if (!value) {
|
||||
recipe.value[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const number = parseFloat(value.replace(/[^0-9.]/g, ""));
|
||||
if (isNaN(number) || number <= 0) {
|
||||
recipe.value[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
recipe.value[property] = number;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<!-- eslint-disable vue/no-mutating-props -->
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
|
||||
<draggable
|
||||
<h2 class="mb-4 text-h5 font-weight-medium opacity-80">
|
||||
{{ $t("recipe.ingredients") }}
|
||||
</h2>
|
||||
<VueDraggable
|
||||
v-if="recipe.recipeIngredient.length > 0"
|
||||
v-model="recipe.recipeIngredient"
|
||||
handle=".handle"
|
||||
delay="250"
|
||||
:delay="250"
|
||||
:delay-on-touch-only="true"
|
||||
v-bind="{
|
||||
animation: 200,
|
||||
@@ -16,7 +19,9 @@
|
||||
@start="drag = true"
|
||||
@end="drag = false"
|
||||
>
|
||||
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
|
||||
<TransitionGroup
|
||||
type="transition"
|
||||
>
|
||||
<RecipeIngredientEditor
|
||||
v-for="(ingredient, index) in recipe.recipeIngredient"
|
||||
:key="ingredient.referenceId"
|
||||
@@ -25,21 +30,29 @@
|
||||
:disable-amount="recipe.settings.disableAmount"
|
||||
@delete="recipe.recipeIngredient.splice(index, 1)"
|
||||
@insert-above="insertNewIngredient(index)"
|
||||
@insert-below="insertNewIngredient(index+1)"
|
||||
@insert-below="insertNewIngredient(index + 1)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</draggable>
|
||||
<v-skeleton-loader v-else boilerplate elevation="2" type="list-item"> </v-skeleton-loader>
|
||||
</VueDraggable>
|
||||
<v-skeleton-loader
|
||||
v-else
|
||||
boilerplate
|
||||
elevation="2"
|
||||
type="list-item"
|
||||
/>
|
||||
<div class="d-flex flex-wrap justify-center justify-sm-end mt-3">
|
||||
<v-tooltip top color="accent">
|
||||
<template #activator="{ on, attrs }">
|
||||
<span v-on="on">
|
||||
<v-tooltip
|
||||
top
|
||||
color="accent"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<span>
|
||||
<BaseButton
|
||||
class="mb-1"
|
||||
:disabled="recipe.settings.disableAmount || hasFoodOrUnit"
|
||||
color="accent"
|
||||
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
|
||||
v-bind="attrs"
|
||||
v-bind="props"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.foods }}
|
||||
@@ -50,124 +63,106 @@
|
||||
</template>
|
||||
<span>{{ parserToolTip }}</span>
|
||||
</v-tooltip>
|
||||
<RecipeDialogBulkAdd class="mx-1 mb-1" @bulk-data="addIngredient" />
|
||||
<BaseButton class="mb-1" @click="addIngredient" > {{ $t("general.add") }} </BaseButton>
|
||||
<RecipeDialogBulkAdd
|
||||
class="mx-1 mb-1"
|
||||
@bulk-data="addIngredient"
|
||||
/>
|
||||
<BaseButton
|
||||
class="mb-1"
|
||||
@click="addIngredient"
|
||||
>
|
||||
{{ $t("general.add") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import draggable from "vuedraggable";
|
||||
import { computed, defineComponent, ref, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
<script setup lang="ts">
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
|
||||
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
||||
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 groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const drag = ref(false);
|
||||
|
||||
const hasFoodOrUnit = computed(() => {
|
||||
if (!props.recipe) {
|
||||
return false;
|
||||
}
|
||||
if (props.recipe.recipeIngredient) {
|
||||
for (const ingredient of props.recipe.recipeIngredient) {
|
||||
if (ingredient.food || ingredient.unit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const parserToolTip = computed(() => {
|
||||
if (props.recipe.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
|
||||
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,
|
||||
});
|
||||
const hasFoodOrUnit = computed(() => {
|
||||
if (!recipe.value) {
|
||||
return false;
|
||||
}
|
||||
if (recipe.value.recipeIngredient) {
|
||||
for (const ingredient of recipe.value.recipeIngredient) {
|
||||
if (ingredient.food || ingredient.unit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function insertNewIngredient(dest: number) {
|
||||
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,
|
||||
};
|
||||
},
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -7,38 +7,47 @@
|
||||
:is-cook-mode="isCookMode"
|
||||
/>
|
||||
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
|
||||
<h2 class="mb-2 mt-4">{{ $t('tool.required-tools') }}</h2>
|
||||
<v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense>
|
||||
<v-checkbox
|
||||
v-model="recipeTools[index].onHand"
|
||||
hide-details
|
||||
class="pt-0 my-auto py-auto"
|
||||
color="secondary"
|
||||
@change="updateTool(index)"
|
||||
<h2 class="mt-4 text-h5 font-weight-medium opacity-80">
|
||||
{{ $t('tool.required-tools') }}
|
||||
</h2>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="(tool, index) in recipe.tools"
|
||||
:key="index"
|
||||
density="compact"
|
||||
>
|
||||
</v-checkbox>
|
||||
<v-list-item-content>
|
||||
{{ tool.name }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<template #prepend>
|
||||
<v-checkbox
|
||||
v-model="recipeTools[index].onHand"
|
||||
hide-details
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { useToolStore } from "~/composables/store";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
|
||||
|
||||
interface RecipeToolWithOnHand extends RecipeTool {
|
||||
onHand: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeIngredients,
|
||||
},
|
||||
@@ -54,7 +63,7 @@ export default defineComponent({
|
||||
isCookMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
@@ -65,14 +74,15 @@ export default defineComponent({
|
||||
|
||||
const recipeTools = computed(() => {
|
||||
if (!(user.householdSlug && toolStore)) {
|
||||
return props.recipe.tools.map((tool) => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
|
||||
} else {
|
||||
return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
|
||||
}
|
||||
else {
|
||||
return props.recipe.tools.map((tool) => {
|
||||
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
|
||||
return { ...tool, onHand } as RecipeToolWithOnHand;
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
function updateTool(index: number) {
|
||||
if (user.id && user.householdSlug && toolStore) {
|
||||
@@ -80,15 +90,18 @@ export default defineComponent({
|
||||
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||
if (!tool.householdsWithTool) {
|
||||
tool.householdsWithTool = [user.householdSlug];
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
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);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
console.log("no user, skipping server update");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
<template>
|
||||
<section @keyup.ctrl.90="undoMerge">
|
||||
<section @keyup.ctrl.z="undoMerge">
|
||||
<!-- 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-app-bar dark color="primary" class="mt-n1 mb-3">
|
||||
<v-icon large left>
|
||||
<v-sheet
|
||||
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 }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline"> {{ $t("recipe.ingredient-linker") }} </v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
</v-app-bar>
|
||||
<v-toolbar-title class="headline">
|
||||
{{ $t("recipe.ingredient-linker") }}
|
||||
</v-toolbar-title>
|
||||
<v-spacer />
|
||||
</v-sheet>
|
||||
|
||||
<v-card-text class="pt-4">
|
||||
<p>
|
||||
{{ activeText }}
|
||||
</p>
|
||||
<v-divider class="mb-4"></v-divider>
|
||||
<v-divider class="mb-4" />
|
||||
<v-checkbox
|
||||
v-for="ing in unusedIngredients"
|
||||
:key="ing.referenceId"
|
||||
@@ -29,7 +42,9 @@
|
||||
</v-checkbox>
|
||||
|
||||
<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-for="ing in usedIngredients"
|
||||
:key="ing.referenceId"
|
||||
@@ -44,19 +59,38 @@
|
||||
</template>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions>
|
||||
<BaseButton cancel @click="dialog = false"> </BaseButton>
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton
|
||||
cancel
|
||||
@click="dialog = false"
|
||||
/>
|
||||
<v-spacer />
|
||||
<div class="d-flex flex-wrap justify-end">
|
||||
<BaseButton class="my-1" color="info" @click="autoSetReferences">
|
||||
<template #icon> {{ $globals.icons.robot }}</template>
|
||||
<BaseButton
|
||||
class="my-1"
|
||||
color="info"
|
||||
@click="autoSetReferences"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.robot }}
|
||||
</template>
|
||||
{{ $t("recipe.auto") }}
|
||||
</BaseButton>
|
||||
<BaseButton class="ml-2 my-1" save @click="setIngredientIds"> </BaseButton>
|
||||
<BaseButton v-if="availableNextStep" class="ml-2 my-1" @click="saveAndOpenNextLinkIngredients">
|
||||
<template #icon> {{ $globals.icons.forward }}</template>
|
||||
<BaseButton
|
||||
class="ml-2 my-1"
|
||||
save
|
||||
@click="setIngredientIds"
|
||||
/>
|
||||
<BaseButton
|
||||
v-if="availableNextStep"
|
||||
class="ml-2 my-1"
|
||||
@click="saveAndOpenNextLinkIngredients"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.forward }}
|
||||
</template>
|
||||
{{ $t("recipe.nextStep") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
@@ -65,169 +99,200 @@
|
||||
</v-dialog>
|
||||
|
||||
<div class="d-flex justify-space-between justify-start">
|
||||
<h2 v-if="!isCookMode" class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
|
||||
<BaseButton v-if="!isEditForm && !isCookMode" minor cancel color="primary" @click="toggleCookMode()">
|
||||
<h2
|
||||
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>
|
||||
{{ $globals.icons.primary }}
|
||||
</template>
|
||||
{{ $t("recipe.cook-mode") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<draggable
|
||||
<VueDraggable
|
||||
v-model="instructionList"
|
||||
:disabled="!isEditForm"
|
||||
:value="value"
|
||||
handle=".handle"
|
||||
delay="250"
|
||||
:delay="250"
|
||||
:delay-on-touch-only="true"
|
||||
v-bind="{
|
||||
animation: 200,
|
||||
group: 'recipe-instructions',
|
||||
ghostClass: 'ghost',
|
||||
}"
|
||||
@input="updateIndex"
|
||||
@start="drag = true"
|
||||
@end="drag = false"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
|
||||
<div v-for="(step, index) in value" :key="step.id" class="list-group-item">
|
||||
<v-app-bar
|
||||
<TransitionGroup
|
||||
type="transition"
|
||||
>
|
||||
<div
|
||||
v-for="(step, index) in instructionList"
|
||||
:key="step.id!"
|
||||
class="list-group-item"
|
||||
>
|
||||
<v-sheet
|
||||
v-if="step.id && showTitleEditor[step.id]"
|
||||
class="primary mt-6"
|
||||
style="cursor: pointer"
|
||||
dark
|
||||
dense
|
||||
rounded
|
||||
color="primary"
|
||||
class="mt-6 mb-2 d-flex align-center"
|
||||
:class="isEditForm ? 'pa-2' : 'pa-3'"
|
||||
style="border-radius: 6px; cursor: pointer; width: 100%;"
|
||||
@click="toggleCollapseSection(index)"
|
||||
>
|
||||
<v-toolbar-title v-if="!isEditForm" class="headline">
|
||||
<v-app-bar-title> {{ step.title }} </v-app-bar-title>
|
||||
</v-toolbar-title>
|
||||
<v-text-field
|
||||
v-if="isEditForm"
|
||||
v-model="step.title"
|
||||
class="headline pa-0 mt-5"
|
||||
dense
|
||||
solo
|
||||
flat
|
||||
:placeholder="$t('recipe.section-title')"
|
||||
background-color="primary"
|
||||
>
|
||||
</v-text-field>
|
||||
</v-app-bar>
|
||||
<v-hover v-slot="{ hover }">
|
||||
<template v-if="isEditForm">
|
||||
<v-text-field
|
||||
v-model="step.title"
|
||||
class="pa-0"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
:placeholder="$t('recipe.section-title')"
|
||||
bg-color="primary"
|
||||
hide-details
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-toolbar-title class="section-title-text">
|
||||
{{ step.title }}
|
||||
</v-toolbar-title>
|
||||
</template>
|
||||
</v-sheet>
|
||||
<v-hover v-slot="{ isHovering }">
|
||||
<v-card
|
||||
class="my-3"
|
||||
:class="[{ 'on-hover': hover }, isChecked(index)]"
|
||||
:elevation="hover ? 12 : 2"
|
||||
:class="[{ 'on-hover': isHovering }, isChecked(index)]"
|
||||
:elevation="isHovering ? 12 : 2"
|
||||
:ripple="false"
|
||||
@click="toggleDisabled(index)"
|
||||
>
|
||||
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
|
||||
<v-text-field
|
||||
v-if="isEditForm"
|
||||
v-model="step.summary"
|
||||
class="headline handle"
|
||||
hide-details
|
||||
dense
|
||||
solo
|
||||
flat
|
||||
:placeholder="$t('recipe.step-index', { step: index + 1 })"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="26">{{ $globals.icons.arrowUpDown }}</v-icon>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field
|
||||
v-if="isEditForm"
|
||||
v-model="step.summary"
|
||||
class="headline handle"
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
:placeholder="$t('recipe.step-index', { step: index + 1 })"
|
||||
>
|
||||
<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>
|
||||
</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: $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-fade-transition>
|
||||
<v-icon
|
||||
v-show="isChecked(index)"
|
||||
size="24"
|
||||
class="ml-auto"
|
||||
color="success"
|
||||
>
|
||||
{{ $globals.icons.checkboxMarkedCircle }}
|
||||
</v-icon>
|
||||
</v-fade-transition>
|
||||
</div>
|
||||
</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 -->
|
||||
<DropZone @drop="(f) => handleImageDrop(index, f)">
|
||||
<v-card-text
|
||||
v-if="isEditForm"
|
||||
@click="$emit('click-instruction-field', `${index}.text`)"
|
||||
v-if="isEditForm"
|
||||
@click="$emit('click-instruction-field', `${index}.text`)"
|
||||
>
|
||||
<MarkdownEditor
|
||||
v-model="value[index]['text']"
|
||||
v-model="instructionList[index]['text']"
|
||||
v-model:preview="previewStates[index]"
|
||||
class="mb-2"
|
||||
:preview.sync="previewStates[index]"
|
||||
:display-preview="false"
|
||||
:textarea="{
|
||||
hint: $t('recipe.attach-images-hint'),
|
||||
@@ -236,14 +301,16 @@
|
||||
/>
|
||||
<RecipeIngredientHtml
|
||||
v-for="ing in step.ingredientReferences"
|
||||
:key="ing.referenceId"
|
||||
:markup="getIngredientByRefId(ing.referenceId)"
|
||||
:key="ing.referenceId!"
|
||||
:markup="getIngredientByRefId(ing.referenceId!)"
|
||||
/>
|
||||
</v-card-text>
|
||||
</DropZone>
|
||||
<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-row>
|
||||
<v-col
|
||||
@@ -254,7 +321,7 @@
|
||||
<div class="ml-n4">
|
||||
<RecipeIngredients
|
||||
: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 || '')
|
||||
})"
|
||||
:scale="scale"
|
||||
@@ -263,9 +330,15 @@
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
<SafeMarkdown class="markdown" :source="step.text" />
|
||||
<SafeMarkdown
|
||||
class="markdown"
|
||||
:source="step.text"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
@@ -275,34 +348,27 @@
|
||||
</v-hover>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</draggable>
|
||||
<v-divider v-if="!isCookMode" class="mt-10 d-flex d-md-none"/>
|
||||
</VueDraggable>
|
||||
<v-divider
|
||||
v-if="!isCookMode"
|
||||
class="mt-10 d-flex d-md-none"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import draggable from "vuedraggable";
|
||||
import {
|
||||
ref,
|
||||
toRefs,
|
||||
reactive,
|
||||
defineComponent,
|
||||
watch,
|
||||
onMounted,
|
||||
useContext,
|
||||
computed,
|
||||
nextTick,
|
||||
} from "@nuxtjs/composition-api";
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
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 { uuid4, detectServerBaseUrl } from "~/composables/use-utils";
|
||||
import { uuid4 } from "~/composables/use-utils";
|
||||
import { useUserApi, useStaticRoutes } from "~/composables/api";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
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 RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
|
||||
|
||||
interface MergerHistory {
|
||||
target: number;
|
||||
source: number;
|
||||
@@ -310,15 +376,15 @@ interface MergerHistory {
|
||||
sourceText: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
draggable,
|
||||
VueDraggable,
|
||||
RecipeIngredientHtml,
|
||||
DropZone,
|
||||
RecipeIngredients
|
||||
RecipeIngredients,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Array as () => RecipeStep[],
|
||||
required: false,
|
||||
default: () => [],
|
||||
@@ -336,10 +402,11 @@ export default defineComponent({
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "click-instruction-field", "update:assets"],
|
||||
|
||||
setup(props, context) {
|
||||
const { i18n, req } = useContext();
|
||||
const BASE_URL = detectServerBaseUrl(req);
|
||||
const i18n = useI18n();
|
||||
const BASE_URL = useRequestURL().origin;
|
||||
|
||||
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
|
||||
|
||||
@@ -374,12 +441,12 @@ export default defineComponent({
|
||||
return !(title === null || title === "" || title === undefined);
|
||||
}
|
||||
|
||||
watch(props.value, (v) => {
|
||||
watch(props.modelValue, (v) => {
|
||||
state.disabledSteps = [];
|
||||
|
||||
v.forEach((element: RecipeStep) => {
|
||||
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?
|
||||
onMounted(() => {
|
||||
props.value.forEach((element: RecipeStep) => {
|
||||
props.modelValue.forEach((element: RecipeStep) => {
|
||||
if (element.id !== undefined) {
|
||||
showTitleEditor.value[element.id] = hasSectionTitle(element.title);
|
||||
showTitleEditor.value[element.id!] = hasSectionTitle(element.title!);
|
||||
}
|
||||
|
||||
// showCookMode.value = false;
|
||||
@@ -411,7 +478,8 @@ export default defineComponent({
|
||||
if (index !== -1) {
|
||||
state.disabledSteps.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.disabledSteps.push(stepIndex);
|
||||
}
|
||||
}
|
||||
@@ -433,8 +501,19 @@ export default defineComponent({
|
||||
showTitleEditor.value = temp;
|
||||
}
|
||||
|
||||
function updateIndex(data: RecipeStep) {
|
||||
context.emit("input", data);
|
||||
const instructionList = ref<RecipeStep[]>([...props.modelValue]);
|
||||
|
||||
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[]) {
|
||||
if (!refs) {
|
||||
props.value[idx].ingredientReferences = [];
|
||||
refs = props.value[idx].ingredientReferences as IngredientReferences[];
|
||||
instructionList.value[idx].ingredientReferences = [];
|
||||
refs = props.modelValue[idx].ingredientReferences as IngredientReferences[];
|
||||
}
|
||||
|
||||
setUsedIngredients();
|
||||
activeText.value = text;
|
||||
activeIndex.value = idx;
|
||||
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() {
|
||||
const instruction = props.value[activeIndex.value];
|
||||
const instruction = props.modelValue[activeIndex.value];
|
||||
instruction.ingredientReferences = activeRefs.value.map((ref) => {
|
||||
return {
|
||||
referenceId: ref,
|
||||
@@ -468,7 +547,7 @@ export default defineComponent({
|
||||
|
||||
// Update the visibility of the cook mode button
|
||||
showCookMode.value = false;
|
||||
props.value.forEach((element) => {
|
||||
props.modelValue.forEach((element) => {
|
||||
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
|
||||
showCookMode.value = true;
|
||||
}
|
||||
@@ -479,24 +558,23 @@ export default defineComponent({
|
||||
function saveAndOpenNextLinkIngredients() {
|
||||
const currentStepIndex = activeIndex.value;
|
||||
|
||||
if(!availableNextStep.value) {
|
||||
if (!availableNextStep.value) {
|
||||
return; // no next step, the button calling this function should not be shown
|
||||
}
|
||||
|
||||
setIngredientIds();
|
||||
const nextStep = props.value[currentStepIndex + 1];
|
||||
const nextStep = props.modelValue[currentStepIndex + 1];
|
||||
// close dialog before opening to reset the scroll position
|
||||
nextTick(() => openDialog(currentStepIndex + 1, nextStep.text, nextStep.ingredientReferences));
|
||||
|
||||
}
|
||||
|
||||
function setUsedIngredients() {
|
||||
const usedRefs: { [key: string]: boolean } = {};
|
||||
|
||||
props.value.forEach((element) => {
|
||||
props.modelValue.forEach((element) => {
|
||||
element.ingredientReferences?.forEach((ref) => {
|
||||
if (ref.referenceId !== undefined) {
|
||||
usedRefs[ref.referenceId] = true;
|
||||
usedRefs[ref.referenceId!] = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -515,7 +593,7 @@ export default defineComponent({
|
||||
props.recipe.recipeIngredient,
|
||||
activeRefs.value,
|
||||
activeText.value,
|
||||
props.recipe.settings.disableAmount
|
||||
props.recipe.settings.disableAmount,
|
||||
).forEach((ingredient: string) => activeRefs.value.push(ingredient));
|
||||
}
|
||||
|
||||
@@ -535,10 +613,8 @@ export default defineComponent({
|
||||
return "";
|
||||
}
|
||||
|
||||
const ing = ingredientLookup.value[refId] ?? "";
|
||||
if (ing === "") {
|
||||
return "";
|
||||
}
|
||||
const ing = ingredientLookup.value[refId];
|
||||
if (!ing) return "";
|
||||
return parseIngredientText(ing, props.recipe.settings.disableAmount, props.scale);
|
||||
}
|
||||
|
||||
@@ -554,12 +630,12 @@ export default defineComponent({
|
||||
mergeHistory.value.push({
|
||||
target,
|
||||
source,
|
||||
targetText: props.value[target].text,
|
||||
sourceText: props.value[source].text,
|
||||
targetText: props.modelValue[target].text,
|
||||
sourceText: props.modelValue[source].text,
|
||||
});
|
||||
|
||||
props.value[target].text += " " + props.value[source].text;
|
||||
props.value.splice(source, 1);
|
||||
instructionList.value[target].text += " " + props.modelValue[source].text;
|
||||
instructionList.value.splice(source, 1);
|
||||
}
|
||||
|
||||
function undoMerge(event: KeyboardEvent) {
|
||||
@@ -573,8 +649,8 @@ export default defineComponent({
|
||||
return;
|
||||
}
|
||||
|
||||
props.value[lastMerge.target].text = lastMerge.targetText;
|
||||
props.value.splice(lastMerge.source, 0, {
|
||||
instructionList.value[lastMerge.target].text = lastMerge.targetText;
|
||||
instructionList.value.splice(lastMerge.source, 0, {
|
||||
id: uuid4(),
|
||||
title: "",
|
||||
text: lastMerge.sourceText,
|
||||
@@ -585,14 +661,15 @@ export default defineComponent({
|
||||
|
||||
function moveTo(dest: string, source: number) {
|
||||
if (dest === "top") {
|
||||
props.value.unshift(props.value.splice(source, 1)[0]);
|
||||
} else {
|
||||
props.value.push(props.value.splice(source, 1)[0]);
|
||||
instructionList.value.unshift(instructionList.value.splice(source, 1)[0]);
|
||||
}
|
||||
else {
|
||||
instructionList.value.push(instructionList.value.splice(source, 1)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
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[]>([]);
|
||||
@@ -606,19 +683,21 @@ export default defineComponent({
|
||||
function toggleCollapseSection(index: number) {
|
||||
const sectionSteps: number[] = [];
|
||||
|
||||
for (let i = index; i < props.value.length; i++) {
|
||||
if (!(i === index) && hasSectionTitle(props.value[i].title)) {
|
||||
for (let i = index; i < instructionList.value.length; i++) {
|
||||
if (!(i === index) && hasSectionTitle(instructionList.value[i].title!)) {
|
||||
break;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
sectionSteps.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const allCollapsed = sectionSteps.every((idx) => state.disabledSteps.includes(idx));
|
||||
const allCollapsed = sectionSteps.every(idx => state.disabledSteps.includes(idx));
|
||||
|
||||
if (allCollapsed) {
|
||||
state.disabledSteps = state.disabledSteps.filter((idx) => !sectionSteps.includes(idx));
|
||||
} else {
|
||||
state.disabledSteps = state.disabledSteps.filter(idx => !sectionSteps.includes(idx));
|
||||
}
|
||||
else {
|
||||
state.disabledSteps = [...state.disabledSteps, ...sectionSteps];
|
||||
}
|
||||
}
|
||||
@@ -674,7 +753,7 @@ export default defineComponent({
|
||||
context.emit("update:assets", [...props.assets, data]);
|
||||
const assetUrl = BASE_URL + recipeAssetPath(props.recipe.id, data.fileName as string);
|
||||
const text = `<img src="${assetUrl}" height="100%" width="100%"/>`;
|
||||
props.value[index].text += text;
|
||||
instructionList.value[index].text += text;
|
||||
}
|
||||
|
||||
function openImageUpload(index: number) {
|
||||
@@ -690,6 +769,8 @@ export default defineComponent({
|
||||
input.click();
|
||||
}
|
||||
|
||||
const breakpoint = useDisplay();
|
||||
|
||||
return {
|
||||
// Image Uploader
|
||||
toggleDragMode,
|
||||
@@ -699,6 +780,7 @@ export default defineComponent({
|
||||
loadingStates,
|
||||
|
||||
// Rest
|
||||
onDragEnd,
|
||||
drag,
|
||||
togglePreviewState,
|
||||
toggleCollapseSection,
|
||||
@@ -719,7 +801,7 @@ export default defineComponent({
|
||||
toggleDisabled,
|
||||
isChecked,
|
||||
toggleShowTitle,
|
||||
updateIndex,
|
||||
instructionList,
|
||||
autoSetReferences,
|
||||
parseIngredientText,
|
||||
toggleCookMode,
|
||||
@@ -727,6 +809,7 @@ export default defineComponent({
|
||||
isCookMode,
|
||||
isEditForm,
|
||||
insert,
|
||||
breakpoint,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -738,28 +821,32 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
/** Select all li under .markdown class */
|
||||
.markdown >>> ul > li {
|
||||
.markdown :deep(ul > li) {
|
||||
display: list-item;
|
||||
list-style-type: disc !important;
|
||||
}
|
||||
|
||||
/** Select all li under .markdown class */
|
||||
.markdown >>> ol > li {
|
||||
.markdown :deep(ol > li) {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
.flip-list-move {
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
|
||||
.no-move {
|
||||
transition: transform 0s;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.list-group {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.list-group-item i {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -780,4 +867,8 @@ export default defineComponent({
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.v-text-field >>> input {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 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">
|
||||
{{ $t("recipe.categories") }}
|
||||
</v-card-title>
|
||||
@@ -14,12 +17,19 @@
|
||||
:show-add="true"
|
||||
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>
|
||||
|
||||
<!-- 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">
|
||||
{{ $t("tag.tags") }}
|
||||
</v-card-title>
|
||||
@@ -32,20 +42,39 @@
|
||||
:show-add="true"
|
||||
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>
|
||||
|
||||
<!-- Recipe Tools Edit -->
|
||||
<v-card v-if="isEditForm" class="mt-2">
|
||||
<v-card-title class="py-2"> {{ $t('tool.required-tools') }} </v-card-title>
|
||||
<v-card
|
||||
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-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>
|
||||
|
||||
<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
|
||||
v-if="recipe.settings.showAssets"
|
||||
v-model="recipe.assets"
|
||||
@@ -56,38 +85,15 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
<script lang="ts" setup>
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import RecipeOrganizerSelector from "@/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import RecipeNutrition from "~/components/Domain/Recipe/RecipeNutrition.vue";
|
||||
import RecipeChips from "@/components/Domain/Recipe/RecipeChips.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);
|
||||
|
||||
|
||||
|
||||
return {
|
||||
isEditForm,
|
||||
user,
|
||||
};
|
||||
},
|
||||
});
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
const { isEditForm } = usePageState(recipe.value.slug);
|
||||
</script>
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
<template>
|
||||
<div class="d-flex justify-space-between align-center pt-2 pb-3">
|
||||
<v-tooltip v-if="!isEditMode" small top color="secondary darken-1">
|
||||
<template #activator="{ on, attrs }">
|
||||
<RecipeScaleEditButton
|
||||
v-model.number="scaleValue"
|
||||
v-bind="attrs"
|
||||
:recipe-servings="recipeServings"
|
||||
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
|
||||
v-on="on"
|
||||
/>
|
||||
</template>
|
||||
<span> {{ $t("recipe.edit-scale") }} </span>
|
||||
</v-tooltip>
|
||||
<RecipeScaleEditButton
|
||||
v-if="!isEditMode"
|
||||
v-model.number="scaleValue"
|
||||
:recipe-servings="recipeServings"
|
||||
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeScaleEditButton,
|
||||
},
|
||||
@@ -36,6 +29,7 @@ export default defineComponent({
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
emits: ["update:scale"],
|
||||
setup(props, { emit }) {
|
||||
const { isEditMode } = usePageState(props.recipe.slug);
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import RecipePage from "./RecipePage.vue";
|
||||
|
||||
export default RecipePage;
|
||||
@@ -1,15 +1,18 @@
|
||||
<template>
|
||||
<div class="print-container">
|
||||
<RecipePrintView :recipe="recipe" :scale="scale" dense />
|
||||
</div>
|
||||
<div class="print-container">
|
||||
<RecipePrintView
|
||||
:recipe="recipe"
|
||||
:scale="scale"
|
||||
:density="'compact'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
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: {
|
||||
RecipePrintView,
|
||||
},
|
||||
|
||||
@@ -1,47 +1,54 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div :class="dense ? 'wrapper' : 'wrapper pa-3'">
|
||||
<section>
|
||||
<v-container class="ma-0 pa-0">
|
||||
<v-row>
|
||||
<v-col
|
||||
v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
|
||||
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
|
||||
cols="4"
|
||||
align-self="center"
|
||||
<v-col v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
|
||||
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
|
||||
cols="4"
|
||||
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 order=0>
|
||||
<v-col order="0">
|
||||
<v-card-title class="headline pl-0">
|
||||
<v-icon left color="primary">
|
||||
<v-icon start
|
||||
color="primary"
|
||||
>
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
{{ recipe.name }}
|
||||
</v-card-title>
|
||||
<div v-if="recipeYield" class="d-flex justify-space-between align-center px-4 pb-2">
|
||||
<v-chip
|
||||
:small="$vuetify.breakpoint.smAndDown"
|
||||
label
|
||||
<div v-if="recipeYield"
|
||||
class="d-flex justify-space-between align-center px-4 pb-2"
|
||||
>
|
||||
<v-chip :size="$vuetify.display.smAndDown ? 'small' : undefined"
|
||||
label
|
||||
>
|
||||
<v-icon left>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="recipeYield"></span>
|
||||
<span v-html="recipeYield" />
|
||||
</v-chip>
|
||||
</div>
|
||||
<v-row class="d-flex justify-start">
|
||||
<RecipeTimeCard
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
small
|
||||
color="white"
|
||||
class="ml-4"
|
||||
<RecipeTimeCard :prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
small
|
||||
color="white"
|
||||
class="ml-4"
|
||||
/>
|
||||
</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" />
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
@@ -51,22 +58,28 @@
|
||||
|
||||
<!-- Ingredients -->
|
||||
<section>
|
||||
<v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title>
|
||||
<div
|
||||
v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
||||
:key="`ingredient-section-${sectionIndex}`"
|
||||
class="print-section"
|
||||
<v-card-title class="headline pl-0">
|
||||
{{ $t("recipe.ingredients") }}
|
||||
</v-card-title>
|
||||
<div v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
||||
: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 }}
|
||||
</h4>
|
||||
<div
|
||||
class="ingredient-grid"
|
||||
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
||||
<div class="ingredient-grid"
|
||||
: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 -->
|
||||
<p :key="`ingredient-${ingredientIndex}`" class="ingredient-body" v-html="parseText(ingredient)" />
|
||||
<p class="ingredient-body"
|
||||
v-html="parseText(ingredient)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,19 +87,35 @@
|
||||
|
||||
<!-- Instructions -->
|
||||
<section>
|
||||
<div
|
||||
v-for="(instructionSection, sectionIndex) in instructionSections"
|
||||
:key="`instruction-section-${sectionIndex}`"
|
||||
:class="{ 'print-section': instructionSection.sectionName }"
|
||||
<div v-for="(instructionSection, sectionIndex) in instructionSections"
|
||||
:key="`instruction-section-${sectionIndex}`"
|
||||
:class="{ 'print-section': instructionSection.sectionName }"
|
||||
>
|
||||
<v-card-title v-if="!sectionIndex" class="headline pl-0">{{ $t("recipe.instructions") }}</v-card-title>
|
||||
<div v-for="(step, stepIndex) in instructionSection.instructions" :key="`instruction-${stepIndex}`">
|
||||
<v-card-title v-if="!sectionIndex"
|
||||
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">
|
||||
<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 }}
|
||||
</h4>
|
||||
<h5>{{ step.summary ? step.summary : $t("recipe.step-index", { step: stepIndex + instructionSection.stepOffset + 1 }) }}</h5>
|
||||
<SafeMarkdown :source="step.text" class="recipe-step-body" />
|
||||
<h5>
|
||||
{{ 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>
|
||||
@@ -94,13 +123,19 @@
|
||||
|
||||
<!-- Notes -->
|
||||
<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>
|
||||
<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">
|
||||
<h4>{{ note.title }}</h4>
|
||||
<SafeMarkdown :source="note.text" class="note-body" />
|
||||
<SafeMarkdown :source="note.text"
|
||||
class="note-body"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -108,13 +143,17 @@
|
||||
|
||||
<!-- Nutrition -->
|
||||
<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>
|
||||
<div class="print-section">
|
||||
<table class="nutrition-table">
|
||||
<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">
|
||||
<td>{{ labels[key].label }}</td>
|
||||
<td>{{ value ? (labels[key].suffix ? `${value} ${labels[key].suffix}` : value) : '-' }}</td>
|
||||
@@ -122,26 +161,23 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import DOMPurify from "dompurify";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
import { Recipe, RecipeIngredient, RecipeStep} from "~/lib/api/types/recipe";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
|
||||
type IngredientSection = {
|
||||
sectionName: string;
|
||||
ingredients: RecipeIngredient[];
|
||||
@@ -153,7 +189,7 @@ type InstructionSection = {
|
||||
instructions: RecipeStep[];
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeTimeCard,
|
||||
},
|
||||
@@ -168,15 +204,15 @@ export default defineComponent({
|
||||
},
|
||||
dense: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const preferences = useUserPrintPreferences();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const {labels} = useNutritionLabels();
|
||||
const { labels } = useNutritionLabels();
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
@@ -187,11 +223,13 @@ export default defineComponent({
|
||||
|
||||
const servingsDisplay = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
|
||||
return scaledAmountDisplay ? i18n.t("recipe.yields-amount-with-text", {
|
||||
amount: scaledAmountDisplay,
|
||||
text: props.recipe.recipeYield,
|
||||
}) as string : "";
|
||||
})
|
||||
return scaledAmountDisplay
|
||||
? i18n.t("recipe.yields-amount-with-text", {
|
||||
amount: scaledAmountDisplay,
|
||||
text: props.recipe.recipeYield,
|
||||
}) as string
|
||||
: "";
|
||||
});
|
||||
|
||||
const yieldDisplay = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
|
||||
@@ -201,10 +239,11 @@ export default defineComponent({
|
||||
const recipeYield = computed(() => {
|
||||
if (servingsDisplay.value && yieldDisplay.value) {
|
||||
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
@@ -320,7 +359,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.wrapper,
|
||||
.wrapper >>> * {
|
||||
.wrapper :deep(*) {
|
||||
opacity: 1 !important;
|
||||
color: black !important;
|
||||
}
|
||||
@@ -396,10 +435,10 @@ li {
|
||||
width: 30%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nutrition-table td {
|
||||
padding: 2px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
<template>
|
||||
<div @click.prevent>
|
||||
<!-- User Rating -->
|
||||
<v-hover v-slot="{ hover }">
|
||||
<v-rating
|
||||
v-if="isOwnGroup && (userRating || hover || !ratingsLoaded)"
|
||||
:value="userRating"
|
||||
color="secondary"
|
||||
background-color="secondary lighten-3"
|
||||
<v-hover v-slot="{ isHovering, props }">
|
||||
<v-rating v-if="isOwnGroup && (userRating || isHovering || !ratingsLoaded)"
|
||||
v-bind="props"
|
||||
:model-value="userRating"
|
||||
active-color="secondary"
|
||||
color="secondary-lighten-3"
|
||||
length="5"
|
||||
:dense="small ? true : undefined"
|
||||
:size="small ? 15 : undefined"
|
||||
:density="small ? 'compact' : 'default'"
|
||||
:size="small ? 'x-small' : undefined"
|
||||
hover
|
||||
clearable
|
||||
@input="updateRating"
|
||||
@update:model-value="updateRating(+$event)"
|
||||
@click="updateRating"
|
||||
/>
|
||||
<!-- Group Rating -->
|
||||
<v-rating
|
||||
v-else
|
||||
:value="groupRating"
|
||||
<v-rating v-else
|
||||
v-bind="props"
|
||||
:model-value="groupRating"
|
||||
:half-increments="true"
|
||||
:readonly="true"
|
||||
color="grey darken-1"
|
||||
background-color="secondary lighten-3"
|
||||
active-color="grey-darken-1"
|
||||
color="secondary-lighten-3"
|
||||
length="5"
|
||||
:dense="small ? true : undefined"
|
||||
:size="small ? 15 : undefined"
|
||||
:density="small ? 'compact' : 'default'"
|
||||
:size="small ? 'x-small' : undefined"
|
||||
hover
|
||||
/>
|
||||
</v-hover>
|
||||
@@ -33,10 +32,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, watch } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useUserSelfRatings } from "~/composables/use-users";
|
||||
export default defineComponent({
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
emitOnly: {
|
||||
type: Boolean,
|
||||
@@ -50,7 +49,7 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
@@ -59,12 +58,13 @@ export default defineComponent({
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const { userRatings, setRating, ready: ratingsLoaded } = useUserSelfRatings();
|
||||
|
||||
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
|
||||
@@ -76,13 +76,13 @@ export default defineComponent({
|
||||
hideGroupRating.value = true;
|
||||
}
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export default defineComponent({
|
||||
if (!props.emitOnly) {
|
||||
setRating(props.slug, val || 0, null);
|
||||
}
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,20 +2,61 @@
|
||||
<div v-if="yieldDisplay">
|
||||
<div class="text-center d-flex align-center">
|
||||
<div>
|
||||
<v-menu v-model="menu" :disabled="!canEditScale" offset-y top nudge-top="6" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-menu
|
||||
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-else
|
||||
class="pa-1 px-2"
|
||||
dark
|
||||
color="secondary darken-1"
|
||||
small
|
||||
v-bind="attrs"
|
||||
color="secondary-darken-1"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
: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 -->
|
||||
<span v-html="yieldDisplay"></span>
|
||||
<span v-html="yieldDisplay" />
|
||||
</v-card>
|
||||
</template>
|
||||
<v-card min-width="300px">
|
||||
@@ -24,10 +65,26 @@
|
||||
</v-card-title>
|
||||
<v-card-text class="mt-n5">
|
||||
<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-tooltip right color="secondary darken-1">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn v-bind="attrs" icon class="mx-1" small v-on="on" @click="scale = 1">
|
||||
<v-text-field
|
||||
:model-value="yieldQuantityEditorValue"
|
||||
type="number"
|
||||
: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>
|
||||
{{ $globals.icons.undo }}
|
||||
</v-icon>
|
||||
@@ -47,13 +104,13 @@
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.minus,
|
||||
text: $tc('recipe.decrease-scale-label'),
|
||||
text: $t('recipe.decrease-scale-label'),
|
||||
event: 'decrement',
|
||||
disabled: disableDecrement,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.createAlt,
|
||||
text: $tc('recipe.increase-scale-label'),
|
||||
text: $t('recipe.increase-scale-label'),
|
||||
event: 'increment',
|
||||
},
|
||||
]"
|
||||
@@ -65,12 +122,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
@@ -83,16 +139,17 @@ export default defineComponent({
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const menu = ref<boolean>(false);
|
||||
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
|
||||
|
||||
const scale = computed({
|
||||
get: () => props.value,
|
||||
get: () => props.modelValue,
|
||||
set: (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) {
|
||||
scale.value = 1;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
scale.value = newYield / props.recipeServings;
|
||||
}
|
||||
}
|
||||
@@ -113,9 +171,11 @@ export default defineComponent({
|
||||
});
|
||||
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
|
||||
const yieldDisplay = computed(() => {
|
||||
return yieldQuantity.value ? i18n.t(
|
||||
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay }
|
||||
) as string : "";
|
||||
return yieldQuantity.value
|
||||
? i18n.t(
|
||||
"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
|
||||
@@ -128,8 +188,8 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
yieldQuantityEditorValue.value = recipeYieldAmount.value.scaledAmount;
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
const disableDecrement = computed(() => {
|
||||
return recipeYieldAmount.value.scaledAmount <= 1;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<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 small :value="false">
|
||||
<v-btn-toggle v-model="selected" tile group color="primary accent-3" mandatory="force" @change="emitMulti">
|
||||
<v-btn size="small" :value="false">
|
||||
{{ $t("search.include") }}
|
||||
</v-btn>
|
||||
<v-btn small :value="true">
|
||||
<v-btn size="small" :value="true">
|
||||
{{ $t("search.exclude") }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
<v-btn-toggle v-model="match" tile group color="primary accent-3" mandatory @change="emitMulti">
|
||||
<v-btn small :value="false" class="text-uppercase">
|
||||
<v-btn-toggle v-model="match" tile group color="primary accent-3" mandatory="force" @change="emitMulti">
|
||||
<v-btn size="small" :value="false" class="text-uppercase">
|
||||
{{ $t("search.and") }}
|
||||
</v-btn>
|
||||
<v-btn small :value="true" class="text-uppercase">
|
||||
<v-btn size="small" :value="true" class="text-uppercase">
|
||||
{{ $t("search.or") }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
@@ -20,17 +20,16 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
|
||||
type SelectionValue = "include" | "exclude" | "any";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: String as () => SelectionValue,
|
||||
default: "include",
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "update"],
|
||||
data() {
|
||||
return {
|
||||
selected: false,
|
||||
@@ -39,7 +38,7 @@ export default defineComponent({
|
||||
},
|
||||
methods: {
|
||||
emitChange() {
|
||||
this.$emit("input", this.selected);
|
||||
this.$emit("update:modelValue", this.selected);
|
||||
},
|
||||
emitMulti() {
|
||||
const updateData = {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<template>
|
||||
<div class="text-center">
|
||||
<v-menu offset-y top nudge-top="6" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn color="accent" dark v-bind="attrs" v-on="on">
|
||||
<v-icon left>
|
||||
<v-menu
|
||||
offset-y
|
||||
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.cog }}
|
||||
</v-icon>
|
||||
{{ $t("general.settings") }}
|
||||
@@ -15,32 +24,24 @@
|
||||
{{ $t("recipe.recipe-settings") }}
|
||||
</div>
|
||||
</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">
|
||||
<RecipeSettingsSwitches v-model="value" :is-owner="isOwner" />
|
||||
<RecipeSettingsSwitches
|
||||
v-model="value"
|
||||
:is-owner="isOwner"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
<script lang="ts" setup>
|
||||
import RecipeSettingsSwitches from "./RecipeSettingsSwitches.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: { RecipeSettingsSwitches },
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isOwner: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const value = defineModel<object>({ required: true });
|
||||
|
||||
defineProps<{ isOwner?: boolean }>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -1,51 +1,39 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-switch
|
||||
v-for="(_, key) in value"
|
||||
v-for="(_, key) in model"
|
||||
:key="key"
|
||||
v-model="value[key]"
|
||||
v-model="model[key]"
|
||||
color="primary"
|
||||
xs
|
||||
dense
|
||||
density="compact"
|
||||
:disabled="key == 'locked' && !isOwner"
|
||||
class="my-1"
|
||||
:label="labels[key]"
|
||||
hide-details
|
||||
></v-switch>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { RecipeSettings } from "~/lib/api/types/recipe";
|
||||
<script lang="ts" setup>
|
||||
import { defineModel, defineProps } from "vue";
|
||||
import type { RecipeSettings } from "~/lib/api/types/recipe";
|
||||
import { useI18n } from "#imports";
|
||||
|
||||
export default defineComponent({
|
||||
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"),
|
||||
};
|
||||
defineProps<{ isOwner?: boolean }>();
|
||||
|
||||
return {
|
||||
labels,
|
||||
};
|
||||
},
|
||||
});
|
||||
const model = defineModel<RecipeSettings>({ required: true });
|
||||
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -12,23 +12,23 @@
|
||||
/>
|
||||
</v-col>
|
||||
<div v-for="(organizer, idx) in missingOrganizers" :key="idx">
|
||||
<v-col
|
||||
v-if="organizer.show"
|
||||
cols="12"
|
||||
>
|
||||
<v-col v-if="organizer.show" cols="12">
|
||||
<div class="d-flex flex-row flex-wrap align-center pt-2">
|
||||
<v-icon class="ma-0 pa-0">{{ organizer.icon }}</v-icon>
|
||||
<v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content;">
|
||||
{{ $tc("recipe-finder.missing") }}:
|
||||
<v-icon class="ma-0 pa-0">
|
||||
{{ organizer.icon }}
|
||||
</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-chip
|
||||
v-for="item in organizer.items"
|
||||
:key="item.item.id"
|
||||
label
|
||||
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>
|
||||
{{ organizer.getLabel(item.item) }}
|
||||
</template>
|
||||
@@ -42,9 +42,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, useContext } from "@nuxtjs/composition-api";
|
||||
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 {
|
||||
type: "food" | "tool";
|
||||
@@ -52,7 +51,7 @@ interface Organizer {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeCardMobile },
|
||||
props: {
|
||||
recipe: {
|
||||
@@ -73,27 +72,31 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { $globals } = useContext();
|
||||
const { $globals } = useNuxtApp();
|
||||
const missingOrganizers = computed(() => [
|
||||
{
|
||||
type: "food",
|
||||
show: props.missingFoods?.length,
|
||||
icon: $globals.icons.foods,
|
||||
items: props.missingFoods ? props.missingFoods.map((food) => {
|
||||
return reactive({type: "food", item: food, selected: false} as Organizer);
|
||||
}) : [],
|
||||
items: props.missingFoods
|
||||
? props.missingFoods.map((food) => {
|
||||
return reactive({ type: "food", item: food, selected: false } as Organizer);
|
||||
})
|
||||
: [],
|
||||
getLabel: (item: IngredientFood) => item.pluralName || item.name,
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
show: props.missingTools?.length,
|
||||
icon: $globals.icons.tools,
|
||||
items: props.missingTools ? props.missingTools.map((tool) => {
|
||||
return reactive({type: "tool", item: tool, selected: false} as Organizer);
|
||||
}) : [],
|
||||
items: props.missingTools
|
||||
? props.missingTools.map((tool) => {
|
||||
return reactive({ type: "tool", item: tool, selected: false } as Organizer);
|
||||
})
|
||||
: [],
|
||||
getLabel: (item: RecipeTool) => item.name,
|
||||
}
|
||||
])
|
||||
},
|
||||
]);
|
||||
|
||||
function handleCheckbox(organizer: Organizer) {
|
||||
if (props.disableCheckbox) {
|
||||
@@ -113,6 +116,6 @@ export default defineComponent({
|
||||
missingOrganizers,
|
||||
handleCheckbox,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,34 +1,77 @@
|
||||
<template v-if="showCards">
|
||||
<div class="text-center">
|
||||
<!-- Total Time -->
|
||||
<div v-if="validateTotalTime" class="time-card-flex mx-auto">
|
||||
<v-row no-gutters class="d-flex flex-no-wrap align-center " :style="fontSize">
|
||||
<v-icon :x-large="!small" left color="primary">
|
||||
{{ $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">
|
||||
<div
|
||||
v-if="validateTotalTime"
|
||||
class="time-card-flex mx-auto"
|
||||
>
|
||||
<v-row
|
||||
no-gutters
|
||||
class="d-flex justify-center align-center" :class="{'flex-column': $vuetify.breakpoint.smAndDown}"
|
||||
style="width: 100%;" :style="fontSize"
|
||||
class="d-flex flex-no-wrap align-center"
|
||||
:style="fontSize"
|
||||
>
|
||||
<div v-if="validatePrepTime" class="d-flex flex-no-wrap my-1">
|
||||
<v-icon :large="!small" :dense="small" left color="primary">
|
||||
<v-icon
|
||||
: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 }}
|
||||
</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>
|
||||
<v-divider v-if="validatePrepTime && validatePerformTime" vertical class="mx-4" />
|
||||
<div v-if="validatePerformTime" class="d-flex flex-no-wrap my-1">
|
||||
<v-icon :large="!small" :dense="small" left color="primary">
|
||||
<v-divider
|
||||
v-if="validatePrepTime && validatePerformTime"
|
||||
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 }}
|
||||
</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>
|
||||
</v-row>
|
||||
</div>
|
||||
@@ -36,9 +79,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
prepTime: {
|
||||
type: String,
|
||||
@@ -54,7 +95,7 @@ export default defineComponent({
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "accent custom-transparent"
|
||||
default: "accent custom-transparent",
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
@@ -62,14 +103,14 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
|
||||
function isEmpty(str: string | null) {
|
||||
return !str || str.length === 0;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
|
||||
@@ -4,55 +4,62 @@
|
||||
<v-spacer />
|
||||
<v-col class="text-right">
|
||||
<!-- Filters -->
|
||||
<v-menu offset-y bottom left nudge-bottom="3" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-badge :content="filterBadgeCount" :value="filterBadgeCount" bordered overlap>
|
||||
<v-btn fab small color="info" v-bind="attrs" v-on="on">
|
||||
<v-menu
|
||||
offset-y
|
||||
bottom
|
||||
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-btn>
|
||||
</v-badge>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item @click="reverseSort">
|
||||
<v-icon left>
|
||||
{{
|
||||
preferences.orderDirection === "asc" ?
|
||||
$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-list-item
|
||||
:prepend-icon="preferences.orderDirection === 'asc' ? $globals.icons.sortCalendarDescending : $globals.icons.sortCalendarAscending"
|
||||
:title="preferences.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
||||
@click="reverseSort"
|
||||
/>
|
||||
<v-divider />
|
||||
<v-list-item class="pa-0">
|
||||
<v-list class="py-0" style="width: 100%;">
|
||||
<v-list-item
|
||||
v-for="option, idx in eventTypeFilterState"
|
||||
:key="idx"
|
||||
>
|
||||
<v-checkbox
|
||||
:input-value="option.checked"
|
||||
readonly
|
||||
@click="toggleEventTypeOption(option.value)"
|
||||
>
|
||||
<template #label>
|
||||
<v-icon left>
|
||||
{{ option.icon }}
|
||||
</v-icon>
|
||||
{{ option.label }}
|
||||
</template>
|
||||
</v-checkbox>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-list-item
|
||||
v-for="option, idx in eventTypeFilterState"
|
||||
:key="idx"
|
||||
>
|
||||
<v-checkbox
|
||||
:model-value="option.checked"
|
||||
color="primary"
|
||||
readonly
|
||||
@click="toggleEventTypeOption(option.value)"
|
||||
>
|
||||
<template #label>
|
||||
<v-icon start>
|
||||
{{ option.icon }}
|
||||
</v-icon>
|
||||
{{ option.label }}
|
||||
</template>
|
||||
</v-checkbox>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-divider class="mx-2"/>
|
||||
<v-divider class="mx-2" />
|
||||
<div
|
||||
v-if="timelineEvents.length"
|
||||
id="timeline-container"
|
||||
@@ -61,7 +68,10 @@
|
||||
class="px-1"
|
||||
: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
|
||||
v-for="(event, index) in timelineEvents"
|
||||
:key="event.id"
|
||||
@@ -73,33 +83,41 @@
|
||||
/>
|
||||
</v-timeline>
|
||||
</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">
|
||||
{{ $t("recipe.timeline-no-events-found-try-adjusting-filters") }}
|
||||
</v-card-title>
|
||||
</v-card>
|
||||
<div v-if="loading" class="mb-3 text-center">
|
||||
<AppLoader :loading="loading" :waiting-text="$tc('general.loading-events')" />
|
||||
<div
|
||||
v-if="loading"
|
||||
class="mb-3 text-center"
|
||||
>
|
||||
<AppLoader
|
||||
:loading="loading"
|
||||
:waiting-text="$t('general.loading-events')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, ref, useAsync, useContext } from "@nuxtjs/composition-api";
|
||||
import { useThrottleFn, whenever } from "@vueuse/core";
|
||||
import RecipeTimelineItem from "./RecipeTimelineItem.vue"
|
||||
import RecipeTimelineItem from "./RecipeTimelineItem.vue";
|
||||
import { useTimelinePreferences } from "~/composables/use-users/preferences";
|
||||
import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events";
|
||||
import { useAsyncKey } from "~/composables/use-utils";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
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 },
|
||||
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@@ -114,12 +132,12 @@ export default defineComponent({
|
||||
showRecipeCards: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const api = useUserApi();
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const preferences = useTimelinePreferences();
|
||||
const { eventTypeOptions } = useTimelineEventTypes();
|
||||
const loading = ref(true);
|
||||
@@ -133,16 +151,16 @@ export default defineComponent({
|
||||
const recipes = new Map<string, Recipe>();
|
||||
const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length);
|
||||
const eventTypeFilterState = computed(() => {
|
||||
return eventTypeOptions.value.map(option => {
|
||||
return eventTypeOptions.value.map((option) => {
|
||||
return {
|
||||
...option,
|
||||
checked: preferences.value.types.includes(option.value),
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
interface ScrollEvent extends Event {
|
||||
target: HTMLInputElement;
|
||||
target: HTMLInputElement;
|
||||
}
|
||||
|
||||
const screenBuffer = 4;
|
||||
@@ -154,17 +172,17 @@ export default defineComponent({
|
||||
const { scrollTop, offsetHeight, scrollHeight } = event.target;
|
||||
|
||||
// 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) {
|
||||
infiniteScroll();
|
||||
}
|
||||
};
|
||||
|
||||
whenever(
|
||||
() => props.value,
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Preferences
|
||||
@@ -173,7 +191,7 @@ export default defineComponent({
|
||||
return;
|
||||
}
|
||||
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
|
||||
@@ -185,7 +203,8 @@ export default defineComponent({
|
||||
const index = preferences.value.types.indexOf(option);
|
||||
if (index === -1) {
|
||||
preferences.value.types.push(option);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
preferences.value.types.splice(index, 1);
|
||||
}
|
||||
|
||||
@@ -194,21 +213,21 @@ export default defineComponent({
|
||||
|
||||
// Timeline Actions
|
||||
async function updateTimelineEvent(index: number) {
|
||||
const event = timelineEvents.value[index]
|
||||
const event = timelineEvents.value[index];
|
||||
const payload: RecipeTimelineEventUpdate = {
|
||||
subject: event.subject,
|
||||
eventMessage: event.eventMessage,
|
||||
image: event.image,
|
||||
};
|
||||
|
||||
const { response } = await api.recipes.updateTimelineEvent(event.id, payload);
|
||||
if (response?.status !== 200) {
|
||||
alert.error(i18n.t("events.something-went-wrong") as string);
|
||||
return;
|
||||
}
|
||||
const { response } = await api.recipes.updateTimelineEvent(event.id, payload);
|
||||
if (response?.status !== 200) {
|
||||
alert.error(i18n.t("events.something-went-wrong") as string);
|
||||
return;
|
||||
}
|
||||
|
||||
alert.success(i18n.t("events.event-updated") as string);
|
||||
};
|
||||
alert.success(i18n.t("events.event-updated") as string);
|
||||
};
|
||||
|
||||
async function deleteTimelineEvent(index: number) {
|
||||
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> {
|
||||
const { data } = await api.recipes.getOne(recipeId);
|
||||
return data
|
||||
return data;
|
||||
};
|
||||
|
||||
async function updateRecipes(events: RecipeTimelineEventOut[]) {
|
||||
const recipePromises: Promise<Recipe | null>[] = [];
|
||||
const seenRecipeIds: string[] = [];
|
||||
events.forEach(event => {
|
||||
if (seenRecipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
|
||||
return;
|
||||
}
|
||||
const recipePromises: Promise<Recipe | null>[] = [];
|
||||
const seenRecipeIds: string[] = [];
|
||||
events.forEach((event) => {
|
||||
if (seenRecipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seenRecipeIds.push(event.recipeId);
|
||||
recipePromises.push(getRecipe(event.recipeId));
|
||||
})
|
||||
seenRecipeIds.push(event.recipeId);
|
||||
recipePromises.push(getRecipe(event.recipeId));
|
||||
});
|
||||
|
||||
const results = await Promise.all(recipePromises);
|
||||
results.forEach(result => {
|
||||
if (result && result.id) {
|
||||
recipes.set(result.id, result);
|
||||
}
|
||||
})
|
||||
const results = await Promise.all(recipePromises);
|
||||
results.forEach((result) => {
|
||||
if (result && result.id) {
|
||||
recipes.set(result.id, result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function scrollTimelineEvents() {
|
||||
const orderBy = "timestamp";
|
||||
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
|
||||
// eslint-disable-next-line quotes
|
||||
const eventTypeValue = `["${preferences.value.types.join('", "')}"]`;
|
||||
const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`
|
||||
|
||||
const eventTypeValue = `["${preferences.value.types.join("\", \"")}"]`;
|
||||
const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`;
|
||||
|
||||
const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter });
|
||||
page.value += 1;
|
||||
@@ -290,7 +309,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
const infiniteScroll = useThrottleFn(() => {
|
||||
useAsync(async () => {
|
||||
useAsyncData(useAsyncKey(), async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
@@ -298,7 +317,7 @@ export default defineComponent({
|
||||
loading.value = true;
|
||||
await scrollTimelineEvents();
|
||||
loading.value = false;
|
||||
}, useAsyncKey());
|
||||
});
|
||||
}, 500);
|
||||
|
||||
// preload events
|
||||
@@ -310,7 +329,7 @@ export default defineComponent({
|
||||
// if the inner element is scrollable, let its scroll event handle the infiniteScroll
|
||||
const timelineContainerElement = document.getElementById("timeline-container");
|
||||
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, 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) {
|
||||
infiniteScroll();
|
||||
}
|
||||
};
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
deleteTimelineEvent,
|
||||
|
||||
@@ -1,32 +1,48 @@
|
||||
<template>
|
||||
<v-tooltip bottom nudge-right="50" :color="buttonStyle ? 'info' : 'secondary'">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-tooltip
|
||||
bottom
|
||||
nudge-right="50"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
small
|
||||
icon
|
||||
:variant="buttonStyle ? 'flat' : undefined"
|
||||
:rounded="buttonStyle ? 'circle' : undefined"
|
||||
size="small"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
:fab="buttonStyle"
|
||||
class="ml-1"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
v-bind="{ ...props, ...$attrs }"
|
||||
@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 }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<BaseDialog v-model="showTimeline" :title="timelineAttrs.title" :icon="$globals.icons.timelineText" width="70%">
|
||||
<RecipeTimeline v-model="showTimeline" :query-filter="timelineAttrs.queryFilter" max-height="60vh" />
|
||||
<BaseDialog
|
||||
v-model="showTimeline"
|
||||
:title="timelineAttrs.title"
|
||||
:icon="$globals.icons.timelineText"
|
||||
width="70%"
|
||||
>
|
||||
<RecipeTimeline
|
||||
v-model="showTimeline"
|
||||
:query-filter="timelineAttrs.queryFilter"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</BaseDialog>
|
||||
|
||||
</template>
|
||||
<span>{{ $t('recipe.open-timeline') }}</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
||||
import RecipeTimeline from "./RecipeTimeline.vue";
|
||||
export default defineComponent({
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeTimeline },
|
||||
|
||||
props: {
|
||||
@@ -45,23 +61,24 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const { $vuetify, i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const { smAndDown } = useDisplay();
|
||||
const showTimeline = ref(false);
|
||||
function toggleTimeline() {
|
||||
showTimeline.value = !showTimeline.value;
|
||||
}
|
||||
|
||||
const timelineAttrs = computed(() => {
|
||||
let title = i18n.tc("recipe.timeline")
|
||||
if ($vuetify.breakpoint.smAndDown) {
|
||||
title += ` – ${props.recipeName}`
|
||||
let title = i18n.t("recipe.timeline");
|
||||
if (smAndDown.value) {
|
||||
title += ` – ${props.recipeName}`;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
queryFilter: `recipe.slug="${props.slug}"`,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
return { showTimeline, timelineAttrs, toggleTimeline };
|
||||
},
|
||||
|
||||
@@ -2,58 +2,69 @@
|
||||
<div class="text-center">
|
||||
<BaseDialog
|
||||
v-model="recipeEventEditDialog"
|
||||
:title="$tc('recipe.edit-timeline-event')"
|
||||
:title="$t('recipe.edit-timeline-event')"
|
||||
:icon="$globals.icons.edit"
|
||||
:submit-text="$tc('general.save')"
|
||||
@submit="$emit('update')"
|
||||
can-submit
|
||||
:submit-text="$t('general.save')"
|
||||
@submit="submitEdit"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-form ref="domMadeThisForm">
|
||||
<v-text-field
|
||||
v-model="event.subject"
|
||||
:label="$tc('general.subject')"
|
||||
/>
|
||||
<v-textarea
|
||||
v-model="event.eventMessage"
|
||||
:label="$tc('general.message')"
|
||||
rows="4"
|
||||
/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-text>
|
||||
<v-form ref="domEditEventForm">
|
||||
<v-text-field v-model="localEvent.subject" :label="$t('general.subject')" />
|
||||
<v-textarea v-model="localEvent.eventMessage" :label="$t('general.message')" rows="4" />
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog
|
||||
v-model="recipeEventDeleteDialog"
|
||||
:title="$tc('events.delete-event')"
|
||||
:title="$t('events.delete-event')"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
can-confirm
|
||||
@confirm="$emit('delete')"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("events.event-delete-confirmation") }}
|
||||
{{ $t('events.event-delete-confirmation') }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<v-menu
|
||||
offset-y
|
||||
left
|
||||
:bottom="!menuTop"
|
||||
:nudge-bottom="!menuTop ? '5' : '0'"
|
||||
:top="menuTop"
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
start
|
||||
:bottom="!props.menuTop"
|
||||
:nudge-bottom="!props.menuTop ? '5' : '0'"
|
||||
:top="props.menuTop"
|
||||
:nudge-top="props.menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="!useMobileFormat"
|
||||
:open-on-hover="!props.useMobileFormat"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn :fab="fab" :x-small="fab" :elevation="elevation" :color="color" :icon="!fab" v-bind="attrs" v-on="on" @click.prevent>
|
||||
<template #activator="{ props: btnProps }">
|
||||
<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-btn>
|
||||
</template>
|
||||
<v-list dense>
|
||||
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
|
||||
<v-list-item-icon>
|
||||
<v-icon :color="item.color"> {{ item.icon }} </v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="(item, index) in menuItems"
|
||||
:key="index"
|
||||
@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>
|
||||
</v-list>
|
||||
@@ -61,10 +72,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||
import { VForm } from "~/types/vuetify";
|
||||
import { RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
||||
<script lang="ts" setup>
|
||||
import { useI18n, useNuxtApp } from "#imports";
|
||||
import type { RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
||||
|
||||
export interface TimelineContextMenuIncludes {
|
||||
edit: boolean;
|
||||
@@ -78,129 +88,90 @@ export interface ContextMenuItem {
|
||||
event: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
useItems: {
|
||||
type: Object as () => TimelineContextMenuIncludes,
|
||||
default: () => ({
|
||||
edit: true,
|
||||
delete: true,
|
||||
}),
|
||||
},
|
||||
// Append items are added at the end of the useItems list
|
||||
appendItems: {
|
||||
type: Array as () => ContextMenuItem[],
|
||||
default: () => [],
|
||||
},
|
||||
// Append items are added at the beginning of the useItems list
|
||||
leadingItems: {
|
||||
type: Array as () => ContextMenuItem[],
|
||||
default: () => [],
|
||||
},
|
||||
menuTop: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
fab: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
elevation: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
},
|
||||
event: {
|
||||
type: Object as () => RecipeTimelineEventOut,
|
||||
required: true,
|
||||
},
|
||||
menuIcon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
useMobileFormat: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
}
|
||||
const props = defineProps<{
|
||||
useItems?: TimelineContextMenuIncludes;
|
||||
appendItems?: ContextMenuItem[];
|
||||
leadingItems?: ContextMenuItem[];
|
||||
menuTop?: boolean;
|
||||
fab?: boolean;
|
||||
elevation?: number | null;
|
||||
color?: string;
|
||||
event: RecipeTimelineEventOut;
|
||||
menuIcon?: string | null;
|
||||
useMobileFormat?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["delete", "update"]);
|
||||
|
||||
const domEditEventForm = ref();
|
||||
const recipeEventEditDialog = ref(false);
|
||||
const recipeEventDeleteDialog = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||
edit: {
|
||||
title: i18n.t("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
color: undefined,
|
||||
event: "edit",
|
||||
},
|
||||
setup(props, context) {
|
||||
const domEditEventForm = ref<VForm>();
|
||||
const state = reactive({
|
||||
recipeEventEditDialog: false,
|
||||
recipeEventDeleteDialog: false,
|
||||
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,
|
||||
};
|
||||
delete: {
|
||||
title: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
color: "error",
|
||||
event: "delete",
|
||||
},
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,61 +1,57 @@
|
||||
<template>
|
||||
<v-timeline-item
|
||||
:class="attrs.class"
|
||||
fill-dot
|
||||
:small="attrs.small"
|
||||
:icon="icon"
|
||||
>
|
||||
<v-timeline-item :class="attrs.class" fill-dot :small="attrs.small" :icon="icon" dot-color="primary">
|
||||
<template v-if="!useMobileFormat" #opposite>
|
||||
<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) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<v-card
|
||||
hover
|
||||
:to="$listeners.selected || !recipe ? undefined : `/g/${groupSlug}/r/${recipe.slug}`"
|
||||
:to="$attrs.selected || !recipe ? undefined : `/g/${groupSlug}/r/${recipe.slug}`"
|
||||
class="elevation-12"
|
||||
@click="$emit('selected')"
|
||||
>
|
||||
<v-card-title class="background">
|
||||
<v-row>
|
||||
<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-if="useMobileFormat" align-self="center" class="pr-0">
|
||||
<v-chip label>
|
||||
<v-chip label>
|
||||
<v-icon> {{ $globals.icons.calendar }} </v-icon>
|
||||
{{ new Date(event.timestamp || "").toLocaleDateString($i18n.locale) }}
|
||||
</v-chip>
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col v-else cols="9" style="margin: auto; text-align: center;">
|
||||
{{ event.subject }}
|
||||
<v-col v-else cols="9" style="margin: auto; text-align: center">
|
||||
{{ event.subject }}
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
<v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0 pt-0">
|
||||
<RecipeTimelineContextMenu
|
||||
v-if="$auth.user && $auth.user.id == event.userId && event.eventType != 'system'"
|
||||
:menu-top="false"
|
||||
:event="event"
|
||||
:menu-icon="$globals.icons.dotsVertical"
|
||||
:use-mobile-format="useMobileFormat"
|
||||
fab
|
||||
color="transparent"
|
||||
:elevation="0"
|
||||
:card-menu="false"
|
||||
:use-items="{
|
||||
edit: true,
|
||||
delete: true,
|
||||
}"
|
||||
@update="$emit('update')"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
<RecipeTimelineContextMenu
|
||||
v-if="currentUser && currentUser.id == event.userId && event.eventType != 'system'"
|
||||
:menu-top="false"
|
||||
:event="event"
|
||||
:menu-icon="$globals.icons.dotsVertical"
|
||||
:use-mobile-format="useMobileFormat"
|
||||
color="transparent"
|
||||
:elevation="0"
|
||||
:card-menu="false"
|
||||
:use-items="{
|
||||
edit: true,
|
||||
delete: true,
|
||||
}"
|
||||
@update="$emit('update')"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-title>
|
||||
<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-col align-self="center" class="pa-0">
|
||||
<v-row :class="useMobileFormat ? 'py-3 mx-0' : 'py-3 mx-0'" style="max-width: 100%">
|
||||
<v-col align-self="center" class="pa-0">
|
||||
<RecipeCardMobile
|
||||
:vertical="useMobileFormat"
|
||||
:name="recipe.name"
|
||||
@@ -67,26 +63,26 @@
|
||||
:is-flat="true"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-divider v-if="showRecipeCards && recipe && (useMobileFormat || event.eventMessage)" />
|
||||
<v-card-text class="background">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<strong v-if="useMobileFormat">{{ event.subject }}</strong>
|
||||
<v-img
|
||||
v-if="eventImageUrl"
|
||||
:src="eventImageUrl"
|
||||
min-height="50"
|
||||
:height="hideImage ? undefined : 'auto'"
|
||||
:max-height="attrs.image.maxHeight"
|
||||
contain
|
||||
:class=attrs.image.class
|
||||
@error="hideImage = true"
|
||||
/>
|
||||
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
|
||||
<SafeMarkdown :source="event.eventMessage" />
|
||||
</div>
|
||||
<strong v-if="useMobileFormat">{{ event.subject }}</strong>
|
||||
<v-img
|
||||
v-if="eventImageUrl"
|
||||
:src="eventImageUrl"
|
||||
min-height="50"
|
||||
:height="hideImage ? undefined : 'auto'"
|
||||
:max-height="attrs.image.maxHeight"
|
||||
contain
|
||||
:class="attrs.image.class"
|
||||
@error="hideImage = true"
|
||||
/>
|
||||
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
|
||||
<SafeMarkdown :source="event.eventMessage" />
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
@@ -95,16 +91,15 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
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 SafeMarkdown from "~/components/global/SafeMarkdown.vue";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeCardMobile, RecipeTimelineContextMenu, UserAvatar, SafeMarkdown },
|
||||
|
||||
props: {
|
||||
@@ -119,20 +114,23 @@ export default defineComponent({
|
||||
showRecipeCards: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
emits: ["selected", "update", "delete"],
|
||||
|
||||
setup(props) {
|
||||
const { $auth, $globals, $vuetify } = useContext();
|
||||
const { $vuetify, $globals } = useNuxtApp();
|
||||
const { recipeTimelineEventImage } = useStaticRoutes();
|
||||
const { eventTypeOptions } = useTimelineEventTypes();
|
||||
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
|
||||
|
||||
const { user: currentUser } = useMealieAuth();
|
||||
|
||||
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(() => {
|
||||
return $vuetify.breakpoint.smAndDown;
|
||||
return $vuetify.display.smAndDown.value;
|
||||
});
|
||||
|
||||
const attrs = computed(() => {
|
||||
@@ -146,9 +144,9 @@ export default defineComponent({
|
||||
},
|
||||
image: {
|
||||
maxHeight: "250",
|
||||
class: "my-3"
|
||||
class: "my-3",
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
else {
|
||||
return {
|
||||
@@ -160,25 +158,25 @@ export default defineComponent({
|
||||
},
|
||||
image: {
|
||||
maxHeight: "300",
|
||||
class: "mb-5"
|
||||
class: "mb-5",
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
const hideImage = ref(false);
|
||||
const eventImageUrl = computed<string>( () => {
|
||||
const eventImageUrl = computed<string>(() => {
|
||||
if (props.event.image !== "has image") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
attrs,
|
||||
@@ -188,6 +186,7 @@ export default defineComponent({
|
||||
hideImage,
|
||||
timelineEvents,
|
||||
useMobileFormat,
|
||||
currentUser,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
<template>
|
||||
<div v-if="scaledAmount" class="d-flex align-center">
|
||||
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger;">
|
||||
<v-icon x-large left color="primary">
|
||||
<div
|
||||
v-if="scaledAmount"
|
||||
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 }}
|
||||
</v-icon>
|
||||
<p class="my-0">
|
||||
<span class="font-weight-bold">{{ $i18n.tc("recipe.yield") }}</span><br>
|
||||
<p class="my-0 opacity-80">
|
||||
<span class="font-weight-bold">{{ $t("recipe.yield") }}</span><br>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="scaledAmount"></span> {{ text }}
|
||||
<span v-html="scaledAmount" /> {{ text }}
|
||||
</p>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from "@nuxtjs/composition-api";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
yieldQuantity: {
|
||||
type: Number,
|
||||
@@ -34,11 +44,10 @@ export default defineComponent({
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "accent custom-transparent"
|
||||
default: "accent custom-transparent",
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
@@ -47,7 +56,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
const scaledAmount = computed(() => {
|
||||
const {scaledAmountDisplay} = useScaledAmount(props.yieldQuantity, props.scale);
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
|
||||
return scaledAmountDisplay;
|
||||
});
|
||||
const text = sanitizeHTML(props.yield);
|
||||
|
||||
@@ -1,56 +1,131 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-menu v-model="state.menu" offset-y bottom nudge-bottom="3" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-badge :value="selected.length > 0" small overlap color="primary" :content="selected.length">
|
||||
<v-btn small color="accent" dark v-bind="attrs" v-on="on">
|
||||
<slot></slot>
|
||||
<v-menu
|
||||
v-model="state.menu"
|
||||
offset-y
|
||||
bottom
|
||||
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-badge>
|
||||
</template>
|
||||
<v-card width="400">
|
||||
<v-card-text>
|
||||
<v-text-field v-model="state.search" class="mb-2" hide-details dense :label="$tc('search.search')" clearable />
|
||||
<div class="d-flex py-4">
|
||||
<v-text-field
|
||||
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-if="requireAll != undefined"
|
||||
v-model="requireAllValue"
|
||||
dense
|
||||
small
|
||||
density="compact"
|
||||
size="small"
|
||||
hide-details
|
||||
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-btn
|
||||
small
|
||||
size="small"
|
||||
color="accent"
|
||||
class="mr-2 my-auto"
|
||||
@click="clearSelection"
|
||||
>
|
||||
{{ $tc("search.clear-selection") }}
|
||||
{{ $t("search.clear-selection") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-card v-if="filtered.length > 0" flat outlined>
|
||||
<v-radio-group v-model="selectedRadio" class="ma-0 pa-0">
|
||||
<v-virtual-scroll :items="filtered" height="300" item-height="51">
|
||||
<template #default="{ item }">
|
||||
<v-list-item :key="item.id" dense :value="item">
|
||||
<v-list-item-action>
|
||||
<v-radio v-if="radio" :value="item" @click="handleRadioClick(item)" />
|
||||
<v-checkbox v-else v-model="selected" :value="item" />
|
||||
</v-list-item-action>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title> {{ item.name }} </v-list-item-title>
|
||||
</v-list-item-content>
|
||||
<v-card
|
||||
v-if="filtered.length > 0"
|
||||
flat
|
||||
variant="text"
|
||||
>
|
||||
<!-- radio filters -->
|
||||
<v-radio-group
|
||||
v-if="radio"
|
||||
v-model="selectedRadio"
|
||||
class="ma-0 pa-0"
|
||||
>
|
||||
<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-divider></v-divider>
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
<v-divider />
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
</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>
|
||||
<div v-else>
|
||||
<v-alert type="info" text> {{ $tc('search.no-results') }} </v-alert>
|
||||
<v-alert
|
||||
type="info"
|
||||
:text="$t('search.no-results')"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -59,20 +134,18 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, computed } from "@nuxtjs/composition-api";
|
||||
|
||||
export interface SelectableItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as () => SelectableItem[],
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Array as () => any[],
|
||||
required: true,
|
||||
},
|
||||
@@ -85,6 +158,7 @@ export default defineComponent({
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:requireAll", "update:modelValue"],
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
search: "",
|
||||
@@ -99,16 +173,16 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.value as SelectableItem[],
|
||||
get: () => props.modelValue as SelectableItem[],
|
||||
set: (value) => {
|
||||
context.emit("input", value);
|
||||
context.emit("update:modelValue", value);
|
||||
},
|
||||
});
|
||||
|
||||
const selectedRadio = computed({
|
||||
get: () => (selected.value.length > 0 ? selected.value[0] : null),
|
||||
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.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) => {
|
||||
if (selectedRadio.value === item) {
|
||||
selectedRadio.value = null;
|
||||
@@ -138,6 +222,7 @@ export default defineComponent({
|
||||
selected,
|
||||
selectedRadio,
|
||||
filtered,
|
||||
handleCheckboxClick,
|
||||
handleRadioClick,
|
||||
clearSelection,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<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;">
|
||||
{{ label.name }}
|
||||
</span>
|
||||
@@ -7,12 +13,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { getTextColor } from "~/composables/use-text-color";
|
||||
import { MultiPurposeLabelSummary } from "~/lib/api/types/recipe";
|
||||
import type { MultiPurposeLabelSummary } from "~/lib/api/types/recipe";
|
||||
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
label: {
|
||||
type: Object as () => MultiPurposeLabelSummary,
|
||||
|
||||
@@ -6,12 +6,24 @@
|
||||
{{ $globals.icons.tags }}
|
||||
</v-icon>
|
||||
</span>
|
||||
{{ value.label.name }}
|
||||
{{ modelValue.label.name }}
|
||||
</div>
|
||||
<div style="min-width: 72px" class="ml-auto text-right">
|
||||
<v-menu offset-x left min-width="125px">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn small class="ml-2 handle" icon v-bind="attrs" v-on="on">
|
||||
<div
|
||||
style="min-width: 72px"
|
||||
class="ml-auto text-right"
|
||||
>
|
||||
<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>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
@@ -23,22 +35,21 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
||||
import { ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/household";
|
||||
import type { ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/household";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Object as () => ShoppingListMultiPurposeLabelOut,
|
||||
required: true,
|
||||
},
|
||||
useColor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
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) {
|
||||
context.emit(event);
|
||||
|
||||
@@ -1,41 +1,74 @@
|
||||
<template>
|
||||
<v-container v-if="!edit" class="pa-0">
|
||||
<v-row no-gutters class="flex-nowrap align-center">
|
||||
<v-container
|
||||
v-if="!edit"
|
||||
class="pa-0"
|
||||
>
|
||||
<v-row
|
||||
no-gutters
|
||||
class="flex-nowrap align-center"
|
||||
>
|
||||
<v-col :cols="itemLabelCols">
|
||||
<v-checkbox
|
||||
v-model="listItem.checked"
|
||||
class="mt-0"
|
||||
color="null"
|
||||
hide-details
|
||||
dense
|
||||
:label="listItem.note"
|
||||
density="compact"
|
||||
:label="listItem.note!"
|
||||
@change="$emit('checked', listItem)"
|
||||
>
|
||||
<template #label>
|
||||
<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>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
<v-col v-if="label && showLabel" cols="3" class="text-right">
|
||||
<MultiPurposeLabel :label="label" small />
|
||||
<v-col
|
||||
v-if="label && showLabel"
|
||||
cols="3"
|
||||
class="text-right"
|
||||
>
|
||||
<MultiPurposeLabel
|
||||
:label="label"
|
||||
size="small"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="auto" class="text-right">
|
||||
<div v-if="!listItem.checked" style="min-width: 72px">
|
||||
<v-menu offset-x left min-width="125px">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-col
|
||||
cols="auto"
|
||||
class="text-right"
|
||||
>
|
||||
<div
|
||||
v-if="!listItem.checked"
|
||||
style="min-width: 72px"
|
||||
>
|
||||
<v-menu
|
||||
offset-x
|
||||
start
|
||||
min-width="125px"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-tooltip
|
||||
v-if="recipeList && recipeList.length"
|
||||
open-delay="200"
|
||||
transition="slide-x-reverse-transition"
|
||||
dense
|
||||
density="compact"
|
||||
right
|
||||
content-class="text-caption"
|
||||
>
|
||||
<template #activator="{ on: onBtn, attrs: attrsBtn }">
|
||||
<v-btn small class="ml-2" icon v-bind="attrsBtn" v-on="onBtn" @click="displayRecipeRefs = !displayRecipeRefs">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
icon
|
||||
v-bind="tooltipProps"
|
||||
@click="displayRecipeRefs = !displayRecipeRefs"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
@@ -44,43 +77,91 @@
|
||||
<span>Toggle Recipes</span>
|
||||
</v-tooltip>
|
||||
<!-- 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>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</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>
|
||||
{{ $globals.icons.edit }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list dense>
|
||||
<v-list-item v-for="action in contextMenu" :key="action.event" dense @click="contextHandler(action.event)">
|
||||
<v-list-item-title>{{ action.text }}</v-list-item-title>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
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>
|
||||
</v-menu>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs" no-gutters class="mb-2">
|
||||
<v-col cols="auto" style="width: 100%;">
|
||||
<RecipeList :recipes="recipeList" :list-item="listItem" :disabled="$nuxt.isOffline" small tile />
|
||||
<v-row
|
||||
v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs"
|
||||
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-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">
|
||||
<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>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<div v-else class="mb-1 mt-6">
|
||||
<div
|
||||
v-else
|
||||
class="mb-1 mt-6"
|
||||
>
|
||||
<ShoppingListItemEditor
|
||||
v-model="localListItem"
|
||||
:labels="labels"
|
||||
@@ -95,13 +176,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition-api";
|
||||
import { useOnline } from "@vueuse/core";
|
||||
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
|
||||
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
|
||||
import MultiPurposeLabel from "./MultiPurposeLabel.vue";
|
||||
import { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||
import { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe";
|
||||
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||
import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe";
|
||||
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
|
||||
|
||||
interface actions {
|
||||
@@ -109,10 +190,10 @@ interface actions {
|
||||
event: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeList, RecipeIngredientListItem },
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Object as () => ShoppingListItemOut,
|
||||
required: true,
|
||||
},
|
||||
@@ -137,10 +218,12 @@ export default defineComponent({
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ["checked", "update:modelValue", "save", "delete"],
|
||||
setup(props, context) {
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
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[] = [
|
||||
{
|
||||
@@ -154,15 +237,15 @@ export default defineComponent({
|
||||
];
|
||||
|
||||
// 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({
|
||||
get: () => {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
// keep local copy in sync
|
||||
localListItem.value = val;
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
const edit = ref(false);
|
||||
@@ -173,7 +256,7 @@ export default defineComponent({
|
||||
|
||||
if (val) {
|
||||
// update local copy of item with the current value
|
||||
localListItem.value = props.value;
|
||||
localListItem.value = props.modelValue;
|
||||
}
|
||||
|
||||
edit.value = val;
|
||||
@@ -182,7 +265,8 @@ export default defineComponent({
|
||||
function contextHandler(event: string) {
|
||||
if (event === "edit") {
|
||||
toggleEdit(true);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
context.emit(event);
|
||||
}
|
||||
}
|
||||
@@ -205,9 +289,7 @@ export default defineComponent({
|
||||
* or the label of the food applied.
|
||||
*/
|
||||
const label = computed<MultiPurposeLabelSummary | undefined>(() => {
|
||||
// @ts-ignore - it _might_ exists
|
||||
if (listItem.value.label) {
|
||||
// @ts-ignore - it _might_ exists
|
||||
return listItem.value.label as MultiPurposeLabelSummary;
|
||||
}
|
||||
|
||||
@@ -225,7 +307,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
listItem.value.recipeReferences.forEach((ref) => {
|
||||
const recipe = props.recipes.get(ref.recipeId)
|
||||
const recipe = props.recipes.get(ref.recipeId);
|
||||
if (recipe) {
|
||||
recipeList.push(recipe);
|
||||
}
|
||||
@@ -247,6 +329,7 @@ export default defineComponent({
|
||||
label,
|
||||
recipeList,
|
||||
toggleEdit,
|
||||
isOffline,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card outlined>
|
||||
<v-card variant="outlined">
|
||||
<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>
|
||||
@@ -8,26 +8,27 @@
|
||||
</div>
|
||||
<InputLabelType
|
||||
v-model="listItem.unit"
|
||||
v-model:item-id="listItem.unitId!"
|
||||
:items="units"
|
||||
:item-id.sync="listItem.unitId"
|
||||
:label="$t('general.units')"
|
||||
:icon="$globals.icons.units"
|
||||
create
|
||||
@create="createAssignUnit"
|
||||
/>
|
||||
<InputLabelType
|
||||
v-model="listItem.food"
|
||||
v-model:item-id="listItem.foodId!"
|
||||
:items="foods"
|
||||
:item-id.sync="listItem.foodId"
|
||||
:label="$t('shopping-list.food')"
|
||||
:icon="$globals.icons.foods"
|
||||
create
|
||||
@create="createAssignFood"
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div class="d-md-flex align-center" style="gap: 20px">
|
||||
<div v-if="!listItem.isFood">
|
||||
<InputQuantity v-model="listItem.quantity" />
|
||||
</div>
|
||||
<InputQuantity v-model="listItem.quantity" />
|
||||
</div>
|
||||
<v-textarea
|
||||
v-model="listItem.note"
|
||||
hide-details
|
||||
@@ -36,17 +37,17 @@
|
||||
auto-grow
|
||||
autofocus
|
||||
@keypress="handleNoteKeyPress"
|
||||
></v-textarea>
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-end" style="gap: 20px">
|
||||
<div class="d-flex align-end">
|
||||
|
||||
<div style="max-width: 300px" class="mt-3 mr-auto">
|
||||
<InputLabelType
|
||||
v-model="listItem.label"
|
||||
v-model:item-id="listItem.labelId!"
|
||||
:items="labels"
|
||||
:item-id.sync="listItem.labelId"
|
||||
:label="$t('shopping-list.label')"
|
||||
width="250"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -54,11 +55,11 @@
|
||||
v-if="listItem.recipeReferences && listItem.recipeReferences.length > 0"
|
||||
open-on-hover
|
||||
offset-y
|
||||
left
|
||||
start
|
||||
top
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-icon class="mt-auto" icon v-bind="attrs" color="warning" v-on="on">
|
||||
<template #activator="{ props }">
|
||||
<v-icon class="mt-auto" :icon="$globals.icons.alert" v-bind="props" color="warning">
|
||||
{{ $globals.icons.alert }}
|
||||
</v-icon>
|
||||
</template>
|
||||
@@ -71,10 +72,10 @@
|
||||
</div>
|
||||
<BaseButton
|
||||
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
|
||||
small
|
||||
size="small"
|
||||
color="info"
|
||||
:icon="$globals.icons.tagArrowRight"
|
||||
:text="$tc('shopping-list.save-label')"
|
||||
:text="$t('shopping-list.save-label')"
|
||||
class="mt-2 align-items-flex-start"
|
||||
@click="assignLabelToFood"
|
||||
/>
|
||||
@@ -84,11 +85,15 @@
|
||||
<v-card-actions class="ma-0 pt-0 pb-1 justify-end">
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
...(allowDelete ? [{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
}] : []),
|
||||
...(allowDelete
|
||||
? [
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: $globals.icons.close,
|
||||
text: $t('general.cancel'),
|
||||
@@ -116,15 +121,14 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, watch } from "@nuxtjs/composition-api";
|
||||
import { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import { MultiPurposeLabelOut } from "~/lib/api/types/labels";
|
||||
import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
import type { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
|
||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Object as () => ShoppingListItemCreate | ShoppingListItemOut,
|
||||
required: true,
|
||||
},
|
||||
@@ -146,6 +150,7 @@ export default defineComponent({
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "save", "cancel", "delete"],
|
||||
setup(props, context) {
|
||||
const foodStore = useFoodStore();
|
||||
const foodData = useFoodData();
|
||||
@@ -155,25 +160,25 @@ export default defineComponent({
|
||||
|
||||
const listItem = computed({
|
||||
get: () => {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.value.food,
|
||||
() => props.modelValue.food,
|
||||
(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.labelId = listItem.value.label?.id || null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function createAssignFood(val: string) {
|
||||
// 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;
|
||||
const newFood = await foodStore.actions.createOne(foodData.data);
|
||||
@@ -186,7 +191,8 @@ export default defineComponent({
|
||||
|
||||
async function createAssignUnit(val: string) {
|
||||
// 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;
|
||||
const newUnit = await unitStore.actions.createOne(unitData.data);
|
||||
@@ -203,7 +209,6 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -222,6 +227,6 @@ export default defineComponent({
|
||||
this.$emit("save");
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -4,12 +4,29 @@
|
||||
:disabled="!user || !tooltip"
|
||||
right
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-list-item-avatar v-if="list" v-bind="attrs" v-on="on">
|
||||
<v-img :src="imageURL" :alt="userId" @load="error = false" @error="error = true"> </v-img>
|
||||
</v-list-item-avatar>
|
||||
<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>
|
||||
<template #activator="{ props }">
|
||||
<v-avatar
|
||||
v-if="list"
|
||||
v-bind="props"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
<span v-if="user">
|
||||
@@ -19,11 +36,9 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs, reactive, useContext, computed } from "@nuxtjs/composition-api";
|
||||
import { useUserStore } from "~/composables/store/use-user-store";
|
||||
import { UserOut } from "~/lib/api/types/user";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
userId: {
|
||||
type: String,
|
||||
@@ -40,22 +55,22 @@ export default defineComponent({
|
||||
tooltip: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const state = reactive({
|
||||
error: false,
|
||||
});
|
||||
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const { store: users } = useUserStore();
|
||||
const user = computed(() => {
|
||||
return users.value.find((user) => user.id === props.userId);
|
||||
})
|
||||
return users.value.find(user => user.id === props.userId);
|
||||
});
|
||||
|
||||
const imageURL = computed(() => {
|
||||
// TODO Setup correct user type for $auth.user
|
||||
const authUser = $auth.user as unknown as UserOut | null;
|
||||
// Note: $auth.user is a ref now
|
||||
const authUser = $auth.user.value;
|
||||
const key = authUser?.cacheKey ?? "";
|
||||
return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`;
|
||||
});
|
||||
|
||||
@@ -1,50 +1,63 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
v-model="inviteDialog"
|
||||
:title="$tc('profile.get-invite-link')"
|
||||
:title="$t('profile.get-invite-link')"
|
||||
:icon="$globals.icons.accountPlusOutline"
|
||||
color="primary">
|
||||
color="primary"
|
||||
>
|
||||
<v-container>
|
||||
<v-form class="mt-5">
|
||||
<v-select
|
||||
v-if="groups && groups.length"
|
||||
v-model="selectedGroup"
|
||||
:items="groups"
|
||||
item-text="name"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
:return-object="false"
|
||||
filled
|
||||
:label="$tc('group.user-group')"
|
||||
:rules="[validators.required]" />
|
||||
variant="filled"
|
||||
:label="$t('group.user-group')"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<v-select
|
||||
v-if="households && households.length"
|
||||
v-model="selectedHousehold"
|
||||
:items="filteredHouseholds"
|
||||
item-text="name" item-value="id"
|
||||
:return-object="false" filled
|
||||
:label="$tc('household.user-household')"
|
||||
:rules="[validators.required]" />
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
:return-object="false"
|
||||
variant="filled"
|
||||
:label="$t('household.user-household')"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<v-row>
|
||||
<v-col cols="9">
|
||||
<v-text-field
|
||||
:label="$tc('profile.invite-link')"
|
||||
type="text" readonly filled
|
||||
:value="generatedSignupLink" />
|
||||
:label="$t('profile.invite-link')"
|
||||
type="text"
|
||||
readonly
|
||||
variant="filled"
|
||||
:value="generatedSignupLink"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="3" class="pl-1 mt-3">
|
||||
<v-col
|
||||
cols="3"
|
||||
class="pl-1 mt-3"
|
||||
>
|
||||
<AppButtonCopy
|
||||
:icon="false"
|
||||
color="info"
|
||||
:copy-text="generatedSignupLink"
|
||||
:disabled="generatedSignupLink" />
|
||||
:disabled="generatedSignupLink"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-text-field
|
||||
v-model="sendTo"
|
||||
:label="$t('user.email')"
|
||||
:rules="[validators.email]"
|
||||
outlined
|
||||
@keydown.enter="sendInvite" />
|
||||
variant="outlined"
|
||||
@keydown.enter="sendInvite"
|
||||
/>
|
||||
</v-form>
|
||||
</v-container>
|
||||
<template #custom-card-action>
|
||||
@@ -52,15 +65,15 @@
|
||||
:disabled="!validEmail"
|
||||
:loading="loading"
|
||||
:icon="$globals.icons.email"
|
||||
@click="sendInvite">
|
||||
{{ $t("group.invite") }}
|
||||
@click="sendInvite"
|
||||
>
|
||||
{{ $t("group.invite") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext, ref, toRefs, reactive } from "@nuxtjs/composition-api";
|
||||
import { watchEffect } from "vue";
|
||||
import { useUserApi } from "@/composables/api";
|
||||
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 { validators } from "~/composables/use-validators";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { GroupInDB } from "~/lib/api/types/user";
|
||||
import { HouseholdInDB } from "~/lib/api/types/household";
|
||||
import type { GroupInDB } from "~/lib/api/types/user";
|
||||
import type { HouseholdInDB } from "~/lib/api/types/household";
|
||||
import { useGroups } from "~/composables/use-groups";
|
||||
import { useAdminHouseholds } from "~/composables/use-households";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
name: "UserInviteDialog",
|
||||
components: {
|
||||
BaseDialog,
|
||||
@@ -81,15 +94,17 @@ export default defineComponent({
|
||||
BaseButton,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
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 selectedGroup = ref<string | null>(null);
|
||||
const selectedHousehold = ref<string | null>(null);
|
||||
@@ -98,7 +113,7 @@ export default defineComponent({
|
||||
const api = useUserApi();
|
||||
|
||||
const fetchGroupsAndHouseholds = () => {
|
||||
if (isAdmin) {
|
||||
if (isAdmin.value) {
|
||||
const groupsResponse = useGroups();
|
||||
const householdsResponse = useAdminHouseholds();
|
||||
watchEffect(() => {
|
||||
@@ -110,10 +125,10 @@ export default defineComponent({
|
||||
|
||||
const inviteDialog = computed<boolean>({
|
||||
get() {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set(val) {
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -156,9 +171,10 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
if (data && data.success) {
|
||||
alert.success(i18n.tc("profile.email-sent"));
|
||||
} else {
|
||||
alert.error(i18n.tc("profile.error-sending-email"));
|
||||
alert.success(i18n.t("profile.email-sent"));
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("profile.error-sending-email"));
|
||||
}
|
||||
state.loading = false;
|
||||
inviteDialog.value = false;
|
||||
@@ -191,10 +207,11 @@ export default defineComponent({
|
||||
households,
|
||||
fetchGroupsAndHouseholds,
|
||||
...toRefs(state),
|
||||
isAdmin,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
modelValue: {
|
||||
immediate: false,
|
||||
handler(val) {
|
||||
if (val && !this.isAdmin) {
|
||||
|
||||
@@ -13,19 +13,18 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRef, useContext } from "@nuxtjs/composition-api";
|
||||
import { usePasswordStrength } from "~/composables/use-passwords";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const asRef = toRef(props, "value");
|
||||
const { i18n } = useContext();
|
||||
const asRef = toRef(props, "modelValue");
|
||||
const i18n = useI18n();
|
||||
|
||||
const pwStrength = usePasswordStrength(asRef, i18n);
|
||||
|
||||
|
||||
@@ -1,55 +1,75 @@
|
||||
<template>
|
||||
<v-card outlined nuxt :to="link.to" height="100%" class="d-flex flex-column">
|
||||
<div v-if="$vuetify.breakpoint.smAndDown" class="pa-2 mx-auto">
|
||||
<v-img max-width="150px" max-height="125" :src="image" />
|
||||
<v-card
|
||||
variant="outlined"
|
||||
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 class="d-flex justify-space-between">
|
||||
<div>
|
||||
<v-card-title class="headline pb-0">
|
||||
<slot name="title"> </slot>
|
||||
<v-card-title class="text-subtitle-1 pb-0">
|
||||
<slot name="title" />
|
||||
</v-card-title>
|
||||
<div class="d-flex justify-center align-center">
|
||||
<v-card-text class="d-flex flex-row mb-auto">
|
||||
<slot name="default"></slot>
|
||||
<slot name="default" />
|
||||
</v-card-text>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$vuetify.breakpoint.mdAndUp" class="py-2 px-10 my-auto">
|
||||
<v-img max-width="150px" max-height="125" :src="image"></v-img>
|
||||
<div
|
||||
v-if="$vuetify.display.mdAndUp"
|
||||
class="py-2 px-10 my-auto"
|
||||
>
|
||||
<v-img
|
||||
width="150px"
|
||||
height="125"
|
||||
:src="image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider class="mt-auto"></v-divider>
|
||||
<v-spacer />
|
||||
<v-divider />
|
||||
<v-card-actions>
|
||||
<v-btn text color="info" :to="link.to">
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="info"
|
||||
:to="link.to"
|
||||
>
|
||||
{{ link.text }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
|
||||
<script lang="ts" setup>
|
||||
interface LinkProp {
|
||||
text: string;
|
||||
url?: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
link: {
|
||||
type: Object as () => LinkProp,
|
||||
required: true,
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: Object as () => LinkProp,
|
||||
required: true,
|
||||
},
|
||||
setup() {
|
||||
return {};
|
||||
image: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Props", props);
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<template>
|
||||
<div>
|
||||
<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>
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<v-form ref="domAccountForm" @submit.prevent>
|
||||
<v-form
|
||||
ref="domAccountForm"
|
||||
@submit.prevent
|
||||
>
|
||||
<v-text-field
|
||||
v-model="accountDetails.username.value"
|
||||
autofocus
|
||||
v-bind="inputAttrs"
|
||||
:label="$tc('user.username')"
|
||||
:label="$t('user.username')"
|
||||
:prepend-icon="$globals.icons.user"
|
||||
:rules="[validators.required]"
|
||||
:error-messages="usernameErrorMessages"
|
||||
@@ -20,7 +28,7 @@
|
||||
<v-text-field
|
||||
v-model="accountDetails.fullName.value"
|
||||
v-bind="inputAttrs"
|
||||
:label="$tc('user.full-name')"
|
||||
:label="$t('user.full-name')"
|
||||
:prepend-icon="$globals.icons.user"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
@@ -28,7 +36,7 @@
|
||||
v-model="accountDetails.email.value"
|
||||
v-bind="inputAttrs"
|
||||
:prepend-icon="$globals.icons.email"
|
||||
:label="$tc('user.email')"
|
||||
:label="$t('user.email')"
|
||||
:rules="[validators.required, validators.email]"
|
||||
:error-messages="emailErrorMessages"
|
||||
@blur="validateEmail"
|
||||
@@ -37,11 +45,11 @@
|
||||
v-model="credentials.password1.value"
|
||||
v-bind="inputAttrs"
|
||||
:type="pwFields.inputType.value"
|
||||
:append-icon="pwFields.passwordIcon.value"
|
||||
:append-inner-icon="pwFields.passwordIcon.value"
|
||||
:prepend-icon="$globals.icons.lock"
|
||||
:label="$tc('user.password')"
|
||||
:label="$t('user.password')"
|
||||
:rules="[validators.required, validators.minLength(8), validators.maxLength(258)]"
|
||||
@click:append="pwFields.togglePasswordShow"
|
||||
@click:append-inner="pwFields.togglePasswordShow"
|
||||
/>
|
||||
|
||||
<UserPasswordStrength :value="credentials.password1.value" />
|
||||
@@ -50,19 +58,19 @@
|
||||
v-model="credentials.password2.value"
|
||||
v-bind="inputAttrs"
|
||||
:type="pwFields.inputType.value"
|
||||
:append-icon="pwFields.passwordIcon.value"
|
||||
:append-inner-icon="pwFields.passwordIcon.value"
|
||||
:prepend-icon="$globals.icons.lock"
|
||||
:label="$tc('user.confirm-password')"
|
||||
:label="$t('user.confirm-password')"
|
||||
:rules="[validators.required, credentials.passwordMatch]"
|
||||
@click:append="pwFields.togglePasswordShow"
|
||||
@click:append-inner="pwFields.togglePasswordShow"
|
||||
/>
|
||||
<div class="px-2">
|
||||
<v-checkbox
|
||||
v-model="accountDetails.advancedOptions.value"
|
||||
:label="$tc('user.enable-advanced-content')"
|
||||
:label="$t('user.enable-advanced-content')"
|
||||
/>
|
||||
<p class="text-caption mt-n4">
|
||||
{{ $tc("user.enable-advanced-content-description") }}
|
||||
{{ $t("user.enable-advanced-content-description") }}
|
||||
</p>
|
||||
</div>
|
||||
</v-form>
|
||||
@@ -71,7 +79,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
||||
import { useDark } from "@vueuse/core";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
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";
|
||||
|
||||
const inputAttrs = {
|
||||
filled: true,
|
||||
rounded: true,
|
||||
validateOnBlur: true,
|
||||
class: "rounded-lg",
|
||||
class: "rounded-lg pb-1",
|
||||
variant: "solo-filled" as any,
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { UserPasswordStrength },
|
||||
layout: "blank",
|
||||
setup() {
|
||||
definePageMeta({
|
||||
layout: "blank",
|
||||
});
|
||||
|
||||
const isDark = useDark();
|
||||
const langDialog = ref(false);
|
||||
|
||||
|
||||
@@ -2,108 +2,146 @@
|
||||
<v-app dark>
|
||||
<TheSnackbar />
|
||||
|
||||
<AppHeader>
|
||||
<v-btn
|
||||
icon
|
||||
@click.stop="sidebar = !sidebar"
|
||||
>
|
||||
<v-icon> {{ $globals.icons.menu }}</v-icon>
|
||||
</v-btn>
|
||||
</AppHeader>
|
||||
|
||||
<AppSidebar
|
||||
v-model="sidebar"
|
||||
absolute
|
||||
:top-link="topLinks"
|
||||
:secondary-links="cookbookLinks || []"
|
||||
:bottom-links="isAdmin ? bottomLinks : []"
|
||||
:bottom-links="bottomLinks"
|
||||
>
|
||||
<v-menu offset-y nudge-bottom="5" close-delay="50" nudge-right="15">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn v-if="isOwnGroup" rounded large class="ml-2 mt-3" v-bind="attrs" v-on="on">
|
||||
<v-icon left large color="primary">
|
||||
<v-menu
|
||||
offset-y
|
||||
nudge-bottom="5"
|
||||
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 }}
|
||||
</v-icon>
|
||||
{{ $t("general.create") }}
|
||||
</v-btn>
|
||||
</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">
|
||||
<div v-if="!item.hide" :key="item.title">
|
||||
<v-divider v-if="item.insertDivider" :key="index" class="mx-2"></v-divider>
|
||||
<v-list-item v-if="!item.restricted || isOwnGroup" :key="item.title" :to="item.to" exact>
|
||||
<v-list-item-avatar>
|
||||
<v-icon>
|
||||
{{ item.icon }}
|
||||
</v-icon>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
{{ item.title }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="item.subtitle">
|
||||
{{ item.subtitle }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<div
|
||||
v-if="!item.hide"
|
||||
:key="item.title"
|
||||
>
|
||||
<v-divider
|
||||
v-if="item.insertDivider"
|
||||
:key="index"
|
||||
class="mx-2"
|
||||
/>
|
||||
<v-list-item
|
||||
v-if="!item.restricted || isOwnGroup"
|
||||
:key="item.title"
|
||||
:to="item.to"
|
||||
exact
|
||||
class="my-1"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<template #bottom>
|
||||
<v-list-item @click.stop="languageDialog = true">
|
||||
<v-list-item-icon>
|
||||
<template #prepend>
|
||||
<v-icon>{{ $globals.icons.translate }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
|
||||
<LanguageDialog v-model="languageDialog" />
|
||||
</v-list-item-content>
|
||||
</template>
|
||||
<v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
|
||||
<LanguageDialog v-model="languageDialog" />
|
||||
</v-list-item>
|
||||
<v-list-item @click="toggleDark">
|
||||
<v-list-item-icon>
|
||||
<template #prepend>
|
||||
<v-icon>
|
||||
{{ $vuetify.theme.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
|
||||
{{ $vuetify.theme.current.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
|
||||
</v-icon>
|
||||
</v-list-item-icon>
|
||||
</template>
|
||||
<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>
|
||||
</template>
|
||||
</AppSidebar>
|
||||
|
||||
<AppHeader>
|
||||
<v-btn icon @click.stop="sidebar = !sidebar">
|
||||
<v-icon> {{ $globals.icons.menu }}</v-icon>
|
||||
</v-btn>
|
||||
</AppHeader>
|
||||
<v-main>
|
||||
<v-main class="pt-16">
|
||||
<v-scroll-x-transition>
|
||||
<Nuxt />
|
||||
<div>
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</v-scroll-x-transition>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, ref, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
|
||||
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 type { SideBarLink } from "~/types/application-types";
|
||||
import { useAppInfo } from "~/composables/api";
|
||||
import { useCookbooks, usePublicCookbooks } from "~/composables/use-group-cookbooks";
|
||||
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
||||
import { useHouseholdStore, usePublicHouseholdStore } from "~/composables/store/use-household-store";
|
||||
import { useToggleDarkMode } from "~/composables/use-utils";
|
||||
import { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
|
||||
|
||||
export default defineComponent({
|
||||
components: { AppHeader, AppSidebar, LanguageDialog, TheSnackbar },
|
||||
export default defineNuxtComponent({
|
||||
setup() {
|
||||
const { $globals, $auth, $vuetify, i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const { $globals, $vuetify } = useNuxtApp();
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const isAdmin = computed(() => $auth.user?.admin);
|
||||
const isAdmin = computed(() => $auth.user.value?.admin);
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const { cookbooks } = isOwnGroup.value ? useCookbooks() : usePublicCookbooks(groupSlug.value || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const loggedInCookbooks = useCookbooks();
|
||||
const publicCookbooks = usePublicCookbooks(groupSlug.value || "");
|
||||
const cookbooks = computed(() =>
|
||||
isOwnGroup.value ? loggedInCookbooks.cookbooks.value : publicCookbooks.cookbooks.value,
|
||||
);
|
||||
|
||||
const cookbookPreferences = useCookbookPreferences();
|
||||
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value || "");
|
||||
|
||||
@@ -121,10 +159,9 @@ export default defineComponent({
|
||||
|
||||
const languageDialog = ref<boolean>(false);
|
||||
|
||||
const sidebar = ref<boolean | null>(null);
|
||||
|
||||
const sidebar = ref<boolean>(false);
|
||||
onMounted(() => {
|
||||
sidebar.value = !$vuetify.breakpoint.md;
|
||||
sidebar.value = $vuetify.display.mdAndUp.value;
|
||||
});
|
||||
|
||||
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[]>(() => {
|
||||
if (!cookbooks.value) {
|
||||
if (!cookbooks.value || !households.value) {
|
||||
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 links: SideBarLink[] = [];
|
||||
const cookbooksByHousehold = cookbooks.value.reduce((acc, cookbook) => {
|
||||
const cookbooksByHousehold = sortedCookbooks.reduce((acc, cookbook) => {
|
||||
const householdName = householdsById.value[cookbook.householdId]?.name || "";
|
||||
if (!acc[householdName]) {
|
||||
acc[householdName] = [];
|
||||
@@ -156,9 +194,13 @@ export default defineComponent({
|
||||
}, {} as Record<string, ReadCookBook[]>);
|
||||
|
||||
Object.entries(cookbooksByHousehold).forEach(([householdName, cookbooks]) => {
|
||||
if (!cookbooks.length) {
|
||||
return;
|
||||
}
|
||||
if (cookbooks[0].householdId === currentUserHouseholdId.value) {
|
||||
ownLinks.push(...cookbooks.map(cookbookAsLink));
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
links.push({
|
||||
key: householdName,
|
||||
icon: $globals.icons.book,
|
||||
@@ -170,19 +212,20 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
links.sort((a, b) => a.title.localeCompare(b.title));
|
||||
if ($auth.user && cookbookPreferences.value.hideOtherHouseholds) {
|
||||
if ($auth.user.value && cookbookPreferences.value.hideOtherHouseholds) {
|
||||
return ownLinks;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
return [...ownLinks, ...links];
|
||||
}
|
||||
});
|
||||
|
||||
const createLinks = computed<SideBarLink[]>(() => [
|
||||
const createLinks = computed(() => [
|
||||
{
|
||||
insertDivider: false,
|
||||
icon: $globals.icons.link,
|
||||
title: i18n.tc("general.import"),
|
||||
subtitle: i18n.tc("new-recipe.import-by-url"),
|
||||
title: i18n.t("general.import"),
|
||||
subtitle: i18n.t("new-recipe.import-by-url"),
|
||||
to: `/g/${groupSlug.value}/r/create/url`,
|
||||
restricted: true,
|
||||
hide: false,
|
||||
@@ -190,8 +233,8 @@ export default defineComponent({
|
||||
{
|
||||
insertDivider: false,
|
||||
icon: $globals.icons.fileImage,
|
||||
title: i18n.tc("recipe.create-from-image"),
|
||||
subtitle: i18n.tc("recipe.create-recipe-from-an-image"),
|
||||
title: i18n.t("recipe.create-from-image"),
|
||||
subtitle: i18n.t("recipe.create-recipe-from-an-image"),
|
||||
to: `/g/${groupSlug.value}/r/create/image`,
|
||||
restricted: true,
|
||||
hide: !showImageImport.value,
|
||||
@@ -199,81 +242,85 @@ export default defineComponent({
|
||||
{
|
||||
insertDivider: true,
|
||||
icon: $globals.icons.edit,
|
||||
title: i18n.tc("general.create"),
|
||||
subtitle: i18n.tc("new-recipe.create-manually"),
|
||||
title: i18n.t("general.create"),
|
||||
subtitle: i18n.t("new-recipe.create-manually"),
|
||||
to: `/g/${groupSlug.value}/r/create/new`,
|
||||
restricted: true,
|
||||
hide: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const bottomLinks = computed<SideBarLink[]>(() => [
|
||||
{
|
||||
icon: $globals.icons.cog,
|
||||
title: i18n.tc("general.settings"),
|
||||
to: "/admin/site-settings",
|
||||
restricted: true,
|
||||
},
|
||||
]);
|
||||
const bottomLinks = computed<SideBarLink[]>(() =>
|
||||
isAdmin.value
|
||||
? [
|
||||
{
|
||||
icon: $globals.icons.cog,
|
||||
title: i18n.t("general.settings"),
|
||||
to: "/admin/site-settings",
|
||||
restricted: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const topLinks = computed<SideBarLink[]>(() => [
|
||||
{
|
||||
icon: $globals.icons.silverwareForkKnife,
|
||||
to: `/g/${groupSlug.value}`,
|
||||
title: i18n.tc("general.recipes"),
|
||||
title: i18n.t("general.recipes"),
|
||||
restricted: false,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.search,
|
||||
to: `/g/${groupSlug.value}/recipes/finder`,
|
||||
title: i18n.tc("recipe-finder.recipe-finder"),
|
||||
title: i18n.t("recipe-finder.recipe-finder"),
|
||||
restricted: false,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.calendarMultiselect,
|
||||
title: i18n.tc("meal-plan.meal-planner"),
|
||||
title: i18n.t("meal-plan.meal-planner"),
|
||||
to: "/household/mealplan/planner/view",
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.formatListCheck,
|
||||
title: i18n.tc("shopping-list.shopping-lists"),
|
||||
title: i18n.t("shopping-list.shopping-lists"),
|
||||
to: "/shopping-lists",
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.timelineText,
|
||||
title: i18n.tc("recipe.timeline"),
|
||||
title: i18n.t("recipe.timeline"),
|
||||
to: `/g/${groupSlug.value}/recipes/timeline`,
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.book,
|
||||
to: `/g/${groupSlug.value}/cookbooks`,
|
||||
title: i18n.tc("cookbook.cookbooks"),
|
||||
title: i18n.t("cookbook.cookbooks"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.organizers,
|
||||
title: i18n.tc("general.organizers"),
|
||||
title: i18n.t("general.organizers"),
|
||||
restricted: true,
|
||||
children: [
|
||||
{
|
||||
icon: $globals.icons.categories,
|
||||
to: `/g/${groupSlug.value}/recipes/categories`,
|
||||
title: i18n.tc("sidebar.categories"),
|
||||
title: i18n.t("sidebar.categories"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.tags,
|
||||
to: `/g/${groupSlug.value}/recipes/tags`,
|
||||
title: i18n.tc("sidebar.tags"),
|
||||
title: i18n.t("sidebar.tags"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.potSteam,
|
||||
to: `/g/${groupSlug.value}/recipes/tools`,
|
||||
title: i18n.tc("tool.tools"),
|
||||
title: i18n.t("tool.tools"),
|
||||
restricted: true,
|
||||
},
|
||||
],
|
||||
@@ -286,7 +333,6 @@ export default defineComponent({
|
||||
createLinks,
|
||||
bottomLinks,
|
||||
topLinks,
|
||||
isAdmin,
|
||||
isOwnGroup,
|
||||
languageDialog,
|
||||
toggleDark,
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
<template>
|
||||
<v-footer color="primary" padless 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-footer
|
||||
color="primary"
|
||||
padless
|
||||
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>
|
||||
{{ $globals.icons.github }}
|
||||
</v-icon>
|
||||
@@ -14,9 +31,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
|
||||
@@ -1,46 +1,83 @@
|
||||
<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 />
|
||||
<router-link :to="routerLink">
|
||||
<v-btn icon>
|
||||
<v-btn
|
||||
icon
|
||||
color="white"
|
||||
>
|
||||
<v-icon size="40"> {{ $globals.icons.primary }} </v-icon>
|
||||
</v-btn>
|
||||
</router-link>
|
||||
|
||||
<div btn class="pl-2">
|
||||
<v-toolbar-title style="cursor: pointer" @click="$router.push(routerLink)"> Mealie </v-toolbar-title>
|
||||
<div
|
||||
btn
|
||||
class="pl-2"
|
||||
>
|
||||
<v-toolbar-title
|
||||
style="cursor: pointer"
|
||||
@click="$router.push(routerLink)"
|
||||
>
|
||||
Mealie
|
||||
</v-toolbar-title>
|
||||
</div>
|
||||
<RecipeDialogSearch ref="domSearchDialog" />
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-spacer />
|
||||
|
||||
<!-- Navigation 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
|
||||
readonly
|
||||
class="mt-6 rounded-xl"
|
||||
class="mt-1"
|
||||
rounded
|
||||
dark
|
||||
solo
|
||||
dense
|
||||
variant="solo-filled"
|
||||
density="compact"
|
||||
flat
|
||||
:prepend-inner-icon="$globals.icons.search"
|
||||
background-color="primary darken-1"
|
||||
color="white"
|
||||
bg-color="primary-darken-1"
|
||||
:placeholder="$t('search.search-hint')"
|
||||
>
|
||||
</v-text-field>
|
||||
</div>
|
||||
<v-btn v-else icon @click="activateSearch">
|
||||
/>
|
||||
</v-responsive>
|
||||
<v-btn
|
||||
v-else
|
||||
icon
|
||||
@click="activateSearch"
|
||||
>
|
||||
<v-icon> {{ $globals.icons.search }}</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-if="loggedIn" :text="$vuetify.breakpoint.smAndUp" :icon="$vuetify.breakpoint.xs" @click="logout()">
|
||||
<v-icon :left="$vuetify.breakpoint.smAndUp">{{ $globals.icons.logout }}</v-icon>
|
||||
{{ $vuetify.breakpoint.smAndUp ? $t("user.logout") : "" }}
|
||||
<v-btn
|
||||
v-if="loggedIn"
|
||||
: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-else text nuxt to="/login">
|
||||
<v-icon left>{{ $globals.icons.user }}</v-icon>
|
||||
<v-btn
|
||||
v-else
|
||||
variant="text"
|
||||
nuxt
|
||||
to="/login"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.user }}
|
||||
</v-icon>
|
||||
{{ $t("user.login") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -48,11 +85,10 @@
|
||||
</template>
|
||||
|
||||
<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 RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeDialogSearch },
|
||||
props: {
|
||||
menu: {
|
||||
@@ -61,11 +97,11 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const { loggedIn } = useLoggedInState();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const { xs, smAndUp } = useDisplay();
|
||||
|
||||
const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/");
|
||||
const domSearchDialog = ref<InstanceType<typeof RecipeDialogSearch> | null>(null);
|
||||
@@ -91,7 +127,12 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
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 {
|
||||
@@ -100,7 +141,14 @@ export default defineComponent({
|
||||
routerLink,
|
||||
loggedIn,
|
||||
logout,
|
||||
xs, smAndUp,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-toolbar {
|
||||
z-index: 1010 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,52 @@
|
||||
<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 -->
|
||||
<template v-if="loggedIn">
|
||||
<v-list-item two-line :to="userProfileLink" exact>
|
||||
<UserAvatar list :user-id="$auth.user.id" :tooltip="false" />
|
||||
<v-list-item lines="two" :to="userProfileLink" exact>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<UserAvatar list :user-id="sessionUser.id" :tooltip="false" />
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title class="pr-2"> {{ $auth.user.fullName }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
<v-btn v-if="isOwnGroup" class="px-2 pa-0" text :to="userFavoritesLink" small>
|
||||
<v-icon left small>
|
||||
{{ $globals.icons.heart }}
|
||||
</v-icon>
|
||||
{{ $t("user.favorite-recipes") }}
|
||||
</v-btn>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<div class="d-flex flex-column justify-start">
|
||||
<v-list-item-title class="pr-2 pl-1">
|
||||
{{ sessionUser.fullName }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="opacity-100">
|
||||
<v-btn v-if="isOwnGroup" class="px-2 pa-0" variant="text" :to="userFavoritesLink" size="small">
|
||||
<v-icon start size="small">
|
||||
{{ $globals.icons.heart }}
|
||||
</v-icon>
|
||||
{{ $t("user.favorite-recipes") }}
|
||||
</v-btn>
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</div>
|
||||
</v-list-item>
|
||||
<v-divider></v-divider>
|
||||
<v-divider />
|
||||
</template>
|
||||
|
||||
<slot></slot>
|
||||
<slot />
|
||||
|
||||
<!-- Primary Links -->
|
||||
<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">
|
||||
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
||||
<!-- Multi Items -->
|
||||
<v-list-group
|
||||
v-if="nav.children"
|
||||
:key="(nav.key || nav.title) + 'multi-item'"
|
||||
v-model="dropDowns[nav.title]"
|
||||
color="primary"
|
||||
:prepend-icon="nav.icon"
|
||||
>
|
||||
<template #activator>
|
||||
<v-list-item-title>{{ nav.title }}</v-list-item-title>
|
||||
<v-list-group v-if="nav.children" :key="(nav.key || nav.title) + 'multi-item'"
|
||||
v-model="dropDowns[nav.title]" color="primary" :prepend-icon="nav.icon" :fluid="true">
|
||||
<template #activator="{ props }">
|
||||
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
|
||||
</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-icon>
|
||||
<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-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to"
|
||||
:prepend-icon="child.icon" :title="child.title" class="ml-4" />
|
||||
</v-list-group>
|
||||
|
||||
<!-- Single Item -->
|
||||
<v-list-item-group
|
||||
v-else
|
||||
:key="(nav.key || nav.title) + 'single-item'"
|
||||
v-model="secondarySelected"
|
||||
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>
|
||||
<template v-else>
|
||||
<v-list-item :key="(nav.key || nav.title) + 'single-item'" exact link :to="nav.to"
|
||||
:prepend-icon="nav.icon" :title="nav.title" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</v-list>
|
||||
@@ -68,39 +54,28 @@
|
||||
|
||||
<!-- Secondary Links -->
|
||||
<template v-if="secondaryLinks.length > 0">
|
||||
<v-divider class="mt-2"></v-divider>
|
||||
<v-list nav dense exact>
|
||||
<v-divider class="mt-2" />
|
||||
<v-list v-model:selected="secondarySelected" nav density="compact" exact>
|
||||
<template v-for="nav in secondaryLinks">
|
||||
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
||||
<!-- Multi Items -->
|
||||
<v-list-group
|
||||
v-if="nav.children"
|
||||
:key="(nav.key || nav.title) + 'multi-item'"
|
||||
v-model="dropDowns[nav.title]"
|
||||
color="primary"
|
||||
:prepend-icon="nav.icon"
|
||||
>
|
||||
<template #activator>
|
||||
<v-list-item-title>{{ nav.title }}</v-list-item-title>
|
||||
<v-list-group v-if="nav.children" :key="(nav.key || nav.title) + 'multi-item'"
|
||||
v-model="dropDowns[nav.title]" color="primary" :prepend-icon="nav.icon" fluid>
|
||||
<template #activator="{ props }">
|
||||
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
|
||||
</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-icon>
|
||||
<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-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to"
|
||||
class="ml-2" :prepend-icon="child.icon" :title="child.title" />
|
||||
</v-list-group>
|
||||
|
||||
<!-- Single Item -->
|
||||
<v-list-item-group v-else :key="(nav.key || nav.title) + 'single-item'" v-model="secondarySelected" 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>
|
||||
<v-list-item v-else :key="(nav.key || nav.title) + 'single-item'" exact link :to="nav.to">
|
||||
<template #prepend>
|
||||
<v-icon>{{ nav.icon }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ nav.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</template>
|
||||
</v-list>
|
||||
@@ -108,46 +83,39 @@
|
||||
|
||||
<!-- Bottom Navigation Links -->
|
||||
<template v-if="bottomLinks" #append>
|
||||
<v-list nav dense>
|
||||
<v-list-item-group v-model="bottomSelected" color="primary">
|
||||
<template v-for="nav in bottomLinks">
|
||||
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
||||
<v-list-item
|
||||
:key="nav.key || nav.title"
|
||||
exact
|
||||
link
|
||||
:to="nav.to || null"
|
||||
:href="nav.href || null"
|
||||
:target="nav.href ? '_blank' : null"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item-group>
|
||||
<slot name="bottom"></slot>
|
||||
<v-list v-model:selected="bottomSelected" nav density="compact">
|
||||
<template v-for="nav in bottomLinks">
|
||||
<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"
|
||||
:target="nav.href ? '_blank' : null">
|
||||
<template #prepend>
|
||||
<v-icon>{{ nav.icon }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ nav.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</template>
|
||||
<slot name="bottom" />
|
||||
</v-list>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<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 { SidebarLinks } from "~/types/application-types";
|
||||
import type { SidebarLinks } from "~/types/application-types";
|
||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
UserAvatar,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
@@ -165,31 +133,16 @@ export default defineComponent({
|
||||
bottomLinks: {
|
||||
type: Array as () => SidebarLinks,
|
||||
required: false,
|
||||
default: null,
|
||||
default: () => ([]),
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
// V-Model Support
|
||||
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 $auth = useMealieAuth();
|
||||
const { loggedIn, isOwnGroup } = useLoggedInState();
|
||||
|
||||
const userFavoritesLink = computed(() => $auth.user ? `/user/${$auth.user.id}/favorites` : undefined);
|
||||
const userProfileLink = computed(() => $auth.user ? "/user/profile" : undefined);
|
||||
const userFavoritesLink = computed(() => $auth.user.value ? `/user/${$auth.user.value.id}/favorites` : undefined);
|
||||
const userProfileLink = computed(() => $auth.user.value ? "/user/profile" : undefined);
|
||||
|
||||
const state = reactive({
|
||||
dropDowns: {} as Record<string, boolean>,
|
||||
@@ -198,12 +151,31 @@ export default defineComponent({
|
||||
bottomSelected: null as string[] | null,
|
||||
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 || [])]);
|
||||
function initDropdowns() {
|
||||
allLinks.value.forEach((link) => {
|
||||
state.dropDowns[link.title] = link.childrenStartExpanded || false;
|
||||
})
|
||||
});
|
||||
}
|
||||
watch(
|
||||
() => allLinks,
|
||||
@@ -212,22 +184,23 @@ export default defineComponent({
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
userFavoritesLink,
|
||||
userProfileLink,
|
||||
drawer,
|
||||
showDrawer,
|
||||
loggedIn,
|
||||
isOwnGroup,
|
||||
sessionUser: $auth.user,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none;
|
||||
|
||||
@@ -1,57 +1,75 @@
|
||||
<template>
|
||||
<div class="text-center">
|
||||
<v-snackbar v-model="toastAlert.open" top :color="toastAlert.color" timeout="2000" @input="toastAlert.open = false">
|
||||
<v-icon dark left>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
<v-snackbar
|
||||
v-model="toastAlert.open"
|
||||
location="top"
|
||||
:color="toastAlert.color"
|
||||
timeout="2000"
|
||||
>
|
||||
<v-icon
|
||||
v-if="icon"
|
||||
dark
|
||||
start
|
||||
:icon="icon"
|
||||
/>
|
||||
|
||||
{{ toastAlert.title }}
|
||||
{{ toastAlert.text }}
|
||||
|
||||
<template #action="{ attrs }">
|
||||
<v-btn text v-bind="attrs" @click="toastAlert.open = false"> {{ $t('general.close') }} </v-btn>
|
||||
<template #actions>
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="toastAlert.open = false"
|
||||
>
|
||||
{{ $t('general.close') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
<v-snackbar
|
||||
v-model="toastLoading.open"
|
||||
content-class="py-2"
|
||||
dense
|
||||
bottom
|
||||
right
|
||||
:value="toastLoading.open"
|
||||
density="compact"
|
||||
location="bottom"
|
||||
:timeout="-1"
|
||||
: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">
|
||||
{{ toastLoading.text }}
|
||||
</div>
|
||||
<v-progress-linear indeterminate color="white darken-2"></v-progress-linear>
|
||||
<v-progress-linear
|
||||
indeterminate
|
||||
color="white-darken-2"
|
||||
/>
|
||||
</div>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { useNuxtApp } from "#app";
|
||||
import { toastAlert, toastLoading } from "~/composables/use-toast";
|
||||
|
||||
export default defineComponent({
|
||||
export default {
|
||||
setup() {
|
||||
const { $globals } = useNuxtApp();
|
||||
const icon = computed(() => {
|
||||
switch (toastAlert.color) {
|
||||
case "error":
|
||||
return "mdi-alert";
|
||||
return $globals.icons.alertOutline;
|
||||
case "success":
|
||||
return "mdi-check-bold";
|
||||
return $globals.icons.checkBold;
|
||||
case "info":
|
||||
return "mdi-information-outline";
|
||||
return $globals.icons.informationOutline;
|
||||
default:
|
||||
return "mdi-alert";
|
||||
return $globals.icons.alertOutline;
|
||||
}
|
||||
});
|
||||
|
||||
return { icon, toastAlert, toastLoading };
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<template>
|
||||
<div scoped-slot></div>
|
||||
<div scoped-slot />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
|
||||
/**
|
||||
* Renderless component that only renders if the user is logged in.
|
||||
* and has advanced options toggled.
|
||||
*/
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
setup(_, ctx) {
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
|
||||
const r = $auth?.user?.advanced || false;
|
||||
const r = $auth.user.value?.advanced || false;
|
||||
|
||||
return () => {
|
||||
return r ? ctx.slots.default?.() : null;
|
||||
|
||||
@@ -2,32 +2,33 @@
|
||||
<v-tooltip
|
||||
ref="copyToolTip"
|
||||
v-model="show"
|
||||
:color="copied? 'success lighten-1' : 'red lighten-1'"
|
||||
:color="copied? 'success-lighten-1' : 'red-lighten-1'"
|
||||
top
|
||||
:open-on-hover="false"
|
||||
:open-on-click="true"
|
||||
close-delay="500"
|
||||
transition="slide-y-transition"
|
||||
>
|
||||
<template #activator="{ on }">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
variant="flat"
|
||||
:icon="icon"
|
||||
:color="color"
|
||||
retain-focus-on-click
|
||||
:class="btnClass"
|
||||
:disabled="copyText !== '' ? false : true"
|
||||
@click="
|
||||
on.click;
|
||||
textToClipboard();
|
||||
"
|
||||
@blur="on.blur"
|
||||
v-bind="props"
|
||||
@click="textToClipboard()"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.contentCopy }}</v-icon>
|
||||
{{ icon ? "" : $t("general.copy") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>
|
||||
<v-icon left dark>
|
||||
<v-icon
|
||||
start
|
||||
dark
|
||||
>
|
||||
{{ $globals.icons.clipboardCheck }}
|
||||
</v-icon>
|
||||
<slot v-if="!isSupported"> {{ $t("general.your-browser-does-not-support-clipboard") }} </slot>
|
||||
@@ -37,11 +38,9 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useClipboard } from "@vueuse/core"
|
||||
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
||||
import { VTooltip } from "~/types/vuetify";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
copyText: {
|
||||
type: String,
|
||||
@@ -61,7 +60,7 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { copy, copied, isSupported } = useClipboard()
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
const show = ref(false);
|
||||
const copyToolTip = ref<VTooltip | null>(null);
|
||||
|
||||
@@ -73,7 +72,7 @@ export default defineComponent({
|
||||
if (isSupported.value) {
|
||||
await copy(props.copyText);
|
||||
if (copied.value) {
|
||||
console.log(`Copied\n${props.copyText}`)
|
||||
console.log(`Copied\n${props.copyText}`);
|
||||
}
|
||||
else {
|
||||
console.warn("Copy failed: ", copied.value);
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
<template>
|
||||
<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 }">
|
||||
<v-btn :loading="isSelecting" :small="small" :color="color" :text="textBtn" :disabled="disabled" @click="onButtonClick">
|
||||
<v-icon left> {{ effIcon }}</v-icon>
|
||||
<v-btn
|
||||
:loading="isSelecting"
|
||||
:small="small"
|
||||
:color="color"
|
||||
:variant="textBtn ? 'text' : 'elevated'"
|
||||
:disabled="disabled"
|
||||
@click="onButtonClick"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ effIcon }}
|
||||
</v-icon>
|
||||
{{ text ? text : defaultText }}
|
||||
</v-btn>
|
||||
</slot>
|
||||
@@ -11,12 +26,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
const UPLOAD_EVENT = "uploaded";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
small: {
|
||||
type: Boolean,
|
||||
@@ -57,14 +71,15 @@ export default defineComponent({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const file = ref<File | null>(null);
|
||||
const uploader = ref<HTMLInputElement | null>(null);
|
||||
const isSelecting = ref(false);
|
||||
|
||||
const { i18n, $globals } = useContext();
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const effIcon = props.icon ? props.icon : $globals.icons.upload;
|
||||
|
||||
const defaultText = i18n.t("general.upload");
|
||||
@@ -82,11 +97,15 @@ export default defineComponent({
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append(props.fileName, file.value);
|
||||
|
||||
const response = await api.upload.file(props.url, formData);
|
||||
|
||||
if (response) {
|
||||
context.emit(UPLOAD_EVENT, response);
|
||||
try {
|
||||
const response = await api.upload.file(props.url, formData);
|
||||
if (response) {
|
||||
context.emit(UPLOAD_EVENT, response);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
context.emit(UPLOAD_EVENT, null);
|
||||
}
|
||||
isSelecting.value = false;
|
||||
}
|
||||
@@ -107,7 +126,7 @@ export default defineComponent({
|
||||
() => {
|
||||
isSelecting.value = false;
|
||||
},
|
||||
{ once: true }
|
||||
{ once: true },
|
||||
);
|
||||
uploader.value?.click();
|
||||
}
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
<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;">
|
||||
<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">
|
||||
<v-icon :size="size.icon" color="primary lighten-2">
|
||||
<v-icon
|
||||
:size="size.icon"
|
||||
color="primary-lighten-2"
|
||||
>
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
<div v-if="large" class="text-small">
|
||||
<div
|
||||
v-if="large"
|
||||
class="text-small"
|
||||
>
|
||||
<slot>
|
||||
{{ (small || tiny) ? "" : waitingText }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</v-progress-circular>
|
||||
<div v-if="!large" class="text-small">
|
||||
<div
|
||||
v-if="!large"
|
||||
class="text-small"
|
||||
>
|
||||
<slot>
|
||||
{{ (small || tiny) ? "" : waitingTextCalculated }}
|
||||
</slot>
|
||||
@@ -23,9 +40,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
@@ -50,7 +65,7 @@ export default defineComponent({
|
||||
waitingText: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
}
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const size = computed(() => {
|
||||
@@ -67,7 +82,8 @@ export default defineComponent({
|
||||
icon: 30,
|
||||
size: 50,
|
||||
};
|
||||
} else if (props.large) {
|
||||
}
|
||||
else if (props.large) {
|
||||
return {
|
||||
width: 4,
|
||||
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;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<template>
|
||||
<v-toolbar color="transparent" flat>
|
||||
<BaseButton color="null" rounded secondary @click="$router.go(-1)">
|
||||
<template #icon> {{ $globals.icons.arrowLeftBold }}</template>
|
||||
<v-toolbar
|
||||
color="transparent"
|
||||
flat
|
||||
>
|
||||
<BaseButton
|
||||
color="null"
|
||||
rounded
|
||||
secondary
|
||||
@click="$router.go(-1)"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.arrowLeftBold }}
|
||||
</template>
|
||||
{{ $t('general.back') }}
|
||||
</BaseButton>
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
back: {
|
||||
type: Boolean,
|
||||
|
||||
@@ -1,49 +1,64 @@
|
||||
<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-col v-for="(inputField, index) in items" :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">
|
||||
<v-col
|
||||
v-for="(inputField, index) in items"
|
||||
: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 }}
|
||||
</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 }}
|
||||
</v-card-text>
|
||||
|
||||
<!-- Check Box -->
|
||||
<v-checkbox
|
||||
v-if="inputField.type === fieldTypes.BOOLEAN"
|
||||
v-model="value[inputField.varName]"
|
||||
class="my-0 py-0"
|
||||
v-model="modelValue[inputField.varName]"
|
||||
:name="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>
|
||||
<div>
|
||||
<v-card-text class="text-body-1 my-0 py-0">
|
||||
{{ inputField.label }}
|
||||
</v-card-text>
|
||||
<v-card-text v-if="inputField.hint" class="text-caption my-0 py-0">
|
||||
{{ inputField.hint }}
|
||||
</v-card-text>
|
||||
</div>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
|
||||
<span class="ml-4">
|
||||
{{ inputField.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
|
||||
<!-- Text Field -->
|
||||
<v-text-field
|
||||
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
|
||||
v-model="value[inputField.varName]"
|
||||
v-model="modelValue[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))"
|
||||
filled
|
||||
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
|
||||
rounded
|
||||
class="rounded-lg"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:autofocus="index === 0"
|
||||
dense
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
@@ -55,15 +70,15 @@
|
||||
<!-- Text Area -->
|
||||
<v-textarea
|
||||
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))"
|
||||
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
||||
filled
|
||||
rounded
|
||||
variant="solo-filled"
|
||||
flat
|
||||
class="rounded-lg"
|
||||
rows="3"
|
||||
auto-grow
|
||||
dense
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
@@ -75,42 +90,53 @@
|
||||
<!-- Option Select -->
|
||||
<v-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))"
|
||||
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
||||
filled
|
||||
rounded
|
||||
variant="solo-filled"
|
||||
flat
|
||||
class="rounded-lg"
|
||||
:prepend-icon="inputField.icons ? value[inputField.varName] : null"
|
||||
:prepend-icon="inputField.icons ? modelValue[inputField.varName] : null"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:items="inputField.options"
|
||||
:item-text="inputField.itemText"
|
||||
:item-title="inputField.itemText"
|
||||
:item-value="inputField.itemValue"
|
||||
:return-object="false"
|
||||
:hint="inputField.hint"
|
||||
density="comfortable"
|
||||
persistent-hint
|
||||
lazy-validation
|
||||
@blur="emitBlur"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ item.text }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ item.description }}</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<div>
|
||||
<v-list-item-title>{{ item.raw.text }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ item.raw.description }}</v-list-item-subtitle>
|
||||
</div>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<!-- 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>
|
||||
<template #activator="{ on }">
|
||||
<v-btn class="my-2 ml-auto" style="min-width: 200px" :color="value[inputField.varName]" dark v-on="on">
|
||||
<template #activator="{ props: templateProps }">
|
||||
<v-btn
|
||||
class="my-2 ml-auto"
|
||||
style="min-width: 200px"
|
||||
:color="modelValue[inputField.varName]"
|
||||
dark
|
||||
v-bind="templateProps"
|
||||
>
|
||||
{{ inputField.label }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-color-picker
|
||||
v-model="value[inputField.varName]"
|
||||
v-model="modelValue[inputField.varName]"
|
||||
value="#7417BE"
|
||||
hide-canvas
|
||||
hide-inputs
|
||||
@@ -122,21 +148,34 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- List Type -->
|
||||
<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>
|
||||
{{ inputField.label }} {{ idx + 1 }}
|
||||
<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>
|
||||
</p>
|
||||
<v-divider class="mb-5 mx-2" />
|
||||
<auto-form
|
||||
v-model="value[inputField.varName][idx]"
|
||||
v-model="modelValue[inputField.varName][idx]"
|
||||
:color="color"
|
||||
:items="inputField.items"
|
||||
@blur="emitBlur"
|
||||
@@ -144,7 +183,10 @@
|
||||
</div>
|
||||
<v-card-actions>
|
||||
<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") }}
|
||||
</BaseButton>
|
||||
</v-card-actions>
|
||||
@@ -154,111 +196,96 @@
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
<script lang="ts" setup>
|
||||
import { validators } from "@/composables/use-validators";
|
||||
import { fieldTypes } from "@/composables/forms";
|
||||
import { AutoFormItems } from "~/types/auto-forms";
|
||||
import type { AutoFormItems } from "~/types/auto-forms";
|
||||
|
||||
const BLUR_EVENT = "blur";
|
||||
|
||||
type ValidatorKey = keyof typeof validators;
|
||||
|
||||
export default defineComponent({
|
||||
name: "AutoForm",
|
||||
props: {
|
||||
value: {
|
||||
default: null,
|
||||
type: [Object, Array],
|
||||
},
|
||||
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[],
|
||||
},
|
||||
// Use defineModel for v-model
|
||||
const modelValue = defineModel<[object, Array<any>]>();
|
||||
|
||||
const props = defineProps({
|
||||
updateMode: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
setup(props, context) {
|
||||
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) {
|
||||
// @ts-ignore- validators[validatorKey] is a function
|
||||
list.push(validators[validatorKey]);
|
||||
} else {
|
||||
// @ts-ignore - validators[validatorKey] is a function
|
||||
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() {
|
||||
context.emit(BLUR_EVENT, props.value);
|
||||
}
|
||||
|
||||
return {
|
||||
rulesByKey,
|
||||
defaultRules,
|
||||
removeByIndex,
|
||||
getTemplate,
|
||||
emitBlur,
|
||||
fieldTypes,
|
||||
validators,
|
||||
};
|
||||
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[],
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<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