Merge branch 'mealie-next' into settings_fix

This commit is contained in:
Michael Genson 2025-06-27 20:39:25 -05:00 committed by GitHub
commit 5cf61b1e36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
424 changed files with 23183 additions and 20305 deletions

View file

@ -11,7 +11,7 @@
// Use -bullseye variants on local on arm64/Apple Silicon. // Use -bullseye variants on local on arm64/Apple Silicon.
"VARIANT": "3.12-bullseye", "VARIANT": "3.12-bullseye",
// Options // Options
"NODE_VERSION": "16" "NODE_VERSION": "20"
} }
}, },
"mounts": [ "mounts": [
@ -55,5 +55,6 @@
"ghcr.io/devcontainers/features/docker-in-docker:2": { "ghcr.io/devcontainers/features/docker-in-docker:2": {
"dockerDashComposeVersion": "v2" "dockerDashComposeVersion": "v2"
} }
} },
"appPort": 3000
} }

View file

@ -19,7 +19,7 @@ jobs:
- name: Setup node env 🏗 - name: Setup node env 🏗
uses: actions/setup-node@v4.0.0 uses: actions/setup-node@v4.0.0
with: with:
node-version: 16 node-version: 20
check-latest: true check-latest: true
- name: Get yarn cache directory path 🛠 - name: Get yarn cache directory path 🛠

View file

@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 20
cache: 'yarn' cache: 'yarn'
cache-dependency-path: ./tests/e2e/yarn.lock cache-dependency-path: ./tests/e2e/yarn.lock
- name: Set up Docker Buildx - name: Set up Docker Buildx

View file

@ -14,7 +14,7 @@ jobs:
- name: Setup node env 🏗 - name: Setup node env 🏗
uses: actions/setup-node@v4.0.0 uses: actions/setup-node@v4.0.0
with: with:
node-version: 16 node-version: 20
check-latest: true check-latest: true
- name: Get yarn cache directory path 🛠 - name: Get yarn cache directory path 🛠
@ -34,6 +34,10 @@ jobs:
run: yarn run: yarn
working-directory: "frontend" working-directory: "frontend"
- name: Prepare nuxt 🚀
run: yarn nuxt prepare
working-directory: "frontend"
- name: Run linter 👀 - name: Run linter 👀
run: yarn lint run: yarn lint
working-directory: "frontend" working-directory: "frontend"

5
.gitignore vendored
View file

@ -10,6 +10,9 @@ docs/site/
*temp/* *temp/*
.secret .secret
frontend/dist/ frontend/dist/
frontend/.output/*
frontend/.yarn/*
frontend/.yarnrc.yml
dev/code-generation/generated/* dev/code-generation/generated/*
dev/data/mealie.db-journal dev/data/mealie.db-journal
@ -164,3 +167,5 @@ dev/code-generation/openapi.json
.run/ .run/
.task/* .task/*
.dev.env
frontend/eslint.config.deprecated.js

View file

@ -12,7 +12,7 @@ repos:
exclude: ^tests/data/ exclude: ^tests/data/
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.11.13 rev: v0.12.0
hooks: hooks:
- id: ruff - id: ruff
- id: ruff-format - id: ruff-format

View file

@ -18,6 +18,7 @@
"source.organizeImports": "never" "source.organizeImports": "never"
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true,
"eslint.useFlatConfig": true,
"eslint.workingDirectories": [ "eslint.workingDirectories": [
"./frontend" "./frontend"
], ],

View file

@ -243,7 +243,7 @@ tasks:
desc: runs the frontend server desc: runs the frontend server
dir: frontend dir: frontend
cmds: cmds:
- yarn run dev - yarn run dev --no-fork
docker:build-from-package: docker:build-from-package:
desc: Builds the Docker image from the existing Python package in dist/ desc: Builds the Docker image from the existing Python package in dist/

View file

@ -156,12 +156,13 @@ PROJECT_DIR = Path(__file__).parent.parent.parent
datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats" datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats"
locales_dir = PROJECT_DIR / "frontend" / "lang" / "messages" locales_dir = PROJECT_DIR / "frontend" / "lang" / "messages"
nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.js" nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.ts"
i18n_config = PROJECT_DIR / "frontend" / "i18n.config.ts"
reg_valid = PROJECT_DIR / "mealie" / "schema" / "_mealie" / "validators.py" reg_valid = PROJECT_DIR / "mealie" / "schema" / "_mealie" / "validators.py"
""" """
This snippet walks the message and dat locales directories and generates the import information This snippet walks the message and dat locales directories and generates the import information
for the nuxt.config.js file and automatically injects it into the nuxt.config.js file. Note that for the nuxt.config.ts file and automatically injects it into the nuxt.config.ts file. Note that
the code generation ID is hardcoded into the script and required in the nuxt config. the code generation ID is hardcoded into the script and required in the nuxt config.
""" """
@ -173,12 +174,12 @@ def inject_nuxt_values():
all_langs = [] all_langs = []
for match in locales_dir.glob("*.json"): for match in locales_dir.glob("*.json"):
lang_string = f'{{ code: "{match.stem}", file: "{match.name}" }},' lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}" }},'
all_langs.append(lang_string) all_langs.append(lang_string)
log.debug(f"injecting locales into nuxt config -> {nuxt_config}") log.debug(f"injecting locales into nuxt config -> {nuxt_config}")
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs) inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)
inject_inline(nuxt_config, CodeKeys.nuxt_local_dates, all_date_locales) inject_inline(i18n_config, CodeKeys.nuxt_local_dates, all_date_locales)
def inject_registration_validation_values(): def inject_registration_validation_values():

View file

@ -1,7 +1,7 @@
############################################### ###############################################
# Frontend Build # Frontend Build
############################################### ###############################################
FROM node:16 AS frontend-builder FROM node:20 AS frontend-builder
WORKDIR /frontend WORKDIR /frontend

View file

@ -156,8 +156,6 @@ Setting the following environmental variables will change the theme of the front
### Docker Secrets ### Docker Secrets
### Docker Secrets
> <super>&dagger;</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger > <super>&dagger;</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger
> symbol next to them support the Docker Compose secrets pattern, below. > symbol next to them support the Docker Compose secrets pattern, below.
[Docker Compose secrets][docker-secrets] can be used to secure sensitive information regarding the Mealie implementation [Docker Compose secrets][docker-secrets] can be used to secure sensitive information regarding the Mealie implementation

View file

@ -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",
},
};

View file

@ -1,378 +1,390 @@
/* cyrillic-ext */ /* cyrillic-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 100; font-weight: 100;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-100-cyrillic-ext1.woff2') format('woff2'); src: url("~assets/fonts/Roboto-100-cyrillic-ext1.woff2") format("woff2");
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
/* cyrillic */ /* cyrillic */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 100; font-weight: 100;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-100-cyrillic2.woff2') format('woff2'); src: url("~assets/fonts/Roboto-100-cyrillic2.woff2") format("woff2");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
/* greek-ext */ /* greek-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 100; font-weight: 100;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-100-greek-ext3.woff2') format('woff2'); src: url("~assets/fonts/Roboto-100-greek-ext3.woff2") format("woff2");
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
/* greek */ /* greek */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 100; font-weight: 100;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-100-greek4.woff2') format('woff2'); src: url("~assets/fonts/Roboto-100-greek4.woff2") format("woff2");
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
/* vietnamese */ /* vietnamese */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 100; font-weight: 100;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-100-vietnamese5.woff2') format('woff2'); src: url("~assets/fonts/Roboto-100-vietnamese5.woff2") format("woff2");
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
} }
/* latin-ext */ /* latin-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 100; font-weight: 100;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-100-latin-ext6.woff2') format('woff2'); src: url("~assets/fonts/Roboto-100-latin-ext6.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 100; font-weight: 100;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-100-latin7.woff2') format('woff2'); src: url("~assets/fonts/Roboto-100-latin7.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
/* cyrillic-ext */ /* cyrillic-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-300-cyrillic-ext8.woff2') format('woff2'); src: url("~assets/fonts/Roboto-300-cyrillic-ext8.woff2") format("woff2");
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
/* cyrillic */ /* cyrillic */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-300-cyrillic9.woff2') format('woff2'); src: url("~assets/fonts/Roboto-300-cyrillic9.woff2") format("woff2");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
/* greek-ext */ /* greek-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-300-greek-ext10.woff2') format('woff2'); src: url("~assets/fonts/Roboto-300-greek-ext10.woff2") format("woff2");
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
/* greek */ /* greek */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-300-greek11.woff2') format('woff2'); src: url("~assets/fonts/Roboto-300-greek11.woff2") format("woff2");
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
/* vietnamese */ /* vietnamese */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-300-vietnamese12.woff2') format('woff2'); src: url("~assets/fonts/Roboto-300-vietnamese12.woff2") format("woff2");
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
} }
/* latin-ext */ /* latin-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-300-latin-ext13.woff2') format('woff2'); src: url("~assets/fonts/Roboto-300-latin-ext13.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-300-latin14.woff2') format('woff2'); src: url("~assets/fonts/Roboto-300-latin14.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
/* cyrillic-ext */ /* cyrillic-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-400-cyrillic-ext15.woff2') format('woff2'); src: url("~assets/fonts/Roboto-400-cyrillic-ext15.woff2") format("woff2");
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
/* cyrillic */ /* cyrillic */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-400-cyrillic16.woff2') format('woff2'); src: url("~assets/fonts/Roboto-400-cyrillic16.woff2") format("woff2");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
/* greek-ext */ /* greek-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-400-greek-ext17.woff2') format('woff2'); src: url("~assets/fonts/Roboto-400-greek-ext17.woff2") format("woff2");
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
/* greek */ /* greek */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-400-greek18.woff2') format('woff2'); src: url("~assets/fonts/Roboto-400-greek18.woff2") format("woff2");
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
/* vietnamese */ /* vietnamese */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-400-vietnamese19.woff2') format('woff2'); src: url("~assets/fonts/Roboto-400-vietnamese19.woff2") format("woff2");
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
} }
/* latin-ext */ /* latin-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-400-latin-ext20.woff2') format('woff2'); src: url("~assets/fonts/Roboto-400-latin-ext20.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-400-latin21.woff2') format('woff2'); src: url("~assets/fonts/Roboto-400-latin21.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
/* cyrillic-ext */ /* cyrillic-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-500-cyrillic-ext22.woff2') format('woff2'); src: url("~assets/fonts/Roboto-500-cyrillic-ext22.woff2") format("woff2");
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
/* cyrillic */ /* cyrillic */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-500-cyrillic23.woff2') format('woff2'); src: url("~assets/fonts/Roboto-500-cyrillic23.woff2") format("woff2");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
/* greek-ext */ /* greek-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-500-greek-ext24.woff2') format('woff2'); src: url("~assets/fonts/Roboto-500-greek-ext24.woff2") format("woff2");
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
/* greek */ /* greek */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-500-greek25.woff2') format('woff2'); src: url("~assets/fonts/Roboto-500-greek25.woff2") format("woff2");
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
/* vietnamese */ /* vietnamese */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-500-vietnamese26.woff2') format('woff2'); src: url("~assets/fonts/Roboto-500-vietnamese26.woff2") format("woff2");
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
} }
/* latin-ext */ /* latin-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-500-latin-ext27.woff2') format('woff2'); src: url("~assets/fonts/Roboto-500-latin-ext27.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-500-latin28.woff2') format('woff2'); src: url("~assets/fonts/Roboto-500-latin28.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
/* cyrillic-ext */ /* cyrillic-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-700-cyrillic-ext29.woff2') format('woff2'); src: url("~assets/fonts/Roboto-700-cyrillic-ext29.woff2") format("woff2");
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
/* cyrillic */ /* cyrillic */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-700-cyrillic30.woff2') format('woff2'); src: url("~assets/fonts/Roboto-700-cyrillic30.woff2") format("woff2");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
/* greek-ext */ /* greek-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-700-greek-ext31.woff2') format('woff2'); src: url("~assets/fonts/Roboto-700-greek-ext31.woff2") format("woff2");
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
/* greek */ /* greek */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-700-greek32.woff2') format('woff2'); src: url("~assets/fonts/Roboto-700-greek32.woff2") format("woff2");
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
/* vietnamese */ /* vietnamese */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-700-vietnamese33.woff2') format('woff2'); src: url("~assets/fonts/Roboto-700-vietnamese33.woff2") format("woff2");
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
} }
/* latin-ext */ /* latin-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-700-latin-ext34.woff2') format('woff2'); src: url("~assets/fonts/Roboto-700-latin-ext34.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-700-latin35.woff2') format('woff2'); src: url("~assets/fonts/Roboto-700-latin35.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
/* cyrillic-ext */ /* cyrillic-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 900; font-weight: 900;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-900-cyrillic-ext36.woff2') format('woff2'); src: url("~assets/fonts/Roboto-900-cyrillic-ext36.woff2") format("woff2");
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
/* cyrillic */ /* cyrillic */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 900; font-weight: 900;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-900-cyrillic37.woff2') format('woff2'); src: url("~assets/fonts/Roboto-900-cyrillic37.woff2") format("woff2");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
/* greek-ext */ /* greek-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 900; font-weight: 900;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-900-greek-ext38.woff2') format('woff2'); src: url("~assets/fonts/Roboto-900-greek-ext38.woff2") format("woff2");
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
/* greek */ /* greek */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 900; font-weight: 900;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-900-greek39.woff2') format('woff2'); src: url("~assets/fonts/Roboto-900-greek39.woff2") format("woff2");
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
/* vietnamese */ /* vietnamese */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 900; font-weight: 900;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-900-vietnamese40.woff2') format('woff2'); src: url("~assets/fonts/Roboto-900-vietnamese40.woff2") format("woff2");
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
} }
/* latin-ext */ /* latin-ext */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 900; font-weight: 900;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-900-latin-ext41.woff2') format('woff2'); src: url("~assets/fonts/Roboto-900-latin-ext41.woff2") format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 900; font-weight: 900;
font-display: swap; font-display: swap;
src: url('~assets/fonts/Roboto-900-latin42.woff2') format('woff2'); src: url("~assets/fonts/Roboto-900-latin42.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }

View file

@ -17,11 +17,11 @@
} }
.theme--dark.v-application { .theme--dark.v-application {
background-color: var(--v-background-base, #1e1e1e) !important; background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
} }
.theme--dark.v-navigation-drawer { .theme--dark.v-navigation-drawer {
background-color: var(--v-background-base, #1e1e1e) !important; background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
} }
.theme--dark.v-card { .theme--dark.v-card {
@ -29,11 +29,11 @@
} }
.left-border { .left-border {
border-left: 5px solid var(--v-primary-base) !important; border-left: 5px solid rgb(var(--v-theme-primary)) !important;
} }
.left-warning-border { .left-warning-border {
border-left: 5px solid var(--v-warning-base) !important; border-left: 5px solid rgb(var(--v-theme-warning)) !important;
} }
.handle { .handle {
@ -56,3 +56,11 @@
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 100%; max-width: 100%;
} }
a {
color: rgb(var(--v-theme-primary));
}
.fill-height {
min-height: 100vh;
}

View file

@ -1,17 +1,41 @@
<template> <template>
<div> <div>
<v-card-text v-if="cookbook" class="px-1"> <v-card-text
<v-text-field v-model="cookbook.name" :label="$t('cookbook.cookbook-name')"></v-text-field> v-if="cookbook"
<v-textarea v-model="cookbook.description" auto-grow :rows="2" :label="$t('recipe.description')"></v-textarea> class="px-1"
>
<v-text-field
v-model="cookbook.name"
:label="$t('cookbook.cookbook-name')"
variant="underlined"
color="primary"
/>
<v-textarea
v-model="cookbook.description"
auto-grow
:rows="2"
:label="$t('recipe.description')"
variant="underlined"
color="primary"
/>
<QueryFilterBuilder <QueryFilterBuilder
:field-defs="fieldDefs" :field-defs="fieldDefs"
:initial-query-filter="cookbook.queryFilter" :initial-query-filter="cookbook.queryFilter"
@input="handleInput" @input="handleInput"
/> />
<v-switch v-model="cookbook.public" hide-details single-line> <v-switch
v-model="cookbook.public"
hide-details
single-line
color="primary"
>
<template #label> <template #label>
{{ $t('cookbook.public-cookbook') }} {{ $t('cookbook.public-cookbook') }}
<HelpIcon small right class="ml-2"> <HelpIcon
size="small"
right
class="ml-2"
>
{{ $t('cookbook.public-cookbook-description') }} {{ $t('cookbook.public-cookbook-description') }}
</HelpIcon> </HelpIcon>
</template> </template>
@ -21,16 +45,15 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, useContext } from "@nuxtjs/composition-api"; import type { ReadCookBook } from "~/lib/api/types/cookbook";
import { ReadCookBook } from "~/lib/api/types/cookbook";
import { Organizer } from "~/lib/api/types/non-generated"; import { Organizer } from "~/lib/api/types/non-generated";
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue"; import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import { FieldDefinition } from "~/composables/use-query-filter-builder"; import type { FieldDefinition } from "~/composables/use-query-filter-builder";
export default defineComponent({ export default defineNuxtComponent({
components: { QueryFilterBuilder }, components: { QueryFilterBuilder },
props: { props: {
cookbook: { modelValue: {
type: Object as () => ReadCookBook, type: Object as () => ReadCookBook,
required: true, required: true,
}, },
@ -39,52 +62,57 @@ export default defineComponent({
required: true, required: true,
}, },
}, },
setup(props) { emits: ["update:modelValue"],
const { i18n } = useContext(); setup(props, { emit }) {
const i18n = useI18n();
const cookbook = toRef(() => props.modelValue);
function handleInput(value: string | undefined) { function handleInput(value: string | undefined) {
props.cookbook.queryFilterString = value || ""; cookbook.value.queryFilterString = value || "";
emit("update:modelValue", cookbook.value);
} }
const fieldDefs: FieldDefinition[] = [ const fieldDefs: FieldDefinition[] = [
{ {
name: "recipe_category.id", name: "recipe_category.id",
label: i18n.tc("category.categories"), label: i18n.t("category.categories"),
type: Organizer.Category, type: Organizer.Category,
}, },
{ {
name: "tags.id", name: "tags.id",
label: i18n.tc("tag.tags"), label: i18n.t("tag.tags"),
type: Organizer.Tag, type: Organizer.Tag,
}, },
{ {
name: "recipe_ingredient.food.id", name: "recipe_ingredient.food.id",
label: i18n.tc("recipe.ingredients"), label: i18n.t("recipe.ingredients"),
type: Organizer.Food, type: Organizer.Food,
}, },
{ {
name: "tools.id", name: "tools.id",
label: i18n.tc("tool.tools"), label: i18n.t("tool.tools"),
type: Organizer.Tool, type: Organizer.Tool,
}, },
{ {
name: "household_id", name: "household_id",
label: i18n.tc("household.households"), label: i18n.t("household.households"),
type: Organizer.Household, type: Organizer.Household,
}, },
{ {
name: "created_at", name: "created_at",
label: i18n.tc("general.date-created"), label: i18n.t("general.date-created"),
type: "date", type: "date",
}, },
{ {
name: "updated_at", name: "updated_at",
label: i18n.tc("general.date-updated"), label: i18n.t("general.date-updated"),
type: "date", type: "date",
}, },
]; ];
return { return {
cookbook,
handleInput, handleInput,
fieldDefs, fieldDefs,
}; };

View file

@ -7,44 +7,59 @@
width="100%" width="100%"
max-width="1100px" max-width="1100px"
:icon="$globals.icons.pages" :icon="$globals.icons.pages"
:title="$tc('general.edit')" :title="$t('general.edit')"
:submit-icon="$globals.icons.save" :submit-icon="$globals.icons.save"
:submit-text="$tc('general.save')" :submit-text="$t('general.save')"
:submit-disabled="!editTarget.queryFilterString" :submit-disabled="!editTarget.queryFilterString"
can-submit
@submit="editCookbook" @submit="editCookbook"
> >
<v-card-text> <v-card-text>
<CookbookEditor :cookbook="editTarget" :actions="actions" /> <CookbookEditor
v-model="editTarget"
:actions="actions"
/>
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<!-- Page --> <v-container
<v-container v-if="book" fluid> v-if="book"
<v-app-bar color="transparent" flat class="mt-n1"> fluid
<v-icon large left> {{ $globals.icons.pages }} </v-icon> class="py-0 my-0"
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title> >
<v-spacer></v-spacer> <v-sheet
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 <BaseButton
v-if="canEdit" v-if="canEdit"
class="mx-1" class="mx-1"
:edit="true" :edit="true"
@click="handleEditCookbook" @click="handleEditCookbook"
/> />
</v-app-bar> </div>
<v-card flat> <div v-if="book.description" class="subtitle-1 text-grey-lighten-1 mb-2">
<v-card-text class="py-0">
{{ book.description }} {{ book.description }}
</v-card-text> </div>
</v-card> </v-sheet>
<v-container class="pa-0"> <v-container class="pa-0">
<RecipeCardSection <RecipeCardSection
class="mb-5 mx-1" class="mb-5 mx-1"
:recipes="recipes" :recipes="recipes"
:query="{ cookbook: slug }" :query="{ cookbook: slug }"
@sortRecipes="assignSorted" @sort-recipes="assignSorted"
@replaceRecipes="replaceRecipes" @replace-recipes="replaceRecipes"
@appendRecipes="appendRecipes" @append-recipes="appendRecipes"
@delete="removeRecipe" @delete="removeRecipe"
/> />
</v-container> </v-container>
@ -53,39 +68,39 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useRoute, ref, useContext, useMeta, reactive, useRouter } from "@nuxtjs/composition-api";
import { useLazyRecipes } from "~/composables/recipes"; import { useLazyRecipes } from "~/composables/recipes";
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import { useCookbook, useCookbooks } from "~/composables/use-group-cookbooks"; import { useCookbookStore } from "~/composables/store/use-cookbook-store";
import { useCookbook } from "~/composables/use-group-cookbooks";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { RecipeCookBook } from "~/lib/api/types/cookbook"; import type { RecipeCookBook } from "~/lib/api/types/cookbook";
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue"; import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
export default defineComponent({ export default defineNuxtComponent({
components: { RecipeCardSection, CookbookEditor }, components: { RecipeCardSection, CookbookEditor },
setup() { setup() {
const { $auth } = useContext(); const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value); const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const slug = route.value.params.slug; const slug = route.params.slug as string;
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value); const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
const { actions } = useCookbooks(); const { actions } = useCookbookStore();
const router = useRouter(); const router = useRouter();
const tab = ref(null); const tab = ref(null);
const book = getOne(slug); const book = getOne(slug);
const isOwnHousehold = computed(() => { const isOwnHousehold = computed(() => {
if (!($auth.user && book.value?.householdId)) { if (!($auth.user.value && book.value?.householdId)) {
return false; return false;
} }
return $auth.user.householdId === book.value.householdId; return $auth.user.value.householdId === book.value.householdId;
}) });
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value); const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
const dialogStates = reactive({ const dialogStates = reactive({
@ -106,8 +121,9 @@
if (response?.slug && book.value?.slug !== response?.slug) { if (response?.slug && book.value?.slug !== response?.slug) {
// if name changed, redirect to new slug // if name changed, redirect to new slug
router.push(`/g/${route.value.params.groupSlug}/cookbooks/${response?.slug}`); router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
} else { }
else {
// otherwise reload the page, since the recipe criteria changed // otherwise reload the page, since the recipe criteria changed
router.go(0); router.go(0);
} }
@ -115,10 +131,8 @@
editTarget.value = null; editTarget.value = null;
} }
useMeta(() => { useSeoMeta({
return {
title: book?.value?.name || "Cookbook", title: book?.value?.name || "Cookbook",
};
}); });
return { return {
@ -138,6 +152,5 @@
actions, actions,
}; };
}, },
head: {}, // Must include for useMeta
}); });
</script> </script>

View file

@ -7,21 +7,24 @@
class="elevation-0" class="elevation-0"
@click:row="downloadData" @click:row="downloadData"
> >
<template #item.expires="{ item }"> <template #[`item.expires`]="{ item }">
{{ getTimeToExpire(item.expires) }} {{ getTimeToExpire(item.expires) }}
</template> </template>
<template #item.actions="{ item }"> <template #[`item.actions`]="{ item }">
<BaseButton download small :download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"> <BaseButton
</BaseButton> download
size="small"
:download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"
/>
</template> </template>
</v-data-table> </v-data-table>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, useContext } from "@nuxtjs/composition-api";
import { parseISO, formatDistanceToNow } from "date-fns"; import { parseISO, formatDistanceToNow } from "date-fns";
import { GroupDataExport } from "~/lib/api/types/group"; import type { GroupDataExport } from "~/lib/api/types/group";
export default defineComponent({
export default defineNuxtComponent({
props: { props: {
exports: { exports: {
type: Array as () => GroupDataExport[], type: Array as () => GroupDataExport[],
@ -29,14 +32,14 @@ export default defineComponent({
}, },
}, },
setup() { setup() {
const { i18n } = useContext(); const i18n = useI18n();
const headers = [ const headers = [
{ text: i18n.t("export.export"), value: "name" }, { title: i18n.t("export.export"), value: "name" },
{ text: i18n.t("export.file-name"), value: "filename" }, { title: i18n.t("export.file-name"), value: "filename" },
{ text: i18n.t("export.size"), value: "size" }, { title: i18n.t("export.size"), value: "size" },
{ text: i18n.t("export.link-expires"), value: "expires" }, { title: i18n.t("export.link-expires"), value: "expires" },
{ text: "", value: "actions" }, { title: "", value: "actions" },
]; ];
function getTimeToExpire(timeString: string) { function getTimeToExpire(timeString: string) {

View file

@ -1,27 +1,30 @@
<template> <template>
<div v-if="preferences"> <div v-if="preferences">
<BaseCardSectionTitle :title="$tc('group.general-preferences')"></BaseCardSectionTitle> <BaseCardSectionTitle :title="$t('group.general-preferences')" />
<v-checkbox v-model="preferences.privateGroup" class="mt-n4" :label="$t('group.private-group')"></v-checkbox> <v-checkbox
v-model="preferences.privateGroup"
class="mt-n4"
:label="$t('group.private-group')"
/>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed } from "@nuxtjs/composition-api"; export default defineNuxtComponent({
export default defineComponent({
props: { props: {
value: { modelValue: {
type: Object, type: Object,
required: true, required: true,
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const preferences = computed({ const preferences = computed({
get() { get() {
return props.value; return props.modelValue;
}, },
set(val) { set(val) {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
@ -32,5 +35,4 @@ export default defineComponent({
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped></style>
</style>

View file

@ -5,31 +5,30 @@
:label="label" :label="label"
:hint="description" :hint="description"
:persistent-hint="!!description" :persistent-hint="!!description"
item-text="name" item-title="name"
:multiple="multiselect" :multiple="multiselect"
:prepend-inner-icon="$globals.icons.household" :prepend-inner-icon="$globals.icons.household"
return-object return-object
> >
<template #selection="data"> <template #chip="data">
<v-chip <v-chip
:key="data.index" :key="data.index"
class="ma-1" class="ma-1"
:input-value="data.selected" :input-value="data.item"
small size="small"
close closable
label label
color="accent" color="accent"
dark dark
@click:close="removeByIndex(data.index)" @click:close="removeByIndex(data.index)"
> >
{{ data.item.name || data.item }} {{ data.item.raw.name || data.item }}
</v-chip> </v-chip>
</template> </template>
</v-select> </v-select>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, onMounted, useContext } from "@nuxtjs/composition-api";
import { useHouseholdStore } from "~/composables/store/use-household-store"; import { useHouseholdStore } from "~/composables/store/use-household-store";
interface HouseholdLike { interface HouseholdLike {
@ -37,9 +36,9 @@ interface HouseholdLike {
name: string; name: string;
} }
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: Array as () => HouseholdLike[], type: Array as () => HouseholdLike[],
required: true, required: true,
}, },
@ -52,11 +51,12 @@ export default defineComponent({
default: "", default: "",
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const selected = computed({ const selected = computed({
get: () => props.value, get: () => props.modelValue,
set: (val) => { set: (val) => {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
@ -66,9 +66,9 @@ export default defineComponent({
} }
}); });
const { i18n } = useContext(); const i18n = useI18n();
const label = computed( const label = computed(
() => props.multiselect ? i18n.tc("household.households") : i18n.tc("household.household") () => props.multiselect ? i18n.t("household.households") : i18n.t("household.household"),
); );
const { store: households } = useHouseholdStore(); const { store: households } = useHouseholdStore();

View file

@ -8,26 +8,41 @@
/> />
<v-menu <v-menu
offset-y offset-y
left start
:bottom="!menuTop" :bottom="!menuTop"
:nudge-bottom="!menuTop ? '5' : '0'" :nudge-bottom="!menuTop ? '5' : '0'"
:top="menuTop" :top="menuTop"
:nudge-top="menuTop ? '5' : '0'" :nudge-top="menuTop ? '5' : '0'"
allow-overflow allow-overflow
close-delay="125" close-delay="125"
:open-on-hover="$vuetify.breakpoint.mdAndUp" :open-on-hover="mdAndUp"
content-class="d-print-none" content-class="d-print-none"
> >
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent> <v-btn
:class="{ 'rounded-circle': fab }"
:size="fab ? 'small' : undefined"
:color="color"
:icon="!fab"
variant="text"
dark
v-bind="props"
@click.prevent
>
<v-icon>{{ icon }}</v-icon> <v-icon>{{ icon }}</v-icon>
</v-btn> </v-btn>
</template> </template>
<v-list dense> <v-list density="compact">
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)"> <v-list-item
<v-list-item-icon> v-for="(item, index) in menuItems"
<v-icon :color="item.color"> {{ item.icon }} </v-icon> :key="index"
</v-list-item-icon> @click="contextMenuEventHandler(item.event)"
>
<template #prepend>
<v-icon :color="item.color">
{{ item.icon }}
</v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title> <v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item> </v-list-item>
</v-list> </v-list>
@ -36,10 +51,9 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api"; import type { Recipe } from "~/lib/api/types/recipe";
import { Recipe } from "~/lib/api/types/recipe";
import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue"; import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
import { ShoppingListSummary } from "~/lib/api/types/household"; import type { ShoppingListSummary } from "~/lib/api/types/household";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
export interface ContextMenuItem { export interface ContextMenuItem {
@ -50,7 +64,7 @@ export interface ContextMenuItem {
isPublic: boolean; isPublic: boolean;
} }
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeDialogAddToShoppingList, RecipeDialogAddToShoppingList,
}, },
@ -77,7 +91,10 @@ export default defineComponent({
}, },
}, },
setup(props, context) { setup(props, context) {
const { $globals, i18n } = useContext(); const { mdAndUp } = useDisplay();
const i18n = useI18n();
const { $globals } = useNuxtApp();
const api = useUserApi(); const api = useUserApi();
const state = reactive({ const state = reactive({
@ -85,7 +102,7 @@ export default defineComponent({
shoppingListDialog: false, shoppingListDialog: false,
menuItems: [ menuItems: [
{ {
title: i18n.tc("recipe.add-to-list"), title: i18n.t("recipe.add-to-list"),
icon: $globals.icons.cartCheck, icon: $globals.icons.cartCheck,
color: undefined, color: undefined,
event: "shoppingList", event: "shoppingList",
@ -103,16 +120,17 @@ export default defineComponent({
scale: 1, scale: 1,
...recipe, ...recipe,
}; };
}) });
}) });
async function getShoppingLists() { async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" }); const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
if (data) { if (data) {
shoppingLists.value = data.items ?? []; shoppingLists.value = data.items as ShoppingListSummary[] ?? [];
} }
} }
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = { const eventHandlers: { [key: string]: () => void | Promise<any> } = {
shoppingList: () => { shoppingList: () => {
getShoppingLists(); getShoppingLists();
@ -139,7 +157,8 @@ export default defineComponent({
icon, icon,
recipesWithScales, recipesWithScales,
shoppingLists, shoppingLists,
} mdAndUp,
};
}, },
}) });
</script> </script>

View file

@ -1,8 +1,19 @@
<template> <template>
<div> <div>
<div class="d-md-flex" style="gap: 10px"> <div
<v-select v-model="inputDay" :items="MEAL_DAY_OPTIONS" :label="$t('meal-plan.rule-day')"></v-select> class="d-md-flex"
<v-select v-model="inputEntryType" :items="MEAL_TYPE_OPTIONS" :label="$t('meal-plan.meal-type')"></v-select> style="gap: 10px"
>
<v-select
v-model="inputDay"
:items="MEAL_DAY_OPTIONS"
:label="$t('meal-plan.rule-day')"
/>
<v-select
v-model="inputEntryType"
:items="MEAL_TYPE_OPTIONS"
:label="$t('meal-plan.meal-type')"
/>
</div> </div>
<div class="mb-5"> <div class="mb-5">
@ -16,19 +27,18 @@
<!-- TODO: proper pluralization of inputDay --> <!-- TODO: proper pluralization of inputDay -->
{{ $t('meal-plan.this-rule-will-apply', { {{ $t('meal-plan.this-rule-will-apply', {
dayCriteria: inputDay === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [inputDay]), dayCriteria: inputDay === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [inputDay]),
mealTypeCriteria: inputEntryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [inputEntryType]) mealTypeCriteria: inputEntryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [inputEntryType]),
}) }} }) }}
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue"; import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import { FieldDefinition } from "~/composables/use-query-filter-builder"; import type { FieldDefinition } from "~/composables/use-query-filter-builder";
import { Organizer } from "~/lib/api/types/non-generated"; import { Organizer } from "~/lib/api/types/non-generated";
import { QueryFilterJSON } from "~/lib/api/types/response"; import type { QueryFilterJSON } from "~/lib/api/types/response";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
QueryFilterBuilder, QueryFilterBuilder,
}, },
@ -54,8 +64,9 @@ export default defineComponent({
default: false, default: false,
}, },
}, },
emits: ["update:day", "update:entry-type", "update:query-filter-string"],
setup(props, context) { setup(props, context) {
const { i18n } = useContext(); const i18n = useI18n();
const MEAL_TYPE_OPTIONS = [ const MEAL_TYPE_OPTIONS = [
{ text: i18n.t("meal-plan.breakfast"), value: "breakfast" }, { text: i18n.t("meal-plan.breakfast"), value: "breakfast" },
@ -110,42 +121,42 @@ export default defineComponent({
const fieldDefs: FieldDefinition[] = [ const fieldDefs: FieldDefinition[] = [
{ {
name: "recipe_category.id", name: "recipe_category.id",
label: i18n.tc("category.categories"), label: i18n.t("category.categories"),
type: Organizer.Category, type: Organizer.Category,
}, },
{ {
name: "tags.id", name: "tags.id",
label: i18n.tc("tag.tags"), label: i18n.t("tag.tags"),
type: Organizer.Tag, type: Organizer.Tag,
}, },
{ {
name: "recipe_ingredient.food.id", name: "recipe_ingredient.food.id",
label: i18n.tc("recipe.ingredients"), label: i18n.t("recipe.ingredients"),
type: Organizer.Food, type: Organizer.Food,
}, },
{ {
name: "tools.id", name: "tools.id",
label: i18n.tc("tool.tools"), label: i18n.t("tool.tools"),
type: Organizer.Tool, type: Organizer.Tool,
}, },
{ {
name: "household_id", name: "household_id",
label: i18n.tc("household.households"), label: i18n.t("household.households"),
type: Organizer.Household, type: Organizer.Household,
}, },
{ {
name: "last_made", name: "last_made",
label: i18n.tc("general.last-made"), label: i18n.t("general.last-made"),
type: "date", type: "date",
}, },
{ {
name: "created_at", name: "created_at",
label: i18n.tc("general.date-created"), label: i18n.t("general.date-created"),
type: "date", type: "date",
}, },
{ {
name: "updated_at", name: "updated_at",
label: i18n.tc("general.date-updated"), label: i18n.t("general.date-updated"),
type: "date", type: "date",
}, },
]; ];

View file

@ -1,27 +1,44 @@
<template> <template>
<div> <div>
<v-card-text> <v-card-text>
<v-switch v-model="webhookCopy.enabled" :label="$t('general.enabled')"></v-switch> <v-switch
<v-text-field v-model="webhookCopy.name" :label="$t('settings.webhooks.webhook-name')"></v-text-field> v-model="webhookCopy.enabled"
<v-text-field v-model="webhookCopy.url" :label="$t('settings.webhooks.webhook-url')"></v-text-field> color="primary"
<v-time-picker v-model="scheduledTime" class="elevation-2" ampm-in-title format="ampm"></v-time-picker> :label="$t('general.enabled')"
/>
<v-text-field
v-model="webhookCopy.name"
:label="$t('settings.webhooks.webhook-name')"
variant="underlined"
/>
<v-text-field
v-model="webhookCopy.url"
:label="$t('settings.webhooks.webhook-url')"
variant="underlined"
/>
<v-time-picker
v-model="scheduledTime"
class="elevation-2"
ampm-in-title
format="ampm"
/>
</v-card-text> </v-card-text>
<v-card-actions class="py-0 justify-end"> <v-card-actions class="py-0 justify-end">
<BaseButtonGroup <BaseButtonGroup
:buttons="[ :buttons="[
{ {
icon: $globals.icons.delete, icon: $globals.icons.delete,
text: $tc('general.delete'), text: $t('general.delete'),
event: 'delete', event: 'delete',
}, },
{ {
icon: $globals.icons.testTube, icon: $globals.icons.testTube,
text: $tc('general.test'), text: $t('general.test'),
event: 'test', event: 'test',
}, },
{ {
icon: $globals.icons.save, icon: $globals.icons.save,
text: $tc('general.save'), text: $t('general.save'),
event: 'save', event: 'save',
}, },
]" ]"
@ -34,11 +51,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, ref } from "@nuxtjs/composition-api"; import type { ReadWebhook } from "~/lib/api/types/household";
import { ReadWebhook } from "~/lib/api/types/household";
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks"; import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
webhook: { webhook: {
type: Object as () => ReadWebhook, type: Object as () => ReadWebhook,
@ -47,6 +63,7 @@ export default defineComponent({
}, },
emits: ["delete", "save", "test"], emits: ["delete", "save", "test"],
setup(props, { emit }) { setup(props, { emit }) {
const i18n = useI18n();
const itemUTC = ref<string>(props.webhook.scheduledTime); const itemUTC = ref<string>(props.webhook.scheduledTime);
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime)); const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
@ -67,6 +84,11 @@ export default defineComponent({
emit("save", webhookCopy.value); emit("save", webhookCopy.value);
} }
// Set page title using useSeoMeta
useSeoMeta({
title: i18n.t("settings.webhooks.webhooks"),
});
return { return {
webhookCopy, webhookCopy,
scheduledTime, scheduledTime,
@ -75,10 +97,5 @@ export default defineComponent({
itemLocal, itemLocal,
}; };
}, },
head() {
return {
title: this.$t("settings.webhooks.webhooks") as string,
};
},
}); });
</script> </script>

View file

@ -1,13 +1,8 @@
<template> <template>
<div v-if="preferences"> <div v-if="preferences">
<BaseCardSectionTitle class="mt-10" :title="$tc('household.household-preferences')"></BaseCardSectionTitle> <BaseCardSectionTitle :title="$t('household.household-preferences')" />
<div class="mb-6"> <div class="mb-6">
<v-checkbox <v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
v-model="preferences.privateHousehold"
hide-details
dense
:label="$t('household.private-household')"
/>
<div class="ml-8"> <div class="ml-8">
<p class="text-subtitle-2 my-0 py-0"> <p class="text-subtitle-2 my-0 py-0">
{{ $t("household.private-household-description") }} {{ $t("household.private-household-description") }}
@ -16,12 +11,7 @@
</div> </div>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<v-checkbox <v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
v-model="preferences.lockRecipeEditsFromOtherHouseholds"
hide-details
dense
:label="$t('household.lock-recipe-edits-from-other-households')"
/>
<div class="ml-8"> <div class="ml-8">
<p class="text-subtitle-2 my-0 py-0"> <p class="text-subtitle-2 my-0 py-0">
{{ $t("household.lock-recipe-edits-from-other-households-description") }} {{ $t("household.lock-recipe-edits-from-other-households-description") }}
@ -32,20 +22,17 @@
v-model="preferences.firstDayOfWeek" v-model="preferences.firstDayOfWeek"
:prepend-icon="$globals.icons.calendarWeekBegin" :prepend-icon="$globals.icons.calendarWeekBegin"
:items="allDays" :items="allDays"
item-text="name" item-title="name"
item-value="value" item-value="value"
:label="$t('settings.first-day-of-week')" :label="$t('settings.first-day-of-week')"
variant="underlined"
flat
/> />
<BaseCardSectionTitle class="mt-5" :title="$tc('household.household-recipe-preferences')"></BaseCardSectionTitle> <BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')" />
<div class="preference-container"> <div class="preference-container">
<div v-for="p in recipePreferences" :key="p.key"> <div v-for="p in recipePreferences" :key="p.key">
<v-checkbox <v-checkbox v-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
v-model="preferences[p.key]"
hide-details
dense
:label="p.label"
/>
<p class="ml-8 text-subtitle-2 my-0 py-0"> <p class="ml-8 text-subtitle-2 my-0 py-0">
{{ p.description }} {{ p.description }}
</p> </p>
@ -55,55 +42,55 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api"; import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
import { ReadHouseholdPreferences } from "~/lib/api/types/household";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: Object, type: Object,
required: true, required: true,
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const { i18n } = useContext(); const i18n = useI18n();
type Preference = { type Preference = {
key: keyof ReadHouseholdPreferences; key: keyof ReadHouseholdPreferences;
label: string; label: string;
description: string; description: string;
} };
const recipePreferences: Preference[] = [ const recipePreferences: Preference[] = [
{ {
key: "recipePublic", key: "recipePublic",
label: i18n.tc("group.allow-users-outside-of-your-group-to-see-your-recipes"), label: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes"),
description: i18n.tc("group.allow-users-outside-of-your-group-to-see-your-recipes-description"), description: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes-description"),
}, },
{ {
key: "recipeShowNutrition", key: "recipeShowNutrition",
label: i18n.tc("group.show-nutrition-information"), label: i18n.t("group.show-nutrition-information"),
description: i18n.tc("group.show-nutrition-information-description"), description: i18n.t("group.show-nutrition-information-description"),
}, },
{ {
key: "recipeShowAssets", key: "recipeShowAssets",
label: i18n.tc("group.show-recipe-assets"), label: i18n.t("group.show-recipe-assets"),
description: i18n.tc("group.show-recipe-assets-description"), description: i18n.t("group.show-recipe-assets-description"),
}, },
{ {
key: "recipeLandscapeView", key: "recipeLandscapeView",
label: i18n.tc("group.default-to-landscape-view"), label: i18n.t("group.default-to-landscape-view"),
description: i18n.tc("group.default-to-landscape-view-description"), description: i18n.t("group.default-to-landscape-view-description"),
}, },
{ {
key: "recipeDisableComments", key: "recipeDisableComments",
label: i18n.tc("group.disable-users-from-commenting-on-recipes"), label: i18n.t("group.disable-users-from-commenting-on-recipes"),
description: i18n.tc("group.disable-users-from-commenting-on-recipes-description"), description: i18n.t("group.disable-users-from-commenting-on-recipes-description"),
}, },
{ {
key: "recipeDisableAmount", key: "recipeDisableAmount",
label: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food"), label: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food"),
description: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food-description"), description: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food-description"),
}, },
]; ];
@ -140,10 +127,10 @@ export default defineComponent({
const preferences = computed({ const preferences = computed({
get() { get() {
return props.value; return props.modelValue;
}, },
set(val) { set(val) {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });

View file

@ -2,10 +2,10 @@
<v-card class="ma-0" style="overflow-x: auto;"> <v-card class="ma-0" style="overflow-x: auto;">
<v-card-text class="ma-0 pa-0"> <v-card-text class="ma-0 pa-0">
<v-container fluid class="ma-0 pa-0"> <v-container fluid class="ma-0 pa-0">
<draggable <VueDraggable
:value="fields" v-model="fields"
handle=".handle" handle=".handle"
delay="250" :delay="250"
:delay-on-touch-only="true" :delay-on-touch-only="true"
v-bind="{ v-bind="{
animation: 200, animation: 200,
@ -17,127 +17,142 @@
> >
<v-row <v-row
v-for="(field, index) in fields" v-for="(field, index) in fields"
:key="index" :key="field.id"
class="d-flex flex-nowrap" class="d-flex flex-nowrap"
style="max-width: 100%;" style="max-width: 100%;"
> >
<!-- drag handle -->
<v-col <v-col
:cols="attrs.fields.icon.cols" :cols="config.items.icon.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.icon.style" :style="config.items.icon.style"
> >
<v-icon <v-icon
class="handle" class="handle"
style="width: 100%; height: 100%;" :size="24"
style="cursor: move;margin: auto;"
> >
{{ $globals.icons.arrowUpDown }} {{ $globals.icons.arrowUpDown }}
</v-icon> </v-icon>
</v-col> </v-col>
<!-- and / or -->
<v-col <v-col
:cols="attrs.fields.logicalOperator.cols" :cols="config.items.logicalOperator.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.logicalOperator.style" :style="config.items.logicalOperator.style"
> >
<v-select <v-select
v-if="index" v-if="index"
v-model="field.logicalOperator" :model-value="field.logicalOperator"
:items="[logOps.AND, logOps.OR]" :items="[logOps.AND, logOps.OR]"
item-text="label" item-title="label"
item-value="value" item-value="value"
@input="setLogicalOperatorValue(field, index, $event)" variant="underlined"
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
> >
<template #selection="{ item }"> <template #chip="{ item }">
<span :class="attrs.select.textClass" style="width: 100%;"> <span :class="config.select.textClass" style="width: 100%;">
{{ item.label }} {{ item.raw.label }}
</span> </span>
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- left parenthesis -->
<v-col <v-col
v-if="showAdvanced" v-if="showAdvanced"
:cols="attrs.fields.leftParens.cols" :cols="config.items.leftParens.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.leftParens.style" :style="config.items.leftParens.style"
> >
<v-select <v-select
v-model="field.leftParenthesis" :model-value="field.leftParenthesis"
:items="['', '(', '((', '(((']" :items="['', '(', '((', '(((']"
@input="setLeftParenthesisValue(field, index, $event)" variant="underlined"
@update:model-value="setLeftParenthesisValue(field, index, $event)"
> >
<template #selection="{ item }"> <template #chip="{ item }">
<span :class="attrs.select.textClass" style="width: 100%;"> <span :class="config.select.textClass" style="width: 100%;">
{{ item }} {{ item.raw }}
</span> </span>
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- field name -->
<v-col <v-col
:cols="attrs.fields.fieldName.cols" :cols="config.items.fieldName.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.fieldName.style" :style="config.items.fieldName.style"
> >
<v-select <v-select
v-model="field.label" chips
:model-value="field.label"
:items="fieldDefs" :items="fieldDefs"
item-text="label" variant="underlined"
@change="setField(index, $event)" item-title="label"
@update:model-value="setField(index, $event)"
> >
<template #selection="{ item }"> <template #chip="{ item }">
<span :class="attrs.select.textClass" style="width: 100%;"> <span :class="config.select.textClass" style="width: 100%;">
{{ item.label }} {{ item.raw.label }}
</span> </span>
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- relational operator -->
<v-col <v-col
:cols="attrs.fields.relationalOperator.cols" :cols="config.items.relationalOperator.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.relationalOperator.style" :style="config.items.relationalOperator.style"
> >
<v-select <v-select
v-if="field.type !== 'boolean'" v-if="field.type !== 'boolean'"
v-model="field.relationalOperatorValue" :model-value="field.relationalOperatorValue"
:items="field.relationalOperatorOptions" :items="field.relationalOperatorOptions"
item-text="label" item-title="label"
item-value="value" item-value="value"
@input="setRelationalOperatorValue(field, index, $event)" variant="underlined"
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
> >
<template #selection="{ item }"> <template #chip="{ item }">
<span :class="attrs.select.textClass" style="width: 100%;"> <span :class="config.select.textClass" style="width: 100%;">
{{ item.label }} {{ item.raw.label }}
</span> </span>
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- field value -->
<v-col <v-col
:cols="attrs.fields.fieldValue.cols" :cols="config.items.fieldValue.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.fieldValue.style" :style="config.items.fieldValue.style"
> >
<v-select <v-select
v-if="field.fieldOptions" v-if="field.fieldOptions"
v-model="field.values" :model-value="field.values"
:items="field.fieldOptions" :items="field.fieldOptions"
item-text="label" item-title="label"
item-value="value" item-value="value"
multiple multiple
@input="setFieldValues(field, index, $event)" variant="underlined"
@update:model-value="setFieldValues(field, index, $event)"
/> />
<v-text-field <v-text-field
v-else-if="field.type === 'string'" v-else-if="field.type === 'string'"
v-model="field.value" :model-value="field.value"
@input="setFieldValue(field, index, $event)" variant="underlined"
@update:model-value="setFieldValue(field, index, $event)"
/> />
<v-text-field <v-text-field
v-else-if="field.type === 'number'" v-else-if="field.type === 'number'"
v-model="field.value" :model-value="field.value"
type="number" type="number"
@input="setFieldValue(field, index, $event)" variant="underlined"
@:model-value="setFieldValue(field, index, $event)"
/> />
<v-checkbox <v-checkbox
v-else-if="field.type === 'boolean'" v-else-if="field.type === 'boolean'"
v-model="field.value" :model-value="field.value"
@change="setFieldValue(field, index, $event)" @update:model-value="setFieldValue(field, index, $event!)"
/> />
<v-menu <v-menu
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
@ -148,137 +163,148 @@
max-width="290px" max-width="290px"
min-width="auto" min-width="auto"
> >
<template #activator="{ on, attrs: menuAttrs }"> <template #activator="{ props }">
<v-text-field <v-text-field
v-model="field.value" v-model="field.value"
persistent-hint persistent-hint
:prepend-icon="$globals.icons.calendar" :prepend-icon="$globals.icons.calendar"
v-bind="menuAttrs" variant="underlined"
color="primary"
v-bind="props"
readonly readonly
v-on="on"
/> />
</template> </template>
<v-date-picker <v-date-picker
v-model="field.value" :model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
no-title hide-header
:first-day-of-week="firstDayOfWeek" :first-day-of-week="firstDayOfWeek"
:local="$i18n.locale" :local="$i18n.locale"
@input="setFieldValue(field, index, $event)" @update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
/> />
</v-menu> </v-menu>
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Category" v-else-if="field.type === Organizer.Category"
v-model="field.organizers" :model-value="field.organizers"
:selector-type="Organizer.Category" :selector-type="Organizer.Category"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
@input="setOrganizerValues(field, index, $event)" @update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tag" v-else-if="field.type === Organizer.Tag"
v-model="field.organizers" :model-value="field.organizers"
:selector-type="Organizer.Tag" :selector-type="Organizer.Tag"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
@input="setOrganizerValues(field, index, $event)" @update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tool" v-else-if="field.type === Organizer.Tool"
v-model="field.organizers" :model-value="field.organizers"
:selector-type="Organizer.Tool" :selector-type="Organizer.Tool"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
@input="setOrganizerValues(field, index, $event)" @update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Food" v-else-if="field.type === Organizer.Food"
v-model="field.organizers" :model-value="field.organizers"
:selector-type="Organizer.Food" :selector-type="Organizer.Food"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
@input="setOrganizerValues(field, index, $event)" @update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Household" v-else-if="field.type === Organizer.Household"
v-model="field.organizers" :model-value="field.organizers"
:selector-type="Organizer.Household" :selector-type="Organizer.Household"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
@input="setOrganizerValues(field, index, $event)" @update:model-value="setOrganizerValues(field, index, $event)"
/> />
</v-col> </v-col>
<!-- right parenthesis -->
<v-col <v-col
v-if="showAdvanced" v-if="showAdvanced"
:cols="attrs.fields.rightParens.cols" :cols="config.items.rightParens.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.rightParens.style" :style="config.items.rightParens.style"
> >
<v-select <v-select
v-model="field.rightParenthesis" :model-value="field.rightParenthesis"
:items="['', ')', '))', ')))']" :items="['', ')', '))', ')))']"
@input="setRightParenthesisValue(field, index, $event)" variant="underlined"
@update:model-value="setRightParenthesisValue(field, index, $event)"
> >
<template #selection="{ item }"> <template #chip="{ item }">
<span :class="attrs.select.textClass" style="width: 100%;"> <span :class="config.select.textClass" style="width: 100%;">
{{ item }} {{ item.raw }}
</span> </span>
</template> </template>
</v-select> </v-select>
</v-col> </v-col>
<!-- field actions -->
<v-col <v-col
:cols="attrs.fields.fieldActions.cols" :cols="config.items.fieldActions.cols"
:class="attrs.col.class" :class="config.col.class"
:style="attrs.fields.fieldActions.style" :style="config.items.fieldActions.style"
> >
<BaseButtonGroup <BaseButtonGroup
:buttons="[ :buttons="[
{ {
icon: $globals.icons.delete, icon: $globals.icons.delete,
text: $tc('general.delete'), text: $t('general.delete'),
event: 'delete', event: 'delete',
disabled: fields.length === 1, disabled: fields.length === 1,
} },
]" ]"
class="my-auto" class="my-auto"
@delete="removeField(index)" @delete="removeField(index)"
/> />
</v-col> </v-col>
</v-row> </v-row>
</draggable> </VueDraggable>
</v-container> </v-container>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-container fluid class="d-flex justify-end pa-0 mx-2"> <v-row fluid class="d-flex justify-end pa-0 mx-2">
<v-spacer />
<v-checkbox <v-checkbox
v-model="showAdvanced" v-model="showAdvanced"
hide-details hide-details
:label="$tc('general.show-advanced')" :label="$t('general.show-advanced')"
class="my-auto mr-4" class="my-auto mr-4"
color="primary"
/> />
<BaseButton create :text="$tc('general.add-field')" @click="addField(fieldDefs[0])" /> <BaseButton
</v-container> create
:text="$t('general.add-field')"
class="my-auto"
@click="addField(fieldDefs[0])"
/>
</v-row>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</template> </template>
<script lang="ts"> <script lang="ts">
import draggable from "vuedraggable"; import { VueDraggable } from "vue-draggable-plus";
import { computed, defineComponent, reactive, ref, toRefs, watch } from "@nuxtjs/composition-api"; import { useDebounceFn } from "@vueuse/core";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue"; import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { Organizer } from "~/lib/api/types/non-generated"; import { Organizer } from "~/lib/api/types/non-generated";
import { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response"; import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store"; import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { Field, FieldDefinition, FieldValue, OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder"; import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
draggable, VueDraggable,
RecipeOrganizerSelector, RecipeOrganizerSelector,
}, },
props: { props: {
@ -289,8 +315,9 @@ export default defineComponent({
initialQueryFilter: { initialQueryFilter: {
type: Object as () => QueryFilterJSON | null, type: Object as () => QueryFilterJSON | null,
default: null, default: null,
}
}, },
},
emits: ["input", "inputJSON"],
setup(props, context) { setup(props, context) {
const { household } = useHouseholdSelf(); const { household } = useHouseholdSelf();
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder(); const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
@ -321,21 +348,27 @@ export default defineComponent({
const newIndex: number = event.newIndex; const newIndex: number = event.newIndex;
state.datePickers[oldIndex] = false; state.datePickers[oldIndex] = false;
state.datePickers[newIndex] = false; state.datePickers[newIndex] = false;
const field = fields.value.splice(oldIndex, 1)[0];
fields.value.splice(newIndex, 0, field);
} }
const fields = ref<Field[]>([]); // add id to fields to prevent reactivity issues
type FieldWithId = Field & { id: number };
const fields = ref<FieldWithId[]>([]);
const uid = ref(1); // init uid to pass to fields
function useUid() {
return uid.value++;
}
function addField(field: FieldDefinition) { function addField(field: FieldDefinition) {
fields.value.push(getFieldFromFieldDef(field)); fields.value.push({
...getFieldFromFieldDef(field),
id: useUid(),
});
state.datePickers.push(false); state.datePickers.push(false);
}; };
function setField(index: number, fieldLabel: string) { function setField(index: number, fieldLabel: string) {
state.datePickers[index] = false; state.datePickers[index] = false;
const fieldDef = props.fieldDefs.find((fieldDef) => fieldDef.label === fieldLabel); const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.label === fieldLabel);
if (!fieldDef) { if (!fieldDef) {
return; return;
} }
@ -346,58 +379,44 @@ export default defineComponent({
// we have to set this explicitly since it might be undefined // we have to set this explicitly since it might be undefined
updatedField.fieldOptions = fieldDef.fieldOptions; updatedField.fieldOptions = fieldDef.fieldOptions;
fields.value.splice(index, 1, getFieldFromFieldDef(updatedField, resetValue)); fields.value[index] = {
...getFieldFromFieldDef(updatedField, resetValue),
id: fields.value[index].id, // keep the id
};
} }
function setLeftParenthesisValue(field: Field, index: number, value: string) { function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
fields.value.splice(index, 1, { fields.value[index].leftParenthesis = value;
...field,
leftParenthesis: value,
});
} }
function setRightParenthesisValue(field: Field, index: number, value: string) { function setRightParenthesisValue(field: FieldWithId, index: number, value: string) {
fields.value.splice(index, 1, { fields.value[index].rightParenthesis = value;
...field,
rightParenthesis: value,
});
} }
function setLogicalOperatorValue(field: Field, index: number, value: LogicalOperator | undefined) { function setLogicalOperatorValue(field: FieldWithId, index: number, value: LogicalOperator | undefined) {
if (!value) { if (!value) {
value = logOps.value.AND.value; value = logOps.value.AND.value;
} }
fields.value.splice(index, 1, { fields.value[index].logicalOperator = value ? logOps.value[value] : undefined;
...field,
logicalOperator: value ? logOps.value[value] : undefined,
});
} }
function setRelationalOperatorValue(field: Field, index: number, value: RelationalKeyword | RelationalOperator) { function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
fields.value.splice(index, 1, { fields.value[index].relationalOperatorValue = relOps.value[value];
...field,
relationalOperatorValue: relOps.value[value],
});
} }
function setFieldValue(field: Field, index: number, value: FieldValue) { function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
state.datePickers[index] = false; state.datePickers[index] = false;
fields.value.splice(index, 1, { fields.value[index].value = value;
...field,
value,
});
} }
function setFieldValues(field: Field, index: number, values: FieldValue[]) { function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
fields.value.splice(index, 1, { fields.value[index].values = values;
...field,
values,
});
} }
function setOrganizerValues(field: Field, index: number, values: OrganizerBase[]) { function setOrganizerValues(field: FieldWithId, index: number, values: OrganizerBase[]) {
setFieldValues(field, index, values.map((value) => value.id.toString())); setFieldValues(field, index, values.map(value => value.id.toString()));
fields.value[index].organizers = values;
} }
function removeField(index: number) { function removeField(index: number) {
@ -405,24 +424,11 @@ export default defineComponent({
state.datePickers.splice(index, 1); state.datePickers.splice(index, 1);
}; };
watch( const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
// Toggling showAdvanced changes the builder logic without changing the field values, /* newFields.forEach((field, index) => {
// so we need to manually trigger reactivity to re-run the builder.
() => state.showAdvanced,
() => {
if (fields.value?.length) {
fields.value = [...fields.value];
}
},
)
watch(
() => fields.value,
(newFields) => {
newFields.forEach((field, index) => {
const updatedField = getFieldFromFieldDef(field); const updatedField = getFieldFromFieldDef(field);
fields.value[index] = updatedField; fields.value[index] = updatedField; // recursive!!!
}); }); */
const qf = buildQueryFilterString(fields.value, state.showAdvanced); const qf = buildQueryFilterString(fields.value, state.showAdvanced);
if (qf) { if (qf) {
@ -432,13 +438,11 @@ export default defineComponent({
context.emit("input", qf || undefined); context.emit("input", qf || undefined);
context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined); context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
}, }, 500);
{
deep: true
},
);
async function hydrateOrganizers(field: Field, index: number) { watch(fields, fieldsUpdater, { deep: true });
async function hydrateOrganizers(field: FieldWithId, index: number) {
if (!field.values?.length || !isOrganizerType(field.type)) { if (!field.values?.length || !isOrganizerType(field.type)) {
return; return;
} }
@ -450,9 +454,15 @@ export default defineComponent({
await actions.refresh(); await actions.refresh();
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-return const organizers = field.values.map((value) => {
const organizers = field.values.map((value) => store.value.find((organizer) => organizer.id === value)); const organizer = store.value.find(item => item?.id?.toString() === value);
field.organizers = organizers.filter((organizer) => organizer !== undefined) as OrganizerBase[]; if (!organizer) {
console.error(`Could not find organizer with id ${value}`);
return undefined;
}
return organizer;
});
field.organizers = organizers.filter(organizer => organizer !== undefined) as OrganizerBase[];
setOrganizerValues(field, index, field.organizers); setOrganizerValues(field, index, field.organizers);
} }
@ -472,22 +482,27 @@ export default defineComponent({
return initFieldsError(); return initFieldsError();
}; };
const initFields: Field[] = []; const initFields: FieldWithId[] = [];
let error = false; let error = false;
props.initialQueryFilter.parts.forEach((part: QueryFilterJSONPart, index: number) => { props.initialQueryFilter.parts.forEach((part: QueryFilterJSONPart, index: number) => {
const fieldDef = props.fieldDefs.find((fieldDef) => fieldDef.name === part.attributeName); const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.name === part.attributeName);
if (!fieldDef) { if (!fieldDef) {
error = true; error = true;
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`); return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
} }
const field = getFieldFromFieldDef(fieldDef); const field: FieldWithId = {
...getFieldFromFieldDef(fieldDef),
id: useUid(),
};
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis; field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis; field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
field.logicalOperator = part.logicalOperator ? field.logicalOperator = part.logicalOperator
logOps.value[part.logicalOperator] : field.logicalOperator; ? logOps.value[part.logicalOperator]
field.relationalOperatorValue = part.relationalOperator ? : field.logicalOperator;
relOps.value[part.relationalOperator] : field.relationalOperatorValue; field.relationalOperatorValue = part.relationalOperator
? relOps.value[part.relationalOperator]
: field.relationalOperatorValue;
if (field.leftParenthesis || field.rightParenthesis) { if (field.leftParenthesis || field.rightParenthesis) {
state.showAdvanced = true; state.showAdvanced = true;
@ -496,35 +511,39 @@ export default defineComponent({
if (field.fieldOptions?.length || isOrganizerType(field.type)) { if (field.fieldOptions?.length || isOrganizerType(field.type)) {
if (typeof part.value === "string") { if (typeof part.value === "string") {
field.values = part.value ? [part.value] : []; field.values = part.value ? [part.value] : [];
} else { }
else {
field.values = part.value || []; field.values = part.value || [];
} }
if (isOrganizerType(field.type)) { if (isOrganizerType(field.type)) {
hydrateOrganizers(field, index); hydrateOrganizers(field, index);
} }
}
} else if (field.type === "boolean") { else if (field.type === "boolean") {
const boolString = part.value || "false"; const boolString = part.value || "false";
field.value = ( field.value = (
boolString[0].toLowerCase() === "t" || boolString[0].toLowerCase() === "t"
boolString[0].toLowerCase() === "y" || || boolString[0].toLowerCase() === "y"
boolString[0] === "1" || boolString[0] === "1"
); );
} else if (field.type === "number") { }
else if (field.type === "number") {
field.value = Number(part.value as string || "0"); field.value = Number(part.value as string || "0");
if (isNaN(field.value)) { if (isNaN(field.value)) {
error = true; error = true;
return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`); return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`);
} }
} else if (field.type === "date") { }
else if (field.type === "date") {
field.value = part.value as string || ""; field.value = part.value as string || "";
const date = new Date(field.value); const date = new Date(field.value);
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {
error = true; error = true;
return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`); return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`);
} }
} else { }
else {
field.value = part.value as string || ""; field.value = part.value as string || "";
} }
@ -533,14 +552,16 @@ export default defineComponent({
if (initFields.length && !error) { if (initFields.length && !error) {
fields.value = initFields; fields.value = initFields;
} else { }
else {
initFieldsError(); initFieldsError();
} }
}; };
try { try {
initializeFields(); initializeFields();
} catch (error) { }
catch (error) {
initFieldsError(`Error initializing fields: ${(error || "").toString()}`); initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
} }
@ -555,10 +576,12 @@ export default defineComponent({
}; };
if (field.fieldOptions?.length || isOrganizerType(field.type)) { if (field.fieldOptions?.length || isOrganizerType(field.type)) {
part.value = field.values.map((value) => value.toString()); part.value = field.values.map(value => value.toString());
} else if (field.type === "boolean") { }
else if (field.type === "boolean") {
part.value = field.value ? "true" : "false"; part.value = field.value ? "true" : "false";
} else { }
else {
part.value = (field.value || "").toString(); part.value = (field.value || "").toString();
} }
@ -570,17 +593,16 @@ export default defineComponent({
return qfJSON; return qfJSON;
} }
const config = computed(() => {
const attrs = computed(() => {
const baseColMaxWidth = 55; const baseColMaxWidth = 55;
const attrs = { return {
col: { col: {
class: "d-flex justify-center align-end field-col pa-1", class: "d-flex justify-center align-end field-col pa-1",
}, },
select: { select: {
textClass: "d-flex justify-center text-center", textClass: "d-flex justify-center text-center",
}, },
fields: { items: {
icon: { icon: {
cols: 1, cols: 1,
style: "width: fit-content;", style: "width: fit-content;",
@ -614,17 +636,15 @@ export default defineComponent({
style: `min-width: ${baseColMaxWidth}px;`, style: `min-width: ${baseColMaxWidth}px;`,
}, },
}, },
} };
});
return attrs;
})
return { return {
Organizer, Organizer,
...toRefs(state), ...toRefs(state),
logOps, logOps,
relOps, relOps,
attrs, config,
firstDayOfWeek, firstDayOfWeek,
onDragEnd, onDragEnd,
// Fields // Fields

View file

@ -1,33 +1,37 @@
<template> <template>
<v-toolbar <v-toolbar
rounded
height="0"
class="fixed-bar mt-0" class="fixed-bar mt-0"
color="rgb(255, 0, 0, 0.0)" style="z-index: 2; position: sticky; background: transparent; box-shadow: none;"
flat density="compact"
style="z-index: 2; position: sticky" elevation="0"
>
<BaseDialog
v-model="deleteDialog"
:title="$tc('recipe.delete-recipe')"
color="error"
:icon="$globals.icons.alertCircle"
@confirm="emitDelete()"
> >
<BaseDialog v-model="deleteDialog" :title="$t('recipe.delete-recipe')" color="error"
:icon="$globals.icons.alertCircle" can-confirm @confirm="emitDelete()">
<v-card-text> <v-card-text>
{{ $t("recipe.delete-confirmation") }} {{ $t("recipe.delete-confirmation") }}
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<v-spacer></v-spacer> <v-spacer />
<div v-if="!open" class="custom-btn-group ma-1"> <div v-if="!open" class="custom-btn-group ma-1">
<RecipeFavoriteBadge v-if="loggedIn" class="ml-1" color="info" button-style :recipe-id="recipe.id" show-always /> <RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always />
<RecipeTimelineBadge v-if="loggedIn" button-style class="ml-1" :slug="recipe.slug" :recipe-name="recipe.name" /> <RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" />
<div v-if="loggedIn"> <div v-if="loggedIn">
<v-tooltip v-if="canEdit" bottom color="info"> <v-tooltip v-if="canEdit" bottom color="info">
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-btn fab small class="ml-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)"> <v-btn
<v-icon> {{ $globals.icons.edit }} </v-icon> icon
variant="flat"
rounded="circle"
size="small"
color="info"
class="ml-1"
v-bind="props"
@click="$emit('edit', true)"
>
<v-icon size="x-large">
{{ $globals.icons.edit }}
</v-icon>
</v-btn> </v-btn>
</template> </template>
<span>{{ $t("general.edit") }}</span> <span>{{ $t("general.edit") }}</span>
@ -37,14 +41,14 @@
<RecipeContextMenu <RecipeContextMenu
show-print show-print
:menu-top="false" :menu-top="false"
:name="recipe.name" :name="recipe.name!"
:slug="recipe.slug" :slug="recipe.slug!"
:menu-icon="$globals.icons.dotsVertical" :menu-icon="$globals.icons.dotsVertical"
fab fab
color="info" color="info"
:card-menu="false" :card-menu="false"
:recipe="recipe" :recipe="recipe"
:recipe-id="recipe.id" :recipe-id="recipe.id!"
:recipe-scale="recipeScale" :recipe-scale="recipeScale"
:use-items="{ :use-items="{
edit: false, edit: false,
@ -66,31 +70,33 @@
<v-btn <v-btn
v-for="(btn, index) in editorButtons" v-for="(btn, index) in editorButtons"
:key="index" :key="index"
:fab="$vuetify.breakpoint.xs" :class="{ 'rounded-circle': $vuetify.display.xs }"
:small="$vuetify.breakpoint.xs" :size="$vuetify.display.xs ? 'small' : undefined"
:color="btn.color" :color="btn.color"
variant="elevated"
@click="emitHandler(btn.event)" @click="emitHandler(btn.event)"
> >
<v-icon :left="!$vuetify.breakpoint.xs">{{ btn.icon }}</v-icon> <v-icon :left="!$vuetify.display.xs">
{{ $vuetify.breakpoint.xs ? "" : btn.text }} {{ btn.icon }}
</v-icon>
{{ $vuetify.display.xs ? "" : btn.text }}
</v-btn> </v-btn>
</div> </div>
</v-toolbar> </v-toolbar>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import RecipeContextMenu from "./RecipeContextMenu.vue"; import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue"; import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue"; import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
const SAVE_EVENT = "save"; const SAVE_EVENT = "save";
const DELETE_EVENT = "delete"; const DELETE_EVENT = "delete";
const CLOSE_EVENT = "close"; const CLOSE_EVENT = "close";
const JSON_EVENT = "json"; const JSON_EVENT = "json";
export default defineComponent({ export default defineNuxtComponent({
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge }, components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
props: { props: {
recipe: { recipe: {
@ -126,10 +132,12 @@ export default defineComponent({
default: false, default: false,
}, },
}, },
emits: ["print", "input", "delete", "close", "edit"],
setup(_, context) { setup(_, context) {
const deleteDialog = ref(false); const deleteDialog = ref(false);
const { i18n, $globals } = useContext(); const i18n = useI18n();
const { $globals } = useNuxtApp();
const editorButtons = [ const editorButtons = [
{ {
text: i18n.t("general.delete"), text: i18n.t("general.delete"),
@ -209,9 +217,13 @@ export default defineComponent({
.fixed-bar { .fixed-bar {
position: sticky; position: sticky;
position: -webkit-sticky; /* for Safari */
top: 4.5em; top: 4.5em;
z-index: 2; z-index: 2;
background: transparent !important;
box-shadow: none !important;
min-height: 0 !important;
height: 48px;
padding: 0 8px;
} }
.fixed-bar-mobile { .fixed-bar-mobile {

View file

@ -4,71 +4,107 @@
<v-card-title class="py-2"> <v-card-title class="py-2">
{{ $t("asset.assets") }} {{ $t("asset.assets") }}
</v-card-title> </v-card-title>
<v-divider class="mx-2"></v-divider> <v-divider class="mx-2" />
<v-list v-if="value.length > 0" :flat="!edit"> <v-list
<v-list-item v-for="(item, i) in value" :key="i"> v-if="value.length > 0"
<v-list-item-icon class="ma-auto"> :flat="!edit"
>
<v-list-item
v-for="(item, i) in value"
:key="i"
>
<template #prepend>
<div class="ma-auto">
<v-tooltip bottom> <v-tooltip bottom>
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-icon v-bind="attrs" v-on="on"> <v-icon v-bind="props">
{{ getIconDefinition(item.icon).icon }} {{ getIconDefinition(item.icon).icon }}
</v-icon> </v-icon>
</template> </template>
<span>{{ getIconDefinition(item.icon).title }}</span> <span>{{ getIconDefinition(item.icon).title }}</span>
</v-tooltip> </v-tooltip>
</v-list-item-icon> </div>
<v-list-item-content> </template>
<v-list-item-title class="pl-2"> <v-list-item-title class="pl-2">
{{ item.name }} {{ item.name }}
</v-list-item-title> </v-list-item-title>
</v-list-item-content>
<v-list-item-action> <v-list-item-action>
<v-btn v-if="!edit" color="primary" icon :href="assetURL(item.fileName)" target="_blank" top> <v-btn
v-if="!edit"
color="primary"
icon
:href="assetURL(item.fileName ?? '')"
target="_blank"
top
>
<v-icon> {{ $globals.icons.download }} </v-icon> <v-icon> {{ $globals.icons.download }} </v-icon>
</v-btn> </v-btn>
<div v-else> <div v-else>
<v-btn color="error" icon top @click="value.splice(i, 1)"> <v-btn
color="error"
icon
top
@click="value.splice(i, 1)"
>
<v-icon>{{ $globals.icons.delete }}</v-icon> <v-icon>{{ $globals.icons.delete }}</v-icon>
</v-btn> </v-btn>
<AppButtonCopy color="" :copy-text="assetEmbed(item.fileName)" /> <AppButtonCopy
color=""
:copy-text="assetEmbed(item.fileName ?? '')"
/>
</div> </div>
</v-list-item-action> </v-list-item-action>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-card> </v-card>
<div class="d-flex ml-auto mt-2"> <div class="d-flex ml-auto mt-2">
<v-spacer></v-spacer> <v-spacer />
<BaseDialog <BaseDialog
v-model="state.newAssetDialog" v-model="state.newAssetDialog"
:title="$tc('asset.new-asset')" :title="$t('asset.new-asset')"
:icon="getIconDefinition(state.newAsset.icon).icon" :icon="getIconDefinition(state.newAsset.icon).icon"
can-submit
@submit="addAsset" @submit="addAsset"
> >
<template #activator> <template #activator>
<BaseButton v-if="edit" small create @click="state.newAssetDialog = true" /> <BaseButton
v-if="edit"
size="small"
create
@click="state.newAssetDialog = true"
/>
</template> </template>
<v-card-text class="pt-4"> <v-card-text class="pt-4">
<v-text-field v-model="state.newAsset.name" dense :label="$t('general.name')"></v-text-field> <v-text-field
v-model="state.newAsset.name"
density="compact"
:label="$t('general.name')"
/>
<div class="d-flex justify-space-between"> <div class="d-flex justify-space-between">
<v-select <v-select
v-model="state.newAsset.icon" v-model="state.newAsset.icon"
dense density="compact"
:prepend-icon="getIconDefinition(state.newAsset.icon).icon" :prepend-icon="getIconDefinition(state.newAsset.icon).icon"
:items="iconOptions" :items="iconOptions"
item-text="title" item-title="title"
item-value="name" item-value="name"
class="mr-2" class="mr-2"
> >
<template #item="{ item }"> <template #item="{ item }">
<v-list-item-avatar> <v-avatar>
<v-icon class="mr-auto"> <v-icon class="mr-auto">
{{ item.icon }} {{ item.raw.icon }}
</v-icon> </v-icon>
</v-list-item-avatar> </v-avatar>
{{ item.title }} {{ item.title }}
</template> </template>
</v-select> </v-select>
<AppButtonUpload :post="false" file-name="file" :text-btn="false" @uploaded="setFileObject" /> <AppButtonUpload
:post="false"
file-name="file"
:text-btn="false"
@uploaded="setFileObject"
/>
</div> </div>
{{ state.fileObject.name }} {{ state.fileObject.name }}
</v-card-text> </v-card-text>
@ -78,13 +114,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, useContext } from "@nuxtjs/composition-api";
import { useStaticRoutes, useUserApi } from "~/composables/api"; import { useStaticRoutes, useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { detectServerBaseUrl } from "~/composables/use-utils"; import type { RecipeAsset } from "~/lib/api/types/recipe";
import { RecipeAsset } from "~/lib/api/types/recipe";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
slug: { slug: {
type: String, type: String,
@ -94,7 +128,7 @@ export default defineComponent({
type: String, type: String,
required: true, required: true,
}, },
value: { modelValue: {
type: Array as () => RecipeAsset[], type: Array as () => RecipeAsset[],
required: true, required: true,
}, },
@ -103,6 +137,7 @@ export default defineComponent({
default: true, default: true,
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const api = useUserApi(); const api = useUserApi();
@ -115,7 +150,8 @@ export default defineComponent({
}, },
}); });
const { $globals, i18n, req } = useContext(); const i18n = useI18n();
const { $globals } = useNuxtApp();
const iconOptions = [ const iconOptions = [
{ {
@ -145,10 +181,10 @@ export default defineComponent({
}, },
]; ];
const serverBase = detectServerBaseUrl(req); const serverBase = useRequestURL().origin;
function getIconDefinition(icon: string) { function getIconDefinition(icon: string) {
return iconOptions.find((item) => item.name === icon) || iconOptions[0]; return iconOptions.find(item => item.name === icon) || iconOptions[0];
} }
const { recipeAssetPath } = useStaticRoutes(); const { recipeAssetPath } = useStaticRoutes();
@ -181,7 +217,7 @@ export default defineComponent({
extension: state.fileObject.name.split(".").pop() || "", extension: state.fileObject.name.split(".").pop() || "",
}); });
context.emit("input", [...props.value, data]); context.emit("update:modelValue", [...props.modelValue, data]);
state.newAsset = { name: "", icon: "mdi-file" }; state.newAsset = { name: "", icon: "mdi-file" };
state.fileObject = {} as File; state.fileObject = {} as File;
} }

View file

@ -1,10 +1,14 @@
<template> <template>
<v-lazy> <v-lazy>
<v-hover v-slot="{ hover }" :open-delay="50"> <v-hover
v-slot="{ isHovering, props }"
:open-delay="50"
>
<v-card <v-card
:class="{ 'on-hover': hover }" v-bind="props"
:class="{ 'on-hover': isHovering }"
:style="{ cursor }" :style="{ cursor }"
:elevation="hover ? 12 : 2" :elevation="isHovering ? 12 : 2"
:to="recipeRoute" :to="recipeRoute"
:min-height="imageHeight + 75" :min-height="imageHeight + 75"
@click.self="$emit('click')" @click.self="$emit('click')"
@ -14,11 +18,15 @@
:height="imageHeight" :height="imageHeight"
:slug="slug" :slug="slug"
:recipe-id="recipeId" :recipe-id="recipeId"
small size="small"
:image-version="image" :image-version="image"
> >
<v-expand-transition v-if="description"> <v-expand-transition v-if="description">
<div v-if="hover" class="d-flex transition-fast-in-fast-out secondary v-card--reveal" style="height: 100%"> <div
v-if="isHovering"
class="d-flex transition-fast-in-fast-out bg-secondary v-card--reveal"
style="height: 100%"
>
<v-card-text class="v-card--text-show white--text"> <v-card-text class="v-card--text-show white--text">
<div class="descriptionWrapper"> <div class="descriptionWrapper">
<SafeMarkdown :source="description" /> <SafeMarkdown :source="description" />
@ -27,24 +35,47 @@
</div> </div>
</v-expand-transition> </v-expand-transition>
</RecipeCardImage> </RecipeCardImage>
<v-card-title class="my-n3 px-2 mb-n6"> <v-card-title class="mb-n3 px-4">
<div class="headerClass"> <div class="headerClass">
{{ name }} {{ name }}
</div> </div>
</v-card-title> </v-card-title>
<slot name="actions"> <slot name="actions">
<v-card-actions v-if="showRecipeContent" class="px-1"> <v-card-actions
<RecipeFavoriteBadge v-if="isOwnGroup" class="absolute" :recipe-id="recipeId" show-always /> v-if="showRecipeContent"
class="px-1"
>
<RecipeFavoriteBadge
v-if="isOwnGroup"
class="absolute"
:recipe-id="recipeId"
show-always
/>
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
<RecipeRating class="pb-1" :value="rating" :recipe-id="recipeId" :slug="slug" :small="true" /> <RecipeRating
<v-spacer></v-spacer> class="ml-n2"
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" v-on="$listeners" /> :value="rating"
:recipe-id="recipeId"
:slug="slug"
small
/>
<v-spacer />
<RecipeChips
:truncate="true"
:items="tags"
:title="false"
:limit="2"
small
url-prefix="tags"
v-bind="$attrs"
/>
<!-- If we're not logged-in, no items display, so we hide this menu --> <!-- If we're not logged-in, no items display, so we hide this menu -->
<RecipeContextMenu <RecipeContextMenu
v-if="isOwnGroup" v-if="isOwnGroup"
color="grey darken-2" color="grey-darken-2"
:slug="slug" :slug="slug"
:name="name" :name="name"
:recipe-id="recipeId" :recipe-id="recipeId"
@ -62,14 +93,13 @@
/> />
</v-card-actions> </v-card-actions>
</slot> </slot>
<slot></slot> <slot />
</v-card> </v-card>
</v-hover> </v-hover>
</v-lazy> </v-lazy>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue"; import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeChips from "./RecipeChips.vue"; import RecipeChips from "./RecipeChips.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue"; import RecipeContextMenu from "./RecipeContextMenu.vue";
@ -77,7 +107,7 @@ import RecipeCardImage from "./RecipeCardImage.vue";
import RecipeRating from "./RecipeRating.vue"; import RecipeRating from "./RecipeRating.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
export default defineComponent({ export default defineNuxtComponent({
components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage }, components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage },
props: { props: {
name: { name: {
@ -119,12 +149,13 @@ export default defineComponent({
default: 200, default: 200,
}, },
}, },
emits: ["click", "delete"],
setup(props) { setup(props) {
const { $auth } = useContext(); const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug); const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => { const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : ""; return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";

View file

@ -2,6 +2,7 @@
<v-img <v-img
v-if="!fallBackImage" v-if="!fallBackImage"
:height="height" :height="height"
cover
min-height="125" min-height="125"
max-height="fill-height" max-height="fill-height"
:src="getImage(recipeId)" :src="getImage(recipeId)"
@ -9,21 +10,28 @@
@load="fallBackImage = false" @load="fallBackImage = false"
@error="fallBackImage = true" @error="fallBackImage = true"
> >
<slot> </slot> <slot />
</v-img> </v-img>
<div v-else class="icon-slot" @click="$emit('click')"> <div
<v-icon color="primary" class="icon-position" :size="iconSize"> v-else
class="icon-slot"
@click="$emit('click')"
>
<v-icon
color="primary"
class="icon-position"
:size="iconSize"
>
{{ $globals.icons.primary }} {{ $globals.icons.primary }}
</v-icon> </v-icon>
<slot> </slot> <slot />
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref, watch } from "@nuxtjs/composition-api";
import { useStaticRoutes, useUserApi } from "~/composables/api"; import { useStaticRoutes, useUserApi } from "~/composables/api";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
tiny: { tiny: {
type: Boolean, type: Boolean,
@ -55,9 +63,10 @@ export default defineComponent({
}, },
height: { height: {
type: [Number, String], type: [Number, String],
default: "fill-height", default: "100%",
}, },
}, },
emits: ["click"],
setup(props) { setup(props) {
const api = useUserApi(); const api = useUserApi();
@ -75,7 +84,7 @@ export default defineComponent({
() => props.recipeId, () => props.recipeId,
() => { () => {
fallBackImage.value = false; fallBackImage.value = false;
} },
); );
function getImage(recipeId: string) { function getImage(recipeId: string) {

View file

@ -1,57 +1,97 @@
<template> <template>
<div :style="`height: ${height}`"> <div :style="`height: ${height}px;`">
<v-expand-transition> <v-expand-transition>
<v-card <v-card
:ripple="false" :ripple="false"
:class="isFlat ? 'mx-auto flat' : 'mx-auto'" :class="isFlat ? 'mx-auto flat' : 'mx-auto'"
:style="{ cursor }" :style="{ cursor }"
hover hover
:to="$listeners.selected ? undefined : recipeRoute" height="100%"
:to="$attrs.selected ? undefined : recipeRoute"
@click="$emit('selected')" @click="$emit('selected')"
> >
<v-img v-if="vertical" class="rounded-sm"> <v-img
v-if="vertical"
class="rounded-sm"
cover
>
<RecipeCardImage <RecipeCardImage
:icon-size="100" :icon-size="100"
:height="height"
:slug="slug" :slug="slug"
:recipe-id="recipeId" :recipe-id="recipeId"
small size="small"
:image-version="image" :image-version="image"
:height="height"
/> />
</v-img> </v-img>
<v-list-item three-line :class="vertical ? 'px-2' : 'px-0'"> <v-list-item
<slot v-if="!vertical" name="avatar"> lines="two"
<v-list-item-avatar tile :height="height" width="125" class="v-mobile-img rounded-sm my-0"> class="py-0"
:class="vertical ? 'px-2' : 'px-0'"
item-props
height="100%"
density="compact"
>
<template #prepend>
<slot
v-if="!vertical"
name="avatar"
>
<RecipeCardImage <RecipeCardImage
:icon-size="100" :icon-size="100"
:height="height"
:slug="slug" :slug="slug"
:recipe-id="recipeId" :recipe-id="recipeId"
:image-version="image" :image-version="image"
small size="small"
width="125"
:height="height"
/> />
</v-list-item-avatar>
</slot> </slot>
<v-list-item-content class="py-0"> </template>
<v-list-item-title class="mt-1 mb-1 text-top">{{ name }}</v-list-item-title> <div class="pl-4 d-flex flex-column justify-space-between align-stretch pr-2">
<v-list-item-title class="mt-3 mb-1 text-top text-truncate w-100">
{{ name }}
</v-list-item-title>
<v-list-item-subtitle class="ma-0 text-top"> <v-list-item-subtitle class="ma-0 text-top">
<SafeMarkdown :source="description" /> <SafeMarkdown v-if="description" :source="description" />
<p v-else>
<br>
<br>
<br>
</p>
</v-list-item-subtitle> </v-list-item-subtitle>
<div class="d-flex flex-wrap justify-start ma-0"> <div
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" v-on="$listeners" /> class="d-flex flex-nowrap justify-start ma-0 pt-2 pb-0"
style="overflow-x: hidden; overflow-y: hidden; white-space: nowrap;"
>
<RecipeChips
:truncate="true"
:items="tags"
:title="false"
:limit="2"
small
url-prefix="tags"
v-bind="$attrs"
/>
</div>
</div> </div>
<div class="d-flex flex-wrap justify-end align-center">
<slot name="actions"> <slot name="actions">
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :recipe-id="recipeId" show-always /> <v-card-actions class="w-100 my-0 px-1 py-0">
<RecipeFavoriteBadge
v-if="isOwnGroup && showRecipeContent"
:recipe-id="recipeId"
show-always
class="ma-0 pa-0"
/>
<div v-else class="my-0 px-1 py-0" /> <!-- Empty div to keep the layout consistent -->
<RecipeRating <RecipeRating
v-if="showRecipeContent" v-if="showRecipeContent"
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'" :class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']"
:value="rating" :value="rating"
:recipe-id="recipeId" :recipe-id="recipeId"
:slug="slug" :slug="slug"
:small="true" small
/> />
<v-spacer></v-spacer>
<!-- If we're not logged-in, no items display, so we hide this menu --> <!-- If we're not logged-in, no items display, so we hide this menu -->
<!-- We also add padding to the v-rating above to compensate --> <!-- We also add padding to the v-rating above to compensate -->
@ -61,6 +101,7 @@
:menu-icon="$globals.icons.dotsHorizontal" :menu-icon="$globals.icons.dotsHorizontal"
:name="name" :name="name"
:recipe-id="recipeId" :recipe-id="recipeId"
class="ml-auto"
:use-items="{ :use-items="{
delete: false, delete: false,
edit: false, edit: false,
@ -73,9 +114,8 @@
}" }"
@deleted="$emit('delete', slug)" @deleted="$emit('delete', slug)"
/> />
</v-card-actions>
</slot> </slot>
</div>
</v-list-item-content>
</v-list-item> </v-list-item>
<slot /> <slot />
</v-card> </v-card>
@ -84,7 +124,6 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue"; import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue"; import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeCardImage from "./RecipeCardImage.vue"; import RecipeCardImage from "./RecipeCardImage.vue";
@ -92,7 +131,7 @@ import RecipeRating from "./RecipeRating.vue";
import RecipeChips from "./RecipeChips.vue"; import RecipeChips from "./RecipeChips.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeFavoriteBadge, RecipeFavoriteBadge,
RecipeContextMenu, RecipeContextMenu,
@ -139,27 +178,23 @@ export default defineComponent({
default: false, default: false,
}, },
height: { height: {
type: [Number, String], type: [Number],
default: 150, default: 150,
}, },
imageHeight: {
type: [Number, String],
default: "fill-height",
},
}, },
emits: ["selected", "delete"],
setup(props) { setup(props) {
const { $auth } = useContext(); const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug); const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => { const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : ""; return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
}); });
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto"); const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
return { return {
isOwnGroup, isOwnGroup,
recipeRoute, recipeRoute,
@ -170,7 +205,10 @@ export default defineComponent({
}); });
</script> </script>
<style> <style scoped>
:deep(.v-list-item__prepend) {
height: 100%;
}
.v-mobile-img { .v-mobile-img {
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;
@ -198,7 +236,8 @@ export default defineComponent({
align-self: start !important; align-self: start !important;
} }
.flat, .theme--dark .flat { .flat,
.theme--dark .flat {
box-shadow: none !important; box-shadow: none !important;
background-color: transparent !important; background-color: transparent !important;
} }

View file

@ -1,67 +1,102 @@
<template> <template>
<div> <div>
<v-app-bar v-if="!disableToolbar" color="transparent" flat class="mt-n1 flex-sm-wrap rounded"> <v-app-bar
v-if="!disableToolbar"
color="transparent"
:absolute="false"
flat
class="mt-n1 flex-sm-wrap rounded position-relative w-100 left-0 top-0"
>
<slot name="title"> <slot name="title">
<v-icon v-if="title" large left> <v-icon
v-if="title"
size="large"
start
>
{{ displayTitleIcon }} {{ displayTitleIcon }}
</v-icon> </v-icon>
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title> <v-toolbar-title class="headline">
{{ title }}
</v-toolbar-title>
</slot> </slot>
<v-spacer></v-spacer> <v-spacer />
<v-btn :icon="$vuetify.breakpoint.xsOnly" text :disabled="recipes.length === 0" @click="navigateRandom"> <v-btn
<v-icon :left="!$vuetify.breakpoint.xsOnly"> :icon="$vuetify.display.xs"
variant="text"
:disabled="recipes.length === 0"
@click="navigateRandom"
>
<v-icon :start="!$vuetify.display.xs">
{{ $globals.icons.diceMultiple }} {{ $globals.icons.diceMultiple }}
</v-icon> </v-icon>
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.random") }} {{ $vuetify.display.xs ? null : $t("general.random") }}
</v-btn> </v-btn>
<v-menu
<v-menu v-if="$listeners.sortRecipes" offset-y left> v-if="!disableSort"
<template #activator="{ on, attrs }"> offset-y
<v-btn text :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" :loading="sortLoading" v-on="on"> start
<v-icon :left="!$vuetify.breakpoint.xsOnly"> >
<template #activator="{ props }">
<v-btn
variant="text"
:icon="$vuetify.display.xs"
v-bind="props"
:loading="sortLoading"
>
<v-icon :start="!$vuetify.display.xs">
{{ preferences.sortIcon }} {{ preferences.sortIcon }}
</v-icon> </v-icon>
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.sort") }} {{ $vuetify.display.xs ? null : $t("general.sort") }}
</v-btn> </v-btn>
</template> </template>
<v-list> <v-list>
<v-list-item @click="sortRecipes(EVENTS.az)"> <v-list-item @click="sortRecipes(EVENTS.az)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
{{ $globals.icons.orderAlphabeticalAscending }} {{ $globals.icons.orderAlphabeticalAscending }}
</v-icon> </v-icon>
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title> <v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
<v-list-item @click="sortRecipes(EVENTS.rating)"> <v-list-item @click="sortRecipes(EVENTS.rating)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
{{ $globals.icons.star }} {{ $globals.icons.star }}
</v-icon> </v-icon>
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title> <v-list-item-title>{{ $t("general.rating") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
<v-list-item @click="sortRecipes(EVENTS.created)"> <v-list-item @click="sortRecipes(EVENTS.created)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
{{ $globals.icons.newBox }} {{ $globals.icons.newBox }}
</v-icon> </v-icon>
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title> <v-list-item-title>{{ $t("general.created") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
<v-list-item @click="sortRecipes(EVENTS.updated)"> <v-list-item @click="sortRecipes(EVENTS.updated)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
{{ $globals.icons.update }} {{ $globals.icons.update }}
</v-icon> </v-icon>
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title> <v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
<v-list-item @click="sortRecipes(EVENTS.lastMade)"> <v-list-item @click="sortRecipes(EVENTS.lastMade)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
{{ $globals.icons.chefHat }} {{ $globals.icons.chefHat }}
</v-icon> </v-icon>
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title> <v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
<ContextMenu <ContextMenu
v-if="!$vuetify.breakpoint.smAndDown" v-if="!$vuetify.display.smAndDown"
:items="[ :items="[
{ {
title: $tc('general.toggle-view'), title: $t('general.toggle-view'),
icon: $globals.icons.eye, icon: $globals.icons.eye,
event: 'toggle-dense-view', event: 'toggle-dense-view',
}, },
@ -72,84 +107,75 @@
<div v-if="recipes && ready"> <div v-if="recipes && ready">
<div class="mt-2"> <div class="mt-2">
<v-row v-if="!useMobileCards"> <v-row v-if="!useMobileCards">
<v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3">
<v-lazy>
<RecipeCard
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:tags="recipe.tags"
:recipe-id="recipe.id"
v-on="$listeners"
/>
</v-lazy>
</v-col>
</v-row>
<v-row v-else dense>
<v-col <v-col
v-for="recipe in recipes" v-for="recipe in recipes"
:key="recipe.name" :key="recipe.id!"
:sm="6"
:md="6"
:lg="4"
:xl="3"
>
<RecipeCard
:name="recipe.name!"
:description="recipe.description!"
:slug="recipe.slug!"
:rating="recipe.rating!"
:image="recipe.image!"
:tags="recipe.tags!"
:recipe-id="recipe.id!"
/>
</v-col>
</v-row>
<v-row
v-else
dense
>
<v-col
v-for="recipe in recipes"
:key="recipe.id!"
cols="12" cols="12"
:sm="singleColumn ? '12' : '12'" :sm="singleColumn ? '12' : '12'"
:md="singleColumn ? '12' : '6'" :md="singleColumn ? '12' : '6'"
:lg="singleColumn ? '12' : '4'" :lg="singleColumn ? '12' : '4'"
:xl="singleColumn ? '12' : '3'" :xl="singleColumn ? '12' : '3'"
> >
<v-lazy>
<RecipeCardMobile <RecipeCardMobile
:name="recipe.name" :name="recipe.name!"
:description="recipe.description" :description="recipe.description!"
:slug="recipe.slug" :slug="recipe.slug!"
:rating="recipe.rating" :rating="recipe.rating!"
:image="recipe.image" :image="recipe.image!"
:tags="recipe.tags" :tags="recipe.tags!"
:recipe-id="recipe.id" :recipe-id="recipe.id!"
v-on="$listeners"
/> />
</v-lazy>
</v-col> </v-col>
</v-row> </v-row>
</div> </div>
<v-card v-intersect="infiniteScroll"></v-card> <v-card v-intersect="infiniteScroll" />
<v-fade-transition> <v-fade-transition>
<AppLoader v-if="loading" :loading="loading" /> <AppLoader
v-if="loading"
:loading="loading"
/>
</v-fade-transition> </v-fade-transition>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import {
computed,
defineComponent,
onMounted,
reactive,
ref,
toRefs,
useAsync,
useContext,
useRoute,
useRouter,
watch,
} from "@nuxtjs/composition-api";
import { useThrottleFn } from "@vueuse/core"; import { useThrottleFn } from "@vueuse/core";
import RecipeCard from "./RecipeCard.vue"; import RecipeCard from "./RecipeCard.vue";
import RecipeCardMobile from "./RecipeCardMobile.vue"; import RecipeCardMobile from "./RecipeCardMobile.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useAsyncKey } from "~/composables/use-utils";
import { useLazyRecipes } from "~/composables/recipes"; import { useLazyRecipes } from "~/composables/recipes";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import { useUserSortPreferences } from "~/composables/use-users/preferences"; import { useUserSortPreferences } from "~/composables/use-users/preferences";
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe"; import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
const REPLACE_RECIPES_EVENT = "replaceRecipes"; const REPLACE_RECIPES_EVENT = "replaceRecipes";
const APPEND_RECIPES_EVENT = "appendRecipes"; const APPEND_RECIPES_EVENT = "appendRecipes";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeCard, RecipeCard,
RecipeCardMobile, RecipeCardMobile,
@ -159,6 +185,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
disableSort: {
type: Boolean,
default: false,
},
icon: { icon: {
type: String, type: String,
default: null, default: null,
@ -181,6 +211,7 @@ export default defineComponent({
}, },
}, },
setup(props, context) { setup(props, context) {
const { $vuetify } = useNuxtApp();
const preferences = useUserSortPreferences(); const preferences = useUserSortPreferences();
const EVENTS = { const EVENTS = {
@ -192,10 +223,11 @@ export default defineComponent({
shuffle: "shuffle", shuffle: "shuffle",
}; };
const { $auth, $globals, $vuetify } = useContext(); const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => { const useMobileCards = computed(() => {
return $vuetify.breakpoint.smAndDown || preferences.value.useMobileCards; return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
}); });
const displayTitleIcon = computed(() => { const displayTitleIcon = computed(() => {
@ -207,7 +239,7 @@ export default defineComponent({
}); });
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const page = ref(1); const page = ref(1);
const perPage = 32; const perPage = 32;
@ -259,14 +291,14 @@ export default defineComponent({
watch( watch(
() => props.query, () => props.query,
async (newValue: RecipeSearchQuery | undefined) => { async (newValue: RecipeSearchQuery | undefined) => {
const newValueString = JSON.stringify(newValue) const newValueString = JSON.stringify(newValue);
if (lastQuery !== newValueString) { if (lastQuery !== newValueString) {
lastQuery = newValueString; lastQuery = newValueString;
ready.value = false; ready.value = false;
await initRecipes(); await initRecipes();
ready.value = true; ready.value = true;
} }
} },
); );
async function initRecipes() { async function initRecipes() {
@ -286,8 +318,7 @@ export default defineComponent({
context.emit(REPLACE_RECIPES_EVENT, newRecipes); context.emit(REPLACE_RECIPES_EVENT, newRecipes);
} }
const infiniteScroll = useThrottleFn(() => { const infiniteScroll = useThrottleFn(async () => {
useAsync(async () => {
if (!hasMore.value || loading.value) { if (!hasMore.value || loading.value) {
return; return;
} }
@ -304,11 +335,9 @@ export default defineComponent({
} }
loading.value = false; loading.value = false;
}, useAsyncKey());
}, 500); }, 500);
async function sortRecipes(sortType: string) {
function sortRecipes(sortType: string) {
if (state.sortLoading || loading.value) { if (state.sortLoading || loading.value) {
return; return;
} }
@ -318,13 +347,14 @@ export default defineComponent({
ascIcon: string, ascIcon: string,
descIcon: string, descIcon: string,
defaultOrderDirection = "asc", defaultOrderDirection = "asc",
filterNull = false filterNull = false,
) { ) {
if (preferences.value.orderBy !== orderBy) { if (preferences.value.orderBy !== orderBy) {
preferences.value.orderBy = orderBy; preferences.value.orderBy = orderBy;
preferences.value.orderDirection = defaultOrderDirection; preferences.value.orderDirection = defaultOrderDirection;
preferences.value.filterNull = filterNull; preferences.value.filterNull = filterNull;
} else { }
else {
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc"; preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
} }
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon; preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
@ -337,7 +367,7 @@ export default defineComponent({
$globals.icons.sortAlphabeticalAscending, $globals.icons.sortAlphabeticalAscending,
$globals.icons.sortAlphabeticalDescending, $globals.icons.sortAlphabeticalDescending,
"asc", "asc",
false false,
); );
break; break;
case EVENTS.rating: case EVENTS.rating:
@ -349,7 +379,7 @@ export default defineComponent({
$globals.icons.sortCalendarAscending, $globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending, $globals.icons.sortCalendarDescending,
"desc", "desc",
false false,
); );
break; break;
case EVENTS.updated: case EVENTS.updated:
@ -361,7 +391,7 @@ export default defineComponent({
$globals.icons.sortCalendarAscending, $globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending, $globals.icons.sortCalendarDescending,
"desc", "desc",
true true,
); );
break; break;
default: default:
@ -369,7 +399,6 @@ export default defineComponent({
return; return;
} }
useAsync(async () => {
// reset pagination // reset pagination
page.value = 1; page.value = 1;
hasMore.value = true; hasMore.value = true;
@ -383,7 +412,6 @@ export default defineComponent({
state.sortLoading = false; state.sortLoading = false;
loading.value = false; loading.value = false;
}, useAsyncKey());
} }
async function navigateRandom() { async function navigateRandom() {

View file

@ -1,13 +1,19 @@
<template> <template>
<div v-if="items.length > 0"> <div v-if="items.length > 0">
<h2 v-if="title" class="mt-4">{{ title }}</h2> <h2
v-if="title"
class="mt-4"
>
{{ title }}
</h2>
<v-chip <v-chip
v-for="category in items.slice(0, limit)" v-for="category in items.slice(0, limit)"
:key="category.name" :key="category.name"
label label
class="ma-1" class="ma-1"
color="accent" color="accent"
:small="small" variant="flat"
:size="small ? 'small' : 'default'"
dark dark
@click.prevent="() => $emit('item-selected', category, urlPrefix)" @click.prevent="() => $emit('item-selected', category, urlPrefix)"
@ -18,12 +24,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api"; import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
export type UrlPrefixParam = "tags" | "categories" | "tools"; export type UrlPrefixParam = "tags" | "categories" | "tools";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
truncate: { truncate: {
type: Boolean, type: Boolean,
@ -54,13 +59,14 @@ export default defineComponent({
default: null, default: null,
}, },
}, },
emits: ["item-selected"],
setup(props) { setup(props) {
const { $auth } = useContext(); const $auth = useMealieAuth();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "") const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const baseRecipeRoute = computed<string>(() => { const baseRecipeRoute = computed<string>(() => {
return `/g/${groupSlug.value}` return `/g/${groupSlug.value}`;
}); });
function truncateText(text: string, length = 20, clamp = "...") { function truncateText(text: string, length = 20, clamp = "...") {

View file

@ -8,6 +8,7 @@
:title="$t('recipe.delete-recipe')" :title="$t('recipe.delete-recipe')"
color="error" color="error"
:icon="$globals.icons.alertCircle" :icon="$globals.icons.alertCircle"
can-confirm
@confirm="deleteRecipe()" @confirm="deleteRecipe()"
> >
<v-card-text> <v-card-text>
@ -19,16 +20,17 @@
:title="$t('recipe.duplicate')" :title="$t('recipe.duplicate')"
color="primary" color="primary"
:icon="$globals.icons.duplicate" :icon="$globals.icons.duplicate"
can-confirm
@confirm="duplicateRecipe()" @confirm="duplicateRecipe()"
> >
<v-card-text> <v-card-text>
<v-text-field <v-text-field
v-model="recipeName" v-model="recipeName"
dense density="compact"
:label="$t('recipe.recipe-name')" :label="$t('recipe.recipe-name')"
autofocus autofocus
@keyup.enter="duplicateRecipe()" @keyup.enter="duplicateRecipe()"
></v-text-field> />
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<BaseDialog <BaseDialog
@ -36,6 +38,7 @@
:title="$t('recipe.add-recipe-to-mealplan')" :title="$t('recipe.add-recipe-to-mealplan')"
color="primary" color="primary"
:icon="$globals.icons.calendar" :icon="$globals.icons.calendar"
can-confirm
@confirm="addRecipeToPlan()" @confirm="addRecipeToPlan()"
> >
<v-card-text> <v-card-text>
@ -47,22 +50,21 @@
max-width="290px" max-width="290px"
min-width="auto" min-width="auto"
> >
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-text-field <v-text-field
v-model="newMealdate" v-model="newMealdateString"
:label="$t('general.date')" :label="$t('general.date')"
:prepend-icon="$globals.icons.calendar" :prepend-icon="$globals.icons.calendar"
v-bind="attrs" v-bind="props"
readonly readonly
v-on="on" />
></v-text-field>
</template> </template>
<v-date-picker <v-date-picker
v-model="newMealdate" v-model="newMealdate"
no-title hide-header
:first-day-of-week="firstDayOfWeek" :first-day-of-week="firstDayOfWeek"
:local="$i18n.locale" :local="$i18n.locale"
@input="pickerMenu = false" @update:model-value="pickerMenu = false"
/> />
</v-menu> </v-menu>
<v-select <v-select
@ -70,7 +72,9 @@
:return-object="false" :return-object="false"
:items="planTypeOptions" :items="planTypeOptions"
:label="$t('recipe.entry-type')" :label="$t('recipe.entry-type')"
></v-select> item-title="text"
item-value="value"
/>
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<RecipeDialogAddToShoppingList <RecipeDialogAddToShoppingList
@ -81,35 +85,53 @@
/> />
<v-menu <v-menu
offset-y offset-y
left start
:bottom="!menuTop" :bottom="!menuTop"
:nudge-bottom="!menuTop ? '5' : '0'" :nudge-bottom="!menuTop ? '5' : '0'"
:top="menuTop" :top="menuTop"
:nudge-top="menuTop ? '5' : '0'" :nudge-top="menuTop ? '5' : '0'"
allow-overflow allow-overflow
close-delay="125" close-delay="125"
:open-on-hover="$vuetify.breakpoint.mdAndUp" :open-on-hover="$vuetify.display.mdAndUp"
content-class="d-print-none" content-class="d-print-none"
> >
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent> <v-btn
<v-icon>{{ icon }}</v-icon> icon
:variant="fab ? 'flat' : undefined"
:rounded="fab ? 'circle' : undefined"
:size="fab ? 'small' : undefined"
:color="fab ? 'info' : 'secondary'"
:fab="fab"
v-bind="props"
@click.prevent
>
<v-icon
:size="!fab ? undefined : 'x-large'"
:color="fab ? 'white' : 'secondary'"
>
{{ icon }}
</v-icon>
</v-btn> </v-btn>
</template> </template>
<v-list dense> <v-list density="compact">
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)"> <v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
<v-list-item-icon> <template #prepend>
<v-icon :color="item.color"> {{ item.icon }} </v-icon> <v-icon :color="item.color">
</v-list-item-icon> {{ item.icon }}
</v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title> <v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item> </v-list-item>
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length"> <div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
<v-divider /> <v-divider />
<v-list-group @click.stop> <v-list-group @click.stop>
<template #activator> <template #activator="{ props }">
<v-list-item-title>{{ $tc("recipe.recipe-actions") }}</v-list-item-title> <v-list-item-title v-bind="props">
{{ $t("recipe.recipe-actions") }}
</v-list-item-title>
</template> </template>
<v-list dense class="ma-0 pa-0"> <v-list density="compact" class="ma-0 pa-0">
<v-list-item <v-list-item
v-for="(action, index) in recipeActions" v-for="(action, index) in recipeActions"
:key="index" :key="index"
@ -129,7 +151,6 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, toRefs, useContext, useRoute, useRouter, ref } from "@nuxtjs/composition-api";
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue"; import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue"; import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
import RecipeDialogShare from "./RecipeDialogShare.vue"; import RecipeDialogShare from "./RecipeDialogShare.vue";
@ -139,15 +160,16 @@ import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { usePlanTypeOptions } from "~/composables/use-group-mealplan"; import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household"; import type { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
import { PlanEntryType } from "~/lib/api/types/meal-plan"; import type { PlanEntryType } from "~/lib/api/types/meal-plan";
import { useAxiosDownloader } from "~/composables/api/use-axios-download"; import { useDownloader } from "~/composables/api/use-downloader";
export interface ContextMenuIncludes { export interface ContextMenuIncludes {
delete: boolean; delete: boolean;
edit: boolean; edit: boolean;
download: boolean; download: boolean;
duplicate: boolean;
mealplanner: boolean; mealplanner: boolean;
shoppingList: boolean; shoppingList: boolean;
print: boolean; print: boolean;
@ -164,7 +186,7 @@ export interface ContextMenuItem {
isPublic: boolean; isPublic: boolean;
} }
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeDialogAddToShoppingList, RecipeDialogAddToShoppingList,
RecipeDialogPrintPreferences, RecipeDialogPrintPreferences,
@ -233,6 +255,7 @@ export default defineComponent({
default: 1, default: 1,
}, },
}, },
emits: ["delete"],
setup(props, context) { setup(props, context) {
const api = useUserApi(); const api = useUserApi();
@ -246,17 +269,23 @@ export default defineComponent({
recipeName: props.name, recipeName: props.name,
loading: false, loading: false,
menuItems: [] as ContextMenuItem[], menuItems: [] as ContextMenuItem[],
newMealdate: "", newMealdate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
newMealType: "dinner" as PlanEntryType, newMealType: "dinner" as PlanEntryType,
pickerMenu: false, pickerMenu: false,
}); });
const { i18n, $auth, $globals } = useContext(); const newMealdateString = computed(() => {
return state.newMealdate.toISOString().substring(0, 10);
});
const i18n = useI18n();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { household } = useHouseholdSelf(); const { household } = useHouseholdSelf();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => { const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0; return household.value?.preferences?.firstDayOfWeek || 0;
@ -267,63 +296,63 @@ export default defineComponent({
const defaultItems: { [key: string]: ContextMenuItem } = { const defaultItems: { [key: string]: ContextMenuItem } = {
edit: { edit: {
title: i18n.tc("general.edit"), title: i18n.t("general.edit"),
icon: $globals.icons.edit, icon: $globals.icons.edit,
color: undefined, color: undefined,
event: "edit", event: "edit",
isPublic: false, isPublic: false,
}, },
delete: { delete: {
title: i18n.tc("general.delete"), title: i18n.t("general.delete"),
icon: $globals.icons.delete, icon: $globals.icons.delete,
color: undefined, color: undefined,
event: "delete", event: "delete",
isPublic: false, isPublic: false,
}, },
download: { download: {
title: i18n.tc("general.download"), title: i18n.t("general.download"),
icon: $globals.icons.download, icon: $globals.icons.download,
color: undefined, color: undefined,
event: "download", event: "download",
isPublic: false, isPublic: false,
}, },
duplicate: { duplicate: {
title: i18n.tc("general.duplicate"), title: i18n.t("general.duplicate"),
icon: $globals.icons.duplicate, icon: $globals.icons.duplicate,
color: undefined, color: undefined,
event: "duplicate", event: "duplicate",
isPublic: false, isPublic: false,
}, },
mealplanner: { mealplanner: {
title: i18n.tc("recipe.add-to-plan"), title: i18n.t("recipe.add-to-plan"),
icon: $globals.icons.calendar, icon: $globals.icons.calendar,
color: undefined, color: undefined,
event: "mealplanner", event: "mealplanner",
isPublic: false, isPublic: false,
}, },
shoppingList: { shoppingList: {
title: i18n.tc("recipe.add-to-list"), title: i18n.t("recipe.add-to-list"),
icon: $globals.icons.cartCheck, icon: $globals.icons.cartCheck,
color: undefined, color: undefined,
event: "shoppingList", event: "shoppingList",
isPublic: false, isPublic: false,
}, },
print: { print: {
title: i18n.tc("general.print"), title: i18n.t("general.print"),
icon: $globals.icons.printer, icon: $globals.icons.printer,
color: undefined, color: undefined,
event: "print", event: "print",
isPublic: true, isPublic: true,
}, },
printPreferences: { printPreferences: {
title: i18n.tc("general.print-preferences"), title: i18n.t("general.print-preferences"),
icon: $globals.icons.printerSettings, icon: $globals.icons.printerSettings,
color: undefined, color: undefined,
event: "printPreferences", event: "printPreferences",
isPublic: true, isPublic: true,
}, },
share: { share: {
title: i18n.tc("general.share"), title: i18n.t("general.share"),
icon: $globals.icons.shareVariant, icon: $globals.icons.shareVariant,
color: undefined, color: undefined,
event: "share", event: "share",
@ -350,8 +379,10 @@ export default defineComponent({
// Context Menu Event Handler // Context Menu Event Handler
const shoppingLists = ref<ShoppingListSummary[]>(); const shoppingLists = ref<ShoppingListSummary[]>();
const recipeRef = ref<Recipe>(props.recipe); const recipeRef = ref<Recipe | undefined>(props.recipe);
const recipeRefWithScale = computed(() => recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined); const recipeRefWithScale = computed(() =>
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
);
async function getShoppingLists() { async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" }); const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
@ -371,13 +402,15 @@ export default defineComponent({
const groupRecipeActionsStore = useGroupRecipeActions(); const groupRecipeActionsStore = useGroupRecipeActions();
async function executeRecipeAction(action: GroupRecipeActionOut) { async function executeRecipeAction(action: GroupRecipeActionOut) {
if (!props.recipe) return;
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale); const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
if (action.actionType === "post") { if (action.actionType === "post") {
if (!response?.error) { if (!response?.error) {
alert.success(i18n.tc("events.message-sent")); alert.success(i18n.t("events.message-sent"));
} else { }
alert.error(i18n.tc("events.something-went-wrong")); else {
alert.error(i18n.t("events.something-went-wrong"));
} }
} }
} }
@ -390,7 +423,7 @@ export default defineComponent({
context.emit("delete", props.slug); context.emit("delete", props.slug);
} }
const download = useAxiosDownloader(); const download = useDownloader();
async function handleDownloadEvent() { async function handleDownloadEvent() {
const { data } = await api.recipes.getZipToken(props.slug); const { data } = await api.recipes.getZipToken(props.slug);
@ -402,7 +435,7 @@ export default defineComponent({
async function addRecipeToPlan() { async function addRecipeToPlan() {
const { response } = await api.mealplans.createOne({ const { response } = await api.mealplans.createOne({
date: state.newMealdate, date: newMealdateString.value,
entryType: state.newMealType, entryType: state.newMealType,
title: "", title: "",
text: "", text: "",
@ -411,7 +444,8 @@ export default defineComponent({
if (response?.status === 201) { if (response?.status === 201) {
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string); alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
} else { }
else {
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string); alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
} }
} }
@ -424,6 +458,7 @@ export default defineComponent({
} }
// Note: Print is handled as an event in the parent component // Note: Print is handled as an event in the parent component
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = { const eventHandlers: { [key: string]: () => void | Promise<any> } = {
delete: () => { delete: () => {
state.recipeDeleteDialog = true; state.recipeDeleteDialog = true;
@ -448,7 +483,9 @@ export default defineComponent({
promises.push(refreshRecipe()); promises.push(refreshRecipe());
} }
Promise.allSettled(promises).then(() => { state.shoppingListDialog = true }); Promise.allSettled(promises).then(() => {
state.shoppingListDialog = true;
});
}, },
share: () => { share: () => {
state.shareDialog = true; state.shareDialog = true;
@ -472,6 +509,7 @@ export default defineComponent({
return { return {
...toRefs(state), ...toRefs(state),
newMealdateString,
recipeRef, recipeRef,
recipeRefWithScale, recipeRefWithScale,
executeRecipeAction, executeRecipeAction,

View file

@ -1,41 +1,29 @@
<template> <template>
<div> <div>
<BaseDialog <BaseDialog v-model="dialog" :title="$t('data-pages.manage-aliases')" :icon="$globals.icons.edit"
v-model="dialog" :submit-icon="$globals.icons.check" :submit-text="$t('general.confirm')" can-submit @submit="saveAliases"
:title="$t('data-pages.manage-aliases')" @cancel="$emit('cancel')">
:icon="$globals.icons.edit"
:submit-icon="$globals.icons.check"
:submit-text="$tc('general.confirm')"
@submit="saveAliases"
@cancel="$emit('cancel')"
>
<v-card-text> <v-card-text>
<v-container> <v-container>
<v-row v-for="alias, i in aliases" :key="i"> <v-row v-for="alias, i in aliases" :key="i">
<v-col cols="10"> <v-col cols="10">
<v-text-field <v-text-field v-model="alias.name" :label="$t('general.name')" :rules="[validators.required]" />
v-model="alias.name"
:label="$t('general.name')"
:rules="[validators.required]"
/>
</v-col> </v-col>
<v-col cols="2"> <v-col cols="2">
<BaseButtonGroup <BaseButtonGroup :buttons="[
:buttons="[
{ {
icon: $globals.icons.delete, icon: $globals.icons.delete,
text: $tc('general.delete'), text: $t('general.delete'),
event: 'delete' event: 'delete',
} },
]" ]" @delete="deleteAlias(i)" />
@delete="deleteAlias(i)"
/>
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
</v-card-text> </v-card-text>
<template #custom-card-action> <template #custom-card-action>
<BaseButton edit @click="createAlias">{{ $t('data-pages.create-alias') }} <BaseButton edit @click="createAlias">
{{ $t('data-pages.create-alias') }}
<template #icon> <template #icon>
{{ $globals.icons.create }} {{ $globals.icons.create }}
</template> </template>
@ -46,18 +34,17 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/core"; import { whenever } from "@vueuse/core";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe"; import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
export interface GenericAlias { export interface GenericAlias {
name: string; name: string;
} }
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@ -66,21 +53,22 @@ export default defineComponent({
required: true, required: true,
}, },
}, },
emits: ["submit", "update:modelValue", "cancel"],
setup(props, context) { setup(props, context) {
// V-Model Support // V-Model Support
const dialog = computed({ const dialog = computed({
get: () => { get: () => {
return props.value; return props.modelValue;
}, },
set: (val) => { set: (val) => {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
function createAlias() { function createAlias() {
aliases.value.push({ aliases.value.push({
"name": "", name: "",
}) });
} }
function deleteAlias(index: number) { function deleteAlias(index: number) {
@ -97,11 +85,11 @@ export default defineComponent({
initAliases(); initAliases();
whenever( whenever(
() => props.value, () => props.modelValue,
() => { () => {
initAliases(); initAliases();
}, },
) );
function saveAliases() { function saveAliases() {
const seenAliasNames: string[] = []; const seenAliasNames: string[] = [];
@ -111,9 +99,7 @@ export default defineComponent({
!alias.name !alias.name
|| alias.name === props.data.name || alias.name === props.data.name
|| alias.name === props.data.pluralName || alias.name === props.data.pluralName
// @ts-ignore only applies to units
|| alias.name === props.data.abbreviation || alias.name === props.data.abbreviation
// @ts-ignore only applies to units
|| alias.name === props.data.pluralAbbreviation || alias.name === props.data.pluralAbbreviation
|| seenAliasNames.includes(alias.name) || seenAliasNames.includes(alias.name)
) { ) {
@ -122,7 +108,7 @@ export default defineComponent({
keepAliases.push(alias); keepAliases.push(alias);
seenAliasNames.push(alias.name); seenAliasNames.push(alias.name);
}) });
aliases.value = keepAliases; aliases.value = keepAliases;
context.emit("submit", keepAliases); context.emit("submit", keepAliases);
@ -135,7 +121,7 @@ export default defineComponent({
deleteAlias, deleteAlias,
saveAliases, saveAliases,
validators, validators,
} };
}, },
}); });
</script> </script>

View file

@ -3,60 +3,73 @@
v-model="selected" v-model="selected"
item-key="id" item-key="id"
show-select show-select
sort-by="dateAdded" :sort-by="[{ key: 'dateAdded', order: 'desc' }]"
sort-desc
:headers="headers" :headers="headers"
:items="recipes" :items="recipes"
:items-per-page="15" :items-per-page="15"
class="elevation-0" class="elevation-0"
:loading="loading" :loading="loading"
@input="setValue(selected)"
> >
<template #body.preappend> <template #[`item.name`]="{ item }">
<tr> <a
<td></td> :href="`/g/${groupSlug}/r/${item.slug}`"
<td>Hello</td> style="color: inherit; text-decoration: inherit; "
<td colspan="4"></td> @click="$emit('click')"
</tr> >{{ item.name }}</a>
</template> </template>
<template #item.name="{ item }"> <template #[`item.tags`]="{ item }">
<a :href="`/g/${groupSlug}/r/${item.slug}`" style="color: inherit; text-decoration: inherit; " @click="$emit('click')">{{ item.name }}</a> <RecipeChip
small
:items="item.tags!"
:is-category="false"
url-prefix="tags"
@item-selected="filterItems"
/>
</template> </template>
<template #item.tags="{ item }"> <template #[`item.recipeCategory`]="{ item }">
<RecipeChip small :items="item.tags" :is-category="false" url-prefix="tags" @item-selected="filterItems" /> <RecipeChip
small
:items="item.recipeCategory!"
@item-selected="filterItems"
/>
</template> </template>
<template #item.recipeCategory="{ item }"> <template #[`item.tools`]="{ item }">
<RecipeChip small :items="item.recipeCategory" @item-selected="filterItems" /> <RecipeChip
small
:items="item.tools"
url-prefix="tools"
@item-selected="filterItems"
/>
</template> </template>
<template #item.tools="{ item }"> <template #[`item.userId`]="{ item }">
<RecipeChip small :items="item.tools" url-prefix="tools" @item-selected="filterItems" /> <div class="d-flex align-center">
<UserAvatar
:user-id="item.userId!"
:tooltip="false"
size="40"
/>
<div class="pl-2">
<span class="text-left">
{{ getMember(item.userId!) }}
</span>
</div>
</div>
</template> </template>
<template #item.userId="{ item }"> <template #[`item.dateAdded`]="{ item }">
<v-list-item class="justify-start"> {{ formatDate(item.dateAdded!) }}
<UserAvatar :user-id="item.userId" :tooltip="false" size="40" />
<v-list-item-content class="pl-2">
<v-list-item-title class="text-left">
{{ getMember(item.userId) }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</template>
<template #item.dateAdded="{ item }">
{{ formatDate(item.dateAdded) }}
</template> </template>
</v-data-table> </v-data-table>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, onMounted, ref, useContext, useRouter } from "@nuxtjs/composition-api";
import UserAvatar from "../User/UserAvatar.vue"; import UserAvatar from "../User/UserAvatar.vue";
import RecipeChip from "./RecipeChips.vue"; import RecipeChip from "./RecipeChips.vue";
import { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe"; import type { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { UserSummary } from "~/lib/api/types/user"; import type { UserSummary } from "~/lib/api/types/user";
import { RecipeTag } from "~/lib/api/types/household"; import type { RecipeTag } from "~/lib/api/types/household";
const INPUT_EVENT = "input"; const INPUT_EVENT = "update:modelValue";
interface ShowHeaders { interface ShowHeaders {
id: boolean; id: boolean;
@ -70,11 +83,11 @@ interface ShowHeaders {
dateAdded: boolean; dateAdded: boolean;
} }
export default defineComponent({ export default defineNuxtComponent({
components: { RecipeChip, UserAvatar }, components: { RecipeChip, UserAvatar },
props: { props: {
value: { modelValue: {
type: Array, type: Array as PropType<Recipe[]>,
required: false, required: false,
default: () => [], default: () => [],
}, },
@ -104,45 +117,48 @@ export default defineComponent({
}, },
}, },
}, },
emits: ["click"],
setup(props, context) { setup(props, context) {
const { $auth, i18n } = useContext(); const i18n = useI18n();
const groupSlug = $auth.user?.groupSlug; const $auth = useMealieAuth();
const groupSlug = $auth.user.value?.groupSlug;
const router = useRouter(); const router = useRouter();
function setValue(value: Recipe[]) { const selected = computed({
context.emit(INPUT_EVENT, value); get: () => props.modelValue,
} set: value => context.emit(INPUT_EVENT, value),
});
const headers = computed(() => { const headers = computed(() => {
const hdrs = []; const hdrs: Array<{ title: string; value: string; align?: string; sortable?: boolean }> = [];
if (props.showHeaders.id) { if (props.showHeaders.id) {
hdrs.push({ text: i18n.t("general.id"), value: "id" }); hdrs.push({ title: i18n.t("general.id"), value: "id" });
} }
if (props.showHeaders.owner) { if (props.showHeaders.owner) {
hdrs.push({ text: i18n.t("general.owner"), value: "userId", align: "center" }); hdrs.push({ title: i18n.t("general.owner"), value: "userId", align: "center", sortable: true });
} }
hdrs.push({ text: i18n.t("general.name"), value: "name" }); hdrs.push({ title: i18n.t("general.name"), value: "name", sortable: true });
if (props.showHeaders.categories) { if (props.showHeaders.categories) {
hdrs.push({ text: i18n.t("recipe.categories"), value: "recipeCategory" }); hdrs.push({ title: i18n.t("recipe.categories"), value: "recipeCategory", sortable: true });
} }
if (props.showHeaders.tags) { if (props.showHeaders.tags) {
hdrs.push({ text: i18n.t("tag.tags"), value: "tags" }); hdrs.push({ title: i18n.t("tag.tags"), value: "tags", sortable: true });
} }
if (props.showHeaders.tools) { if (props.showHeaders.tools) {
hdrs.push({ text: i18n.t("tool.tools"), value: "tools" }); hdrs.push({ title: i18n.t("tool.tools"), value: "tools", sortable: true });
} }
if (props.showHeaders.recipeServings) { if (props.showHeaders.recipeServings) {
hdrs.push({ text: i18n.t("recipe.servings"), value: "recipeServings" }); hdrs.push({ title: i18n.t("recipe.servings"), value: "recipeServings", sortable: true });
} }
if (props.showHeaders.recipeYieldQuantity) { if (props.showHeaders.recipeYieldQuantity) {
hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYieldQuantity" }); hdrs.push({ title: i18n.t("recipe.yield"), value: "recipeYieldQuantity", sortable: true });
} }
if (props.showHeaders.recipeYield) { if (props.showHeaders.recipeYield) {
hdrs.push({ text: i18n.t("recipe.yield-text"), value: "recipeYield" }); hdrs.push({ title: i18n.t("recipe.yield-text"), value: "recipeYield", sortable: true });
} }
if (props.showHeaders.dateAdded) { if (props.showHeaders.dateAdded) {
hdrs.push({ text: i18n.t("general.date-added"), value: "dateAdded" }); hdrs.push({ title: i18n.t("general.date-added"), value: "dateAdded", sortable: true });
} }
return hdrs; return hdrs;
@ -151,7 +167,8 @@ export default defineComponent({
function formatDate(date: string) { function formatDate(date: string) {
try { try {
return i18n.d(Date.parse(date), "medium"); return i18n.d(Date.parse(date), "medium");
} catch { }
catch {
return ""; return "";
} }
} }
@ -181,15 +198,15 @@ export default defineComponent({
function getMember(id: string) { function getMember(id: string) {
if (members.value[0]) { if (members.value[0]) {
return members.value.find((m) => m.id === id)?.fullName; return members.value.find(m => m.id === id)?.fullName;
} }
return i18n.t("general.none"); return i18n.t("general.none");
} }
return { return {
selected,
groupSlug, groupSlug,
setValue,
headers, headers,
formatDate, formatDate,
members, members,
@ -197,16 +214,5 @@ export default defineComponent({
filterItems, filterItems,
}; };
}, },
data() {
return {
selected: [],
};
},
watch: {
value(val) {
this.selected = val;
},
},
}); });
</script> </script>

View file

@ -1,9 +1,16 @@
<template> <template>
<div v-if="dialog"> <div v-if="dialog">
<BaseDialog v-if="shoppingListDialog && ready" v-model="dialog" :title="$t('recipe.add-to-list')" :icon="$globals.icons.cartCheck"> <BaseDialog
v-if="shoppingListDialog && ready"
v-model="dialog"
:title="$t('recipe.add-to-list')"
:icon="$globals.icons.cartCheck"
>
<v-container v-if="!shoppingListChoices.length"> <v-container v-if="!shoppingListChoices.length">
<BasePageTitle> <BasePageTitle>
<template #title>{{ $t('shopping-list.no-shopping-lists-found') }}</template> <template #title>
{{ $t('shopping-list.no-shopping-lists-found') }}
</template>
</BasePageTitle> </BasePageTitle>
</v-container> </v-container>
<v-card-text> <v-card-text>
@ -21,14 +28,23 @@
</v-card-text> </v-card-text>
<template #card-actions> <template #card-actions>
<v-btn <v-btn
text variant="text"
color="grey" color="grey"
@click="dialog = false" @click="dialog = false"
> >
{{ $t("general.cancel") }} {{ $t("general.cancel") }}
</v-btn> </v-btn>
<div class="d-flex justify-end" style="width: 100%;"> <div
<v-checkbox v-model="preferences.viewAllLists" hide-details :label="$tc('general.show-all')" class="my-auto mr-4" @click="setShowAllToggled()" /> class="d-flex justify-end"
style="width: 100%;"
>
<v-checkbox
v-model="preferences.viewAllLists"
hide-details
:label="$t('general.show-all')"
class="my-auto mr-4"
@click="setShowAllToggled()"
/>
</div> </div>
</template> </template>
</BaseDialog> </BaseDialog>
@ -38,32 +54,52 @@
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')" :title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
:icon="$globals.icons.cartCheck" :icon="$globals.icons.cartCheck"
width="70%" width="70%"
:submit-text="$tc('recipe.add-to-list')" :submit-text="$t('recipe.add-to-list')"
can-submit
@submit="addRecipesToList()" @submit="addRecipesToList()"
> >
<div style="max-height: 70vh; overflow-y: auto"> <div style="max-height: 70vh; overflow-y: auto">
<v-card <v-card
v-for="(recipeSection, recipeSectionIndex) in recipeIngredientSections" :key="recipeSection.recipeId + recipeSectionIndex" v-for="(recipeSection, recipeSectionIndex) in recipeIngredientSections"
:key="recipeSection.recipeId + recipeSectionIndex"
elevation="0" elevation="0"
height="fit-content" height="fit-content"
width="100%" width="100%"
> >
<v-divider v-if="recipeSectionIndex > 0" class="mt-3" /> <v-divider
v-if="recipeSectionIndex > 0"
class="mt-3"
/>
<v-card-title <v-card-title
v-if="recipeIngredientSections.length > 1" v-if="recipeIngredientSections.length > 1"
class="justify-center text-h5" class="justify-center text-h5"
width="100%" width="100%"
> >
<v-container style="width: 100%;"> <v-container style="width: 100%;">
<v-row no-gutters class="ma-0 pa-0"> <v-row
<v-col cols="12" align-self="center" class="text-center"> no-gutters
class="ma-0 pa-0"
>
<v-col
cols="12"
align-self="center"
class="text-center"
>
{{ recipeSection.recipeName }} {{ recipeSection.recipeName }}
</v-col> </v-col>
</v-row> </v-row>
<v-row v-if="recipeSection.recipeScale > 1" no-gutters class="ma-0 pa-0"> <v-row
v-if="recipeSection.recipeScale > 1"
no-gutters
class="ma-0 pa-0"
>
<!-- TODO: make this editable in the dialog and visible on single-recipe lists --> <!-- TODO: make this editable in the dialog and visible on single-recipe lists -->
<v-col cols="12" align-self="center" class="text-center"> <v-col
({{ $tc("recipe.quantity") }}: {{ recipeSection.recipeScale }}) cols="12"
align-self="center"
class="text-center"
>
({{ $t("recipe.quantity") }}: {{ recipeSection.recipeScale }})
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
@ -73,17 +109,20 @@
v-for="(ingredientSection, ingredientSectionIndex) in recipeSection.ingredientSections" v-for="(ingredientSection, ingredientSectionIndex) in recipeSection.ingredientSections"
:key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex" :key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex"
> >
<v-card-title v-if="ingredientSection.sectionName" class="ingredient-title mt-2 pb-0 text-h6"> <v-card-title
v-if="ingredientSection.sectionName"
class="ingredient-title mt-2 pb-0 text-h6"
>
{{ ingredientSection.sectionName }} {{ ingredientSection.sectionName }}
</v-card-title> </v-card-title>
<div <div
:class="$vuetify.breakpoint.smAndDown ? '' : 'ingredient-grid'" :class="$vuetify.display.smAndDown ? '' : 'ingredient-grid'"
:style="$vuetify.breakpoint.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }" :style="$vuetify.display.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
> >
<v-list-item <v-list-item
v-for="(ingredientData, i) in ingredientSection.ingredients" v-for="(ingredientData, i) in ingredientSection.ingredients"
:key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex + i" :key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex + i"
dense density="compact"
@click="recipeIngredientSections[recipeSectionIndex] @click="recipeIngredientSections[recipeSectionIndex]
.ingredientSections[ingredientSectionIndex] .ingredientSections[ingredientSectionIndex]
.ingredients[i].checked = !recipeIngredientSections[recipeSectionIndex] .ingredients[i].checked = !recipeIngredientSections[recipeSectionIndex]
@ -93,16 +132,18 @@
> >
<v-checkbox <v-checkbox
hide-details hide-details
:input-value="ingredientData.checked" :model-value="ingredientData.checked"
class="pt-0 my-auto py-auto" class="pt-0 my-auto py-auto"
color="secondary" color="secondary"
density="compact"
/> />
<v-list-item-content :key="ingredientData.ingredient.quantity"> <div :key="ingredientData.ingredient.quantity">
<RecipeIngredientListItem <RecipeIngredientListItem
:ingredient="ingredientData.ingredient" :ingredient="ingredientData.ingredient"
:disable-amount="ingredientData.disableAmount" :disable-amount="ingredientData.disableAmount"
:scale="recipeSection.recipeScale" /> :scale="recipeSection.recipeScale"
</v-list-item-content> />
</div>
</v-list-item> </v-list-item>
</div> </div>
</div> </div>
@ -114,12 +155,12 @@
:buttons="[ :buttons="[
{ {
icon: $globals.icons.checkboxBlankOutline, icon: $globals.icons.checkboxBlankOutline,
text: $tc('shopping-list.uncheck-all-items'), text: $t('shopping-list.uncheck-all-items'),
event: 'uncheck', event: 'uncheck',
}, },
{ {
icon: $globals.icons.checkboxOutline, icon: $globals.icons.checkboxOutline,
text: $tc('shopping-list.check-all-items'), text: $t('shopping-list.check-all-items'),
event: 'check', event: 'check',
}, },
]" ]"
@ -132,14 +173,13 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, ref, useContext, watchEffect } from "@nuxtjs/composition-api";
import { toRefs } from "@vueuse/core"; import { toRefs } from "@vueuse/core";
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue"; import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { useShoppingListPreferences } from "~/composables/use-users/preferences"; import { useShoppingListPreferences } from "~/composables/use-users/preferences";
import { RecipeIngredient, ShoppingListAddRecipeParamsBulk, ShoppingListSummary } from "~/lib/api/types/household"; import type { RecipeIngredient, ShoppingListAddRecipeParamsBulk, ShoppingListSummary } from "~/lib/api/types/household";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
export interface RecipeWithScale extends Recipe { export interface RecipeWithScale extends Recipe {
scale: number; scale: number;
@ -163,12 +203,12 @@ export interface ShoppingListRecipeIngredientSection {
ingredientSections: ShoppingListIngredientSection[]; ingredientSections: ShoppingListIngredientSection[];
} }
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeIngredientListItem, RecipeIngredientListItem,
}, },
props: { props: {
value: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@ -181,8 +221,10 @@ export default defineComponent({
default: () => [], default: () => [],
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const { $auth, i18n } = useContext(); const i18n = useI18n();
const $auth = useMealieAuth();
const api = useUserApi(); const api = useUserApi();
const preferences = useShoppingListPreferences(); const preferences = useShoppingListPreferences();
const ready = ref(false); const ready = ref(false);
@ -190,10 +232,10 @@ export default defineComponent({
// v-model support // v-model support
const dialog = computed({ const dialog = computed({
get: () => { get: () => {
return props.value; return props.modelValue;
}, },
set: (val) => { set: (val) => {
context.emit("input", val); context.emit("update:modelValue", val);
initState(); initState();
}, },
}); });
@ -205,11 +247,11 @@ export default defineComponent({
}); });
const userHousehold = computed(() => { const userHousehold = computed(() => {
return $auth.user?.householdSlug || ""; return $auth.user.value?.householdSlug || "";
}); });
const shoppingListChoices = computed(() => { const shoppingListChoices = computed(() => {
return props.shoppingLists.filter((list) => preferences.value.viewAllLists || list.userId === $auth.user?.id); return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
}); });
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]); const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
@ -220,7 +262,8 @@ export default defineComponent({
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) { if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
selectedShoppingList.value = shoppingListChoices.value[0]; selectedShoppingList.value = shoppingListChoices.value[0];
openShoppingListIngredientDialog(selectedShoppingList.value); openShoppingListIngredientDialog(selectedShoppingList.value);
} else { }
else {
ready.value = true; ready.value = true;
} }
}, },
@ -234,7 +277,6 @@ export default defineComponent({
} }
if (recipeSectionMap.has(recipe.slug)) { if (recipeSectionMap.has(recipe.slug)) {
// @ts-ignore not undefined, see above
recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale; recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
continue; continue;
} }
@ -247,7 +289,8 @@ export default defineComponent({
recipe.id = data.id || ""; recipe.id = data.id || "";
recipe.name = data.name || ""; recipe.name = data.name || "";
recipe.recipeIngredient = data.recipeIngredient; recipe.recipeIngredient = data.recipeIngredient;
} else if (!recipe.recipeIngredient.length) { }
else if (!recipe.recipeIngredient.length) {
continue; continue;
} }
@ -257,7 +300,7 @@ export default defineComponent({
checked: !householdsWithFood.includes(userHousehold.value), checked: !householdsWithFood.includes(userHousehold.value),
ingredient: ing, ingredient: ing,
disableAmount: recipe.settings?.disableAmount || false, disableAmount: recipe.settings?.disableAmount || false,
} };
}); });
let currentTitle = ""; let currentTitle = "";
@ -300,7 +343,7 @@ export default defineComponent({
recipeName: recipe.name, recipeName: recipe.name,
recipeScale: recipe.scale, recipeScale: recipe.scale,
ingredientSections: shoppingListIngredientSections, ingredientSections: shoppingListIngredientSections,
}) });
} }
recipeIngredientSections.value = Array.from(recipeSectionMap.values()); recipeIngredientSections.value = Array.from(recipeSectionMap.values());
@ -366,13 +409,13 @@ export default defineComponent({
recipeId: section.recipeId, recipeId: section.recipeId,
recipeIncrementQuantity: section.recipeScale, recipeIncrementQuantity: section.recipeScale,
recipeIngredients: ingredients, recipeIngredients: ingredients,
} },
); );
}); });
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData); const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
error ? alert.error(i18n.tc("recipe.failed-to-add-recipes-to-list")) // eslint-disable-next-line @typescript-eslint/no-unused-expressions
: alert.success(i18n.tc("recipe.successfully-added-to-list")); error ? alert.error(i18n.t("recipe.failed-to-add-recipes-to-list")) : alert.success(i18n.t("recipe.successfully-added-to-list"));
state.shoppingListDialog = false; state.shoppingListDialog = false;
state.shoppingListIngredientDialog = false; state.shoppingListIngredientDialog = false;
@ -391,9 +434,9 @@ export default defineComponent({
setShowAllToggled, setShowAllToggled,
recipeIngredientSections, recipeIngredientSections,
selectedShoppingList, selectedShoppingList,
} };
}, },
}) });
</script> </script>
<style scoped lang="css"> <style scoped lang="css">

View file

@ -1,54 +1,88 @@
<template> <template>
<div class="text-center"> <div class="text-center">
<v-dialog v-model="dialog" width="800"> <v-dialog
<template #activator="{ on, attrs }"> v-model="dialog"
<BaseButton v-bind="attrs" v-on="on" @click="inputText = inputTextProp"> width="800"
>
<template #activator="{ props }">
<BaseButton
v-bind="props"
@click="inputText = inputTextProp"
>
{{ $t("new-recipe.bulk-add") }} {{ $t("new-recipe.bulk-add") }}
</BaseButton> </BaseButton>
</template> </template>
<v-card> <v-card>
<v-app-bar dense dark color="primary" class="mb-2"> <v-app-bar
<v-icon large left> density="compact"
dark
color="primary"
class="mb-2 position-relative left-0 top-0 w-100"
>
<v-icon
size="large"
start
>
{{ $globals.icons.createAlt }} {{ $globals.icons.createAlt }}
</v-icon> </v-icon>
<v-toolbar-title class="headline"> {{ $t("new-recipe.bulk-add") }}</v-toolbar-title> <v-toolbar-title class="headline">
<v-spacer></v-spacer> {{ $t("new-recipe.bulk-add") }}
</v-toolbar-title>
<v-spacer />
</v-app-bar> </v-app-bar>
<v-card-text> <v-card-text>
<v-textarea <v-textarea
v-model="inputText" v-model="inputText"
outlined variant="outlined"
rows="12" rows="12"
hide-details hide-details
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')" :placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
> />
</v-textarea>
<v-divider></v-divider> <v-divider />
<template v-for="(util, idx) in utilities"> <template
<v-list-item :key="util.id" dense class="py-1"> v-for="(util) in utilities"
:key="util.id"
>
<v-list-item
density="compact"
class="py-1"
>
<v-list-item-title> <v-list-item-title>
<v-list-item-subtitle class="wrap-word"> <v-list-item-subtitle class="wrap-word">
{{ util.description }} {{ util.description }}
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item-title> </v-list-item-title>
<BaseButton small color="info" @click="util.action"> <BaseButton
<template #icon> {{ $globals.icons.robot }}</template> size="small"
color="info"
@click="util.action"
>
<template #icon>
{{ $globals.icons.robot }}
</template>
{{ $t("general.run") }} {{ $t("general.run") }}
</BaseButton> </BaseButton>
</v-list-item> </v-list-item>
<v-divider :key="`divider-${idx}`" class="mx-2"></v-divider> <v-divider class="mx-2" />
</template> </template>
</v-card-text> </v-card-text>
<v-divider></v-divider> <v-divider />
<v-card-actions> <v-card-actions>
<BaseButton cancel @click="dialog = false"> </BaseButton> <BaseButton
<v-spacer></v-spacer> cancel
<BaseButton save color="success" @click="save"> </BaseButton> @click="dialog = false"
/>
<v-spacer />
<BaseButton
save
color="success"
@click="save"
/>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
@ -56,8 +90,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { reactive, toRefs, defineComponent, useContext } from "@nuxtjs/composition-api"; export default defineNuxtComponent({
export default defineComponent({
props: { props: {
inputTextProp: { inputTextProp: {
type: String, type: String,
@ -65,6 +98,7 @@ export default defineComponent({
default: "", default: "",
}, },
}, },
emits: ["bulk-data"],
setup(props, context) { setup(props, context) {
const state = reactive({ const state = reactive({
dialog: false, dialog: false,
@ -72,12 +106,12 @@ export default defineComponent({
}); });
function splitText() { function splitText() {
return state.inputText.split("\n").filter((line) => !(line === "\n" || !line)); return state.inputText.split("\n").filter(line => !(line === "\n" || !line));
} }
function removeFirstCharacter() { function removeFirstCharacter() {
state.inputText = splitText() state.inputText = splitText()
.map((line) => line.substring(1)) .map(line => line.substring(1))
.join("\n"); .join("\n");
} }
@ -108,22 +142,22 @@ export default defineComponent({
state.dialog = false; state.dialog = false;
} }
const { i18n } = useContext(); const i18n = useI18n();
const utilities = [ const utilities = [
{ {
id: "trim-whitespace", id: "trim-whitespace",
description: i18n.tc("new-recipe.trim-whitespace-description"), description: i18n.t("new-recipe.trim-whitespace-description"),
action: trimAllLines, action: trimAllLines,
}, },
{ {
id: "trim-prefix", id: "trim-prefix",
description: i18n.tc("new-recipe.trim-prefix-description"), description: i18n.t("new-recipe.trim-prefix-description"),
action: removeFirstCharacter, action: removeFirstCharacter,
}, },
{ {
id: "split-by-numbered-line", id: "split-by-numbered-line",
description: i18n.tc("new-recipe.split-by-numbered-line-description"), description: i18n.t("new-recipe.split-by-numbered-line-description"),
action: splitByNumberedLine, action: splitByNumberedLine,
}, },
]; ];

View file

@ -2,16 +2,29 @@
<BaseDialog <BaseDialog
v-model="dialog" v-model="dialog"
:icon="$globals.icons.printerSettings" :icon="$globals.icons.printerSettings"
:title="$tc('general.print-preferences')" :title="$t('general.print-preferences')"
width="70%" width="70%"
max-width="816px" max-width="816px"
> >
<div class="pa-6"> <div class="pa-6">
<v-container class="print-config mb-3 pa-0"> <v-container class="print-config mb-3 pa-0">
<v-row> <v-row>
<v-col cols="auto" align-self="center" class="text-center"> <v-col
<div class="text-subtitle-2" style="text-align: center;">{{ $tc('recipe.recipe-image') }}</div> cols="auto"
<v-btn-toggle v-model="preferences.imagePosition" mandatory style="width: fit-content;"> align-self="center"
class="text-center"
>
<div
class="text-subtitle-2"
style="text-align: center;"
>
{{ $t('recipe.recipe-image') }}
</div>
<v-btn-toggle
v-model="preferences.imagePosition"
mandatory="force"
style="width: fit-content;"
>
<v-btn :value="ImagePosition.left"> <v-btn :value="ImagePosition.left">
<v-icon>{{ $globals.icons.dockLeft }}</v-icon> <v-icon>{{ $globals.icons.dockLeft }}</v-icon>
</v-btn> </v-btn>
@ -23,20 +36,37 @@
</v-btn> </v-btn>
</v-btn-toggle> </v-btn-toggle>
</v-col> </v-col>
<v-col cols="auto" align-self="start"> <v-col
cols="auto"
align-self="start"
>
<v-row no-gutters> <v-row no-gutters>
<v-switch v-model="preferences.showDescription" hide-details :label="$tc('recipe.description')" /> <v-switch
v-model="preferences.showDescription"
hide-details
:label="$t('recipe.description')"
/>
</v-row> </v-row>
<v-row no-gutters> <v-row no-gutters>
<v-switch v-model="preferences.showNotes" hide-details :label="$tc('recipe.notes')" /> <v-switch
v-model="preferences.showNotes"
hide-details
:label="$t('recipe.notes')"
/>
</v-row> </v-row>
</v-col> </v-col>
<v-col cols="auto" align-self="start"> <v-col
<v-row no-gutters> cols="auto"
<v-switch v-model="preferences.showNutrition" hide-details :label="$tc('recipe.nutrition')" /> align-self="start"
</v-row> >
<v-row no-gutters> <v-row no-gutters>
<v-switch
v-model="preferences.showNutrition"
hide-details
:label="$t('recipe.nutrition')"
/>
</v-row> </v-row>
<v-row no-gutters />
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
@ -54,35 +84,36 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api"; import type { Recipe } from "~/lib/api/types/recipe";
import { Recipe } from "~/lib/api/types/recipe";
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences"; import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue"; import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipePrintView, RecipePrintView,
}, },
props: { props: {
value: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
recipe: { recipe: {
type: Object as () => Recipe, type: Object as () => NoUndefinedField<Recipe>,
default: undefined, default: undefined,
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const preferences = useUserPrintPreferences(); const preferences = useUserPrintPreferences();
// V-Model Support // V-Model Support
const dialog = computed({ const dialog = computed({
get: () => { get: () => {
return props.value; return props.modelValue;
}, },
set: (val) => { set: (val) => {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
@ -90,7 +121,7 @@ export default defineComponent({
dialog, dialog,
ImagePosition, ImagePosition,
preferences, preferences,
} };
} },
}); });
</script> </script>

View file

@ -1,37 +1,61 @@
<template> <template>
<div> <div>
<slot v-bind="{ open, close }"> </slot> <slot v-bind="{ open, close }" />
<v-dialog v-model="dialog" max-width="988px" content-class="top-dialog" :scrollable="false"> <v-dialog
<v-app-bar sticky dark color="primary lighten-1" :rounded="!$vuetify.breakpoint.xs"> v-model="dialog"
max-width="988px"
content-class="top-dialog"
:scrollable="false"
>
<v-app-bar
sticky
dark
color="primary-lighten-1 top-0 position-relative left-0"
:rounded="!$vuetify.display.xs"
>
<v-text-field <v-text-field
id="arrow-search" id="arrow-search"
v-model="search.query.value" v-model="search.query.value"
autofocus autofocus
solo variant="solo-filled"
flat flat
autocomplete="off" autocomplete="off"
background-color="primary lighten-1" bg-color="primary-lighten-1"
color="white" color="white"
dense density="compact"
class="mx-2 arrow-search" class="mx-2 arrow-search"
hide-details hide-details
single-line single-line
:placeholder="$t('search.search')" :placeholder="$t('search.search')"
:prepend-inner-icon="$globals.icons.search" :prepend-inner-icon="$globals.icons.search"
></v-text-field> />
<v-btn v-if="$vuetify.breakpoint.xs" x-small fab light @click="dialog = false"> <v-btn
v-if="$vuetify.display.xs"
size="x-small"
class="rounded-circle"
light
@click="dialog = false"
>
<v-icon> <v-icon>
{{ $globals.icons.close }} {{ $globals.icons.close }}
</v-icon> </v-icon>
</v-btn> </v-btn>
</v-app-bar> </v-app-bar>
<v-card class="mt-1 pa-1 scroll" max-height="700px" relative :loading="loading"> <v-card
class="position-relative mt-1 pa-1 scroll"
max-height="700px"
relative
:loading="loading"
>
<v-card-actions> <v-card-actions>
<div class="mr-auto"> <div class="mr-auto">
{{ $t("search.results") }} {{ $t("search.results") }}
</div> </div>
<router-link :to="advancedSearchUrl"> {{ $t("search.advanced-search") }} </router-link> <!-- <router-link
:to="advancedSearchUrl"
class="text-primary"
> {{ $t("search.advanced-search") }} </router-link> -->
</v-card-actions> </v-card-actions>
<RecipeCardMobile <RecipeCardMobile
@ -39,13 +63,13 @@
:key="index" :key="index"
:tabindex="index" :tabindex="index"
class="ma-1 arrow-nav" class="ma-1 arrow-nav"
:name="recipe.name" :name="recipe.name ?? ''"
:description="recipe.description || ''" :description="recipe.description ?? ''"
:slug="recipe.slug" :slug="recipe.slug ?? ''"
:rating="recipe.rating" :rating="recipe.rating ?? 0"
:image="recipe.image" :image="recipe.image"
:recipe-id="recipe.id" :recipe-id="recipe.id ?? ''"
v-on="$listeners.selected ? { selected: () => handleSelect(recipe) } : {}" v-bind="$attrs.selected ? { selected: () => handleSelect(recipe) } : {}"
/> />
</v-card> </v-card>
</v-dialog> </v-dialog>
@ -53,21 +77,21 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, toRefs, reactive, ref, watch, useContext, useRoute } from "@nuxtjs/composition-api";
import RecipeCardMobile from "./RecipeCardMobile.vue"; import RecipeCardMobile from "./RecipeCardMobile.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { RecipeSummary } from "~/lib/api/types/recipe"; import type { RecipeSummary } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search"; import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
import { usePublicExploreApi } from "~/composables/api/api-client"; import { usePublicExploreApi } from "~/composables/api/api-client";
const SELECTED_EVENT = "selected"; const SELECTED_EVENT = "selected";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeCardMobile, RecipeCardMobile,
}, },
setup(_, context) { setup(_, context) {
const { $auth } = useContext(); const $auth = useMealieAuth();
const state = reactive({ const state = reactive({
loading: false, loading: false,
selectedIndex: -1, selectedIndex: -1,
@ -110,13 +134,16 @@ export default defineComponent({
if (e.key === "Enter") { if (e.key === "Enter") {
console.log(document.activeElement); console.log(document.activeElement);
// (document.activeElement as HTMLElement).click(); // (document.activeElement as HTMLElement).click();
} else if (e.key === "ArrowUp") { }
else if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
state.selectedIndex--; state.selectedIndex--;
} else if (e.key === "ArrowDown") { }
else if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
state.selectedIndex++; state.selectedIndex++;
} else { }
else {
return; return;
} }
selectRecipe(); selectRecipe();
@ -125,14 +152,15 @@ export default defineComponent({
watch(dialog, (val) => { watch(dialog, (val) => {
if (!val) { if (!val) {
document.removeEventListener("keyup", onUpDown); document.removeEventListener("keyup", onUpDown);
} else { }
else {
document.addEventListener("keyup", onUpDown); document.addEventListener("keyup", onUpDown);
} }
}); });
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const route = useRoute(); const route = useRoute();
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`) const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`);
watch(route, close); watch(route, close);
function open() { function open() {

View file

@ -1,6 +1,10 @@
<template> <template>
<div> <div>
<BaseDialog v-model="dialog" :title="$t('recipe-share.share-recipe')" :icon="$globals.icons.link"> <BaseDialog
v-model="dialog"
:title="$t('recipe-share.share-recipe')"
:icon="$globals.icons.link"
>
<v-card-text> <v-card-text>
<v-menu <v-menu
v-model="datePickerMenu" v-model="datePickerMenu"
@ -10,68 +14,94 @@
max-width="290px" max-width="290px"
min-width="auto" min-width="auto"
> >
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-text-field <v-text-field
v-model="expirationDate" v-model="expirationDateString"
:label="$t('recipe-share.expiration-date')" :label="$t('recipe-share.expiration-date')"
:hint="$t('recipe-share.default-30-days')" :hint="$t('recipe-share.default-30-days')"
persistent-hint persistent-hint
:prepend-icon="$globals.icons.calendar" :prepend-icon="$globals.icons.calendar"
v-bind="attrs" v-bind="props"
readonly readonly
v-on="on" />
></v-text-field>
</template> </template>
<v-date-picker <v-date-picker
v-model="expirationDate" v-model="expirationDate"
no-title hide-header
:first-day-of-week="firstDayOfWeek" :first-day-of-week="firstDayOfWeek"
:local="$i18n.locale" :local="$i18n.locale"
@input="datePickerMenu = false" @update:model-value="datePickerMenu = false"
/> />
</v-menu> </v-menu>
</v-card-text> </v-card-text>
<v-card-actions class="justify-end"> <v-card-actions class="justify-end">
<BaseButton small @click="createNewToken"> {{ $t("general.new") }}</BaseButton> <BaseButton
size="small"
@click="createNewToken"
>
{{ $t("general.new") }}
</BaseButton>
</v-card-actions> </v-card-actions>
<v-list-item v-for="token in tokens" :key="token.id" @click="shareRecipe(token.id)"> <v-list-item
<v-list-item-avatar color="grey"> v-for="token in tokens"
<v-icon dark class="pa-2"> {{ $globals.icons.link }} </v-icon> :key="token.id"
</v-list-item-avatar> class="px-2"
style="padding-top: 8px; padding-bottom: 8px;"
@click="shareRecipe(token.id)"
>
<div class="d-flex align-center" style="width: 100%;">
<v-avatar color="grey">
<v-icon>
{{ $globals.icons.link }}
</v-icon>
</v-avatar>
<v-list-item-content> <div class="pl-3 flex-grow-1">
<v-list-item-title> {{ $t("recipe-share.expires-at") }} </v-list-item-title> <v-list-item-title>
{{ $t("recipe-share.expires-at") }}
</v-list-item-title>
<v-list-item-subtitle>
{{ $d(new Date(token.expiresAt!), "long") }}
</v-list-item-subtitle>
</div>
<v-list-item-subtitle>{{ $d(new Date(token.expiresAt), "long") }}</v-list-item-subtitle> <v-btn
</v-list-item-content> icon
variant="text"
<v-list-item-action> class="ml-2"
<v-btn icon @click.stop="deleteToken(token.id)"> @click.stop="deleteToken(token.id)"
<v-icon color="error lighten-1"> {{ $globals.icons.delete }} </v-icon> >
<v-icon color="error-lighten-1">
{{ $globals.icons.delete }}
</v-icon>
</v-btn> </v-btn>
</v-list-item-action> <v-btn
<v-list-item-action> icon
<v-btn icon @click.stop="copyTokenLink(token.id)"> variant="text"
<v-icon color="info lighten-1"> {{ $globals.icons.contentCopy }} </v-icon> class="ml-2"
@click.stop="copyTokenLink(token.id)"
>
<v-icon color="info-lighten-1">
{{ $globals.icons.contentCopy }}
</v-icon>
</v-btn> </v-btn>
</v-list-item-action> </div>
</v-list-item> </v-list-item>
</BaseDialog> </BaseDialog>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, toRefs, reactive, useContext, useRoute } from "@nuxtjs/composition-api";
import { useClipboard, useShare, whenever } from "@vueuse/core"; import { useClipboard, useShare, whenever } from "@vueuse/core";
import { RecipeShareToken } from "~/lib/api/types/recipe"; import type { RecipeShareToken } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@ -84,38 +114,43 @@ export default defineComponent({
required: true, required: true,
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
// V-Model Support // V-Model Support
const dialog = computed({ const dialog = computed({
get: () => { get: () => {
return props.value; return props.modelValue;
}, },
set: (val) => { set: (val) => {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
const state = reactive({ const state = reactive({
datePickerMenu: false, datePickerMenu: false,
expirationDate: "", expirationDate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
tokens: [] as RecipeShareToken[], tokens: [] as RecipeShareToken[],
}); });
const expirationDateString = computed(() => {
return state.expirationDate.toISOString().substring(0, 10);
});
whenever( whenever(
() => props.value, () => props.modelValue,
() => { () => {
// Set expiration date to today + 30 Days // Set expiration date to today + 30 Days
const today = new Date(); const today = new Date();
const expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000); state.expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
state.expirationDate = expirationDate.toISOString().substring(0, 10);
refreshTokens(); refreshTokens();
} },
); );
const { $auth, i18n } = useContext(); const i18n = useI18n();
const $auth = useMealieAuth();
const { household } = useHouseholdSelf(); const { household } = useHouseholdSelf();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => { const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0; return household.value?.preferences?.firstDayOfWeek || 0;
@ -128,11 +163,9 @@ export default defineComponent({
async function createNewToken() { async function createNewToken() {
// Convert expiration date to timestamp // Convert expiration date to timestamp
const expirationDate = new Date(state.expirationDate);
const { data } = await userApi.recipes.share.createOne({ const { data } = await userApi.recipes.share.createOne({
recipeId: props.recipeId, recipeId: props.recipeId,
expiresAt: expirationDate.toISOString(), expiresAt: state.expirationDate.toISOString(),
}); });
if (data) { if (data) {
@ -142,7 +175,7 @@ export default defineComponent({
async function deleteToken(id: string) { async function deleteToken(id: string) {
await userApi.recipes.share.deleteOne(id); await userApi.recipes.share.deleteOne(id);
state.tokens = state.tokens.filter((token) => token.id !== id); state.tokens = state.tokens.filter(token => token.id !== id);
} }
async function refreshTokens() { async function refreshTokens() {
@ -187,13 +220,15 @@ export default defineComponent({
url: getTokenLink(token), url: getTokenLink(token),
text: getRecipeText() as string, text: getRecipeText() as string,
}); });
} else { }
else {
await copyTokenLink(token); await copyTokenLink(token);
} }
} }
return { return {
...toRefs(state), ...toRefs(state),
expirationDateString,
dialog, dialog,
createNewToken, createNewToken,
deleteToken, deleteToken,

View file

@ -1,16 +1,22 @@
<template> <template>
<v-container fluid class="pa-0"> <v-container
<div class="search-container py-8"> fluid
<form class="search-box pa-2" @submit.prevent="search"> class="pa-0"
<div class="d-flex justify-center my-2"> >
<div class="search-container pb-8">
<form
class="search-box pa-2"
@submit.prevent="search"
>
<div class="d-flex justify-center mb-2">
<v-text-field <v-text-field
ref="input" ref="input"
v-model="state.search" v-model="state.search"
outlined variant="outlined"
hide-details hide-details
clearable clearable
color="primary" color="primary"
:placeholder="$tc('search.search-placeholder')" :placeholder="$t('search.search-placeholder')"
:prepend-inner-icon="$globals.icons.search" :prepend-inner-icon="$globals.icons.search"
@keyup.enter="hideKeyboard" @keyup.enter="hideKeyboard"
/> />
@ -20,134 +26,184 @@
<SearchFilter <SearchFilter
v-if="categories" v-if="categories"
v-model="selectedCategories" v-model="selectedCategories"
:require-all.sync="state.requireAllCategories" v-model:require-all="state.requireAllCategories"
:items="categories" :items="categories"
> >
<v-icon left> <v-icon start>
{{ $globals.icons.categories }} {{ $globals.icons.categories }}
</v-icon> </v-icon>
{{ $t("category.categories") }} {{ $t("category.categories") }}
</SearchFilter> </SearchFilter>
<!-- Tag Filter --> <!-- Tag Filter -->
<SearchFilter v-if="tags" v-model="selectedTags" :require-all.sync="state.requireAllTags" :items="tags"> <SearchFilter
<v-icon left> v-if="tags"
v-model="selectedTags"
v-model:require-all="state.requireAllTags"
:items="tags"
>
<v-icon start>
{{ $globals.icons.tags }} {{ $globals.icons.tags }}
</v-icon> </v-icon>
{{ $t("tag.tags") }} {{ $t("tag.tags") }}
</SearchFilter> </SearchFilter>
<!-- Tool Filter --> <!-- Tool Filter -->
<SearchFilter v-if="tools" v-model="selectedTools" :require-all.sync="state.requireAllTools" :items="tools"> <SearchFilter
<v-icon left> v-if="tools"
v-model="selectedTools"
v-model:require-all="state.requireAllTools"
:items="tools"
>
<v-icon start>
{{ $globals.icons.potSteam }} {{ $globals.icons.potSteam }}
</v-icon> </v-icon>
{{ $t("tool.tools") }} {{ $t("tool.tools") }}
</SearchFilter> </SearchFilter>
<!-- Food Filter --> <!-- Food Filter -->
<SearchFilter v-if="foods" v-model="selectedFoods" :require-all.sync="state.requireAllFoods" :items="foods"> <SearchFilter
<v-icon left> v-if="foods"
v-model="selectedFoods"
v-model:require-all="state.requireAllFoods"
:items="foods"
>
<v-icon start>
{{ $globals.icons.foods }} {{ $globals.icons.foods }}
</v-icon> </v-icon>
{{ $t("general.foods") }} {{ $t("general.foods") }}
</SearchFilter> </SearchFilter>
<!-- Household Filter --> <!-- Household Filter -->
<SearchFilter v-if="households.length > 1" v-model="selectedHouseholds" :items="households" radio> <SearchFilter
<v-icon left> v-if="households.length > 1"
v-model="selectedHouseholds"
:items="households"
radio
>
<v-icon start>
{{ $globals.icons.household }} {{ $globals.icons.household }}
</v-icon> </v-icon>
{{ $t("household.households") }} {{ $t("household.households") }}
</SearchFilter> </SearchFilter>
<!-- Sort Options --> <!-- Sort Options -->
<v-menu offset-y nudge-bottom="3"> <v-menu
<template #activator="{ on, attrs }"> offset-y
<v-btn class="ml-auto" small color="accent" v-bind="attrs" v-on="on"> nudge-bottom="3"
<v-icon :left="!$vuetify.breakpoint.xsOnly"> >
<template #activator="{ props }">
<v-btn
class="ml-auto"
size="small"
color="accent"
v-bind="props"
>
<v-icon :start="!$vuetify.display.xs">
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }} {{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
</v-icon> </v-icon>
{{ $vuetify.breakpoint.xsOnly ? null : sortText }} {{ $vuetify.display.xs ? null : sortText }}
</v-btn> </v-btn>
</template> </template>
<v-card> <v-card>
<v-list> <v-list>
<v-list-item @click="toggleOrderDirection()"> <v-list-item
<v-icon left> slim
{{ density="comfortable"
state.orderDirection === "asc" ? :prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
$globals.icons.sortDescending : $globals.icons.sortAscending :title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
}} @click="toggleOrderDirection()"
</v-icon> />
<v-list-item-title>
{{ state.orderDirection === "asc" ? $tc("general.sort-descending") : $tc("general.sort-ascending") }}
</v-list-item-title>
</v-list-item>
<v-divider /> <v-divider />
<v-list-item <v-list-item
v-for="v in sortable" v-for="v in sortable"
:key="v.name" :key="v.name"
:input-value="state.orderBy === v.value" :active="state.orderBy === v.value"
slim
density="comfortable"
:prepend-icon="v.icon"
:title="v.name"
@click="state.orderBy = v.value" @click="state.orderBy = v.value"
> />
<v-icon left>
{{ v.icon }}
</v-icon>
<v-list-item-title>{{ v.name }}</v-list-item-title>
</v-list-item>
</v-list> </v-list>
</v-card> </v-card>
</v-menu> </v-menu>
<!-- Settings --> <!-- Settings -->
<v-menu offset-y bottom left nudge-bottom="3" :close-on-content-click="false"> <v-menu
<template #activator="{ on, attrs }"> offset-y
<v-btn small color="accent" dark v-bind="attrs" v-on="on"> bottom
<v-icon small> start
nudge-bottom="3"
:close-on-content-click="false"
>
<template #activator="{ props }">
<v-btn
size="small"
color="accent"
dark
v-bind="props"
>
<v-icon size="small">
{{ $globals.icons.cog }} {{ $globals.icons.cog }}
</v-icon> </v-icon>
</v-btn> </v-btn>
</template> </template>
<v-card> <v-card>
<v-card-text> <v-card-text>
<v-switch v-model="state.auto" :label="$t('search.auto-search')" single-line></v-switch> <v-switch
<v-btn block color="primary" @click="reset"> v-model="state.auto"
{{ $tc("general.reset") }} :label="$t('search.auto-search')"
single-line
/>
<v-btn
block
color="primary"
@click="reset"
>
{{ $t("general.reset") }}
</v-btn> </v-btn>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-menu> </v-menu>
</div> </div>
<div v-if="!state.auto" class="search-button-container"> <div
<v-btn x-large color="primary" type="submit" block> v-if="!state.auto"
<v-icon left> class="search-button-container"
>
<v-btn
size="x-large"
color="primary"
type="submit"
block
>
<v-icon start>
{{ $globals.icons.search }} {{ $globals.icons.search }}
</v-icon> </v-icon>
{{ $tc("search.search") }} {{ $t("search.search") }}
</v-btn> </v-btn>
</div> </div>
</form> </form>
</div> </div>
<v-divider></v-divider> <v-divider />
<v-container class="mt-6 px-md-6"> <v-container class="mt-6 px-md-6">
<RecipeCardSection <RecipeCardSection
v-if="state.ready" v-if="state.ready"
class="mt-n5" class="mt-n5"
:icon="$globals.icons.silverwareForkKnife" :icon="$globals.icons.silverwareForkKnife"
:title="$tc('general.recipes')" :title="$t('general.recipes')"
:recipes="recipes" :recipes="recipes"
:query="passedQueryWithSeed" :query="passedQueryWithSeed"
disable-sort
@item-selected="filterItems" @item-selected="filterItems"
@replaceRecipes="replaceRecipes" @replace-recipes="replaceRecipes"
@appendRecipes="appendRecipes" @append-recipes="appendRecipes"
/> />
</v-container> </v-container>
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import { ref, defineComponent, useRouter, onMounted, useContext, computed, Ref, useRoute, watch } from "@nuxtjs/composition-api";
import { watchDebounced } from "@vueuse/shared"; import { watchDebounced } from "@vueuse/shared";
import SearchFilter from "~/components/Domain/SearchFilter.vue"; import SearchFilter from "~/components/Domain/SearchFilter.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
@ -165,17 +221,19 @@ import {
} from "~/composables/store"; } from "~/composables/store";
import { useUserSearchQuerySession } from "~/composables/use-users/preferences"; import { useUserSearchQuerySession } from "~/composables/use-users/preferences";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe"; import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useLazyRecipes } from "~/composables/recipes"; import { useLazyRecipes } from "~/composables/recipes";
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe"; import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
import { HouseholdSummary } from "~/lib/api/types/household"; import type { HouseholdSummary } from "~/lib/api/types/household";
export default defineComponent({ export default defineNuxtComponent({
components: { SearchFilter, RecipeCardSection }, components: { SearchFilter, RecipeCardSection },
setup() { setup() {
const router = useRouter(); const router = useRouter();
const { $auth, $globals, i18n } = useContext(); const i18n = useI18n();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const state = ref({ const state = ref({
@ -193,7 +251,7 @@ export default defineComponent({
}); });
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const searchQuerySession = useUserSearchQuerySession(); const searchQuerySession = useUserSearchQuerySession();
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value); const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
@ -236,9 +294,9 @@ export default defineComponent({
const passedQueryWithSeed = computed(() => { const passedQueryWithSeed = computed(() => {
return { return {
...passedQuery.value, ...passedQuery.value,
_searchSeed: Date.now().toString() _searchSeed: Date.now().toString(),
}; };
}) });
const queryDefaults = { const queryDefaults = {
search: "", search: "",
@ -248,7 +306,7 @@ export default defineComponent({
requireAllTags: false, requireAllTags: false,
requireAllTools: false, requireAllTools: false,
requireAllFoods: false, requireAllFoods: false,
} };
function reset() { function reset() {
state.value.search = queryDefaults.search; state.value.search = queryDefaults.search;
@ -271,11 +329,11 @@ export default defineComponent({
function toIDArray(array: { id: string }[]) { function toIDArray(array: { id: string }[]) {
// we sort the array to make sure the query is always the same // we sort the array to make sure the query is always the same
return array.map((item) => item.id).sort(); return array.map(item => item.id).sort();
} }
function hideKeyboard() { function hideKeyboard() {
input.value.blur() input.value.blur();
} }
const input: Ref<any> = ref(null); const input: Ref<any> = ref(null);
@ -306,7 +364,7 @@ export default defineComponent({
requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined, requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined, requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined,
}, },
} };
await router.push({ query }); await router.push({ query });
searchQuerySession.value.recipe = JSON.stringify(query); searchQuerySession.value.recipe = JSON.stringify(query);
} }
@ -314,7 +372,7 @@ export default defineComponent({
function waitUntilAndExecute( function waitUntilAndExecute(
condition: () => boolean, condition: () => boolean,
callback: () => void, callback: () => void,
opts = { timeout: 2000, interval: 500 } opts = { timeout: 2000, interval: 500 },
): Promise<void> { ): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const state = { const state = {
@ -341,7 +399,7 @@ export default defineComponent({
} }
const sortText = computed(() => { const sortText = computed(() => {
const sort = sortable.find((s) => s.value === state.value.orderBy); const sort = sortable.find(s => s.value === state.value.orderBy);
if (!sort) return ""; if (!sort) return "";
return `${sort.name}`; return `${sort.name}`;
}); });
@ -349,103 +407,112 @@ export default defineComponent({
const sortable = [ const sortable = [
{ {
icon: $globals.icons.orderAlphabeticalAscending, icon: $globals.icons.orderAlphabeticalAscending,
name: i18n.tc("general.sort-alphabetically"), name: i18n.t("general.sort-alphabetically"),
value: "name", value: "name",
}, },
{ {
icon: $globals.icons.newBox, icon: $globals.icons.newBox,
name: i18n.tc("general.created"), name: i18n.t("general.created"),
value: "created_at", value: "created_at",
}, },
{ {
icon: $globals.icons.chefHat, icon: $globals.icons.chefHat,
name: i18n.tc("general.last-made"), name: i18n.t("general.last-made"),
value: "last_made", value: "last_made",
}, },
{ {
icon: $globals.icons.star, icon: $globals.icons.star,
name: i18n.tc("general.rating"), name: i18n.t("general.rating"),
value: "rating", value: "rating",
}, },
{ {
icon: $globals.icons.update, icon: $globals.icons.update,
name: i18n.tc("general.updated"), name: i18n.t("general.updated"),
value: "updated_at", value: "updated_at",
}, },
{ {
icon: $globals.icons.diceMultiple, icon: $globals.icons.diceMultiple,
name: i18n.tc("general.random"), name: i18n.t("general.random"),
value: "random", value: "random",
}, },
]; ];
watch( watch(
() => route.value.query, () => route.query,
() => { () => {
if (!Object.keys(route.value.query).length) { if (!Object.keys(route.query).length) {
reset(); reset();
} }
} },
) );
function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) { function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) {
if (urlPrefix === "categories") { if (urlPrefix === "categories") {
const result = categories.store.value.filter((category) => (category.id as string).includes(item.id as string)); const result = categories.store.value.filter(category => (category.id as string).includes(item.id as string));
selectedCategories.value = result as NoUndefinedField<RecipeTag>[]; selectedCategories.value = result as NoUndefinedField<RecipeTag>[];
} else if (urlPrefix === "tags") { }
const result = tags.store.value.filter((tag) => (tag.id as string).includes(item.id as string)); else if (urlPrefix === "tags") {
const result = tags.store.value.filter(tag => (tag.id as string).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[]; selectedTags.value = result as NoUndefinedField<RecipeTag>[];
} else if (urlPrefix === "tools") { }
const result = tools.store.value.filter((tool) => (tool.id ).includes(item.id || "" )); else if (urlPrefix === "tools") {
const result = tools.store.value.filter(tool => (tool.id).includes(item.id || ""));
selectedTags.value = result as NoUndefinedField<RecipeTag>[]; selectedTags.value = result as NoUndefinedField<RecipeTag>[];
} }
} }
async function hydrateSearch() { async function hydrateSearch() {
const query = router.currentRoute.query; const query = router.currentRoute.value.query;
if (query.auto?.length) { if (query.auto?.length) {
state.value.auto = query.auto === "true"; state.value.auto = query.auto === "true";
} }
if (query.search?.length) { if (query.search?.length) {
state.value.search = query.search as string; state.value.search = query.search as string;
} else { }
else {
state.value.search = queryDefaults.search; state.value.search = queryDefaults.search;
} }
if (query.orderBy?.length) { if (query.orderBy?.length) {
state.value.orderBy = query.orderBy as string; state.value.orderBy = query.orderBy as string;
} else { }
else {
state.value.orderBy = queryDefaults.orderBy; state.value.orderBy = queryDefaults.orderBy;
} }
if (query.orderDirection?.length) { if (query.orderDirection?.length) {
state.value.orderDirection = query.orderDirection as "asc" | "desc"; state.value.orderDirection = query.orderDirection as "asc" | "desc";
} else { }
else {
state.value.orderDirection = queryDefaults.orderDirection; state.value.orderDirection = queryDefaults.orderDirection;
} }
if (query.requireAllCategories?.length) { if (query.requireAllCategories?.length) {
state.value.requireAllCategories = query.requireAllCategories === "true"; state.value.requireAllCategories = query.requireAllCategories === "true";
} else { }
else {
state.value.requireAllCategories = queryDefaults.requireAllCategories; state.value.requireAllCategories = queryDefaults.requireAllCategories;
} }
if (query.requireAllTags?.length) { if (query.requireAllTags?.length) {
state.value.requireAllTags = query.requireAllTags === "true"; state.value.requireAllTags = query.requireAllTags === "true";
} else { }
else {
state.value.requireAllTags = queryDefaults.requireAllTags; state.value.requireAllTags = queryDefaults.requireAllTags;
} }
if (query.requireAllTools?.length) { if (query.requireAllTools?.length) {
state.value.requireAllTools = query.requireAllTools === "true"; state.value.requireAllTools = query.requireAllTools === "true";
} else { }
else {
state.value.requireAllTools = queryDefaults.requireAllTools; state.value.requireAllTools = queryDefaults.requireAllTools;
} }
if (query.requireAllFoods?.length) { if (query.requireAllFoods?.length) {
state.value.requireAllFoods = query.requireAllFoods === "true"; state.value.requireAllFoods = query.requireAllFoods === "true";
} else { }
else {
state.value.requireAllFoods = queryDefaults.requireAllFoods; state.value.requireAllFoods = queryDefaults.requireAllFoods;
} }
@ -456,15 +523,16 @@ export default defineComponent({
waitUntilAndExecute( waitUntilAndExecute(
() => categories.store.value.length > 0, () => categories.store.value.length > 0,
() => { () => {
const result = categories.store.value.filter((item) => const result = categories.store.value.filter(item =>
(query.categories as string[]).includes(item.id as string) (query.categories as string[]).includes(item.id as string),
); );
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[]; selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
} },
) ),
); );
} else { }
else {
selectedCategories.value = []; selectedCategories.value = [];
} }
@ -473,12 +541,13 @@ export default defineComponent({
waitUntilAndExecute( waitUntilAndExecute(
() => tags.store.value.length > 0, () => tags.store.value.length > 0,
() => { () => {
const result = tags.store.value.filter((item) => (query.tags as string[]).includes(item.id as string)); const result = tags.store.value.filter(item => (query.tags as string[]).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[]; selectedTags.value = result as NoUndefinedField<RecipeTag>[];
} },
) ),
); );
} else { }
else {
selectedTags.value = []; selectedTags.value = [];
} }
@ -487,12 +556,13 @@ export default defineComponent({
waitUntilAndExecute( waitUntilAndExecute(
() => tools.store.value.length > 0, () => tools.store.value.length > 0,
() => { () => {
const result = tools.store.value.filter((item) => (query.tools as string[]).includes(item.id)); const result = tools.store.value.filter(item => (query.tools as string[]).includes(item.id));
selectedTools.value = result as NoUndefinedField<RecipeTool>[]; selectedTools.value = result as NoUndefinedField<RecipeTool>[];
} },
) ),
); );
} else { }
else {
selectedTools.value = []; selectedTools.value = [];
} }
@ -506,12 +576,13 @@ export default defineComponent({
return false; return false;
}, },
() => { () => {
const result = foods.store.value?.filter((item) => (query.foods as string[]).includes(item.id)); const result = foods.store.value?.filter(item => (query.foods as string[]).includes(item.id));
selectedFoods.value = result ?? []; selectedFoods.value = result ?? [];
} },
) ),
); );
} else { }
else {
selectedFoods.value = []; selectedFoods.value = [];
} }
@ -525,12 +596,13 @@ export default defineComponent({
return false; return false;
}, },
() => { () => {
const result = households.store.value?.filter((item) => (query.households as string[]).includes(item.id)); const result = households.store.value?.filter(item => (query.households as string[]).includes(item.id));
selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? []; selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? [];
} },
) ),
); );
} else { }
else {
selectedHouseholds.value = []; selectedHouseholds.value = [];
} }
@ -539,11 +611,12 @@ export default defineComponent({
onMounted(async () => { onMounted(async () => {
// restore the user's last search query // restore the user's last search query
if (searchQuerySession.value.recipe && !(Object.keys(route.value.query).length > 0)) { if (searchQuerySession.value.recipe && !(Object.keys(route.query).length > 0)) {
try { try {
const query = JSON.parse(searchQuerySession.value.recipe); const query = JSON.parse(searchQuerySession.value.recipe);
await router.replace({ query }); await router.replace({ query });
} catch (error) { }
catch {
searchQuerySession.value.recipe = ""; searchQuerySession.value.recipe = "";
router.replace({ query: {} }); router.replace({ query: {} });
} }
@ -576,7 +649,7 @@ export default defineComponent({
}, },
{ {
debounce: 500, debounce: 500,
} },
); );
return { return {
@ -610,7 +683,6 @@ export default defineComponent({
filterItems, filterItems,
}; };
}, },
head: {},
}); });
</script> </script>

View file

@ -1,17 +1,25 @@
<template> <template>
<v-tooltip bottom nudge-right="50" :color="buttonStyle ? 'info' : 'secondary'"> <v-tooltip
<template #activator="{ on, attrs }"> location="bottom"
nudge-right="50"
:color="buttonStyle ? 'info' : 'secondary'"
>
<template #activator="{ props }">
<v-btn <v-btn
v-if="isFavorite || showAlways" v-if="isFavorite || showAlways"
small icon
:variant="buttonStyle ? 'flat' : undefined"
:rounded="buttonStyle ? 'circle' : undefined"
size="small"
:color="buttonStyle ? 'info' : 'secondary'" :color="buttonStyle ? 'info' : 'secondary'"
:icon="!buttonStyle"
:fab="buttonStyle" :fab="buttonStyle"
v-bind="attrs" v-bind="{ ...props, ...$attrs }"
@click.prevent="toggleFavorite" @click.prevent="toggleFavorite"
v-on="on"
> >
<v-icon :small="!buttonStyle" :color="buttonStyle ? 'white' : 'secondary'"> <v-icon
:size="!buttonStyle ? undefined : 'x-large'"
:color="buttonStyle ? 'white' : 'secondary'"
>
{{ isFavorite ? $globals.icons.heart : $globals.icons.heartOutline }} {{ isFavorite ? $globals.icons.heart : $globals.icons.heartOutline }}
</v-icon> </v-icon>
</v-btn> </v-btn>
@ -21,11 +29,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import { useUserSelfRatings } from "~/composables/use-users"; import { useUserSelfRatings } from "~/composables/use-users";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { UserOut } from "~/lib/api/types/user";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
recipeId: { recipeId: {
type: String, type: String,
@ -42,22 +49,21 @@ export default defineComponent({
}, },
setup(props) { setup(props) {
const api = useUserApi(); const api = useUserApi();
const { $auth } = useContext(); const $auth = useMealieAuth();
const { userRatings, refreshUserRatings } = useUserSelfRatings(); const { userRatings, refreshUserRatings } = useUserSelfRatings();
// TODO Setup the correct type for $auth.user
// See https://github.com/nuxt-community/auth-module/issues/1097
const user = computed(() => $auth.user as unknown as UserOut);
const isFavorite = computed(() => { const isFavorite = computed(() => {
const rating = userRatings.value.find((r) => r.recipeId === props.recipeId); const rating = userRatings.value.find(r => r.recipeId === props.recipeId);
return rating?.isFavorite || false; return rating?.isFavorite || false;
}); });
async function toggleFavorite() { async function toggleFavorite() {
if (!$auth.user.value) return;
if (!isFavorite.value) { if (!isFavorite.value) {
await api.users.addFavorite(user.value?.id, props.recipeId); await api.users.addFavorite($auth.user.value?.id, props.recipeId);
} else { }
await api.users.removeFavorite(user.value?.id, props.recipeId); else {
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
} }
await refreshUserRatings(); await refreshUserRatings();
} }

View file

@ -1,9 +1,19 @@
<template> <template>
<div class="text-center"> <div class="text-center">
<v-menu v-model="menu" offset-y top nudge-top="6" :close-on-content-click="false"> <v-menu
<template #activator="{ on, attrs }"> v-model="menu"
<v-btn color="accent" dark v-bind="attrs" v-on="on"> offset-y
<v-icon left> top
nudge-top="6"
:close-on-content-click="false"
>
<template #activator="{ props }">
<v-btn
color="accent"
dark
v-bind="props"
>
<v-icon start>
{{ $globals.icons.fileImage }} {{ $globals.icons.fileImage }}
</v-icon> </v-icon>
{{ $t("general.image") }} {{ $t("general.image") }}
@ -25,9 +35,21 @@
</v-card-title> </v-card-title>
<v-card-text class="mt-n5"> <v-card-text class="mt-n5">
<div> <div>
<v-text-field v-model="url" :label="$t('general.url')" class="pt-5" clearable :messages="messages"> <v-text-field
<template #append-outer> v-model="url"
<v-btn class="ml-2" color="primary" :loading="loading" :disabled="!slug" @click="getImageFromURL"> :label="$t('general.url')"
class="pt-5"
clearable
:messages="messages"
>
<template #append>
<v-btn
class="ml-2"
color="primary"
:loading="loading"
:disabled="!slug"
@click="getImageFromURL"
>
{{ $t("general.get") }} {{ $t("general.get") }}
</v-btn> </v-btn>
</template> </template>
@ -40,13 +62,12 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
const REFRESH_EVENT = "refresh"; const REFRESH_EVENT = "refresh";
const UPLOAD_EVENT = "upload"; const UPLOAD_EVENT = "upload";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
slug: { slug: {
type: String, type: String,
@ -58,7 +79,7 @@ export default defineComponent({
url: "", url: "",
loading: false, loading: false,
menu: false, menu: false,
}) });
function uploadImage(fileObject: File) { function uploadImage(fileObject: File) {
context.emit(UPLOAD_EVENT, fileObject); context.emit(UPLOAD_EVENT, fileObject);
@ -75,7 +96,7 @@ export default defineComponent({
state.menu = false; state.menu = false;
} }
const { i18n } = useContext(); const i18n = useI18n();
const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")]; const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")];
return { return {

View file

@ -1,101 +1,148 @@
<template> <template>
<div> <div>
<v-text-field <v-text-field
v-if="value.title || showTitle" v-if="model.title || showTitle"
v-model="value.title" v-model="model.title"
dense density="compact"
variant="underlined"
hide-details hide-details
class="mx-1 mt-3 mb-4" class="mx-1 mt-3 mb-4"
:placeholder="$t('recipe.section-title')" :placeholder="$t('recipe.section-title')"
style="max-width: 500px" style="max-width: 500px"
@click="$emit('clickIngredientField', 'title')" @click="$emit('clickIngredientField', 'title')"
> />
</v-text-field> <v-row
<v-row :no-gutters="$vuetify.breakpoint.mdAndUp" dense class="d-flex flex-wrap my-1"> :no-gutters="mdAndUp"
<v-col v-if="!disableAmount" sm="12" md="2" cols="12" class="flex-grow-0 flex-shrink-0">
<v-text-field
v-model="value.quantity"
solo
hide-details
dense dense
class="d-flex flex-wrap my-1"
>
<v-col
v-if="!disableAmount"
sm="12"
md="2"
cols="12"
class="flex-grow-0 flex-shrink-0"
>
<v-text-field
v-model="model.quantity"
variant="solo"
hide-details
density="compact"
type="number" type="number"
:placeholder="$t('recipe.quantity')" :placeholder="$t('recipe.quantity')"
@keypress="quantityFilter" @keypress="quantityFilter"
> >
<v-icon v-if="$listeners && $listeners.delete" slot="prepend" class="mr-n1 handle"> <template #prepend>
<v-icon
class="mr-n1 handle"
>
{{ $globals.icons.arrowUpDown }} {{ $globals.icons.arrowUpDown }}
</v-icon> </v-icon>
</template>
</v-text-field> </v-text-field>
</v-col> </v-col>
<v-col v-if="!disableAmount" sm="12" md="3" cols="12"> <v-col
v-if="!disableAmount"
sm="12"
md="3"
cols="12"
>
<v-autocomplete <v-autocomplete
ref="unitAutocomplete" ref="unitAutocomplete"
v-model="value.unit" v-model="model.unit"
:search-input.sync="unitSearch" v-model:search="unitSearch"
auto-select-first auto-select-first
hide-details hide-details
dense density="compact"
solo variant="solo"
return-object return-object
:items="units || []" :items="units || []"
item-text="name" item-title="name"
class="mx-1" class="mx-1"
:placeholder="$t('recipe.choose-unit')" :placeholder="$t('recipe.choose-unit')"
clearable clearable
@keyup.enter="handleUnitEnter" @keyup.enter="handleUnitEnter"
> >
<template #no-data> <template #no-data>
<div class="caption text-center pb-2">{{ $t("recipe.press-enter-to-create") }}</div> <div class="caption text-center pb-2">
{{ $t("recipe.press-enter-to-create") }}
</div>
</template> </template>
<template #append-item> <template #append-item>
<div class="px-2"> <div class="px-2">
<BaseButton block small @click="createAssignUnit()"></BaseButton> <BaseButton
block
size="small"
@click="createAssignUnit()"
/>
</div> </div>
</template> </template>
</v-autocomplete> </v-autocomplete>
</v-col> </v-col>
<!-- Foods Input --> <!-- Foods Input -->
<v-col v-if="!disableAmount" m="12" md="3" cols="12" class=""> <v-col
v-if="!disableAmount"
m="12"
md="3"
cols="12"
class=""
>
<v-autocomplete <v-autocomplete
ref="foodAutocomplete" ref="foodAutocomplete"
v-model="value.food" v-model="model.food"
:search-input.sync="foodSearch" v-model:search="foodSearch"
auto-select-first auto-select-first
hide-details hide-details
dense density="compact"
solo variant="solo"
return-object return-object
:items="foods || []" :items="foods || []"
item-text="name" item-title="name"
class="mx-1 py-0" class="mx-1 py-0"
:placeholder="$t('recipe.choose-food')" :placeholder="$t('recipe.choose-food')"
clearable clearable
@keyup.enter="handleFoodEnter" @keyup.enter="handleFoodEnter"
> >
<template #no-data> <template #no-data>
<div class="caption text-center pb-2">{{ $t("recipe.press-enter-to-create") }}</div> <div class="caption text-center pb-2">
{{ $t("recipe.press-enter-to-create") }}
</div>
</template> </template>
<template #append-item> <template #append-item>
<div class="px-2"> <div class="px-2">
<BaseButton block small @click="createAssignFood()"></BaseButton> <BaseButton
block
size="small"
@click="createAssignFood()"
/>
</div> </div>
</template> </template>
</v-autocomplete> </v-autocomplete>
</v-col> </v-col>
<v-col sm="12" md="" cols="12"> <v-col
sm="12"
md=""
cols="12"
>
<div class="d-flex"> <div class="d-flex">
<v-text-field <v-text-field
v-model="value.note" v-model="model.note"
hide-details hide-details
dense density="compact"
solo variant="solo"
:placeholder="$t('recipe.notes')" :placeholder="$t('recipe.notes')"
class="mb-auto"
@click="$emit('clickIngredientField', 'note')" @click="$emit('clickIngredientField', 'note')"
> >
<v-icon v-if="disableAmount && $listeners && $listeners.delete" slot="prepend" class="mr-n1 handle"> <template #prepend>
<v-icon
v-if="disableAmount && $attrs && $attrs.delete"
class="mr-n1 handle"
>
{{ $globals.icons.arrowUpDown }} {{ $globals.icons.arrowUpDown }}
</v-icon> </v-icon>
</template>
</v-text-field> </v-text-field>
<BaseButtonGroup <BaseButtonGroup
hover hover
@ -112,26 +159,32 @@
</div> </div>
</v-col> </v-col>
</v-row> </v-row>
<p v-if="showOriginalText" class="text-caption"> <p
{{ $t("recipe.original-text-with-value", { originalText: value.originalText }) }} v-if="showOriginalText"
class="text-caption"
>
{{ $t("recipe.original-text-with-value", { originalText: model.originalText }) }}
</p> </p>
<v-divider v-if="!$vuetify.breakpoint.mdAndUp" class="my-4"></v-divider> <v-divider
v-if="!mdAndUp"
class="my-4"
/>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api"; import { ref, computed, reactive, toRefs } from "vue";
import { useDisplay } from "vuetify";
import { useI18n } from "vue-i18n";
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store"; import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
import { validators } from "~/composables/use-validators"; import { useNuxtApp } from "#app";
import { RecipeIngredient } from "~/lib/api/types/recipe"; import type { RecipeIngredient } from "~/lib/api/types/recipe";
export default defineComponent({ // defineModel replaces modelValue prop
props: { const model = defineModel<RecipeIngredient>({ required: true });
value: {
type: Object as () => RecipeIngredient, const props = defineProps({
required: true,
},
disableAmount: { disableAmount: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -139,45 +192,52 @@ export default defineComponent({
allowInsertIngredient: { allowInsertIngredient: {
type: Boolean, type: Boolean,
default: false, default: false,
}
}, },
setup(props, { listeners }) { });
const { i18n, $globals } = useContext();
defineEmits([
"clickIngredientField",
"insert-above",
"insert-below",
"insert-ingredient",
"delete",
]);
const { mdAndUp } = useDisplay();
const i18n = useI18n();
const { $globals } = useNuxtApp();
const state = reactive({
showTitle: false,
showOriginalText: false,
});
const contextMenuOptions = computed(() => { const contextMenuOptions = computed(() => {
const options = [ const options = [
{ {
text: i18n.tc("recipe.toggle-section"), text: i18n.t("recipe.toggle-section"),
event: "toggle-section", event: "toggle-section",
}, },
{ {
text: i18n.tc("recipe.insert-above"), text: i18n.t("recipe.insert-above"),
event: "insert-above", event: "insert-above",
}, },
{ {
text: i18n.tc("recipe.insert-below"), text: i18n.t("recipe.insert-below"),
event: "insert-below", event: "insert-below",
}, },
]; ];
if (props.allowInsertIngredient) { if (props.allowInsertIngredient) {
options.push({ options.push({
text: i18n.tc("recipe.insert-ingredient") , text: i18n.t("recipe.insert-ingredient"),
event: "insert-ingredient", event: "insert-ingredient",
}) });
} }
// FUTURE: add option to parse a single ingredient if (model.value.originalText) {
// if (!value.food && !value.unit && value.note) {
// options.push({
// text: "Parse Ingredient",
// event: "parse-ingredient",
// });
// }
if (props.value.originalText) {
options.push({ options.push({
text: i18n.tc("recipe.see-original-text"), text: i18n.t("recipe.see-original-text"),
event: "toggle-original", event: "toggle-original",
}); });
} }
@ -189,25 +249,24 @@ export default defineComponent({
const out = [ const out = [
{ {
icon: $globals.icons.dotsVertical, icon: $globals.icons.dotsVertical,
text: i18n.tc("general.menu"), text: i18n.t("general.menu"),
event: "open", event: "open",
children: contextMenuOptions.value, children: contextMenuOptions.value,
}, },
]; ];
if (listeners && listeners.delete) { // If delete event is being listened for, show delete button
// @ts-expect-error - TODO: fix this // $attrs is not available in <script setup>, so always show if parent listens
out.unshift({ out.unshift({
icon: $globals.icons.delete, icon: $globals.icons.delete,
text: i18n.tc("general.delete"), text: i18n.t("general.delete"),
event: "delete", event: "delete",
children: undefined,
}); });
}
return out; return out;
}); });
// ==================================================
// Foods // Foods
const foodStore = useFoodStore(); const foodStore = useFoodStore();
const foodData = useFoodData(); const foodData = useFoodData();
@ -216,12 +275,11 @@ export default defineComponent({
async function createAssignFood() { async function createAssignFood() {
foodData.data.name = foodSearch.value; foodData.data.name = foodSearch.value;
props.value.food = await foodStore.actions.createOne(foodData.data) || undefined; model.value.food = await foodStore.actions.createOne(foodData.data) || undefined;
foodData.reset(); foodData.reset();
foodAutocomplete.value?.blur(); foodAutocomplete.value?.blur();
} }
// ==================================================
// Units // Units
const unitStore = useUnitStore(); const unitStore = useUnitStore();
const unitsData = useUnitData(); const unitsData = useUnitData();
@ -230,19 +288,14 @@ export default defineComponent({
async function createAssignUnit() { async function createAssignUnit() {
unitsData.data.name = unitSearch.value; unitsData.data.name = unitSearch.value;
props.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined; model.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined;
unitsData.reset(); unitsData.reset();
unitAutocomplete.value?.blur(); unitAutocomplete.value?.blur();
} }
const state = reactive({
showTitle: false,
showOriginalText: false,
});
function toggleTitle() { function toggleTitle() {
if (state.showTitle) { if (state.showTitle) {
props.value.title = ""; model.value.title = "";
} }
state.showTitle = !state.showTitle; state.showTitle = !state.showTitle;
} }
@ -253,9 +306,9 @@ export default defineComponent({
function handleUnitEnter() { function handleUnitEnter() {
if ( if (
props.value.unit === undefined || model.value.unit === undefined
props.value.unit === null || || model.value.unit === null
!props.value.unit.name.includes(unitSearch.value) || !model.value.unit.name.includes(unitSearch.value)
) { ) {
createAssignUnit(); createAssignUnit();
} }
@ -263,44 +316,24 @@ export default defineComponent({
function handleFoodEnter() { function handleFoodEnter() {
if ( if (
props.value.food === undefined || model.value.food === undefined
props.value.food === null || || model.value.food === null
!props.value.food.name.includes(foodSearch.value) || !model.value.food.name.includes(foodSearch.value)
) { ) {
createAssignFood(); createAssignFood();
} }
} }
function quantityFilter(e: KeyboardEvent) { function quantityFilter(e: KeyboardEvent) {
// if digit is pressed, add to quantity
if (e.key === "-" || e.key === "+" || e.key === "e") { if (e.key === "-" || e.key === "+" || e.key === "e") {
e.preventDefault(); e.preventDefault();
} }
} }
return { const { showTitle, showOriginalText } = toRefs(state);
...toRefs(state),
quantityFilter, const foods = foodStore.store;
toggleOriginalText, const units = unitStore.store;
contextMenuOptions,
handleUnitEnter,
handleFoodEnter,
foodAutocomplete,
createAssignFood,
unitAutocomplete,
createAssignUnit,
foods: foodStore.store,
foodSearch,
toggleTitle,
unitActions: unitStore.actions,
units: unitStore.store,
unitSearch,
validators,
workingUnitData: unitsData.data,
btns,
};
},
});
</script> </script>
<style> <style>

View file

@ -1,12 +1,12 @@
<template> <template>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="safeMarkup"></div> <div v-html="safeMarkup" />
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients"; import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
export default defineComponent({
export default defineNuxtComponent({
props: { props: {
markup: { markup: {
type: String, type: String,
@ -17,7 +17,7 @@ export default defineComponent({
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup)); const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
return { return {
safeMarkup, safeMarkup,
} };
} },
}); });
</script> </script>

View file

@ -1,20 +1,38 @@
<template> <template>
<div class="ma-0 pa-0 text-subtitle-1 dense-markdown ingredient-item"> <div class="ma-0 pa-0 text-subtitle-1 dense-markdown ingredient-item">
<SafeMarkdown v-if="parsedIng.quantity" class="d-inline" :source="parsedIng.quantity" /> <SafeMarkdown
<template v-if="parsedIng.unit">{{ parsedIng.unit }} </template> v-if="parsedIng.quantity"
<SafeMarkdown v-if="parsedIng.note && !parsedIng.name" class="text-bold d-inline" :source="parsedIng.note" /> class="d-inline"
:source="parsedIng.quantity"
/>
<template v-if="parsedIng.unit">
{{ parsedIng.unit }}
</template>
<SafeMarkdown
v-if="parsedIng.note && !parsedIng.name"
class="text-bold d-inline"
:source="parsedIng.note"
/>
<template v-else> <template v-else>
<SafeMarkdown v-if="parsedIng.name" class="text-bold d-inline" :source="parsedIng.name" /> <SafeMarkdown
<SafeMarkdown v-if="parsedIng.note" class="note" :source="parsedIng.note" /> v-if="parsedIng.name"
class="text-bold d-inline"
:source="parsedIng.name"
/>
<SafeMarkdown
v-if="parsedIng.note"
class="note"
:source="parsedIng.note"
/>
</template> </template>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api"; import type { RecipeIngredient } from "~/lib/api/types/household";
import { RecipeIngredient } from "~/lib/api/types/household";
import { useParsedIngredientText } from "~/composables/recipes"; import { useParsedIngredientText } from "~/composables/recipes";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
ingredient: { ingredient: {
type: Object as () => RecipeIngredient, type: Object as () => RecipeIngredient,
@ -40,8 +58,16 @@ export default defineComponent({
}, },
}); });
</script> </script>
<style lang="scss"> <style lang="scss">
.ingredient-item { .ingredient-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25em;
word-break: break-word;
min-width: 0;
.d-inline { .d-inline {
& > p { & > p {
display: inline; display: inline;
@ -66,12 +92,19 @@ export default defineComponent({
.text-bold { .text-bold {
font-weight: bold; font-weight: bold;
white-space: normal;
word-break: break-word;
} }
} }
.note { .note {
line-height: 1.25em; flex-basis: 100%;
width: 100%;
display: block;
line-height: 1.3em;
font-size: 0.8em; font-size: 0.8em;
opacity: 0.7; opacity: 0.7;
white-space: normal;
word-break: break-word;
} }
</style> </style>

View file

@ -1,20 +1,51 @@
<template> <template>
<div v-if="value && value.length > 0"> <div v-if="value && value.length > 0">
<div v-if="!isCookMode" class="d-flex justify-start" > <div
<h2 class="mb-2 mt-1">{{ $t("recipe.ingredients") }}</h2> v-if="!isCookMode"
<AppButtonCopy btn-class="ml-auto" :copy-text="ingredientCopyText" /> class="d-flex justify-start"
>
<h2 class="mt-1 text-h5 font-weight-medium opacity-80">
{{ $t("recipe.ingredients") }}
</h2>
<AppButtonCopy
btn-class="ml-auto"
:copy-text="ingredientCopyText"
/>
</div> </div>
<div> <div>
<div v-for="(ingredient, index) in value" :key="'ingredient' + index"> <div
v-for="(ingredient, index) in value"
:key="'ingredient' + index"
>
<template v-if="!isCookMode"> <template v-if="!isCookMode">
<h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3> <h3
<v-divider v-if="showTitleEditor[index]"></v-divider> v-if="showTitleEditor[index]"
class="mt-2"
>
{{ ingredient.title }}
</h3>
<v-divider v-if="showTitleEditor[index]" />
</template> </template>
<v-list-item dense @click.stop="toggleChecked(index)"> <v-list-item
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" /> density="compact"
<v-list-item-content :key="ingredient.quantity"> @click.stop="toggleChecked(index)"
<RecipeIngredientListItem :ingredient="ingredient" :disable-amount="disableAmount" :scale="scale" /> >
</v-list-item-content> <template #prepend>
<v-checkbox
v-model="checked[index]"
hide-details
class="pt-0 my-auto py-auto"
color="secondary"
density="comfortable"
/>
</template>
<v-list-item-title>
<RecipeIngredientListItem
:ingredient="ingredient"
:disable-amount="disableAmount"
:scale="scale"
/>
</v-list-item-title>
</v-list-item> </v-list-item>
</div> </div>
</div> </div>
@ -22,12 +53,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, toRefs } from "@nuxtjs/composition-api";
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue"; import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import { parseIngredientText } from "~/composables/recipes"; import { parseIngredientText } from "~/composables/recipes";
import { RecipeIngredient } from "~/lib/api/types/recipe"; import type { RecipeIngredient } from "~/lib/api/types/recipe";
export default defineComponent({ export default defineNuxtComponent({
components: { RecipeIngredientListItem }, components: { RecipeIngredientListItem },
props: { props: {
value: { value: {
@ -45,7 +75,7 @@ export default defineComponent({
isCookMode: { isCookMode: {
type: Boolean, type: Boolean,
default: false, default: false,
} },
}, },
setup(props) { setup(props) {
function validateTitle(title?: string) { function validateTitle(title?: string) {
@ -54,7 +84,7 @@ export default defineComponent({
const state = reactive({ const state = reactive({
checked: props.value.map(() => false), checked: props.value.map(() => false),
showTitleEditor: computed(() => props.value.map((x) => validateTitle(x.title))), showTitleEditor: computed(() => props.value.map(x => validateTitle(x.title))),
}); });
const ingredientCopyText = computed(() => { const ingredientCopyText = computed(() => {

View file

@ -4,8 +4,9 @@
<BaseDialog <BaseDialog
v-model="madeThisDialog" v-model="madeThisDialog"
:icon="$globals.icons.chefHat" :icon="$globals.icons.chefHat"
:title="$tc('recipe.made-this')" :title="$t('recipe.made-this')"
:submit-text="$tc('recipe.add-to-timeline')" :submit-text="$t('recipe.add-to-timeline')"
can-submit
@submit="createTimelineEvent" @submit="createTimelineEvent"
> >
<v-card-text> <v-card-text>
@ -13,37 +14,35 @@
<v-textarea <v-textarea
v-model="newTimelineEvent.eventMessage" v-model="newTimelineEvent.eventMessage"
autofocus autofocus
:label="$tc('recipe.comment')" :label="$t('recipe.comment')"
:hint="$tc('recipe.how-did-it-turn-out')" :hint="$t('recipe.how-did-it-turn-out')"
persistent-hint persistent-hint
rows="4" rows="4"
></v-textarea> />
<v-container> <v-container>
<v-row> <v-row>
<v-col cols="auto"> <v-col cols="6">
<v-menu <v-menu
v-model="datePickerMenu" v-model="datePickerMenu"
:close-on-content-click="false" :close-on-content-click="false"
transition="scale-transition" transition="scale-transition"
offset-y offset-y
max-width="290px" max-width="290px"
min-width="auto"
> >
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-text-field <v-text-field
v-model="newTimelineEventTimestamp" v-model="newTimelineEventTimestampString"
:prepend-icon="$globals.icons.calendar" :prepend-icon="$globals.icons.calendar"
v-bind="attrs" v-bind="props"
readonly readonly
v-on="on" />
></v-text-field>
</template> </template>
<v-date-picker <v-date-picker
v-model="newTimelineEventTimestamp" v-model="newTimelineEventTimestamp"
no-title hide-header
:first-day-of-week="firstDayOfWeek" :first-day-of-week="firstDayOfWeek"
:local="$i18n.locale" :local="$i18n.locale"
@input="datePickerMenu = false" @update:model-value="datePickerMenu = false"
/> />
</v-menu> </v-menu>
</v-col> </v-col>
@ -55,18 +54,16 @@
url="none" url="none"
file-name="image" file-name="image"
accept="image/*" accept="image/*"
:text="$i18n.tc('recipe.upload-image')" :text="$t('recipe.upload-image')"
:text-btn="false" :text-btn="false"
:post="false" :post="false"
@uploaded="uploadImage" @uploaded="uploadImage"
/> />
<v-btn <v-btn v-if="!!newTimelineEventImage" color="error" @click="clearImage">
v-if="!!newTimelineEventImage" <v-icon start>
color="error" {{ $globals.icons.close }}
@click="clearImage" </v-icon>
> {{ $t("recipe.remove-image") }}
<v-icon left>{{ $globals.icons.close }}</v-icon>
{{ $i18n.tc('recipe.remove-image') }}
</v-btn> </v-btn>
</v-col> </v-col>
</v-row> </v-row>
@ -87,24 +84,31 @@
</div> </div>
<div> <div>
<div v-if="lastMadeReady" class="d-flex justify-center flex-wrap"> <div v-if="lastMadeReady" class="d-flex justify-center flex-wrap">
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger;"> <v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger">
<v-tooltip bottom> <v-tooltip bottom>
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-btn <v-btn
rounded rounded
outlined variant="outlined"
x-large size="x-large"
color="primary" v-bind="props"
v-bind="attrs" style="border-color: rgb(var(--v-theme-primary));"
v-on="on"
@click="madeThisDialog = true" @click="madeThisDialog = true"
> >
<v-icon left large>{{ $globals.icons.calendar }}</v-icon> <v-icon start size="large" color="primary">
<span class="text--secondary" style="letter-spacing: normal;"><b>{{ $tc("general.last-made") }}</b><br>{{ lastMade ? new Date(lastMade).toLocaleDateString($i18n.locale) : $tc("general.never") }}</span> {{ $globals.icons.calendar }}
<v-icon right large>{{ $globals.icons.createAlt }}</v-icon> </v-icon>
<span class="text-body-1 opacity-80">
<b>{{ $t("general.last-made") }}</b>
<br>
{{ lastMade ? new Date(lastMade).toLocaleDateString($i18n.locale) : $t("general.never") }}
</span>
<v-icon end size="large" color="primary">
{{ $globals.icons.createAlt }}
</v-icon>
</v-btn> </v-btn>
</template> </template>
<span>{{ $tc("recipe.made-this") }}</span> <span>{{ $t("recipe.made-this") }}</span>
</v-tooltip> </v-tooltip>
</v-row> </v-row>
</div> </div>
@ -113,25 +117,26 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, onMounted, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/core"; import { whenever } from "@vueuse/core";
import { VForm } from "~/types/vuetify";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
import { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe"; import type { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
import type { VForm } from "~/types/auto-forms";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
recipe: { recipe: {
type: Object as () => Recipe, type: Object as () => Recipe,
required: true, required: true,
}, },
}, },
emits: ["eventCreated"],
setup(props, context) { setup(props, context) {
const madeThisDialog = ref(false); const madeThisDialog = ref(false);
const userApi = useUserApi(); const userApi = useUserApi();
const { household } = useHouseholdSelf(); const { household } = useHouseholdSelf();
const { $auth, i18n } = useContext(); const i18n = useI18n();
const $auth = useMealieAuth();
const domMadeThisForm = ref<VForm>(); const domMadeThisForm = ref<VForm>();
const newTimelineEvent = ref<RecipeTimelineEventIn>({ const newTimelineEvent = ref<RecipeTimelineEventIn>({
subject: "", subject: "",
@ -143,14 +148,18 @@ export default defineComponent({
const newTimelineEventImage = ref<Blob | File>(); const newTimelineEventImage = ref<Blob | File>();
const newTimelineEventImageName = ref<string>(""); const newTimelineEventImageName = ref<string>("");
const newTimelineEventImagePreviewUrl = ref<string>(); const newTimelineEventImagePreviewUrl = ref<string>();
const newTimelineEventTimestamp = ref<string>(); const newTimelineEventTimestamp = ref<Date>(new Date());
const newTimelineEventTimestampString = computed(() => {
return newTimelineEventTimestamp.value.toISOString().substring(0, 10);
});
const lastMade = ref(props.recipe.lastMade); const lastMade = ref(props.recipe.lastMade);
const lastMadeReady = ref(false); const lastMadeReady = ref(false);
onMounted(async () => { onMounted(async () => {
if (!$auth.user?.householdSlug) { if (!$auth.user?.value?.householdSlug) {
lastMade.value = props.recipe.lastMade; lastMade.value = props.recipe.lastMade;
} else { }
else {
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || ""); const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
lastMade.value = data?.lastMade; lastMade.value = data?.lastMade;
} }
@ -158,15 +167,12 @@ export default defineComponent({
lastMadeReady.value = true; lastMadeReady.value = true;
}); });
whenever( whenever(
() => madeThisDialog.value, () => madeThisDialog.value,
() => { () => {
// Set timestamp to now // Set timestamp to now
newTimelineEventTimestamp.value = ( newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
new Date(Date.now() - (new Date()).getTimezoneOffset() * 60000) },
).toISOString().substring(0, 10);
}
); );
const firstDayOfWeek = computed(() => { const firstDayOfWeek = computed(() => {
@ -192,17 +198,17 @@ export default defineComponent({
const state = reactive({ datePickerMenu: false }); const state = reactive({ datePickerMenu: false });
async function createTimelineEvent() { async function createTimelineEvent() {
if (!(newTimelineEventTimestamp.value && props.recipe?.id && props.recipe?.slug)) { if (!(newTimelineEventTimestampString.value && props.recipe?.id && props.recipe?.slug)) {
return; return;
} }
newTimelineEvent.value.recipeId = props.recipe.id newTimelineEvent.value.recipeId = props.recipe.id;
// @ts-expect-error - TS doesn't like the $auth global user attribute // Note: $auth.user is now a ref
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.fullName }) newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
// the user only selects the date, so we set the time to end of day local time // the user only selects the date, so we set the time to end of day local time
// we choose the end of day so it always comes after "new recipe" events // we choose the end of day so it always comes after "new recipe" events
newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestamp.value + "T23:59:59").toISOString(); newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestampString.value + "T23:59:59").toISOString();
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value); const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
const newEvent = eventResponse.data; const newEvent = eventResponse.data;
@ -221,7 +227,6 @@ export default defineComponent({
newTimelineEventImageName.value, newTimelineEventImageName.value,
); );
if (imageResponse.data) { if (imageResponse.data) {
// @ts-ignore the image response data will always match a value of TimelineEventImage
newEvent.image = imageResponse.data.image; newEvent.image = imageResponse.data.image;
} }
} }
@ -245,6 +250,7 @@ export default defineComponent({
newTimelineEventImage, newTimelineEventImage,
newTimelineEventImagePreviewUrl, newTimelineEventImagePreviewUrl,
newTimelineEventTimestamp, newTimelineEventTimestamp,
newTimelineEventTimestampString,
lastMade, lastMade,
lastMadeReady, lastMadeReady,
createTimelineEvent, createTimelineEvent,

View file

@ -7,34 +7,57 @@
:class="attrs.class.sheet" :class="attrs.class.sheet"
:style="tile ? 'max-width: 100%; width: fit-content;' : 'width: 100%;'" :style="tile ? 'max-width: 100%; width: fit-content;' : 'width: 100%;'"
> >
<v-list-item :to="disabled ? '' : '/g/' + groupSlug + '/r/' + recipe.slug" :class="attrs.class.listItem"> <v-list-item
<v-list-item-avatar :class="attrs.class.avatar"> :to="disabled ? '' : '/g/' + groupSlug + '/r/' + recipe.slug"
<v-icon :class="attrs.class.icon" dark :small="small"> {{ $globals.icons.primary }} </v-icon> :class="attrs.class.listItem"
</v-list-item-avatar> >
<v-list-item-content :class="attrs.class.text"> <template #prepend>
<v-list-item-title :class="listItem && listItemDescriptions[index] ? '' : 'pr-4'" :style="attrs.style.text.title"> <v-avatar color="primary" :class="attrs.class.avatar">
<v-icon
:class="attrs.class.icon"
dark
:size="small ? 'small' : 'default'"
>
{{ $globals.icons.primary }}
</v-icon>
</v-avatar>
</template>
<div :class="attrs.class.text">
<v-list-item-title
:class="listItem && listItemDescriptions[index] ? '' : 'pr-4'"
:style="attrs.style.text.title"
>
{{ recipe.name }} {{ recipe.name }}
</v-list-item-title> </v-list-item-title>
<v-list-item-subtitle v-if="showDescription">{{ recipe.description }}</v-list-item-subtitle> <v-list-item-subtitle v-if="showDescription">
<v-list-item-subtitle v-if="listItem && listItemDescriptions[index]" :style="attrs.style.text.subTitle"> {{ recipe.description }}
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="listItemDescriptions[index]"></div>
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item-content> <v-list-item-subtitle
<slot :name="'actions-' + recipe.id" :v-bind="{ item: recipe }"> </slot> v-if="listItem && listItemDescriptions[index]"
:style="attrs.style.text.subTitle"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="listItemDescriptions[index]" />
</v-list-item-subtitle>
</div>
<template #append>
<slot
:name="'actions-' + recipe.id"
:v-bind="{ item: recipe }"
/>
</template>
</v-list-item> </v-list-item>
</v-sheet> </v-sheet>
</v-list> </v-list>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useFraction } from "~/composables/recipes/use-fraction"; import { useFraction } from "~/composables/recipes/use-fraction";
import { ShoppingListItemOut } from "~/lib/api/types/household"; import type { ShoppingListItemOut } from "~/lib/api/types/household";
import { RecipeSummary } from "~/lib/api/types/recipe"; import type { RecipeSummary } from "~/lib/api/types/recipe";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
recipes: { recipes: {
type: Array as () => RecipeSummary[], type: Array as () => RecipeSummary[],
@ -59,16 +82,17 @@ export default defineComponent({
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false, default: false,
} },
}, },
setup(props) { setup(props) {
const { $auth } = useContext(); const $auth = useMealieAuth();
const { frac } = useFraction(); const { frac } = useFraction();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
const attrs = computed(() => { const attrs = computed(() => {
return props.small ? { return props.small
? {
class: { class: {
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center", sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
listItem: "px-0", listItem: "px-0",
@ -82,7 +106,8 @@ export default defineComponent({
subTitle: "font-size: x-small;", subTitle: "font-size: x-small;",
}, },
}, },
} : { }
: {
class: { class: {
sheet: props.tile ? "mx-1 justify-center align-center" : "mb-1 justify-center align-center", sheet: props.tile ? "mx-1 justify-center align-center" : "mb-1 justify-center align-center",
listItem: "px-4", listItem: "px-4",
@ -96,7 +121,7 @@ export default defineComponent({
subTitle: "", subTitle: "",
}, },
}, },
} };
}); });
function sanitizeHTML(rawHtml: string) { function sanitizeHTML(rawHtml: string) {
@ -112,7 +137,7 @@ export default defineComponent({
|| !props.listItem?.recipeReferences || !props.listItem?.recipeReferences
|| props.listItem.recipeReferences.length !== props.recipes.length || props.listItem.recipeReferences.length !== props.recipes.length
) { ) {
return props.recipes.map((_) => "") return props.recipes.map(_ => "");
} }
const listItemDescriptions: string[] = []; const listItemDescriptions: string[] = [];
@ -120,7 +145,7 @@ export default defineComponent({
const itemRef = props.listItem?.recipeReferences[i]; const itemRef = props.listItem?.recipeReferences[i];
const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1); const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1);
let listItemDescription = "" let listItemDescription = "";
if (props.listItem.unit?.fraction) { if (props.listItem.unit?.fraction) {
const fraction = frac(quantity, 10, true); const fraction = frac(quantity, 10, true);
if (fraction[0] !== undefined && fraction[0] > 0) { if (fraction[0] !== undefined && fraction[0] > 0) {
@ -140,13 +165,14 @@ export default defineComponent({
if (props.listItem.unit) { if (props.listItem.unit) {
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
? props.listItem.unit.abbreviation : props.listItem.unit.name; ? props.listItem.unit.abbreviation
: props.listItem.unit.name;
listItemDescription += ` ${unitDisplay}` listItemDescription += ` ${unitDisplay}`;
} }
if (itemRef.recipeNote) { if (itemRef.recipeNote) {
listItemDescription += `, ${itemRef.recipeNote}` listItemDescription += `, ${itemRef.recipeNote}`;
} }
listItemDescriptions.push(sanitizeHTML(listItemDescription)); listItemDescriptions.push(sanitizeHTML(listItemDescription));

View file

@ -1,16 +1,40 @@
<template> <template>
<div v-if="value.length > 0 || edit" class="mt-8"> <div
<h2 class="my-4">{{ $t("recipe.note") }}</h2> v-if="model.length > 0 || edit"
<div v-for="(note, index) in value" :id="'note' + index" :key="'note' + index" class="mt-1"> class="mt-8"
>
<h2 class="my-4 text-h5 font-weight-medium opacity-80">
{{ $t("recipe.note") }}
</h2>
<div
v-for="(note, index) in model"
:id="'note' + index"
:key="'note' + index"
class="mt-1"
>
<v-card v-if="edit"> <v-card v-if="edit">
<v-card-text> <v-card-text>
<div class="d-flex align-center"> <div class="d-flex align-center">
<v-text-field v-model="value[index]['title']" :label="$t('recipe.title')" /> <v-text-field
<v-btn icon class="mr-2" elevation="0" @click="removeByIndex(value, index)"> v-model="model[index]['title']"
variant="underlined"
:label="$t('recipe.title')"
/>
<v-btn
icon
class="mr-2"
elevation="0"
@click="removeByIndex(index)"
>
<v-icon>{{ $globals.icons.delete }}</v-icon> <v-icon>{{ $globals.icons.delete }}</v-icon>
</v-btn> </v-btn>
</div> </div>
<v-textarea v-model="value[index]['text']" auto-grow :placeholder="$t('recipe.note')" /> <v-textarea
v-model="model[index]['text']"
variant="underlined"
auto-grow
:placeholder="$t('recipe.note')"
/>
</v-card-text> </v-card-text>
</v-card> </v-card>
<div v-else> <div v-else>
@ -23,44 +47,39 @@
</div> </div>
</div> </div>
<div v-if="edit" class="d-flex justify-end"> <div
<BaseButton class="ml-auto my-2" @click="addNote"> {{ $t("general.add") }}</BaseButton> v-if="edit"
class="d-flex justify-end"
>
<BaseButton
class="ml-auto my-2"
@click="addNote"
>
{{ $t("general.add") }}
</BaseButton>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent } from "@nuxtjs/composition-api"; import type { RecipeNote } from "~/lib/api/types/recipe";
import { RecipeNote } from "~/lib/api/types/recipe";
export default defineComponent({ const model = defineModel<RecipeNote[]>({ default: () => [] });
props: {
value: {
type: Array as () => RecipeNote[],
required: false,
default: () => [],
},
defineProps({
edit: { edit: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
},
setup(props) {
function addNote() {
props.value.push({ title: "", text: "" });
}
function removeByIndex(list: unknown[], index: number) {
list.splice(index, 1);
}
return {
addNote,
removeByIndex,
};
},
}); });
</script>
<style></style> function addNote() {
model.value = [...model.value, { title: "", text: "" }];
}
function removeByIndex(index: number) {
const newNotes = [...model.value];
newNotes.splice(index, 1);
model.value = newNotes;
}
</script>

View file

@ -4,23 +4,42 @@
<v-card-title class="pt-2 pb-0"> <v-card-title class="pt-2 pb-0">
{{ $t("recipe.nutrition") }} {{ $t("recipe.nutrition") }}
</v-card-title> </v-card-title>
<v-divider class="mx-2 my-1"></v-divider> <v-divider class="mx-2 my-1" />
<v-card-text v-if="edit"> <v-card-text v-if="edit">
<div v-for="(item, key, index) in value" :key="index"> <div
v-for="(item, key, index) in modelValue"
:key="index"
>
<v-text-field <v-text-field
dense :value="value[key]" :label="labels[key].label" :suffix="labels[key].suffix" type="number" density="compact"
autocomplete="off" @input="updateValue(key, $event)"></v-text-field> :model-value="modelValue[key]"
:label="labels[key].label"
:suffix="labels[key].suffix"
type="number"
autocomplete="off"
@update:model-value="updateValue(key, $event)"
/>
</div> </div>
</v-card-text> </v-card-text>
<v-list v-if="showViewer" dense class="mt-0 pt-0"> <v-list
<v-list-item v-for="(item, key, index) in renderedList" :key="index" style="min-height: 25px" dense> v-if="showViewer"
<v-list-item-content> density="compact"
class="mt-0 pt-0"
>
<v-list-item
v-for="(item, key, index) in renderedList"
:key="index"
style="min-height: 25px"
>
<div>
<v-list-item-title class="pl-4 caption flex row"> <v-list-item-title class="pl-4 caption flex row">
<div>{{ item.label }}</div> <div>{{ item.label }}</div>
<div class="ml-auto mr-1">{{ item.value }}</div> <div class="ml-auto mr-1">
{{ item.value }}
</div>
<div>{{ item.suffix }}</div> <div>{{ item.suffix }}</div>
</v-list-item-title> </v-list-item-title>
</v-list-item-content> </div>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-card> </v-card>
@ -28,13 +47,13 @@ dense :value="value[key]" :label="labels[key].label" :suffix="labels[key].suffix
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import { useNutritionLabels } from "~/composables/recipes"; import { useNutritionLabels } from "~/composables/recipes";
import { Nutrition } from "~/lib/api/types/recipe"; import type { Nutrition } from "~/lib/api/types/recipe";
import { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition"; import type { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
export default defineComponent({
export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: Object as () => Nutrition, type: Object as () => Nutrition,
required: true, required: true,
}, },
@ -43,12 +62,13 @@ export default defineComponent({
default: true, default: true,
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const { labels } = useNutritionLabels(); const { labels } = useNutritionLabels();
const valueNotNull = computed(() => { const valueNotNull = computed(() => {
let key: keyof Nutrition; let key: keyof Nutrition;
for (key in props.value) { for (key in props.modelValue) {
if (props.value[key] !== null) { if (props.modelValue[key] !== null) {
return true; return true;
} }
} }
@ -58,16 +78,16 @@ export default defineComponent({
const showViewer = computed(() => !props.edit && valueNotNull.value); const showViewer = computed(() => !props.edit && valueNotNull.value);
function updateValue(key: number | string, event: Event) { function updateValue(key: number | string, event: Event) {
context.emit("input", { ...props.value, [key]: event }); context.emit("update:modelValue", { ...props.modelValue, [key]: event });
} }
// Build a new list that only contains nutritional information that has a value // Build a new list that only contains nutritional information that has a value
const renderedList = computed(() => { const renderedList = computed(() => {
return Object.entries(labels).reduce((item: NutritionLabelType, [key, label]) => { return Object.entries(labels).reduce((item: NutritionLabelType, [key, label]) => {
if (props.value[key]?.trim()) { if (props.modelValue[key]?.trim()) {
item[key] = { item[key] = {
...label, ...label,
value: props.value[key], value: props.modelValue[key],
}; };
} }
return item; return item;

View file

@ -1,36 +1,58 @@
<template> <template>
<div> <div>
<v-dialog v-model="dialog" width="500"> <v-dialog
v-model="dialog"
width="500"
>
<v-card> <v-card>
<v-app-bar dense dark color="primary mb-2"> <v-app-bar
<v-icon large left class="mt-1"> density="compact"
{{ itemType === Organizer.Tool ? $globals.icons.potSteam : dark
itemType === Organizer.Category ? $globals.icons.categories : color="primary mb-2 position-relative left-0 top-0 w-100 pl-3"
$globals.icons.tags }} >
<v-icon
size="large"
start
class="mt-1"
>
{{ itemType === Organizer.Tool ? $globals.icons.potSteam
: itemType === Organizer.Category ? $globals.icons.categories
: $globals.icons.tags }}
</v-icon> </v-icon>
<v-toolbar-title class="headline"> <v-toolbar-title class="headline">
{{ properties.title }} {{ properties.title }}
</v-toolbar-title> </v-toolbar-title>
<v-spacer></v-spacer> <v-spacer />
</v-app-bar> </v-app-bar>
<v-card-title> </v-card-title> <v-card-title />
<v-form @submit.prevent="select"> <v-form @submit.prevent="select">
<v-card-text> <v-card-text>
<v-text-field <v-text-field
v-model="name" v-model="name"
dense density="compact"
:label="properties.label" :label="properties.label"
:rules="[rules.required]" :rules="[rules.required]"
autofocus autofocus
></v-text-field> />
<v-checkbox v-if="itemType === Organizer.Tool" v-model="onHand" :label="$t('tool.on-hand')"></v-checkbox> <v-checkbox
v-if="itemType === Organizer.Tool"
v-model="onHand"
:label="$t('tool.on-hand')"
/>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<BaseButton cancel @click="dialog = false" /> <BaseButton
<v-spacer></v-spacer> cancel
<BaseButton type="submit" create :disabled="!name" /> @click="dialog = false"
/>
<v-spacer />
<BaseButton
type="submit"
create
:disabled="!name"
/>
</v-card-actions> </v-card-actions>
</v-form> </v-form>
</v-card> </v-card>
@ -39,16 +61,15 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, toRefs, useContext, watch } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store"; import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
import { RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated"; import { type RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
const CREATED_ITEM_EVENT = "created-item"; const CREATED_ITEM_EVENT = "created-item";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@ -65,8 +86,9 @@ export default defineComponent({
default: "category", default: "category",
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const { i18n } = useContext(); const i18n = useI18n();
const state = reactive({ const state = reactive({
name: "", name: "",
@ -75,18 +97,18 @@ export default defineComponent({
const dialog = computed({ const dialog = computed({
get() { get() {
return props.value; return props.modelValue;
}, },
set(value) { set(value) {
context.emit("input", value); context.emit("update:modelValue", value);
}, },
}); });
watch( watch(
() => props.value, () => props.modelValue,
(val: boolean) => { (val: boolean) => {
if (!val) state.name = ""; if (!val) state.name = "";
} },
); );
const userApi = useUserApi(); const userApi = useUserApi();
@ -135,7 +157,7 @@ export default defineComponent({
await store.actions.createOne({ ...state }); await store.actions.createOne({ ...state });
} }
const newItem = store.store.value.find((item) => item.name === state.name); const newItem = store.store.value.find(item => item.name === state.name);
context.emit(CREATED_ITEM_EVENT, newItem); context.emit(CREATED_ITEM_EVENT, newItem);
dialog.value = false; dialog.value = false;

View file

@ -1,6 +1,9 @@
<template> <template>
<div v-if="items"> <div v-if="items">
<RecipeOrganizerDialog v-model="dialogs.organizer" :item-type="itemType" /> <RecipeOrganizerDialog
v-model="dialogs.organizer"
:item-type="itemType"
/>
<BaseDialog <BaseDialog
v-if="deleteTarget" v-if="deleteTarget"
@ -8,18 +11,34 @@
:title="$t('general.delete-with-name', { name: $t(translationKey) })" :title="$t('general.delete-with-name', { name: $t(translationKey) })"
color="error" color="error"
:icon="$globals.icons.alertCircle" :icon="$globals.icons.alertCircle"
can-confirm
@confirm="deleteOne()" @confirm="deleteOne()"
> >
<v-card-text> <v-card-text>
<p>{{ $t("general.confirm-delete-generic-with-name", { name: $t(translationKey) }) }}</p> <p>{{ $t("general.confirm-delete-generic-with-name", { name: $t(translationKey) }) }}</p>
<p class="mt-4 mb-0 ml-4">{{ deleteTarget.name }}</p> <p class="mt-4 mb-0 ml-4">
{{ deleteTarget.name }}
</p>
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<BaseDialog v-if="updateTarget" v-model="dialogs.update" :title="$t('general.update')" @confirm="updateOne()"> <BaseDialog
v-if="updateTarget"
v-model="dialogs.update"
:title="$t('general.update')"
can-confirm
@confirm="updateOne()"
>
<v-card-text> <v-card-text>
<v-text-field v-model="updateTarget.name" :label="$t('general.name')"> </v-text-field> <v-text-field
<v-checkbox v-if="itemType === Organizer.Tool" v-model="updateTarget.onHand" :label="$t('tool.on-hand')"></v-checkbox> v-model="updateTarget.name"
:label="$t('general.name')"
/>
<v-checkbox
v-if="itemType === Organizer.Tool"
v-model="updateTarget.onHand"
:label="$t('tool.on-hand')"
/>
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
@ -27,32 +46,61 @@
<v-col> <v-col>
<v-text-field <v-text-field
v-model="searchString" v-model="searchString"
outlined variant="outlined"
autofocus autofocus
color="primary accent-3" color="primary accent-3"
:placeholder="$t('search.search-placeholder')" :placeholder="$t('search.search-placeholder')"
:prepend-inner-icon="$globals.icons.search" :prepend-inner-icon="$globals.icons.search"
clearable clearable
> />
</v-text-field>
</v-col> </v-col>
</v-row> </v-row>
<v-app-bar color="transparent" flat class="mt-n1 rounded align-center"> <v-app-bar
<v-icon large left> color="transparent"
flat
class="mt-n1 rounded align-center px-4 position-relative w-100 left-0 top-0"
>
<v-icon
size="large"
start
>
{{ icon }} {{ icon }}
</v-icon> </v-icon>
<v-toolbar-title class="headline"> <v-toolbar-title class="headline">
<slot name="title"> </slot> <slot name="title" />
</v-toolbar-title> </v-toolbar-title>
<v-spacer></v-spacer> <v-spacer />
<BaseButton create @click="dialogs.organizer = true" /> <BaseButton
create
@click="dialogs.organizer = true"
/>
</v-app-bar> </v-app-bar>
<section v-for="(itms, key, idx) in itemsSorted" :key="'header' + idx" :class="idx === 1 ? null : 'my-4'"> <section
<BaseCardSectionTitle v-if="isTitle(key)" :title="key" /> v-for="(itms, key, idx) in itemsSorted"
:key="'header' + idx"
:class="idx === 1 ? null : 'my-4'"
>
<BaseCardSectionTitle
v-if="isTitle(key)"
:title="key"
/>
<v-row> <v-row>
<v-col v-for="(item, index) in itms" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3"> <v-col
<v-card v-if="item" class="left-border" hover :to="`/g/${groupSlug}?${itemType}=${item.id}`"> v-for="(item, index) in itms"
:key="'cat' + index"
cols="12"
:sm="12"
:md="6"
:lg="4"
:xl="3"
>
<v-card
v-if="item"
class="left-border"
hover
:to="`/g/${groupSlug}?${itemType}=${item.id}`"
>
<v-card-actions> <v-card-actions>
<v-icon> <v-icon>
{{ icon }} {{ icon }}
@ -60,7 +108,7 @@
<v-card-title class="py-1"> <v-card-title class="py-1">
{{ item.name }} {{ item.name }}
</v-card-title> </v-card-title>
<v-spacer></v-spacer> <v-spacer />
<ContextMenu <ContextMenu
:items="[presets.delete, presets.edit]" :items="[presets.delete, presets.edit]"
@delete="confirmDelete(item)" @delete="confirmDelete(item)"
@ -76,10 +124,10 @@
<script lang="ts"> <script lang="ts">
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { defineComponent, computed, ref, reactive, useContext, useRoute } from "@nuxtjs/composition-api";
import { useContextPresets } from "~/composables/use-context-presents"; import { useContextPresets } from "~/composables/use-context-presents";
import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue"; import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue";
import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated"; import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
import { useRouteQuery } from "~/composables/use-router"; import { useRouteQuery } from "~/composables/use-router";
import { deepCopy } from "~/composables/use-utils"; import { deepCopy } from "~/composables/use-utils";
@ -90,7 +138,7 @@ interface GenericItem {
onHand: boolean; onHand: boolean;
} }
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeOrganizerDialog, RecipeOrganizerDialog,
}, },
@ -108,6 +156,7 @@ export default defineComponent({
required: true, required: true,
}, },
}, },
emits: ["update", "delete"],
setup(props, { emit }) { setup(props, { emit }) {
const state = reactive({ const state = reactive({
// Search Options // Search Options
@ -124,9 +173,9 @@ export default defineComponent({
}, },
}); });
const { $auth } = useContext(); const $auth = useMealieAuth();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
// ================================================================= // =================================================================
// Context Menu // Context Menu
@ -141,11 +190,11 @@ export default defineComponent({
const translationKey = computed<string>(() => { const translationKey = computed<string>(() => {
const typeMap = { const typeMap = {
"categories": "category.category", categories: "category.category",
"tags": "tag.tag", tags: "tag.tag",
"tools": "tool.tool", tools: "tool.tool",
"foods": "shopping-list.food", foods: "shopping-list.food",
"households": "household.household", households: "household.household",
}; };
return typeMap[props.itemType] || ""; return typeMap[props.itemType] || "";
}); });
@ -193,7 +242,7 @@ export default defineComponent({
return props.items; return props.items;
} }
const result = fuse.value.search(searchString.value.trim() as string); const result = fuse.value.search(searchString.value.trim() as string);
return result.map((x) => x.item); return result.map(x => x.item);
}); });
// ================================================================= // =================================================================
@ -206,7 +255,7 @@ export default defineComponent({
return byLetter; return byLetter;
} }
fuzzyItems.value [...fuzzyItems.value]
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.forEach((item) => { .forEach((item) => {
const letter = item.name[0].toUpperCase(); const letter = item.name[0].toUpperCase();
@ -240,7 +289,5 @@ export default defineComponent({
translationKey, translationKey,
}; };
}, },
// Needed for useMeta
head: {},
}); });
</script> </script>

View file

@ -1,62 +1,61 @@
<template> <template>
<v-autocomplete <v-autocomplete
v-model="selected" v-model="selected"
v-bind="inputAttrs"
v-model:search="searchInput"
:items="storeItem" :items="storeItem"
:value="value"
:label="label" :label="label"
chips chips
deletable-chips closable-chips
item-text="name" item-title="name"
multiple multiple
variant="underlined"
:prepend-inner-icon="icon" :prepend-inner-icon="icon"
:append-icon="$globals.icons.create"
return-object return-object
v-bind="inputAttrs"
auto-select-first auto-select-first
:search-input.sync="searchInput"
class="pa-0" class="pa-0"
@change="resetSearchInput" @update:model-value="resetSearchInput"
@click:append="dialog = true"
> >
<template #selection="data"> <template #chip="{ item, index }">
<v-chip <v-chip
:key="data.index" :key="index"
class="ma-1" class="ma-1"
:input-value="data.selected"
small
close
label
color="accent" color="accent"
dark variant="flat"
@click:close="removeByIndex(data.index)" label
closable
@click:close="removeByIndex(index)"
> >
{{ data.item.name || data.item }} {{ item.value }}
</v-chip> </v-chip>
</template> </template>
<template v-if="showAdd" #append-outer>
<v-btn icon @click="dialog = true"> <template
<v-icon> v-if="showAdd"
{{ $globals.icons.create }} #append
</v-icon> >
</v-btn> <RecipeOrganizerDialog
<RecipeOrganizerDialog v-model="dialog" :item-type="selectorType" @created-item="appendCreated" /> v-model="dialog"
:item-type="selectorType"
@created-item="appendCreated"
/>
</template> </template>
</v-autocomplete> </v-autocomplete>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api"; import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue"; import type { RecipeTool } from "~/lib/api/types/admin";
import { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe"; import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
import { RecipeTool } from "~/lib/api/types/admin"; import type { HouseholdSummary } from "~/lib/api/types/household";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store"; import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated";
import { HouseholdSummary } from "~/lib/api/types/household";
export default defineComponent({ export default defineNuxtComponent({
components: {
RecipeOrganizerDialog,
},
props: { props: {
value: { modelValue: {
type: Array as () => ( type: Array as () => (
| HouseholdSummary | HouseholdSummary
| RecipeTag | RecipeTag
@ -95,12 +94,13 @@ export default defineComponent({
default: true, default: true,
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const selected = computed({ const selected = computed({
get: () => props.value, get: () => props.modelValue,
set: (val) => { set: (val) => {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
@ -110,7 +110,8 @@ export default defineComponent({
} }
}); });
const { $globals, i18n } = useContext(); const i18n = useI18n();
const { $globals } = useNuxtApp();
const label = computed(() => { const label = computed(() => {
if (!props.showLabel) { if (!props.showLabel) {
@ -168,11 +169,11 @@ export default defineComponent({
const store = computed(() => { const store = computed(() => {
const { store } = storeMap[props.selectorType]; const { store } = storeMap[props.selectorType];
return store.value; return store.value;
}) });
const items = computed(() => { const items = computed(() => {
if (!props.returnObject) { if (!props.returnObject) {
return store.value.map((item) => item.name); return store.value.map(item => item.name);
} }
return store.value; return store.value;
}); });

View file

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<v-container v-show="!isCookMode" key="recipe-page" :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }"> <v-container v-show="!isCookMode" key="recipe-page" class="pt-0" :class="{ 'pa-0': $vuetify.display.smAndDown.value }">
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none"> <v-card :flat="$vuetify.display.smAndDown.value" class="d-print-none">
<RecipePageHeader <RecipePageHeader
:recipe="recipe" :recipe="recipe"
:recipe-scale="scale" :recipe-scale="scale"
@ -9,7 +9,13 @@
@save="saveRecipe" @save="saveRecipe"
@delete="deleteRecipe" @delete="deleteRecipe"
/> />
<LazyRecipeJsonEditor v-if="isEditJSON" v-model="recipe" class="mt-10" :options="EDITOR_OPTIONS" /> <RecipeJsonEditor
v-if="isEditJSON"
v-model="recipe"
class="mt-10"
mode="text"
:main-menu-bar="false"
/>
<v-card-text v-else> <v-card-text v-else>
<!-- <!--
This is where most of the main content is rendered. Some components include state for both Edit and View modes This is where most of the main content is rendered. Some components include state for both Edit and View modes
@ -21,10 +27,18 @@
a significant amount of prop management. When we move to Vue 3 and have access to some of the newer API's the plan to update this a significant amount of prop management. When we move to Vue 3 and have access to some of the newer API's the plan to update this
data management and mutation system we're using. data management and mutation system we're using.
--> -->
<RecipePageInfoEditor v-if="isEditMode" :recipe="recipe" :landscape="landscape" /> <div>
<RecipePageEditorToolbar v-if="isEditForm" :recipe="recipe" /> <RecipePageInfoEditor v-if="isEditMode" v-model="recipe" />
<RecipePageIngredientEditor v-if="isEditForm" :recipe="recipe" /> </div>
<RecipePageScale :recipe="recipe" :scale.sync="scale" /> <div>
<RecipePageEditorToolbar v-if="isEditForm" v-model="recipe" />
</div>
<div>
<RecipePageIngredientEditor v-if="isEditForm" v-model="recipe" />
</div>
<div>
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
</div>
<!-- <!--
This section contains the 2 column layout for the recipe steps and other content. This section contains the 2 column layout for the recipe steps and other content.
@ -35,9 +49,9 @@
--> -->
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4"> <v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4">
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" /> <RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
<RecipePageOrganizers v-if="$vuetify.breakpoint.mdAndUp" :recipe="recipe" @item-selected="chipClicked" /> <RecipePageOrganizers v-if="$vuetify.display.mdAndUp" v-model="recipe" @item-selected="chipClicked" />
</v-col> </v-col>
<v-divider v-if="$vuetify.breakpoint.mdAndUp && !isCookMode" class="my-divider" :vertical="true" /> <v-divider v-if="$vuetify.display.mdAndUp && !isCookMode" class="my-divider" :vertical="true" />
<!-- <!--
the right column is always rendered, but it's layout width is determined by where the left column is the right column is always rendered, but it's layout width is determined by where the left column is
@ -46,104 +60,102 @@
<v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4" :lg="8 + (isCookMode ? 1 : 0) * 4"> <v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4" :lg="8 + (isCookMode ? 1 : 0) * 4">
<RecipePageInstructions <RecipePageInstructions
v-model="recipe.recipeInstructions" v-model="recipe.recipeInstructions"
:assets.sync="recipe.assets" v-model:assets="recipe.assets"
:recipe="recipe" :recipe="recipe"
:scale="scale" :scale="scale"
/> />
<div v-if="isEditForm" class="d-flex"> <div v-if="isEditForm" class="d-flex">
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" /> <RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.add") }}</BaseButton> <BaseButton class="my-2" @click="addStep()">
{{ $t("general.add") }}
</BaseButton>
</div> </div>
<div v-if="!$vuetify.breakpoint.mdAndUp"> <div v-if="!$vuetify.display.mdAndUp">
<RecipePageOrganizers :recipe="recipe" /> <RecipePageOrganizers v-model="recipe" />
</div> </div>
<RecipeNotes v-model="recipe.notes" :edit="isEditForm" /> <RecipeNotes v-model="recipe.notes" :edit="isEditForm" />
</v-col> </v-col>
</v-row> </v-row>
<RecipePageFooter :recipe="recipe" /> <RecipePageFooter v-model="recipe" />
</v-card-text> </v-card-text>
</v-card> </v-card>
<WakelockSwitch /> <WakelockSwitch />
<RecipePageComments <RecipePageComments
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode" v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
:recipe="recipe" v-model="recipe"
class="px-1 my-4 d-print-none" class="px-1 my-4 d-print-none"
/> />
<RecipePrintContainer :recipe="recipe" :scale="scale" /> <RecipePrintContainer :recipe="recipe" :scale="scale" />
</v-container> </v-container>
<!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same timer --> <!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same timer -->
<v-sheet v-show="isCookMode && !hasLinkedIngredients" key="cookmode" :style="{height: $vuetify.breakpoint.smAndUp ? 'calc(100vh - 48px)' : ''}"> <!-- the calc is to account for the toolbar a more dynamic solution could be needed --> <v-sheet
<v-row style="height: 100%;" no-gutters class="overflow-hidden"> v-show="isCookMode && !hasLinkedIngredients"
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%;"> key="cookmode"
:style="{ height: $vuetify.display.smAndUp ? 'calc(100vh - 48px)' : '' }"
>
<!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
<v-row style="height: 100%" no-gutters class="overflow-hidden">
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%">
<div class="d-flex align-center"> <div class="d-flex align-center">
<RecipePageScale :recipe="recipe" :scale.sync="scale" /> <RecipePageScale v-model:scale="scale" :recipe="recipe" />
</div> </div>
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" :is-cook-mode="isCookMode" /> <RecipePageIngredientToolsView
<v-divider></v-divider> v-if="!isEditForm"
:recipe="recipe"
:scale="scale"
:is-cook-mode="isCookMode"
/>
<v-divider />
</v-col> </v-col>
<v-col class="overflow-y-auto py-2" style="height: 100%;" cols="12" sm="7"> <v-col class="overflow-y-auto py-2" style="height: 100%" cols="12" sm="7">
<RecipePageInstructions <RecipePageInstructions
v-model="recipe.recipeInstructions" v-model="recipe.recipeInstructions"
v-model:assets="recipe.assets"
class="overflow-y-hidden px-4" class="overflow-y-hidden px-4"
:assets.sync="recipe.assets"
:recipe="recipe" :recipe="recipe"
:scale="scale" :scale="scale"
/> />
</v-col> </v-col>
</v-row> </v-row>
</v-sheet> </v-sheet>
<v-sheet v-show="isCookMode && hasLinkedIngredients"> <v-sheet v-show="isCookMode && hasLinkedIngredients">
<div class="mt-2 px-2 px-md-4"> <div class="mt-2 px-2 px-md-4">
<RecipePageScale :recipe="recipe" :scale.sync="scale"/> <RecipePageScale v-model:scale="scale" :recipe="recipe" />
</div> </div>
<RecipePageInstructions <RecipePageInstructions
v-model="recipe.recipeInstructions" v-model="recipe.recipeInstructions"
v-model:assets="recipe.assets"
class="overflow-y-hidden mt-n5 px-2 px-md-4" class="overflow-y-hidden mt-n5 px-2 px-md-4"
:assets.sync="recipe.assets"
:recipe="recipe" :recipe="recipe"
:scale="scale" :scale="scale"
/> />
<div v-if="notLinkedIngredients.length > 0" class="px-2 px-md-4 pb-4"> <div v-if="notLinkedIngredients.length > 0" class="px-2 px-md-4 pb-4">
<v-divider></v-divider> <v-divider />
<v-card flat> <v-card flat>
<v-card-title>{{ $t('recipe.not-linked-ingredients') }}</v-card-title> <v-card-title>{{ $t("recipe.not-linked-ingredients") }}</v-card-title>
<RecipeIngredients <RecipeIngredients
:value="notLinkedIngredients" :value="notLinkedIngredients"
:scale="scale" :scale="scale"
:disable-amount="recipe.settings.disableAmount" :disable-amount="recipe.settings.disableAmount"
:is-cook-mode="isCookMode"> :is-cook-mode="isCookMode"
/>
</RecipeIngredients>
</v-card> </v-card>
</div> </div>
</v-sheet> </v-sheet>
<v-btn <v-btn
v-if="isCookMode" v-if="isCookMode"
fab icon
small
color="primary" color="primary"
style="position: fixed; right: 12px; top: 60px;" style="position: fixed; right: 12px; top: 60px"
@click="toggleCookMode()" @click="toggleCookMode()"
> >
<v-icon>mdi-close</v-icon> <v-icon>{{ $globals.icons.close }}</v-icon>
</v-btn> </v-btn>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import {
defineComponent,
useContext,
useRouter,
computed,
ref,
onMounted,
onUnmounted,
useRoute,
} from "@nuxtjs/composition-api";
import { invoke, until } from "@vueuse/core"; import { invoke, until } from "@vueuse/core";
import RecipeIngredients from "../RecipeIngredients.vue"; import RecipeIngredients from "../RecipeIngredients.vue";
import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue"; import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue";
@ -156,17 +168,14 @@ import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue"; import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue"; import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue";
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue"; import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue"; import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue";
import { import {
clearPageState, clearPageState,
EditorMode,
PageMode, PageMode,
usePageState, usePageState,
usePageUser,
} from "~/composables/recipe-page/shared-state"; } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe"; import type { Recipe, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { useRouteQuery } from "~/composables/use-router"; import { useRouteQuery } from "~/composables/use-router";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { uuid4, deepCopy } from "~/composables/use-utils"; import { uuid4, deepCopy } from "~/composables/use-utils";
@ -174,51 +183,27 @@ import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue"; import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
import { useNavigationWarning } from "~/composables/use-navigation-warning"; import { useNavigationWarning } from "~/composables/use-navigation-warning";
const EDITOR_OPTIONS = { const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
mode: "code",
search: false,
mainMenuBar: false,
};
export default defineComponent({ const { $vuetify } = useNuxtApp();
components: { const i18n = useI18n();
RecipePageHeader, const $auth = useMealieAuth();
RecipePrintContainer,
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 route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const { isOwnGroup } = useLoggedInState(); const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
const router = useRouter(); const router = useRouter();
const api = useUserApi(); const api = useUserApi();
const { pageMode, editMode, setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode } = const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode }
usePageState(props.recipe.slug); = usePageState(recipe.value.slug);
const { deactivateNavigationWarning } = useNavigationWarning(); const { deactivateNavigationWarning } = useNavigationWarning();
const notLinkedIngredients = computed(() => { const notLinkedIngredients = computed(() => {
return props.recipe.recipeIngredient.filter((ingredient) => { return recipe.value.recipeIngredient.filter((ingredient) => {
return !props.recipe.recipeInstructions.some((step) => step.ingredientReferences?.map((ref) => ref.referenceId).includes(ingredient.referenceId)); return !recipe.value.recipeInstructions.some(step =>
}) step.ingredientReferences?.map(ref => ref.referenceId).includes(ingredient.referenceId),
}) );
});
});
/** ============================================================= /** =============================================================
* Recipe Snapshot on Mount * Recipe Snapshot on Mount
@ -228,30 +213,30 @@ export default defineComponent({
const originalRecipe = ref<Recipe | null>(null); const originalRecipe = ref<Recipe | null>(null);
invoke(async () => { invoke(async () => {
await until(props.recipe).not.toBeNull(); await until(recipe.value).not.toBeNull();
originalRecipe.value = deepCopy(props.recipe); originalRecipe.value = deepCopy(recipe.value);
}); });
onUnmounted(async () => { onUnmounted(async () => {
const isSame = JSON.stringify(props.recipe) === JSON.stringify(originalRecipe.value); const isSame = JSON.stringify(recipe.value) === JSON.stringify(originalRecipe.value);
if (isEditMode.value && !isSame && props.recipe?.slug !== undefined) { if (isEditMode.value && !isSame && recipe.value?.slug !== undefined) {
const save = window.confirm( const save = window.confirm(i18n.t("general.unsaved-changes"));
i18n.tc("general.unsaved-changes"),
);
if (save) { if (save) {
await api.recipes.updateOne(props.recipe.slug, props.recipe); await api.recipes.updateOne(recipe.value.slug, recipe.value);
} }
} }
deactivateNavigationWarning(); deactivateNavigationWarning();
toggleCookMode() toggleCookMode();
clearPageState(props.recipe.slug || ""); clearPageState(recipe.value.slug || "");
console.debug("reset RecipePage state during unmount"); console.debug("reset RecipePage state during unmount");
}); });
const hasLinkedIngredients = computed(() => { const hasLinkedIngredients = computed(() => {
return props.recipe.recipeInstructions.some((step) => step.ingredientReferences && step.ingredientReferences.length > 0); return recipe.value.recipeInstructions.some(
}) step => step.ingredientReferences && step.ingredientReferences.length > 0,
);
});
/** ============================================================= /** =============================================================
* Set State onMounted * Set State onMounted
*/ */
@ -271,7 +256,7 @@ export default defineComponent({
*/ */
async function saveRecipe() { async function saveRecipe() {
const { data } = await api.recipes.updateOne(props.recipe.slug, props.recipe); const { data } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
setMode(PageMode.VIEW); setMode(PageMode.VIEW);
if (data?.slug) { if (data?.slug) {
router.push(`/g/${groupSlug.value}/r/` + data.slug); router.push(`/g/${groupSlug.value}/r/` + data.slug);
@ -279,7 +264,7 @@ export default defineComponent({
} }
async function deleteRecipe() { async function deleteRecipe() {
const { data } = await api.recipes.deleteOne(props.recipe.slug); const { data } = await api.recipes.deleteOne(recipe.value.slug);
if (data?.slug) { if (data?.slug) {
router.push(`/g/${groupSlug.value}`); router.push(`/g/${groupSlug.value}`);
} }
@ -288,15 +273,14 @@ export default defineComponent({
/** ============================================================= /** =============================================================
* View Preferences * View Preferences
*/ */
const { $vuetify, i18n } = useContext();
const landscape = computed(() => { const landscape = computed(() => {
const preferLandscape = props.recipe.settings.landscapeView; const preferLandscape = recipe.value.settings.landscapeView;
const smallScreen = !$vuetify.breakpoint.smAndUp; const smallScreen = !$vuetify.display.smAndUp.value;
if (preferLandscape) { if (preferLandscape) {
return true; return true;
} else if (smallScreen) { }
else if (smallScreen) {
return true; return true;
} }
@ -309,7 +293,7 @@ export default defineComponent({
*/ */
function addStep(steps: Array<string> | null = null) { function addStep(steps: Array<string> | null = null) {
if (!props.recipe.recipeInstructions) { if (!recipe.value.recipeInstructions) {
return; return;
} }
@ -318,17 +302,19 @@ export default defineComponent({
return { id: uuid4(), text: step, title: "", ingredientReferences: [] }; return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
}); });
props.recipe.recipeInstructions.push(...cleanedSteps); recipe.value.recipeInstructions.push(...cleanedSteps);
} else { }
props.recipe.recipeInstructions.push({ id: uuid4(), text: "", title: "", ingredientReferences: [] }); else {
recipe.value.recipeInstructions.push({
id: uuid4(),
text: "",
title: "",
summary: "",
ingredientReferences: [],
});
} }
} }
/** =============================================================
* Meta Tags
*/
const { user } = usePageUser();
/** ============================================================= /** =============================================================
* RecipeChip Clicked * RecipeChip Clicked
*/ */
@ -340,48 +326,29 @@ export default defineComponent({
router.push(`/g/${groupSlug.value}?${itemType}=${item.id}`); router.push(`/g/${groupSlug.value}?${itemType}=${item.id}`);
} }
return { const scale = ref(1);
user,
isOwnGroup,
api,
scale: ref(1),
EDITOR_OPTIONS,
landscape,
pageMode, // expose to template
editMode, // (all variables used in template are top-level in <script setup>)
PageMode,
EditorMode,
isEditMode,
isEditForm,
isEditJSON,
isCookMode,
toggleCookMode,
saveRecipe,
deleteRecipe,
addStep,
hasLinkedIngredients,
notLinkedIngredients,
chipClicked,
};
},
head: {},
});
</script> </script>
<style lang="css"> <style lang="css">
.flip-list-move { .flip-list-move {
transition: transform 0.5s; transition: transform 0.5s;
} }
.no-move { .no-move {
transition: transform 0s; transition: transform 0s;
} }
.ghost { .ghost {
opacity: 0.5; opacity: 0.5;
} }
.list-group { .list-group {
min-height: 38px; min-height: 38px;
} }
.list-group-item i { .list-group-item i {
cursor: pointer; cursor: pointer;
} }

View file

@ -6,44 +6,73 @@
</v-icon> </v-icon>
{{ $t("recipe.comments") }} {{ $t("recipe.comments") }}
</v-card-title> </v-card-title>
<v-divider class="mx-2"></v-divider> <v-divider class="mx-2" />
<div v-if="user.id" class="d-flex flex-column"> <div
<div class="d-flex mt-3" style="gap: 10px"> v-if="user.id"
<UserAvatar :tooltip="false" size="40" :user-id="user.id" /> class="d-flex flex-column"
>
<div
class="d-flex mt-3"
style="gap: 10px"
>
<UserAvatar
:tooltip="false"
size="40"
:user-id="user.id"
/>
<v-textarea <v-textarea
v-model="comment" v-model="comment"
hide-details="" hide-details
dense density="compact"
single-line single-line
outlined variant="outlined"
auto-grow auto-grow
rows="2" rows="2"
:placeholder="$t('recipe.join-the-conversation')" :placeholder="$t('recipe.join-the-conversation')"
> />
</v-textarea>
</div> </div>
<div class="ml-auto mt-1"> <div class="ml-auto mt-1">
<BaseButton small :disabled="!comment" @click="submitComment"> <BaseButton
<template #icon>{{ $globals.icons.check }}</template> size="small"
:disabled="!comment"
@click="submitComment"
>
<template #icon>
{{ $globals.icons.check }}
</template>
{{ $t("general.submit") }} {{ $t("general.submit") }}
</BaseButton> </BaseButton>
</div> </div>
</div> </div>
<div v-for="comment in recipe.comments" :key="comment.id" class="d-flex my-2" style="gap: 10px"> <div
<UserAvatar :tooltip="false" size="40" :user-id="comment.userId" /> v-for="recipeComment in recipe.comments"
<v-card outlined class="flex-grow-1"> :key="recipeComment.id"
class="d-flex my-2"
style="gap: 10px"
>
<UserAvatar
:tooltip="false"
size="40"
:user-id="recipeComment.userId"
/>
<v-card
variant="outlined"
class="flex-grow-1"
>
<v-card-text class="pa-3 pb-0"> <v-card-text class="pa-3 pb-0">
<p class="">{{ comment.user.fullName }} {{ $d(Date.parse(comment.createdAt), "medium") }}</p> <p class="">
<SafeMarkdown :source="comment.text" /> {{ recipeComment.user.fullName }} {{ $d(Date.parse(recipeComment.createdAt), "medium") }}
</p>
<SafeMarkdown :source="recipeComment.text" />
</v-card-text> </v-card-text>
<v-card-actions class="justify-end mt-0 pt-0"> <v-card-actions class="justify-end mt-0 pt-0">
<v-btn <v-btn
v-if="user.id == comment.user.id || user.admin" v-if="user.id == recipeComment.user.id || user.admin"
color="error" color="error"
text variant="text"
x-small size="x-small"
@click="deleteComment(comment.id)" @click="deleteComment(recipeComment.id)"
> >
{{ $t("general.delete") }} {{ $t("general.delete") }}
</v-btn> </v-btn>
@ -53,58 +82,37 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, toRefs, reactive } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue"; import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { usePageUser } from "~/composables/recipe-page/shared-state"; import { usePageUser } from "~/composables/recipe-page/shared-state";
import SafeMarkdown from "~/components/global/SafeMarkdown.vue"; import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
export default defineComponent({ const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
components: {
UserAvatar,
SafeMarkdown
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const api = useUserApi(); const api = useUserApi();
const { user } = usePageUser(); const { user } = usePageUser();
const comment = ref("");
const state = reactive({
comment: "",
});
async function submitComment() { async function submitComment() {
const { data } = await api.recipes.comments.createOne({ const { data } = await api.recipes.comments.createOne({
recipeId: props.recipe.id, recipeId: recipe.value.id,
text: state.comment, text: comment.value,
}); });
if (data) { if (data) {
// @ts-ignore username is always populated here recipe.value.comments.push(data);
props.recipe.comments.push(data);
} }
state.comment = ""; comment.value = "";
} }
async function deleteComment(id: string) { async function deleteComment(id: string) {
const { response } = await api.recipes.comments.deleteOne(id); const { response } = await api.recipes.comments.deleteOne(id);
if (response?.status === 200) { if (response?.status === 200) {
props.recipe.comments = props.recipe.comments.filter((comment) => comment.id !== id); recipe.value.comments = recipe.value.comments.filter(comment => comment.id !== id);
} }
} }
return { api, ...toRefs(state), submitComment, deleteComment, user };
},
});
</script> </script>

View file

@ -1,28 +1,44 @@
<template> <template>
<div class="d-flex justify-start align-top py-2"> <div class="d-flex justify-start align-top py-2">
<RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" /> <RecipeImageUploadBtn
class="my-1"
:slug="recipe.slug"
@upload="uploadImage"
@refresh="imageKey++"
/>
<RecipeSettingsMenu <RecipeSettingsMenu
v-model="recipe.settings"
class="my-1 mx-1" class="my-1 mx-1"
:value="recipe.settings"
:is-owner="recipe.userId == user.id" :is-owner="recipe.userId == user.id"
@upload="uploadImage" @upload="uploadImage"
/> />
<v-spacer /> <v-spacer />
<v-container class="py-0" style="width: 40%;"> <v-container
class="py-0"
style="width: 40%;"
>
<v-select <v-select
v-model="recipe.userId" v-model="recipe.userId"
:items="allUsers" :items="allUsers"
item-text="fullName" item-title="fullName"
item-value="id" item-value="id"
:label="$tc('general.owner')" :label="$t('general.owner')"
hide-details hide-details
:disabled="!canEditOwner" :disabled="!canEditOwner"
variant="underlined"
> >
<template #prepend> <template #prepend>
<UserAvatar :user-id="recipe.userId" :tooltip="false" /> <UserAvatar
:user-id="recipe.userId"
:tooltip="false"
/>
</template> </template>
</v-select> </v-select>
<v-card-text v-if="ownerHousehold" class="pa-0 d-flex" style="align-items: flex-end;"> <v-card-text
v-if="ownerHousehold"
class="pa-0 d-flex"
style="align-items: flex-end;"
>
<v-spacer /> <v-spacer />
<v-icon>{{ $globals.icons.household }}</v-icon> <v-icon>{{ $globals.icons.household }}</v-icon>
<span class="pl-1">{{ ownerHousehold.name }}</span> <span class="pl-1">{{ ownerHousehold.name }}</span>
@ -31,11 +47,11 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api"; import { computed } from "vue";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state"; import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBtn.vue"; import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBtn.vue";
import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue"; import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue";
@ -43,57 +59,34 @@ import { useUserStore } from "~/composables/store/use-user-store";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue"; import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import { useHouseholdStore } from "~/composables/store"; import { useHouseholdStore } from "~/composables/store";
export default defineComponent({ const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
components: {
RecipeImageUploadBtn,
RecipeSettingsMenu,
UserAvatar,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const { user } = usePageUser(); const { user } = usePageUser();
const api = useUserApi(); const api = useUserApi();
const { imageKey } = usePageState(props.recipe.slug); const { imageKey } = usePageState(recipe.value.slug);
const canEditOwner = computed(() => { const canEditOwner = computed(() => {
return user.id === props.recipe.userId || user.admin; return user.id === recipe.value.userId || user.admin;
}) });
const { store: allUsers } = useUserStore(); const { store: allUsers } = useUserStore();
const { store: households } = useHouseholdStore(); const { store: households } = useHouseholdStore();
const ownerHousehold = computed(() => { const ownerHousehold = computed(() => {
const owner = allUsers.value.find((u) => u.id === props.recipe.userId); const owner = allUsers.value.find(u => u.id === recipe.value.userId);
if (!owner) { if (!owner) {
return null; return null;
}; }
return households.value.find(h => h.id === owner.householdId);
return households.value.find((h) => h.id === owner.householdId);
}); });
async function uploadImage(fileObject: File) { async function uploadImage(fileObject: File) {
if (!props.recipe || !props.recipe.slug) { if (!recipe.value || !recipe.value.slug) {
return; return;
} }
const newVersion = await api.recipes.updateImage(props.recipe.slug, fileObject); const newVersion = await api.recipes.updateImage(recipe.value.slug, fileObject);
if (newVersion?.data?.image) { if (newVersion?.data?.image) {
props.recipe.image = newVersion.data.image; recipe.value.image = newVersion.data.image;
} }
imageKey.value++; imageKey.value++;
} }
return {
user,
canEditOwner,
uploadImage,
imageKey,
allUsers,
ownerHousehold,
};
},
});
</script> </script>

View file

@ -5,35 +5,54 @@
v-if="isEditForm" v-if="isEditForm"
v-model="recipe.orgURL" v-model="recipe.orgURL"
class="mt-10" class="mt-10"
variant="underlined"
:label="$t('recipe.original-url')" :label="$t('recipe.original-url')"
></v-text-field> />
<v-btn <v-btn
v-else-if="recipe.orgURL && !isCookMode" v-else-if="recipe.orgURL && !isCookMode"
dense
small
:hover="false" :hover="false"
type="label"
:ripple="false" :ripple="false"
elevation="0" variant="flat"
:href="recipe.orgURL" :href="recipe.orgURL"
color="secondary darken-1" color="secondary-darken-1"
target="_blank" target="_blank"
class="rounded-sm mr-n2" class="mr-n2"
size="small"
> >
{{ $t("recipe.original-url") }} {{ $t("recipe.original-url") }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
<AdvancedOnly> <AdvancedOnly>
<v-card v-if="isEditForm" flat class="mb-2 mx-n2"> <v-card
<v-card-title> {{ $t('recipe.api-extras') }} </v-card-title> v-if="isEditForm"
<v-divider class="ml-4"></v-divider> flat
class="mb-2 mx-n2"
>
<v-card-title class="text-h5 font-weight-medium opacity-80">
{{ $t('recipe.api-extras') }}
</v-card-title>
<v-divider class="ml-4" />
<v-card-text> <v-card-text>
{{ $t('recipe.api-extras-description') }} {{ $t('recipe.api-extras-description') }}
<v-row v-for="(_, key) in recipe.extras" :key="key" class="mt-1"> <v-row
v-for="(_, key) in recipe.extras"
:key="key"
class="mt-1"
>
<v-col style="max-width: 400px;"> <v-col style="max-width: 400px;">
<v-text-field v-model="recipe.extras[key]" dense :label="key"> <v-text-field
v-model="recipe.extras[key]"
density="compact"
variant="underlined"
:label="key"
>
<template #prepend> <template #prepend>
<v-btn color="error" icon class="mt-n4" @click="removeApiExtra(key)"> <v-btn
color="error"
icon
class="mt-n4"
@click="removeApiExtra(key)"
>
<v-icon> {{ $globals.icons.delete }} </v-icon> <v-icon> {{ $globals.icons.delete }} </v-icon>
</v-btn> </v-btn>
</template> </template>
@ -43,69 +62,58 @@
</v-card-text> </v-card-text>
<v-card-actions class="d-flex ml-2 mt-n3"> <v-card-actions class="d-flex ml-2 mt-n3">
<div> <div>
<v-text-field v-model="apiNewKey" :label="$t('recipe.message-key')"></v-text-field> <v-text-field
v-model="apiNewKey"
min-width="200px"
:label="$t('recipe.message-key')"
variant="underlined"
/>
</div> </div>
<BaseButton create small class="ml-5" @click="createApiExtra" /> <BaseButton
create
size="small"
class="ml-5"
@click="createApiExtra"
/>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</AdvancedOnly> </AdvancedOnly>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { usePageState } from "~/composables/recipe-page/shared-state"; import { usePageState } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
export default defineComponent({
props: { const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
recipe: { const { isEditForm, isCookMode } = usePageState(recipe.value.slug);
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const { isEditForm, isCookMode } = usePageState(props.recipe.slug);
const apiNewKey = ref(""); const apiNewKey = ref("");
function createApiExtra() { function createApiExtra() {
if (!props.recipe) { if (!recipe.value) {
return; return;
} }
if (!recipe.value.extras) {
if (!props.recipe.extras) { recipe.value.extras = {};
props.recipe.extras = {};
} }
// check for duplicate keys // check for duplicate keys
if (Object.keys(props.recipe.extras).includes(apiNewKey.value)) { if (Object.keys(recipe.value.extras).includes(apiNewKey.value)) {
return; return;
} }
recipe.value.extras[apiNewKey.value] = "";
props.recipe.extras[apiNewKey.value] = "";
apiNewKey.value = ""; apiNewKey.value = "";
} }
function removeApiExtra(key: string | number) { function removeApiExtra(key: string | number) {
if (!props.recipe) { if (!recipe.value) {
return; return;
} }
if (!recipe.value.extras) {
if (!props.recipe.extras) {
return; return;
} }
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete props.recipe.extras[key]; delete recipe.value.extras[key];
props.recipe.extras = { ...props.recipe.extras }; recipe.value.extras = { ...recipe.value.extras };
} }
return {
removeApiExtra,
createApiExtra,
apiNewKey,
isEditForm,
isCookMode,
};
},
});
</script> </script>

View file

@ -1,6 +1,10 @@
<template> <template>
<div> <div>
<RecipePageInfoCard :recipe="recipe" :recipe-scale="recipeScale" :landscape="landscape" /> <RecipePageInfoCard
:recipe="recipe"
:recipe-scale="recipeScale"
:landscape="landscape"
/>
<v-divider /> <v-divider />
<RecipeActionMenu <RecipeActionMenu
:recipe="recipe" :recipe="recipe"
@ -11,7 +15,7 @@
:logged-in="isOwnGroup" :logged-in="isOwnGroup"
:open="isEditMode" :open="isEditMode"
:recipe-id="recipe.id" :recipe-id="recipe.id"
class="ml-auto mt-n2 pb-4" class="ml-auto mt-n7 pb-4"
@close="setMode(PageMode.VIEW)" @close="setMode(PageMode.VIEW)"
@json="toggleEditMode()" @json="toggleEditMode()"
@edit="setMode(PageMode.EDIT)" @edit="setMode(PageMode.EDIT)"
@ -23,17 +27,17 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useRecipePermissions } from "~/composables/recipes"; import { useRecipePermissions } from "~/composables/recipes";
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue"; import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue"; import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
import { useStaticRoutes, useUserApi } from "~/composables/api"; import { useStaticRoutes, useUserApi } from "~/composables/api";
import { HouseholdSummary } from "~/lib/api/types/household"; import type { HouseholdSummary } from "~/lib/api/types/household";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state"; import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
export default defineComponent({
export default defineNuxtComponent({
components: { components: {
RecipePageInfoCard, RecipePageInfoCard,
RecipeActionMenu, RecipeActionMenu,
@ -52,8 +56,9 @@ export default defineComponent({
default: false, default: false,
}, },
}, },
emits: ["save", "delete"],
setup(props) { setup(props) {
const { $vuetify } = useContext(); const { $vuetify } = useNuxtApp();
const { recipeImage } = useStaticRoutes(); const { recipeImage } = useStaticRoutes();
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug); const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
const { user } = usePageUser(); const { user } = usePageUser();
@ -74,7 +79,7 @@ export default defineComponent({
const hideImage = ref(false); const hideImage = ref(false);
const imageHeight = computed(() => { const imageHeight = computed(() => {
return $vuetify.breakpoint.xs ? "200" : "400"; return $vuetify.display.xs.value ? "200" : "400";
}); });
const recipeImageUrl = computed(() => { const recipeImageUrl = computed(() => {
@ -85,7 +90,7 @@ export default defineComponent({
() => recipeImageUrl.value, () => recipeImageUrl.value,
() => { () => {
hideImage.value = false; hideImage.value = false;
} },
); );
return { return {

View file

@ -1,24 +1,36 @@
<template> <template>
<div> <div>
<div class="d-flex justify-end flex-wrap align-stretch"> <div class="d-flex justify-end flex-wrap align-stretch">
<RecipePageInfoCardImage v-if="landscape" :recipe="recipe" /> <RecipePageInfoCardImage
v-if="landscape"
:recipe="recipe"
/>
<v-card <v-card
:width="landscape ? '100%' : '50%'" :width="landscape ? '100%' : '50%'"
flat flat
class="d-flex flex-column justify-center align-center" class="d-flex flex-column justify-center align-center"
> >
<v-card-text> <v-card-text class="w-100">
<v-card-title class="headline pa-0 flex-column align-center"> <v-card-title class="text-h5 font-weight-regular pa-0 d-flex flex-column align-center justify-center opacity-80">
{{ recipe.name }} {{ recipe.name }}
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" /> <RecipeRating
:key="recipe.slug"
:value="recipe.rating"
:recipe-id="recipe.id"
:slug="recipe.slug"
/>
</v-card-title> </v-card-title>
<v-divider class="my-2" /> <v-divider class="my-2" />
<SafeMarkdown :source="recipe.description" /> <SafeMarkdown :source="recipe.description" class="my-3" />
<v-divider v-if="recipe.description" /> <v-divider v-if="recipe.description" />
<v-container class="d-flex flex-row flex-wrap justify-center"> <v-container class="d-flex flex-row flex-wrap justify-center">
<div class="mx-6"> <div class="mx-6">
<v-row no-gutters> <v-row no-gutters>
<v-col v-if="recipe.recipeYieldQuantity || recipe.recipeYield" cols="12" class="d-flex flex-wrap justify-center"> <v-col
v-if="recipe.recipeYieldQuantity || recipe.recipeYield"
cols="12"
class="d-flex flex-wrap justify-center"
>
<RecipeYield <RecipeYield
:yield-quantity="recipe.recipeYieldQuantity" :yield-quantity="recipe.recipeYieldQuantity"
:yield="recipe.recipeYield" :yield="recipe.recipeYield"
@ -28,7 +40,10 @@
</v-col> </v-col>
</v-row> </v-row>
<v-row no-gutters> <v-row no-gutters>
<v-col cols="12" class="d-flex flex-wrap justify-center"> <v-col
cols="12"
class="d-flex flex-wrap justify-center"
>
<RecipeLastMade <RecipeLastMade
v-if="isOwnGroup" v-if="isOwnGroup"
:recipe="recipe" :recipe="recipe"
@ -49,22 +64,27 @@
</v-container> </v-container>
</v-card-text> </v-card-text>
</v-card> </v-card>
<RecipePageInfoCardImage v-if="!landscape" :recipe="recipe" max-width="50%" class="my-auto" /> <RecipePageInfoCardImage
v-if="!landscape"
:recipe="recipe"
max-width="50%"
class="my-auto"
/>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue"; import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue"; import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue"; import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import RecipeYield from "~/components/Domain/Recipe/RecipeYield.vue"; import RecipeYield from "~/components/Domain/Recipe/RecipeYield.vue";
import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue"; import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineComponent({
export default defineNuxtComponent({
components: { components: {
RecipeRating, RecipeRating,
RecipeLastMade, RecipeLastMade,
@ -87,15 +107,11 @@ export default defineComponent({
}, },
}, },
setup() { setup() {
const { $vuetify } = useContext();
const useMobile = computed(() => $vuetify.breakpoint.smAndDown);
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
return { return {
isOwnGroup, isOwnGroup,
useMobile,
}; };
} },
}); });
</script> </script>

View file

@ -3,6 +3,8 @@
:key="imageKey" :key="imageKey"
:max-width="maxWidth" :max-width="maxWidth"
min-height="50" min-height="50"
cover
width="100%"
:height="hideImage ? undefined : imageHeight" :height="hideImage ? undefined : imageHeight"
:src="recipeImageUrl" :src="recipeImageUrl"
class="d-print-none" class="d-print-none"
@ -11,13 +13,13 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
import { useStaticRoutes, useUserApi } from "~/composables/api"; import { useStaticRoutes, useUserApi } from "~/composables/api";
import { HouseholdSummary } from "~/lib/api/types/household"; import type { HouseholdSummary } from "~/lib/api/types/household";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state"; import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineComponent({
export default defineNuxtComponent({
props: { props: {
recipe: { recipe: {
type: Object as () => NoUndefinedField<Recipe>, type: Object as () => NoUndefinedField<Recipe>,
@ -29,7 +31,7 @@ export default defineComponent({
}, },
}, },
setup(props) { setup(props) {
const { $vuetify } = useContext(); const { $vuetify } = useNuxtApp();
const { recipeImage } = useStaticRoutes(); const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug); const { imageKey } = usePageState(props.recipe.slug);
const { user } = usePageUser(); const { user } = usePageUser();
@ -44,7 +46,7 @@ export default defineComponent({
const hideImage = ref(false); const hideImage = ref(false);
const imageHeight = computed(() => { const imageHeight = computed(() => {
return $vuetify.breakpoint.xs ? "200" : "400"; return $vuetify.display.xs.value ? "200" : "400";
}); });
const recipeImageUrl = computed(() => { const recipeImageUrl = computed(() => {
@ -55,7 +57,7 @@ export default defineComponent({
() => recipeImageUrl.value, () => recipeImageUrl.value,
() => { () => {
hideImage.value = false; hideImage.value = false;
} },
); );
return { return {
@ -64,6 +66,6 @@ export default defineComponent({
hideImage, hideImage,
imageHeight, imageHeight,
}; };
} },
}); });
</script> </script>

View file

@ -5,67 +5,90 @@
class="my-3" class="my-3"
:label="$t('recipe.recipe-name')" :label="$t('recipe.recipe-name')"
:rules="[validators.required]" :rules="[validators.required]"
density="compact"
variant="underlined"
/> />
<v-container class="ma-0 pa-0"> <v-container class="ma-0 pa-0">
<v-row> <v-row>
<v-col cols="3"> <v-col cols="3">
<v-text-field <v-text-field
v-model="recipeServings" :model-value="recipeServings"
type="number" type="number"
:min="0" :min="0"
hide-spin-buttons hide-spin-buttons
dense density="compact"
:label="$t('recipe.servings')" :label="$t('recipe.servings')"
@input="validateInput($event, 'recipeServings')" variant="underlined"
@update:model-value="validateInput($event, 'recipeServings')"
/> />
</v-col> </v-col>
<v-col cols="3"> <v-col cols="3">
<v-text-field <v-text-field
v-model="recipeYieldQuantity" :model-value="recipeYieldQuantity"
type="number" type="number"
:min="0" :min="0"
hide-spin-buttons hide-spin-buttons
dense density="compact"
:label="$t('recipe.yield')" :label="$t('recipe.yield')"
@input="validateInput($event, 'recipeYieldQuantity')" variant="underlined"
@update:model-value="validateInput($event, 'recipeYieldQuantity')"
/> />
</v-col> </v-col>
<v-col cols="6"> <v-col cols="6">
<v-text-field <v-text-field
v-model="recipe.recipeYield" v-model="recipe.recipeYield"
dense density="compact"
:label="$t('recipe.yield-text')" :label="$t('recipe.yield-text')"
variant="underlined"
/> />
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
<div class="d-flex flex-wrap" style="gap: 1rem"> <div
<v-text-field v-model="recipe.totalTime" :label="$t('recipe.total-time')" /> class="d-flex flex-wrap"
<v-text-field v-model="recipe.prepTime" :label="$t('recipe.prep-time')" /> style="gap: 1rem"
<v-text-field v-model="recipe.performTime" :label="$t('recipe.perform-time')" /> >
<v-text-field
v-model="recipe.totalTime"
:label="$t('recipe.total-time')"
density="compact"
variant="underlined"
/>
<v-text-field
v-model="recipe.prepTime"
:label="$t('recipe.prep-time')"
density="compact"
variant="underlined"
/>
<v-text-field
v-model="recipe.performTime"
:label="$t('recipe.perform-time')"
density="compact"
variant="underlined"
/>
</div> </div>
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')" /> <v-textarea
v-model="recipe.description"
auto-grow
min-height="100"
:label="$t('recipe.description')"
density="compact"
variant="underlined"
/>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
export default defineComponent({
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const recipeServings = computed<number>({ const recipeServings = computed<number>({
get() { get() {
return props.recipe.recipeServings; return recipe.value.recipeServings;
}, },
set(val) { set(val) {
validateInput(val.toString(), "recipeServings"); validateInput(val.toString(), "recipeServings");
@ -74,7 +97,7 @@ export default defineComponent({
const recipeYieldQuantity = computed<number>({ const recipeYieldQuantity = computed<number>({
get() { get() {
return props.recipe.recipeYieldQuantity; return recipe.value.recipeYieldQuantity;
}, },
set(val) { set(val) {
validateInput(val.toString(), "recipeYieldQuantity"); validateInput(val.toString(), "recipeYieldQuantity");
@ -83,25 +106,16 @@ export default defineComponent({
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") { function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
if (!value) { if (!value) {
props.recipe[property] = 0; recipe.value[property] = 0;
return; return;
} }
const number = parseFloat(value.replace(/[^0-9.]/g, "")); const number = parseFloat(value.replace(/[^0-9.]/g, ""));
if (isNaN(number) || number <= 0) { if (isNaN(number) || number <= 0) {
props.recipe[property] = 0; recipe.value[property] = 0;
return; return;
} }
props.recipe[property] = number; recipe.value[property] = number;
} }
return {
validators,
recipeServings,
recipeYieldQuantity,
validateInput,
};
},
});
</script> </script>

View file

@ -1,11 +1,14 @@
<!-- eslint-disable vue/no-mutating-props -->
<template> <template>
<div> <div>
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2> <h2 class="mb-4 text-h5 font-weight-medium opacity-80">
<draggable {{ $t("recipe.ingredients") }}
</h2>
<VueDraggable
v-if="recipe.recipeIngredient.length > 0" v-if="recipe.recipeIngredient.length > 0"
v-model="recipe.recipeIngredient" v-model="recipe.recipeIngredient"
handle=".handle" handle=".handle"
delay="250" :delay="250"
:delay-on-touch-only="true" :delay-on-touch-only="true"
v-bind="{ v-bind="{
animation: 200, animation: 200,
@ -16,7 +19,9 @@
@start="drag = true" @start="drag = true"
@end="drag = false" @end="drag = false"
> >
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''"> <TransitionGroup
type="transition"
>
<RecipeIngredientEditor <RecipeIngredientEditor
v-for="(ingredient, index) in recipe.recipeIngredient" v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId" :key="ingredient.referenceId"
@ -28,18 +33,26 @@
@insert-below="insertNewIngredient(index + 1)" @insert-below="insertNewIngredient(index + 1)"
/> />
</TransitionGroup> </TransitionGroup>
</draggable> </VueDraggable>
<v-skeleton-loader v-else boilerplate elevation="2" type="list-item"> </v-skeleton-loader> <v-skeleton-loader
v-else
boilerplate
elevation="2"
type="list-item"
/>
<div class="d-flex flex-wrap justify-center justify-sm-end mt-3"> <div class="d-flex flex-wrap justify-center justify-sm-end mt-3">
<v-tooltip top color="accent"> <v-tooltip
<template #activator="{ on, attrs }"> top
<span v-on="on"> color="accent"
>
<template #activator="{ props }">
<span>
<BaseButton <BaseButton
class="mb-1" class="mb-1"
:disabled="recipe.settings.disableAmount || hasFoodOrUnit" :disabled="recipe.settings.disableAmount || hasFoodOrUnit"
color="accent" color="accent"
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`" :to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
v-bind="attrs" v-bind="props"
> >
<template #icon> <template #icon>
{{ $globals.icons.foods }} {{ $globals.icons.foods }}
@ -50,62 +63,56 @@
</template> </template>
<span>{{ parserToolTip }}</span> <span>{{ parserToolTip }}</span>
</v-tooltip> </v-tooltip>
<RecipeDialogBulkAdd class="mx-1 mb-1" @bulk-data="addIngredient" /> <RecipeDialogBulkAdd
<BaseButton class="mb-1" @click="addIngredient" > {{ $t("general.add") }} </BaseButton> class="mx-1 mb-1"
@bulk-data="addIngredient"
/>
<BaseButton
class="mb-1"
@click="addIngredient"
>
{{ $t("general.add") }}
</BaseButton>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import draggable from "vuedraggable"; import { VueDraggable } from "vue-draggable-plus";
import { computed, defineComponent, ref, useContext, useRoute } from "@nuxtjs/composition-api"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state"; import type { Recipe } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue"; import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue"; import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
import { uuid4 } from "~/composables/use-utils"; import { uuid4 } from "~/composables/use-utils";
export default defineComponent({
components: { const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
draggable, const i18n = useI18n();
RecipeDialogBulkAdd, const $auth = useMealieAuth();
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 drag = ref(false);
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const hasFoodOrUnit = computed(() => { const hasFoodOrUnit = computed(() => {
if (!props.recipe) { if (!recipe.value) {
return false; return false;
} }
if (props.recipe.recipeIngredient) { if (recipe.value.recipeIngredient) {
for (const ingredient of props.recipe.recipeIngredient) { for (const ingredient of recipe.value.recipeIngredient) {
if (ingredient.food || ingredient.unit) { if (ingredient.food || ingredient.unit) {
return true; return true;
} }
} }
} }
return false; return false;
}); });
const parserToolTip = computed(() => { const parserToolTip = computed(() => {
if (props.recipe.settings.disableAmount) { if (recipe.value.settings.disableAmount) {
return i18n.t("recipe.enable-ingredient-amounts-to-use-this-feature"); return i18n.t("recipe.enable-ingredient-amounts-to-use-this-feature");
} else if (hasFoodOrUnit.value) { }
else if (hasFoodOrUnit.value) {
return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed"); return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed");
} }
return i18n.t("recipe.parse-ingredients"); return i18n.t("recipe.parse-ingredients");
@ -127,10 +134,11 @@ export default defineComponent({
if (newIngredients) { if (newIngredients) {
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set // @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
props.recipe.recipeIngredient.push(...newIngredients); recipe.value.recipeIngredient.push(...newIngredients);
} }
} else { }
props.recipe.recipeIngredient.push({ else {
recipe.value.recipeIngredient.push({
referenceId: uuid4(), referenceId: uuid4(),
title: "", title: "",
note: "", note: "",
@ -145,7 +153,7 @@ export default defineComponent({
} }
function insertNewIngredient(dest: number) { function insertNewIngredient(dest: number) {
props.recipe.recipeIngredient.splice(dest, 0, { recipe.value.recipeIngredient.splice(dest, 0, {
referenceId: uuid4(), referenceId: uuid4(),
title: "", title: "",
note: "", note: "",
@ -157,17 +165,4 @@ export default defineComponent({
quantity: 1, quantity: 1,
}); });
} }
return {
user,
groupSlug,
addIngredient,
parserToolTip,
hasFoodOrUnit,
imageKey,
drag,
insertNewIngredient,
};
},
});
</script> </script>

View file

@ -7,38 +7,47 @@
:is-cook-mode="isCookMode" :is-cook-mode="isCookMode"
/> />
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0"> <div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
<h2 class="mb-2 mt-4">{{ $t('tool.required-tools') }}</h2> <h2 class="mt-4 text-h5 font-weight-medium opacity-80">
<v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense> {{ $t('tool.required-tools') }}
</h2>
<v-list density="compact">
<v-list-item
v-for="(tool, index) in recipe.tools"
:key="index"
density="compact"
>
<template #prepend>
<v-checkbox <v-checkbox
v-model="recipeTools[index].onHand" v-model="recipeTools[index].onHand"
hide-details hide-details
class="pt-0 my-auto py-auto" class="pt-0 my-auto py-auto"
color="secondary" color="secondary"
density="compact"
@change="updateTool(index)" @change="updateTool(index)"
> />
</v-checkbox> </template>
<v-list-item-content> <v-list-item-title>
{{ tool.name }} {{ tool.name }}
</v-list-item-content> </v-list-item-title>
</v-list-item> </v-list-item>
</v-list>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state"; import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { useToolStore } from "~/composables/store"; import { useToolStore } from "~/composables/store";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe, RecipeTool } from "~/lib/api/types/recipe"; import type { Recipe, RecipeTool } from "~/lib/api/types/recipe";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue"; import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
interface RecipeToolWithOnHand extends RecipeTool { interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean; onHand: boolean;
} }
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeIngredients, RecipeIngredients,
}, },
@ -54,7 +63,7 @@ export default defineComponent({
isCookMode: { isCookMode: {
type: Boolean, type: Boolean,
default: false, default: false,
} },
}, },
setup(props) { setup(props) {
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
@ -65,14 +74,15 @@ export default defineComponent({
const recipeTools = computed(() => { const recipeTools = computed(() => {
if (!(user.householdSlug && toolStore)) { if (!(user.householdSlug && toolStore)) {
return props.recipe.tools.map((tool) => ({ ...tool, onHand: false }) as RecipeToolWithOnHand); return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
} else { }
else {
return props.recipe.tools.map((tool) => { return props.recipe.tools.map((tool) => {
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false; const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
return { ...tool, onHand } as RecipeToolWithOnHand; return { ...tool, onHand } as RecipeToolWithOnHand;
}); });
} }
}) });
function updateTool(index: number) { function updateTool(index: number) {
if (user.id && user.householdSlug && toolStore) { if (user.id && user.householdSlug && toolStore) {
@ -80,15 +90,18 @@ export default defineComponent({
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) { if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
if (!tool.householdsWithTool) { if (!tool.householdsWithTool) {
tool.householdsWithTool = [user.householdSlug]; tool.householdsWithTool = [user.householdSlug];
} else { }
else {
tool.householdsWithTool.push(user.householdSlug); tool.householdsWithTool.push(user.householdSlug);
} }
} else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) { }
tool.householdsWithTool = tool.householdsWithTool.filter((household) => household !== user.householdSlug); else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== user.householdSlug);
} }
toolStore.actions.updateOne(tool); toolStore.actions.updateOne(tool);
} else { }
else {
console.log("no user, skipping server update"); console.log("no user, skipping server update");
} }
} }

View file

@ -1,21 +1,34 @@
<template> <template>
<section @keyup.ctrl.90="undoMerge"> <section @keyup.ctrl.z="undoMerge">
<!-- Ingredient Link Editor --> <!-- Ingredient Link Editor -->
<v-dialog v-if="dialog" v-model="dialog" width="600"> <v-dialog
v-if="dialog"
v-model="dialog"
width="600"
>
<v-card :ripple="false"> <v-card :ripple="false">
<v-app-bar dark color="primary" class="mt-n1 mb-3"> <v-sheet
<v-icon large left> color="primary"
class="mt-n1 mb-3 pa-3 d-flex align-center"
style="border-radius: 6px; width: 100%;"
>
<v-icon
size="large"
start
>
{{ $globals.icons.link }} {{ $globals.icons.link }}
</v-icon> </v-icon>
<v-toolbar-title class="headline"> {{ $t("recipe.ingredient-linker") }} </v-toolbar-title> <v-toolbar-title class="headline">
<v-spacer></v-spacer> {{ $t("recipe.ingredient-linker") }}
</v-app-bar> </v-toolbar-title>
<v-spacer />
</v-sheet>
<v-card-text class="pt-4"> <v-card-text class="pt-4">
<p> <p>
{{ activeText }} {{ activeText }}
</p> </p>
<v-divider class="mb-4"></v-divider> <v-divider class="mb-4" />
<v-checkbox <v-checkbox
v-for="ing in unusedIngredients" v-for="ing in unusedIngredients"
:key="ing.referenceId" :key="ing.referenceId"
@ -29,7 +42,9 @@
</v-checkbox> </v-checkbox>
<template v-if="usedIngredients.length > 0"> <template v-if="usedIngredients.length > 0">
<h4 class="py-3 ml-1">{{ $t("recipe.linked-to-other-step") }}</h4> <h4 class="py-3 ml-1">
{{ $t("recipe.linked-to-other-step") }}
</h4>
<v-checkbox <v-checkbox
v-for="ing in usedIngredients" v-for="ing in usedIngredients"
:key="ing.referenceId" :key="ing.referenceId"
@ -44,19 +59,38 @@
</template> </template>
</v-card-text> </v-card-text>
<v-divider></v-divider> <v-divider />
<v-card-actions> <v-card-actions>
<BaseButton cancel @click="dialog = false"> </BaseButton> <BaseButton
<v-spacer></v-spacer> cancel
@click="dialog = false"
/>
<v-spacer />
<div class="d-flex flex-wrap justify-end"> <div class="d-flex flex-wrap justify-end">
<BaseButton class="my-1" color="info" @click="autoSetReferences"> <BaseButton
<template #icon> {{ $globals.icons.robot }}</template> class="my-1"
color="info"
@click="autoSetReferences"
>
<template #icon>
{{ $globals.icons.robot }}
</template>
{{ $t("recipe.auto") }} {{ $t("recipe.auto") }}
</BaseButton> </BaseButton>
<BaseButton class="ml-2 my-1" save @click="setIngredientIds"> </BaseButton> <BaseButton
<BaseButton v-if="availableNextStep" class="ml-2 my-1" @click="saveAndOpenNextLinkIngredients"> class="ml-2 my-1"
<template #icon> {{ $globals.icons.forward }}</template> save
@click="setIngredientIds"
/>
<BaseButton
v-if="availableNextStep"
class="ml-2 my-1"
@click="saveAndOpenNextLinkIngredients"
>
<template #icon>
{{ $globals.icons.forward }}
</template>
{{ $t("recipe.nextStep") }} {{ $t("recipe.nextStep") }}
</BaseButton> </BaseButton>
</div> </div>
@ -65,76 +99,97 @@
</v-dialog> </v-dialog>
<div class="d-flex justify-space-between justify-start"> <div class="d-flex justify-space-between justify-start">
<h2 v-if="!isCookMode" class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2> <h2
<BaseButton v-if="!isEditForm && !isCookMode" minor cancel color="primary" @click="toggleCookMode()"> v-if="!isCookMode"
class="mt-1 text-h5 font-weight-medium opacity-80"
>
{{ $t("recipe.instructions") }}
</h2>
<BaseButton
v-if="!isEditForm && !isCookMode"
minor
cancel
color="primary"
@click="toggleCookMode()"
>
<template #icon> <template #icon>
{{ $globals.icons.primary }} {{ $globals.icons.primary }}
</template> </template>
{{ $t("recipe.cook-mode") }} {{ $t("recipe.cook-mode") }}
</BaseButton> </BaseButton>
</div> </div>
<draggable <VueDraggable
v-model="instructionList"
:disabled="!isEditForm" :disabled="!isEditForm"
:value="value"
handle=".handle" handle=".handle"
delay="250" :delay="250"
:delay-on-touch-only="true" :delay-on-touch-only="true"
v-bind="{ v-bind="{
animation: 200, animation: 200,
group: 'recipe-instructions', group: 'recipe-instructions',
ghostClass: 'ghost', ghostClass: 'ghost',
}" }"
@input="updateIndex"
@start="drag = true" @start="drag = true"
@end="drag = false" @end="onDragEnd"
> >
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''"> <TransitionGroup
<div v-for="(step, index) in value" :key="step.id" class="list-group-item"> type="transition"
<v-app-bar >
<div
v-for="(step, index) in instructionList"
:key="step.id!"
class="list-group-item"
>
<v-sheet
v-if="step.id && showTitleEditor[step.id]" v-if="step.id && showTitleEditor[step.id]"
class="primary mt-6" color="primary"
style="cursor: pointer" class="mt-6 mb-2 d-flex align-center"
dark :class="isEditForm ? 'pa-2' : 'pa-3'"
dense style="border-radius: 6px; cursor: pointer; width: 100%;"
rounded
@click="toggleCollapseSection(index)" @click="toggleCollapseSection(index)"
> >
<v-toolbar-title v-if="!isEditForm" class="headline"> <template v-if="isEditForm">
<v-app-bar-title> {{ step.title }} </v-app-bar-title>
</v-toolbar-title>
<v-text-field <v-text-field
v-if="isEditForm"
v-model="step.title" v-model="step.title"
class="headline pa-0 mt-5" class="pa-0"
dense density="compact"
solo variant="solo"
flat flat
:placeholder="$t('recipe.section-title')" :placeholder="$t('recipe.section-title')"
background-color="primary" bg-color="primary"
> hide-details
</v-text-field> />
</v-app-bar> </template>
<v-hover v-slot="{ hover }"> <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 <v-card
class="my-3" class="my-3"
:class="[{ 'on-hover': hover }, isChecked(index)]" :class="[{ 'on-hover': isHovering }, isChecked(index)]"
:elevation="hover ? 12 : 2" :elevation="isHovering ? 12 : 2"
:ripple="false" :ripple="false"
@click="toggleDisabled(index)" @click="toggleDisabled(index)"
> >
<v-card-title :class="{ 'pb-0': !isChecked(index) }"> <v-card-title :class="{ 'pb-0': !isChecked(index) }">
<div class="d-flex align-center">
<v-text-field <v-text-field
v-if="isEditForm" v-if="isEditForm"
v-model="step.summary" v-model="step.summary"
class="headline handle" class="headline handle"
hide-details hide-details
dense density="compact"
solo variant="solo"
flat flat
:placeholder="$t('recipe.step-index', { step: index + 1 })" :placeholder="$t('recipe.step-index', { step: index + 1 })"
> >
<template #prepend> <template #prepend>
<v-icon size="26">{{ $globals.icons.arrowUpDown }}</v-icon> <v-icon size="26">
{{ $globals.icons.arrowUpDown }}
</v-icon>
</template> </template>
</v-text-field> </v-text-field>
<span v-else> <span v-else>
@ -147,7 +202,7 @@
:buttons="[ :buttons="[
{ {
icon: $globals.icons.delete, icon: $globals.icons.delete,
text: $tc('general.delete'), text: $t('general.delete'),
event: 'delete', event: 'delete',
}, },
{ {
@ -156,42 +211,42 @@
event: 'open', event: 'open',
children: [ children: [
{ {
text: $tc('recipe.toggle-section'), text: $t('recipe.toggle-section'),
event: 'toggle-section', event: 'toggle-section',
}, },
{ {
text: $tc('recipe.link-ingredients'), text: $t('recipe.link-ingredients'),
event: 'link-ingredients', event: 'link-ingredients',
}, },
{ {
text: $tc('recipe.upload-image'), text: $t('recipe.upload-image'),
event: 'upload-image' event: 'upload-image',
}, },
{ {
icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye, icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye,
text: previewStates[index] ? $tc('recipe.edit-markdown') : $tc('markdown-editor.preview-markdown-button-label'), text: previewStates[index] ? $t('recipe.edit-markdown') : $t('markdown-editor.preview-markdown-button-label'),
event: 'preview-step', event: 'preview-step',
divider: true, divider: true,
}, },
{ {
text: $tc('recipe.merge-above'), text: $t('recipe.merge-above'),
event: 'merge-above', event: 'merge-above',
}, },
{ {
text: $tc('recipe.move-to-top'), text: $t('recipe.move-to-top'),
event: 'move-to-top', event: 'move-to-top',
}, },
{ {
text: $tc('recipe.move-to-bottom'), text: $t('recipe.move-to-bottom'),
event: 'move-to-bottom', event: 'move-to-bottom',
}, },
{ {
text: $tc('recipe.insert-above'), text: $t('recipe.insert-above'),
event: 'insert-above' event: 'insert-above',
}, },
{ {
text: $tc('recipe.insert-below'), text: $t('recipe.insert-below'),
event: 'insert-below' event: 'insert-below',
}, },
], ],
}, },
@ -201,22 +256,32 @@
@move-to-bottom="moveTo('bottom', index)" @move-to-bottom="moveTo('bottom', index)"
@insert-above="insert(index)" @insert-above="insert(index)"
@insert-below="insert(index + 1)" @insert-below="insert(index + 1)"
@toggle-section="toggleShowTitle(step.id)" @toggle-section="toggleShowTitle(step.id!)"
@link-ingredients="openDialog(index, step.text, step.ingredientReferences)" @link-ingredients="openDialog(index, step.text, step.ingredientReferences)"
@preview-step="togglePreviewState(index)" @preview-step="togglePreviewState(index)"
@upload-image="openImageUpload(index)" @upload-image="openImageUpload(index)"
@delete="value.splice(index, 1)" @delete="instructionList.splice(index, 1)"
/> />
</div> </div>
</template> </template>
<v-fade-transition> <v-fade-transition>
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success"> <v-icon
v-show="isChecked(index)"
size="24"
class="ml-auto"
color="success"
>
{{ $globals.icons.checkboxMarkedCircle }} {{ $globals.icons.checkboxMarkedCircle }}
</v-icon> </v-icon>
</v-fade-transition> </v-fade-transition>
</div>
</v-card-title> </v-card-title>
<v-progress-linear v-if="isEditForm && loadingStates[index]" :active="true" :indeterminate="true" /> <v-progress-linear
v-if="isEditForm && loadingStates[index]"
:active="true"
:indeterminate="true"
/>
<!-- Content --> <!-- Content -->
<DropZone @drop="(f) => handleImageDrop(index, f)"> <DropZone @drop="(f) => handleImageDrop(index, f)">
@ -225,9 +290,9 @@
@click="$emit('click-instruction-field', `${index}.text`)" @click="$emit('click-instruction-field', `${index}.text`)"
> >
<MarkdownEditor <MarkdownEditor
v-model="value[index]['text']" v-model="instructionList[index]['text']"
v-model:preview="previewStates[index]"
class="mb-2" class="mb-2"
:preview.sync="previewStates[index]"
:display-preview="false" :display-preview="false"
:textarea="{ :textarea="{
hint: $t('recipe.attach-images-hint'), hint: $t('recipe.attach-images-hint'),
@ -236,14 +301,16 @@
/> />
<RecipeIngredientHtml <RecipeIngredientHtml
v-for="ing in step.ingredientReferences" v-for="ing in step.ingredientReferences"
:key="ing.referenceId" :key="ing.referenceId!"
:markup="getIngredientByRefId(ing.referenceId)" :markup="getIngredientByRefId(ing.referenceId!)"
/> />
</v-card-text> </v-card-text>
</DropZone> </DropZone>
<v-expand-transition> <v-expand-transition>
<div v-show="!isChecked(index) && !isEditForm" class="m-0 p-0"> <div
v-if="!isChecked(index) && !isEditForm"
class="m-0 p-0"
>
<v-card-text class="markdown"> <v-card-text class="markdown">
<v-row> <v-row>
<v-col <v-col
@ -263,9 +330,15 @@
/> />
</div> </div>
</v-col> </v-col>
<v-divider v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0 && $vuetify.breakpoint.smAndUp" vertical ></v-divider> <v-divider
v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0 && $vuetify.display.smAndUp"
vertical
/>
<v-col> <v-col>
<SafeMarkdown class="markdown" :source="step.text" /> <SafeMarkdown
class="markdown"
:source="step.text"
/>
</v-col> </v-col>
</v-row> </v-row>
</v-card-text> </v-card-text>
@ -275,34 +348,28 @@
</v-hover> </v-hover>
</div> </div>
</TransitionGroup> </TransitionGroup>
</draggable> </VueDraggable>
<v-divider v-if="!isCookMode" class="mt-10 d-flex d-md-none"/> <v-divider
v-if="!isCookMode"
class="mt-10 d-flex d-md-none"
/>
</section> </section>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import draggable from "vuedraggable"; import { VueDraggable } from "vue-draggable-plus";
import { import { computed, nextTick, onMounted, ref, watch } from "vue";
ref,
toRefs,
reactive,
defineComponent,
watch,
onMounted,
useContext,
computed,
nextTick,
} from "@nuxtjs/composition-api";
import RecipeIngredientHtml from "../../RecipeIngredientHtml.vue"; import RecipeIngredientHtml from "../../RecipeIngredientHtml.vue";
import { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe"; import type { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe";
import { parseIngredientText } from "~/composables/recipes"; import { parseIngredientText } from "~/composables/recipes";
import { uuid4, detectServerBaseUrl } from "~/composables/use-utils"; import { uuid4 } from "~/composables/use-utils";
import { useUserApi, useStaticRoutes } from "~/composables/api"; import { useUserApi, useStaticRoutes } from "~/composables/api";
import { usePageState } from "~/composables/recipe-page/shared-state"; import { usePageState } from "~/composables/recipe-page/shared-state";
import { useExtractIngredientReferences } from "~/composables/recipe-page/use-extract-ingredient-references"; import { useExtractIngredientReferences } from "~/composables/recipe-page/use-extract-ingredient-references";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import DropZone from "~/components/global/DropZone.vue"; import DropZone from "~/components/global/DropZone.vue";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue"; import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
interface MergerHistory { interface MergerHistory {
target: number; target: number;
source: number; source: number;
@ -310,63 +377,33 @@ interface MergerHistory {
sourceText: string; sourceText: string;
} }
export default defineComponent({ const instructionList = defineModel<RecipeStep[]>("modelValue", { required: true, default: () => [] });
components: { const assets = defineModel<RecipeAsset[]>("assets", { required: true, default: () => [] });
draggable,
RecipeIngredientHtml, const props = defineProps({
DropZone,
RecipeIngredients
},
props: {
value: {
type: Array as () => RecipeStep[],
required: false,
default: () => [],
},
recipe: { recipe: {
type: Object as () => NoUndefinedField<Recipe>, type: Object as () => NoUndefinedField<Recipe>,
required: true, required: true,
}, },
assets: {
type: Array as () => RecipeAsset[],
required: true,
},
scale: { scale: {
type: Number, type: Number,
default: 1, default: 1,
}, },
}, });
setup(props, context) { const emit = defineEmits(["click-instruction-field", "update:assets"]);
const { i18n, req } = useContext();
const BASE_URL = detectServerBaseUrl(req); const BASE_URL = useRequestURL().origin;
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug); const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
const state = reactive({ const dialog = ref(false);
dialog: false, const disabledSteps = ref<number[]>([]);
disabledSteps: [] as number[], const unusedIngredients = ref<RecipeIngredient[]>([]);
unusedIngredients: [] as RecipeIngredient[], const usedIngredients = ref<RecipeIngredient[]>([]);
usedIngredients: [] as RecipeIngredient[],
});
const showTitleEditor = ref<{ [key: string]: boolean }>({}); const showTitleEditor = ref<{ [key: string]: boolean }>({});
const actionEvents = [
{
text: i18n.t("recipe.toggle-section") as string,
event: "toggle-section",
},
{
text: i18n.t("recipe.link-ingredients") as string,
event: "link-ingredients",
},
{
text: i18n.t("recipe.merge-above") as string,
event: "merge-above",
},
];
// =============================================================== // ===============================================================
// UI State Helpers // UI State Helpers
@ -374,50 +411,53 @@ export default defineComponent({
return !(title === null || title === "" || title === undefined); return !(title === null || title === "" || title === undefined);
} }
watch(props.value, (v) => { watch(instructionList, (v) => {
state.disabledSteps = []; disabledSteps.value = [];
v.forEach((element: RecipeStep) => { v.forEach((element: RecipeStep) => {
if (element.id !== undefined) { if (element.id !== undefined) {
showTitleEditor.value[element.id] = hasSectionTitle(element.title); showTitleEditor.value[element.id!] = hasSectionTitle(element.title!);
} }
}); });
}); }, { deep: true });
const showCookMode = ref(false); const showCookMode = ref(false);
// Eliminate state with an eager call to watcher?
onMounted(() => { onMounted(() => {
props.value.forEach((element: RecipeStep) => { instructionList.value.forEach((element: RecipeStep) => {
if (element.id !== undefined) { if (element.id !== undefined) {
showTitleEditor.value[element.id] = hasSectionTitle(element.title); showTitleEditor.value[element.id!] = hasSectionTitle(element.title!);
} }
// showCookMode.value = false;
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) { if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
showCookMode.value = true; showCookMode.value = true;
} }
showTitleEditor.value = { ...showTitleEditor.value }; showTitleEditor.value = { ...showTitleEditor.value };
}); });
if (assets.value === undefined) {
emit("update:assets", []);
}
}); });
function toggleDisabled(stepIndex: number) { function toggleDisabled(stepIndex: number) {
if (isEditForm.value) { if (isEditForm.value) {
return; return;
} }
if (state.disabledSteps.includes(stepIndex)) { if (disabledSteps.value.includes(stepIndex)) {
const index = state.disabledSteps.indexOf(stepIndex); const index = disabledSteps.value.indexOf(stepIndex);
if (index !== -1) { if (index !== -1) {
state.disabledSteps.splice(index, 1); disabledSteps.value.splice(index, 1);
} }
} else { }
state.disabledSteps.push(stepIndex); else {
disabledSteps.value.push(stepIndex);
} }
} }
function isChecked(stepIndex: number) { function isChecked(stepIndex: number) {
if (state.disabledSteps.includes(stepIndex) && !isEditForm.value) { if (disabledSteps.value.includes(stepIndex) && !isEditForm.value) {
return "disabled-card"; return "disabled-card";
} }
} }
@ -433,8 +473,8 @@ export default defineComponent({
showTitleEditor.value = temp; showTitleEditor.value = temp;
} }
function updateIndex(data: RecipeStep) { function onDragEnd() {
context.emit("input", data); drag.value = false;
} }
// =============================================================== // ===============================================================
@ -445,21 +485,21 @@ export default defineComponent({
function openDialog(idx: number, text: string, refs?: IngredientReferences[]) { function openDialog(idx: number, text: string, refs?: IngredientReferences[]) {
if (!refs) { if (!refs) {
props.value[idx].ingredientReferences = []; instructionList.value[idx].ingredientReferences = [];
refs = props.value[idx].ingredientReferences as IngredientReferences[]; refs = instructionList.value[idx].ingredientReferences as IngredientReferences[];
} }
setUsedIngredients(); setUsedIngredients();
activeText.value = text; activeText.value = text;
activeIndex.value = idx; activeIndex.value = idx;
state.dialog = true; dialog.value = 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 < instructionList.value.length - 1);
function setIngredientIds() { function setIngredientIds() {
const instruction = props.value[activeIndex.value]; const instruction = instructionList.value[activeIndex.value];
instruction.ingredientReferences = activeRefs.value.map((ref) => { instruction.ingredientReferences = activeRefs.value.map((ref) => {
return { return {
referenceId: ref, referenceId: ref,
@ -468,12 +508,12 @@ export default defineComponent({
// Update the visibility of the cook mode button // Update the visibility of the cook mode button
showCookMode.value = false; showCookMode.value = false;
props.value.forEach((element) => { instructionList.value.forEach((element) => {
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) { if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
showCookMode.value = true; showCookMode.value = true;
} }
}); });
state.dialog = false; dialog.value = false;
} }
function saveAndOpenNextLinkIngredients() { function saveAndOpenNextLinkIngredients() {
@ -484,28 +524,27 @@ export default defineComponent({
} }
setIngredientIds(); setIngredientIds();
const nextStep = props.value[currentStepIndex + 1]; const nextStep = instructionList.value[currentStepIndex + 1];
// close dialog before opening to reset the scroll position // close dialog before opening to reset the scroll position
nextTick(() => openDialog(currentStepIndex + 1, nextStep.text, nextStep.ingredientReferences)); nextTick(() => openDialog(currentStepIndex + 1, nextStep.text, nextStep.ingredientReferences));
} }
function setUsedIngredients() { function setUsedIngredients() {
const usedRefs: { [key: string]: boolean } = {}; const usedRefs: { [key: string]: boolean } = {};
props.value.forEach((element) => { instructionList.value.forEach((element) => {
element.ingredientReferences?.forEach((ref) => { element.ingredientReferences?.forEach((ref) => {
if (ref.referenceId !== undefined) { if (ref.referenceId !== undefined) {
usedRefs[ref.referenceId] = true; usedRefs[ref.referenceId!] = true;
} }
}); });
}); });
state.usedIngredients = props.recipe.recipeIngredient.filter((ing) => { usedIngredients.value = props.recipe.recipeIngredient.filter((ing) => {
return ing.referenceId !== undefined && ing.referenceId in usedRefs; return ing.referenceId !== undefined && ing.referenceId in usedRefs;
}); });
state.unusedIngredients = props.recipe.recipeIngredient.filter((ing) => { unusedIngredients.value = props.recipe.recipeIngredient.filter((ing) => {
return !(ing.referenceId !== undefined && ing.referenceId in usedRefs); return !(ing.referenceId !== undefined && ing.referenceId in usedRefs);
}); });
} }
@ -515,7 +554,7 @@ export default defineComponent({
props.recipe.recipeIngredient, props.recipe.recipeIngredient,
activeRefs.value, activeRefs.value,
activeText.value, activeText.value,
props.recipe.settings.disableAmount props.recipe.settings.disableAmount,
).forEach((ingredient: string) => activeRefs.value.push(ingredient)); ).forEach((ingredient: string) => activeRefs.value.push(ingredient));
} }
@ -535,10 +574,8 @@ export default defineComponent({
return ""; return "";
} }
const ing = ingredientLookup.value[refId] ?? ""; const ing = ingredientLookup.value[refId];
if (ing === "") { if (!ing) return "";
return "";
}
return parseIngredientText(ing, props.recipe.settings.disableAmount, props.scale); return parseIngredientText(ing, props.recipe.settings.disableAmount, props.scale);
} }
@ -554,12 +591,12 @@ export default defineComponent({
mergeHistory.value.push({ mergeHistory.value.push({
target, target,
source, source,
targetText: props.value[target].text, targetText: instructionList.value[target].text,
sourceText: props.value[source].text, sourceText: instructionList.value[source].text,
}); });
props.value[target].text += " " + props.value[source].text; instructionList.value[target].text += " " + instructionList.value[source].text;
props.value.splice(source, 1); instructionList.value.splice(source, 1);
} }
function undoMerge(event: KeyboardEvent) { function undoMerge(event: KeyboardEvent) {
@ -573,8 +610,8 @@ export default defineComponent({
return; return;
} }
props.value[lastMerge.target].text = lastMerge.targetText; instructionList.value[lastMerge.target].text = lastMerge.targetText;
props.value.splice(lastMerge.source, 0, { instructionList.value.splice(lastMerge.source, 0, {
id: uuid4(), id: uuid4(),
title: "", title: "",
text: lastMerge.sourceText, text: lastMerge.sourceText,
@ -585,14 +622,15 @@ export default defineComponent({
function moveTo(dest: string, source: number) { function moveTo(dest: string, source: number) {
if (dest === "top") { if (dest === "top") {
props.value.unshift(props.value.splice(source, 1)[0]); instructionList.value.unshift(instructionList.value.splice(source, 1)[0]);
} else { }
props.value.push(props.value.splice(source, 1)[0]); else {
instructionList.value.push(instructionList.value.splice(source, 1)[0]);
} }
} }
function insert(dest: number) { function insert(dest: number) {
props.value.splice(dest, 0, { id: uuid4(), text: "", title: "", ingredientReferences: [] }); instructionList.value.splice(dest, 0, { id: uuid4(), text: "", title: "", ingredientReferences: [] });
} }
const previewStates = ref<boolean[]>([]); const previewStates = ref<boolean[]>([]);
@ -606,20 +644,22 @@ export default defineComponent({
function toggleCollapseSection(index: number) { function toggleCollapseSection(index: number) {
const sectionSteps: number[] = []; const sectionSteps: number[] = [];
for (let i = index; i < props.value.length; i++) { for (let i = index; i < instructionList.value.length; i++) {
if (!(i === index) && hasSectionTitle(props.value[i].title)) { if (!(i === index) && hasSectionTitle(instructionList.value[i].title!)) {
break; break;
} else { }
else {
sectionSteps.push(i); sectionSteps.push(i);
} }
} }
const allCollapsed = sectionSteps.every((idx) => state.disabledSteps.includes(idx)); const allCollapsed = sectionSteps.every(idx => disabledSteps.value.includes(idx));
if (allCollapsed) { if (allCollapsed) {
state.disabledSteps = state.disabledSteps.filter((idx) => !sectionSteps.includes(idx)); disabledSteps.value = disabledSteps.value.filter(idx => !sectionSteps.includes(idx));
} else { }
state.disabledSteps = [...state.disabledSteps, ...sectionSteps]; else {
disabledSteps.value = [...disabledSteps.value, ...sectionSteps];
} }
} }
@ -630,19 +670,6 @@ export default defineComponent({
const api = useUserApi(); const api = useUserApi();
const { recipeAssetPath } = useStaticRoutes(); const { recipeAssetPath } = useStaticRoutes();
const imageUploadMode = ref(false);
function toggleDragMode() {
console.log("Toggling Drag Mode");
imageUploadMode.value = !imageUploadMode.value;
}
onMounted(() => {
if (props.assets === undefined) {
context.emit("update:assets", []);
}
});
const loadingStates = ref<{ [key: number]: boolean }>({}); const loadingStates = ref<{ [key: number]: boolean }>({});
async function handleImageDrop(index: number, files: File[]) { async function handleImageDrop(index: number, files: File[]) {
@ -671,10 +698,10 @@ export default defineComponent({
return; // TODO: Handle error return; // TODO: Handle error
} }
context.emit("update:assets", [...props.assets, data]); emit("update:assets", [...assets.value, data]);
const assetUrl = BASE_URL + recipeAssetPath(props.recipe.id, data.fileName as string); const assetUrl = BASE_URL + recipeAssetPath(props.recipe.id, data.fileName as string);
const text = `<img src="${assetUrl}" height="100%" width="100%"/>`; const text = `<img src="${assetUrl}" height="100%" width="100%"/>`;
props.value[index].text += text; instructionList.value[index].text += text;
} }
function openImageUpload(index: number) { function openImageUpload(index: number) {
@ -689,47 +716,6 @@ export default defineComponent({
}; };
input.click(); input.click();
} }
return {
// Image Uploader
toggleDragMode,
handleImageDrop,
imageUploadMode,
openImageUpload,
loadingStates,
// Rest
drag,
togglePreviewState,
toggleCollapseSection,
previewStates,
...toRefs(state),
actionEvents,
activeRefs,
activeText,
getIngredientByRefId,
showTitleEditor,
mergeAbove,
moveTo,
openDialog,
setIngredientIds,
availableNextStep,
saveAndOpenNextLinkIngredients,
undoMerge,
toggleDisabled,
isChecked,
toggleShowTitle,
updateIndex,
autoSetReferences,
parseIngredientText,
toggleCookMode,
showCookMode,
isCookMode,
isEditForm,
insert,
};
},
});
</script> </script>
<style lang="css" scoped> <style lang="css" scoped>
@ -738,28 +724,32 @@ export default defineComponent({
} }
/** Select all li under .markdown class */ /** Select all li under .markdown class */
.markdown >>> ul > li { .markdown :deep(ul > li) {
display: list-item; display: list-item;
list-style-type: disc !important; list-style-type: disc !important;
} }
/** Select all li under .markdown class */ /** Select all li under .markdown class */
.markdown >>> ol > li { .markdown :deep(ol > li) {
display: list-item; display: list-item;
} }
.flip-list-move { .flip-list-move {
transition: transform 0.5s; transition: transform 0.5s;
} }
.no-move { .no-move {
transition: transform 0s; transition: transform 0s;
} }
.ghost { .ghost {
opacity: 0.5; opacity: 0.5;
} }
.list-group { .list-group {
min-height: 38px; min-height: 38px;
} }
.list-group-item i { .list-group-item i {
cursor: pointer; cursor: pointer;
} }
@ -780,4 +770,8 @@ export default defineComponent({
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
z-index: 1; z-index: 1;
} }
.v-text-field >>> input {
font-size: 1.5rem;
}
</style> </style>

View file

@ -1,7 +1,10 @@
<template> <template>
<div> <div>
<!-- Recipe Categories --> <!-- Recipe Categories -->
<v-card v-if="recipe.recipeCategory.length > 0 || isEditForm" :class="{'mt-10': !isEditForm}"> <v-card
v-if="recipe.recipeCategory.length > 0 || isEditForm"
:class="{ 'mt-10': !isEditForm }"
>
<v-card-title class="py-2"> <v-card-title class="py-2">
{{ $t("recipe.categories") }} {{ $t("recipe.categories") }}
</v-card-title> </v-card-title>
@ -14,12 +17,19 @@
:show-add="true" :show-add="true"
selector-type="categories" selector-type="categories"
/> />
<RecipeChips v-else :items="recipe.recipeCategory" v-on="$listeners" /> <RecipeChips
v-else
:items="recipe.recipeCategory"
v-bind="$attrs"
/>
</v-card-text> </v-card-text>
</v-card> </v-card>
<!-- Recipe Tags --> <!-- Recipe Tags -->
<v-card v-if="recipe.tags.length > 0 || isEditForm" class="mt-4"> <v-card
v-if="recipe.tags.length > 0 || isEditForm"
class="mt-4"
>
<v-card-title class="py-2"> <v-card-title class="py-2">
{{ $t("tag.tags") }} {{ $t("tag.tags") }}
</v-card-title> </v-card-title>
@ -32,20 +42,39 @@
:show-add="true" :show-add="true"
selector-type="tags" selector-type="tags"
/> />
<RecipeChips v-else :items="recipe.tags" url-prefix="tags" v-on="$listeners" /> <RecipeChips
v-else
:items="recipe.tags"
url-prefix="tags"
v-bind="$attrs"
/>
</v-card-text> </v-card-text>
</v-card> </v-card>
<!-- Recipe Tools Edit --> <!-- Recipe Tools Edit -->
<v-card v-if="isEditForm" class="mt-2"> <v-card
<v-card-title class="py-2"> {{ $t('tool.required-tools') }} </v-card-title> v-if="isEditForm"
class="mt-2"
>
<v-card-title class="py-2">
{{ $t('tool.required-tools') }}
</v-card-title>
<v-divider class="mx-2" /> <v-divider class="mx-2" />
<v-card-text class="pt-0"> <v-card-text class="pt-0">
<RecipeOrganizerSelector v-model="recipe.tools" selector-type="tools" v-on="$listeners" /> <RecipeOrganizerSelector
v-model="recipe.tools"
selector-type="tools"
v-bind="$attrs"
/>
</v-card-text> </v-card-text>
</v-card> </v-card>
<RecipeNutrition v-if="recipe.settings.showNutrition" v-model="recipe.nutrition" class="mt-4" :edit="isEditForm" /> <RecipeNutrition
v-if="recipe.settings.showNutrition"
v-model="recipe.nutrition"
class="mt-4"
:edit="isEditForm"
/>
<RecipeAssets <RecipeAssets
v-if="recipe.settings.showAssets" v-if="recipe.settings.showAssets"
v-model="recipe.assets" v-model="recipe.assets"
@ -56,38 +85,15 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from "@nuxtjs/composition-api"; import { usePageState } from "~/composables/recipe-page/shared-state";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { Recipe } from "~/lib/api/types/recipe";
import { Recipe } from "~/lib/api/types/recipe";
import RecipeOrganizerSelector from "@/components/Domain/Recipe/RecipeOrganizerSelector.vue"; import RecipeOrganizerSelector from "@/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import RecipeNutrition from "~/components/Domain/Recipe/RecipeNutrition.vue"; import RecipeNutrition from "~/components/Domain/Recipe/RecipeNutrition.vue";
import RecipeChips from "@/components/Domain/Recipe/RecipeChips.vue"; import RecipeChips from "@/components/Domain/Recipe/RecipeChips.vue";
import RecipeAssets from "@/components/Domain/Recipe/RecipeAssets.vue"; import RecipeAssets from "@/components/Domain/Recipe/RecipeAssets.vue";
export default defineComponent({
components: {
RecipeOrganizerSelector,
RecipeNutrition,
RecipeChips,
RecipeAssets,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const { user } = usePageUser();
const { isEditForm } = usePageState(props.recipe.slug);
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const { isEditForm } = usePageState(recipe.value.slug);
return {
isEditForm,
user,
};
},
});
</script> </script>

View file

@ -1,28 +1,21 @@
<template> <template>
<div class="d-flex justify-space-between align-center pt-2 pb-3"> <div class="d-flex justify-space-between align-center pt-2 pb-3">
<v-tooltip v-if="!isEditMode" small top color="secondary darken-1">
<template #activator="{ on, attrs }">
<RecipeScaleEditButton <RecipeScaleEditButton
v-if="!isEditMode"
v-model.number="scaleValue" v-model.number="scaleValue"
v-bind="attrs"
:recipe-servings="recipeServings" :recipe-servings="recipeServings"
:edit-scale="!recipe.settings.disableAmount && !isEditMode" :edit-scale="!recipe.settings.disableAmount && !isEditMode"
v-on="on"
/> />
</template>
<span> {{ $t("recipe.edit-scale") }} </span>
</v-tooltip>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue"; import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import { usePageState } from "~/composables/recipe-page/shared-state"; import { usePageState } from "~/composables/recipe-page/shared-state";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeScaleEditButton, RecipeScaleEditButton,
}, },
@ -36,6 +29,7 @@ export default defineComponent({
default: 1, default: 1,
}, },
}, },
emits: ["update:scale"],
setup(props, { emit }) { setup(props, { emit }) {
const { isEditMode } = usePageState(props.recipe.slug); const { isEditMode } = usePageState(props.recipe.slug);

View file

@ -1,3 +0,0 @@
import RecipePage from "./RecipePage.vue";
export default RecipePage;

View file

@ -1,15 +1,18 @@
<template> <template>
<div class="print-container"> <div class="print-container">
<RecipePrintView :recipe="recipe" :scale="scale" dense /> <RecipePrintView
:recipe="recipe"
:scale="scale"
:density="'compact'"
/>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue"; import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipePrintView, RecipePrintView,
}, },

View file

@ -1,38 +1,43 @@
<!-- eslint-disable vue/no-v-html -->
<template> <template>
<div :class="dense ? 'wrapper' : 'wrapper pa-3'"> <div :class="dense ? 'wrapper' : 'wrapper pa-3'">
<section> <section>
<v-container class="ma-0 pa-0"> <v-container class="ma-0 pa-0">
<v-row> <v-row>
<v-col <v-col v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1" :order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
cols="4" cols="4"
align-self="center" align-self="center"
> >
<img :key="imageKey" :src="recipeImageUrl" style="min-height: 50; max-width: 100%;" /> <img :key="imageKey"
:src="recipeImageUrl"
style="min-height: 50; max-width: 100%;"
>
</v-col> </v-col>
<v-col order=0> <v-col order="0">
<v-card-title class="headline pl-0"> <v-card-title class="headline pl-0">
<v-icon left color="primary"> <v-icon start
color="primary"
>
{{ $globals.icons.primary }} {{ $globals.icons.primary }}
</v-icon> </v-icon>
{{ recipe.name }} {{ recipe.name }}
</v-card-title> </v-card-title>
<div v-if="recipeYield" class="d-flex justify-space-between align-center px-4 pb-2"> <div v-if="recipeYield"
<v-chip class="d-flex justify-space-between align-center px-4 pb-2"
:small="$vuetify.breakpoint.smAndDown" >
<v-chip :size="$vuetify.display.smAndDown ? 'small' : undefined"
label label
> >
<v-icon left> <v-icon start>
{{ $globals.icons.potSteam }} {{ $globals.icons.potSteam }}
</v-icon> </v-icon>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="recipeYield"></span> <span v-html="recipeYield" />
</v-chip> </v-chip>
</div> </div>
<v-row class="d-flex justify-start"> <v-row class="d-flex justify-start">
<RecipeTimeCard <RecipeTimeCard :prep-time="recipe.prepTime"
:prep-time="recipe.prepTime"
:total-time="recipe.totalTime" :total-time="recipe.totalTime"
:perform-time="recipe.performTime" :perform-time="recipe.performTime"
small small
@ -41,7 +46,9 @@
/> />
</v-row> </v-row>
<v-card-text v-if="preferences.showDescription" class="px-0"> <v-card-text v-if="preferences.showDescription"
class="px-0"
>
<SafeMarkdown :source="recipe.description" /> <SafeMarkdown :source="recipe.description" />
</v-card-text> </v-card-text>
</v-col> </v-col>
@ -51,22 +58,28 @@
<!-- Ingredients --> <!-- Ingredients -->
<section> <section>
<v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title> <v-card-title class="headline pl-0">
<div {{ $t("recipe.ingredients") }}
v-for="(ingredientSection, sectionIndex) in ingredientSections" </v-card-title>
<div v-for="(ingredientSection, sectionIndex) in ingredientSections"
:key="`ingredient-section-${sectionIndex}`" :key="`ingredient-section-${sectionIndex}`"
class="print-section" class="print-section"
> >
<h4 v-if="ingredientSection.ingredients[0].title" class="ingredient-title mt-2"> <h4 v-if="ingredientSection.ingredients[0].title"
class="ingredient-title mt-2"
>
{{ ingredientSection.ingredients[0].title }} {{ ingredientSection.ingredients[0].title }}
</h4> </h4>
<div <div class="ingredient-grid"
class="ingredient-grid"
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }" :style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
> >
<template v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients"> <template v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients"
:key="`ingredient-${ingredientIndex}`"
>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<p :key="`ingredient-${ingredientIndex}`" class="ingredient-body" v-html="parseText(ingredient)" /> <p class="ingredient-body"
v-html="parseText(ingredient)"
/>
</template> </template>
</div> </div>
</div> </div>
@ -74,19 +87,35 @@
<!-- Instructions --> <!-- Instructions -->
<section> <section>
<div <div v-for="(instructionSection, sectionIndex) in instructionSections"
v-for="(instructionSection, sectionIndex) in instructionSections"
:key="`instruction-section-${sectionIndex}`" :key="`instruction-section-${sectionIndex}`"
:class="{ 'print-section': instructionSection.sectionName }" :class="{ 'print-section': instructionSection.sectionName }"
> >
<v-card-title v-if="!sectionIndex" class="headline pl-0">{{ $t("recipe.instructions") }}</v-card-title> <v-card-title v-if="!sectionIndex"
<div v-for="(step, stepIndex) in instructionSection.instructions" :key="`instruction-${stepIndex}`"> class="headline pl-0"
>
{{ $t("recipe.instructions") }}
</v-card-title>
<div v-for="(step, stepIndex) in instructionSection.instructions"
:key="`instruction-${stepIndex}`"
>
<div class="print-section"> <div class="print-section">
<h4 v-if="step.title" :key="`instruction-title-${stepIndex}`" class="instruction-title mb-2"> <h4 v-if="step.title"
:key="`instruction-title-${stepIndex}`"
class="instruction-title mb-2"
>
{{ step.title }} {{ step.title }}
</h4> </h4>
<h5>{{ step.summary ? step.summary : $t("recipe.step-index", { step: stepIndex + instructionSection.stepOffset + 1 }) }}</h5> <h5>
<SafeMarkdown :source="step.text" class="recipe-step-body" /> {{ step.summary ? step.summary : $t("recipe.step-index", {
step: stepIndex
+ instructionSection.stepOffset
+ 1,
}) }}
</h5>
<SafeMarkdown :source="step.text"
class="recipe-step-body"
/>
</div> </div>
</div> </div>
</div> </div>
@ -94,13 +123,19 @@
<!-- Notes --> <!-- Notes -->
<div v-if="preferences.showNotes"> <div v-if="preferences.showNotes">
<v-divider v-if="hasNotes" class="grey my-4"></v-divider> <v-divider v-if="hasNotes"
class="grey my-4"
/>
<section> <section>
<div v-for="(note, index) in recipe.notes" :key="index + 'note'"> <div v-for="(note, index) in recipe.notes"
:key="index + 'note'"
>
<div class="print-section"> <div class="print-section">
<h4>{{ note.title }}</h4> <h4>{{ note.title }}</h4>
<SafeMarkdown :source="note.text" class="note-body" /> <SafeMarkdown :source="note.text"
class="note-body"
/>
</div> </div>
</div> </div>
</section> </section>
@ -108,13 +143,17 @@
<!-- Nutrition --> <!-- Nutrition -->
<div v-if="preferences.showNutrition"> <div v-if="preferences.showNutrition">
<v-card-title class="headline pl-0"> {{ $t("recipe.nutrition") }} </v-card-title> <v-card-title class="headline pl-0">
{{ $t("recipe.nutrition") }}
</v-card-title>
<section> <section>
<div class="print-section"> <div class="print-section">
<table class="nutrition-table"> <table class="nutrition-table">
<tbody> <tbody>
<tr v-for="(value, key) in recipe.nutrition" :key="key"> <tr v-for="(value, key) in recipe.nutrition"
:key="key"
>
<template v-if="value"> <template v-if="value">
<td>{{ labels[key].label }}</td> <td>{{ labels[key].label }}</td>
<td>{{ value ? (labels[key].suffix ? `${value} ${labels[key].suffix}` : value) : '-' }}</td> <td>{{ value ? (labels[key].suffix ? `${value} ${labels[key].suffix}` : value) : '-' }}</td>
@ -125,23 +164,20 @@
</div> </div>
</section> </section>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue"; import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import { useStaticRoutes } from "~/composables/api"; import { useStaticRoutes } from "~/composables/api";
import { Recipe, RecipeIngredient, RecipeStep} from "~/lib/api/types/recipe"; import type { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences"; import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes"; import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
import { usePageState } from "~/composables/recipe-page/shared-state"; import { usePageState } from "~/composables/recipe-page/shared-state";
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount"; import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
type IngredientSection = { type IngredientSection = {
sectionName: string; sectionName: string;
ingredients: RecipeIngredient[]; ingredients: RecipeIngredient[];
@ -153,7 +189,7 @@ type InstructionSection = {
instructions: RecipeStep[]; instructions: RecipeStep[];
}; };
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
RecipeTimeCard, RecipeTimeCard,
}, },
@ -168,11 +204,11 @@ export default defineComponent({
}, },
dense: { dense: {
type: Boolean, type: Boolean,
default: false default: false,
} },
}, },
setup(props) { setup(props) {
const { i18n } = useContext(); const i18n = useI18n();
const preferences = useUserPrintPreferences(); const preferences = useUserPrintPreferences();
const { recipeImage } = useStaticRoutes(); const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug); const { imageKey } = usePageState(props.recipe.slug);
@ -187,11 +223,13 @@ export default defineComponent({
const servingsDisplay = computed(() => { const servingsDisplay = computed(() => {
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale); const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
return scaledAmountDisplay ? i18n.t("recipe.yields-amount-with-text", { return scaledAmountDisplay
? i18n.t("recipe.yields-amount-with-text", {
amount: scaledAmountDisplay, amount: scaledAmountDisplay,
text: props.recipe.recipeYield, text: props.recipe.recipeYield,
}) as string : ""; }) as string
}) : "";
});
const yieldDisplay = computed(() => { const yieldDisplay = computed(() => {
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale); const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
@ -201,10 +239,11 @@ export default defineComponent({
const recipeYield = computed(() => { const recipeYield = computed(() => {
if (servingsDisplay.value && yieldDisplay.value) { if (servingsDisplay.value && yieldDisplay.value) {
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`); return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
} else { }
else {
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value); return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
} }
}) });
const recipeImageUrl = computed(() => { const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value); return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
@ -320,7 +359,7 @@ export default defineComponent({
} }
.wrapper, .wrapper,
.wrapper >>> * { .wrapper :deep(*) {
opacity: 1 !important; opacity: 1 !important;
color: black !important; color: black !important;
} }
@ -396,10 +435,10 @@ li {
width: 30%; width: 30%;
text-align: right; text-align: right;
} }
.nutrition-table td { .nutrition-table td {
padding: 2px; padding: 2px;
text-align: left; text-align: left;
font-size: 14px; font-size: 14px;
} }
</style> </style>

View file

@ -1,31 +1,30 @@
<template> <template>
<div @click.prevent> <div @click.prevent>
<!-- User Rating --> <!-- User Rating -->
<v-hover v-slot="{ hover }"> <v-hover v-slot="{ isHovering, props }">
<v-rating <v-rating v-if="isOwnGroup && (userRating || isHovering || !ratingsLoaded)"
v-if="isOwnGroup && (userRating || hover || !ratingsLoaded)" v-bind="props"
:value="userRating" :model-value="userRating"
color="secondary" active-color="secondary"
background-color="secondary lighten-3" color="secondary-lighten-3"
length="5" length="5"
:dense="small ? true : undefined" :density="small ? 'compact' : 'default'"
:size="small ? 15 : undefined" :size="small ? 'x-small' : undefined"
hover hover
clearable clearable
@input="updateRating" @update:model-value="updateRating(+$event)"
@click="updateRating" @click="updateRating"
/> />
<!-- Group Rating --> <!-- Group Rating -->
<v-rating <v-rating v-else
v-else v-bind="props"
:value="groupRating" :model-value="groupRating"
:half-increments="true" :half-increments="true"
:readonly="true" active-color="grey-darken-1"
color="grey darken-1" color="secondary-lighten-3"
background-color="secondary lighten-3"
length="5" length="5"
:dense="small ? true : undefined" :density="small ? 'compact' : 'default'"
:size="small ? 15 : undefined" :size="small ? 'x-small' : undefined"
hover hover
/> />
</v-hover> </v-hover>
@ -33,10 +32,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref, watch } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useUserSelfRatings } from "~/composables/use-users"; import { useUserSelfRatings } from "~/composables/use-users";
export default defineComponent({
export default defineNuxtComponent({
props: { props: {
emitOnly: { emitOnly: {
type: Boolean, type: Boolean,
@ -50,7 +49,7 @@ export default defineComponent({
type: String, type: String,
default: "", default: "",
}, },
value: { modelValue: {
type: Number, type: Number,
default: 0, default: 0,
}, },
@ -59,12 +58,13 @@ export default defineComponent({
default: false, default: false,
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const { userRatings, setRating, ready: ratingsLoaded } = useUserSelfRatings(); const { userRatings, setRating, ready: ratingsLoaded } = useUserSelfRatings();
const userRating = computed(() => { const userRating = computed(() => {
return userRatings.value.find((r) => r.recipeId === props.recipeId)?.rating; return userRatings.value.find(r => r.recipeId === props.recipeId)?.rating ?? undefined;
}); });
// if a user unsets their rating, we don't want to fall back to the group rating since it's out of sync // if a user unsets their rating, we don't want to fall back to the group rating since it's out of sync
@ -76,13 +76,13 @@ export default defineComponent({
hideGroupRating.value = true; hideGroupRating.value = true;
} }
}, },
) );
const groupRating = computed(() => { const groupRating = computed(() => {
return hideGroupRating.value ? 0 : props.value; return hideGroupRating.value ? 0 : props.modelValue;
}); });
function updateRating(val: number | null) { function updateRating(val?: number) {
if (!isOwnGroup.value) { if (!isOwnGroup.value) {
return; return;
} }
@ -90,7 +90,7 @@ export default defineComponent({
if (!props.emitOnly) { if (!props.emitOnly) {
setRating(props.slug, val || 0, null); setRating(props.slug, val || 0, null);
} }
context.emit("input", val); context.emit("update:modelValue", val);
} }
return { return {

View file

@ -2,20 +2,61 @@
<div v-if="yieldDisplay"> <div v-if="yieldDisplay">
<div class="text-center d-flex align-center"> <div class="text-center d-flex align-center">
<div> <div>
<v-menu v-model="menu" :disabled="!canEditScale" offset-y top nudge-top="6" :close-on-content-click="false"> <v-menu
<template #activator="{ on, attrs }"> v-model="menu"
:disabled="!canEditScale"
offset-y
top
nudge-top="6"
:close-on-content-click="false"
>
<template #activator="{ props }">
<v-tooltip
v-if="canEditScale"
size="small"
top
color="secondary-darken-1"
>
<template #activator="{ props: tooltipProps }">
<v-card <v-card
class="pa-1 px-2" class="pa-1 px-2"
dark dark
color="secondary darken-1" color="secondary-darken-1"
small size="small"
v-bind="attrs" v-bind="{ ...props, ...tooltipProps }"
:style="{ cursor: canEditScale ? '' : 'default' }" :style="{ cursor: canEditScale ? '' : 'default' }"
v-on="on"
> >
<v-icon v-if="canEditScale" small class="mr-2">{{ $globals.icons.edit }}</v-icon> <v-icon
v-if="canEditScale"
size="small"
class="mr-2"
>
{{ $globals.icons.edit }}
</v-icon>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="yieldDisplay"></span> <span v-html="yieldDisplay" />
</v-card>
</template>
<span> {{ $t("recipe.edit-scale") }} </span>
</v-tooltip>
<v-card
v-else
class="pa-1 px-2"
dark
color="secondary-darken-1"
size="small"
v-bind="props"
: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> </v-card>
</template> </template>
<v-card min-width="300px"> <v-card min-width="300px">
@ -24,10 +65,26 @@
</v-card-title> </v-card-title>
<v-card-text class="mt-n5"> <v-card-text class="mt-n5">
<div class="mt-4 d-flex align-center"> <div class="mt-4 d-flex align-center">
<v-text-field v-model="yieldQuantityEditorValue" type="number" :min="0" hide-spin-buttons @input="recalculateScale(yieldQuantityEditorValue)" /> <v-text-field
<v-tooltip right color="secondary darken-1"> :model-value="yieldQuantityEditorValue"
<template #activator="{ on, attrs }"> type="number"
<v-btn v-bind="attrs" icon class="mx-1" small v-on="on" @click="scale = 1"> :min="0"
variant="underlined"
hide-spin-buttons
@update:model-value="recalculateScale(yieldQuantityEditorValue)"
/>
<v-tooltip
end
color="secondary-darken-1"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
icon
class="mx-1"
size="small"
@click="scale = 1"
>
<v-icon> <v-icon>
{{ $globals.icons.undo }} {{ $globals.icons.undo }}
</v-icon> </v-icon>
@ -47,13 +104,13 @@
:buttons="[ :buttons="[
{ {
icon: $globals.icons.minus, icon: $globals.icons.minus,
text: $tc('recipe.decrease-scale-label'), text: $t('recipe.decrease-scale-label'),
event: 'decrement', event: 'decrement',
disabled: disableDecrement, disabled: disableDecrement,
}, },
{ {
icon: $globals.icons.createAlt, icon: $globals.icons.createAlt,
text: $tc('recipe.increase-scale-label'), text: $t('recipe.increase-scale-label'),
event: 'increment', event: 'increment',
}, },
]" ]"
@ -65,12 +122,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount"; import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: Number, type: Number,
required: true, required: true,
}, },
@ -83,16 +139,17 @@ export default defineComponent({
default: false, default: false,
}, },
}, },
emits: ["update:modelValue"],
setup(props, { emit }) { setup(props, { emit }) {
const { i18n } = useContext(); const i18n = useI18n();
const menu = ref<boolean>(false); const menu = ref<boolean>(false);
const canEditScale = computed(() => props.editScale && props.recipeServings > 0); const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
const scale = computed({ const scale = computed({
get: () => props.value, get: () => props.modelValue,
set: (value) => { set: (value) => {
const newScaleNumber = parseFloat(`${value}`); const newScaleNumber = parseFloat(`${value}`);
emit("input", isNaN(newScaleNumber) ? 0 : newScaleNumber); emit("update:modelValue", isNaN(newScaleNumber) ? 0 : newScaleNumber);
}, },
}); });
@ -103,7 +160,8 @@ export default defineComponent({
if (props.recipeServings <= 0) { if (props.recipeServings <= 0) {
scale.value = 1; scale.value = 1;
} else { }
else {
scale.value = newYield / props.recipeServings; scale.value = newYield / props.recipeServings;
} }
} }
@ -113,9 +171,11 @@ export default defineComponent({
}); });
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount); const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
const yieldDisplay = computed(() => { const yieldDisplay = computed(() => {
return yieldQuantity.value ? i18n.t( return yieldQuantity.value
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay } ? i18n.t(
) as string : ""; "recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay },
) as string
: "";
}); });
// only update yield quantity when the menu opens, so we don't override the user's input // only update yield quantity when the menu opens, so we don't override the user's input
@ -128,8 +188,8 @@ export default defineComponent({
} }
yieldQuantityEditorValue.value = recipeYieldAmount.value.scaledAmount; yieldQuantityEditorValue.value = recipeYieldAmount.value.scaledAmount;
} },
) );
const disableDecrement = computed(() => { const disableDecrement = computed(() => {
return recipeYieldAmount.value.scaledAmount <= 1; return recipeYieldAmount.value.scaledAmount <= 1;

View file

@ -1,18 +1,18 @@
<template> <template>
<div class="d-flex justify-center align-center"> <div class="d-flex justify-center align-center">
<v-btn-toggle v-model="selected" tile group color="primary accent-3" mandatory @change="emitMulti"> <v-btn-toggle v-model="selected" tile group color="primary accent-3" mandatory="force" @change="emitMulti">
<v-btn small :value="false"> <v-btn size="small" :value="false">
{{ $t("search.include") }} {{ $t("search.include") }}
</v-btn> </v-btn>
<v-btn small :value="true"> <v-btn size="small" :value="true">
{{ $t("search.exclude") }} {{ $t("search.exclude") }}
</v-btn> </v-btn>
</v-btn-toggle> </v-btn-toggle>
<v-btn-toggle v-model="match" tile group color="primary accent-3" mandatory @change="emitMulti"> <v-btn-toggle v-model="match" tile group color="primary accent-3" mandatory="force" @change="emitMulti">
<v-btn small :value="false" class="text-uppercase"> <v-btn size="small" :value="false" class="text-uppercase">
{{ $t("search.and") }} {{ $t("search.and") }}
</v-btn> </v-btn>
<v-btn small :value="true" class="text-uppercase"> <v-btn size="small" :value="true" class="text-uppercase">
{{ $t("search.or") }} {{ $t("search.or") }}
</v-btn> </v-btn>
</v-btn-toggle> </v-btn-toggle>
@ -20,17 +20,16 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
type SelectionValue = "include" | "exclude" | "any"; type SelectionValue = "include" | "exclude" | "any";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: String as () => SelectionValue, type: String as () => SelectionValue,
default: "include", default: "include",
}, },
}, },
emits: ["update:modelValue", "update"],
data() { data() {
return { return {
selected: false, selected: false,
@ -39,7 +38,7 @@ export default defineComponent({
}, },
methods: { methods: {
emitChange() { emitChange() {
this.$emit("input", this.selected); this.$emit("update:modelValue", this.selected);
}, },
emitMulti() { emitMulti() {
const updateData = { const updateData = {

View file

@ -1,9 +1,18 @@
<template> <template>
<div class="text-center"> <div class="text-center">
<v-menu offset-y top nudge-top="6" :close-on-content-click="false"> <v-menu
<template #activator="{ on, attrs }"> offset-y
<v-btn color="accent" dark v-bind="attrs" v-on="on"> top
<v-icon left> nudge-top="6"
:close-on-content-click="false"
>
<template #activator="{ props }">
<v-btn
color="accent"
dark
v-bind="props"
>
<v-icon start>
{{ $globals.icons.cog }} {{ $globals.icons.cog }}
</v-icon> </v-icon>
{{ $t("general.settings") }} {{ $t("general.settings") }}
@ -15,32 +24,24 @@
{{ $t("recipe.recipe-settings") }} {{ $t("recipe.recipe-settings") }}
</div> </div>
</v-card-title> </v-card-title>
<v-divider class="mx-2"></v-divider> <v-divider class="mx-2" />
<v-card-text class="mt-n5 pt-6 pb-2"> <v-card-text class="mt-n5 pt-6 pb-2">
<RecipeSettingsSwitches v-model="value" :is-owner="isOwner" /> <RecipeSettingsSwitches
v-model="value"
:is-owner="isOwner"
/>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-menu> </v-menu>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from "@nuxtjs/composition-api";
import RecipeSettingsSwitches from "./RecipeSettingsSwitches.vue"; import RecipeSettingsSwitches from "./RecipeSettingsSwitches.vue";
export default defineComponent({ const value = defineModel<object>({ required: true });
components: { RecipeSettingsSwitches },
props: { defineProps<{ isOwner?: boolean }>();
value: {
type: Object,
required: true,
},
isOwner: {
type: Boolean,
required: false,
},
},
});
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View file

@ -1,51 +1,39 @@
<template> <template>
<div> <div>
<v-switch <v-switch
v-for="(_, key) in value" v-for="(_, key) in model"
:key="key" :key="key"
v-model="value[key]" v-model="model[key]"
color="primary"
xs xs
dense density="compact"
:disabled="key == 'locked' && !isOwner" :disabled="key == 'locked' && !isOwner"
class="my-1" class="my-1"
:label="labels[key]" :label="labels[key]"
hide-details hide-details
></v-switch> />
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, useContext } from "@nuxtjs/composition-api"; import { defineModel, defineProps } from "vue";
import { RecipeSettings } from "~/lib/api/types/recipe"; import type { RecipeSettings } from "~/lib/api/types/recipe";
import { useI18n } from "#imports";
export default defineComponent({ defineProps<{ isOwner?: boolean }>();
props: {
value: { const model = defineModel<RecipeSettings>({ required: true });
type: Object as () => RecipeSettings,
required: true, const i18n = useI18n();
},
isOwner: {
type: Boolean,
required: false,
},
},
setup() {
const { i18n } = useContext();
const labels: Record<keyof RecipeSettings, string> = { const labels: Record<keyof RecipeSettings, string> = {
public: i18n.tc("recipe.public-recipe"), public: i18n.t("recipe.public-recipe"),
showNutrition: i18n.tc("recipe.show-nutrition-values"), showNutrition: i18n.t("recipe.show-nutrition-values"),
showAssets: i18n.tc("asset.show-assets"), showAssets: i18n.t("asset.show-assets"),
landscapeView: i18n.tc("recipe.landscape-view-coming-soon"), landscapeView: i18n.t("recipe.landscape-view-coming-soon"),
disableComments: i18n.tc("recipe.disable-comments"), disableComments: i18n.t("recipe.disable-comments"),
disableAmount: i18n.tc("recipe.disable-amount"), disableAmount: i18n.t("recipe.disable-amount"),
locked: i18n.tc("recipe.locked"), locked: i18n.t("recipe.locked"),
}; };
return {
labels,
};
},
});
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View file

@ -12,23 +12,23 @@
/> />
</v-col> </v-col>
<div v-for="(organizer, idx) in missingOrganizers" :key="idx"> <div v-for="(organizer, idx) in missingOrganizers" :key="idx">
<v-col <v-col v-if="organizer.show" cols="12">
v-if="organizer.show"
cols="12"
>
<div class="d-flex flex-row flex-wrap align-center pt-2"> <div class="d-flex flex-row flex-wrap align-center pt-2">
<v-icon class="ma-0 pa-0">{{ organizer.icon }}</v-icon> <v-icon class="ma-0 pa-0">
<v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content;"> {{ organizer.icon }}
{{ $tc("recipe-finder.missing") }}: </v-icon>
<v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content">
{{ $t("recipe-finder.missing") }}:
</v-card-text> </v-card-text>
<v-chip <v-chip
v-for="item in organizer.items" v-for="item in organizer.items"
:key="item.item.id" :key="item.item.id"
label label
color="secondary custom-transparent" color="secondary custom-transparent"
class="mr-2 my-1" class="mr-2 my-1 pl-1"
variant="flat"
> >
<v-checkbox dark :ripple="false" @click="handleCheckbox(item)"> <v-checkbox dark :ripple="false" hide-details @click="handleCheckbox(item)">
<template #label> <template #label>
{{ organizer.getLabel(item.item) }} {{ organizer.getLabel(item.item) }}
</template> </template>
@ -42,9 +42,8 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, useContext } from "@nuxtjs/composition-api";
import RecipeCardMobile from "./RecipeCardMobile.vue"; import RecipeCardMobile from "./RecipeCardMobile.vue";
import { IngredientFood, RecipeSummary, RecipeTool } from "~/lib/api/types/recipe"; import type { IngredientFood, RecipeSummary, RecipeTool } from "~/lib/api/types/recipe";
interface Organizer { interface Organizer {
type: "food" | "tool"; type: "food" | "tool";
@ -52,7 +51,7 @@ interface Organizer {
selected: boolean; selected: boolean;
} }
export default defineComponent({ export default defineNuxtComponent({
components: { RecipeCardMobile }, components: { RecipeCardMobile },
props: { props: {
recipe: { recipe: {
@ -73,27 +72,31 @@ export default defineComponent({
}, },
}, },
setup(props, context) { setup(props, context) {
const { $globals } = useContext(); const { $globals } = useNuxtApp();
const missingOrganizers = computed(() => [ const missingOrganizers = computed(() => [
{ {
type: "food", type: "food",
show: props.missingFoods?.length, show: props.missingFoods?.length,
icon: $globals.icons.foods, icon: $globals.icons.foods,
items: props.missingFoods ? props.missingFoods.map((food) => { items: props.missingFoods
? props.missingFoods.map((food) => {
return reactive({ type: "food", item: food, selected: false } as Organizer); return reactive({ type: "food", item: food, selected: false } as Organizer);
}) : [], })
: [],
getLabel: (item: IngredientFood) => item.pluralName || item.name, getLabel: (item: IngredientFood) => item.pluralName || item.name,
}, },
{ {
type: "tool", type: "tool",
show: props.missingTools?.length, show: props.missingTools?.length,
icon: $globals.icons.tools, icon: $globals.icons.tools,
items: props.missingTools ? props.missingTools.map((tool) => { items: props.missingTools
? props.missingTools.map((tool) => {
return reactive({ type: "tool", item: tool, selected: false } as Organizer); return reactive({ type: "tool", item: tool, selected: false } as Organizer);
}) : [], })
: [],
getLabel: (item: RecipeTool) => item.name, getLabel: (item: RecipeTool) => item.name,
} },
]) ]);
function handleCheckbox(organizer: Organizer) { function handleCheckbox(organizer: Organizer) {
if (props.disableCheckbox) { if (props.disableCheckbox) {
@ -113,6 +116,6 @@ export default defineComponent({
missingOrganizers, missingOrganizers,
handleCheckbox, handleCheckbox,
}; };
} },
}); });
</script> </script>

View file

@ -1,34 +1,77 @@
<template v-if="showCards"> <template v-if="showCards">
<div class="text-center"> <div class="text-center">
<!-- Total Time --> <!-- Total Time -->
<div v-if="validateTotalTime" class="time-card-flex mx-auto"> <div
<v-row no-gutters class="d-flex flex-no-wrap align-center " :style="fontSize"> v-if="validateTotalTime"
<v-icon :x-large="!small" left color="primary"> class="time-card-flex mx-auto"
{{ $globals.icons.clockOutline }} >
</v-icon>
<p class="my-0"><span class="font-weight-bold">{{ validateTotalTime.name }}</span><br>{{ validateTotalTime.value }}</p>
</v-row>
</div>
<v-divider v-if="validateTotalTime && (validatePrepTime || validatePerformTime)" class="my-2" />
<!-- Prep Time & Perform Time -->
<div v-if="validatePrepTime || validatePerformTime" class="time-card-flex mx-auto">
<v-row <v-row
no-gutters no-gutters
class="d-flex justify-center align-center" :class="{'flex-column': $vuetify.breakpoint.smAndDown}" class="d-flex flex-no-wrap align-center"
style="width: 100%;" :style="fontSize" :style="fontSize"
>
<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"
> >
<div v-if="validatePrepTime" class="d-flex flex-no-wrap my-1">
<v-icon :large="!small" :dense="small" left color="primary">
{{ $globals.icons.knfife }} {{ $globals.icons.knfife }}
</v-icon> </v-icon>
<p class="my-0"><span class="font-weight-bold">{{ validatePrepTime.name }}</span><br>{{ validatePrepTime.value }}</p> <p class="my-0">
<span class="font-weight-bold opacity-80">{{ validatePrepTime.name }}</span><br>{{ validatePrepTime.value }}
</p>
</div> </div>
<v-divider v-if="validatePrepTime && validatePerformTime" vertical class="mx-4" /> <v-divider
<div v-if="validatePerformTime" class="d-flex flex-no-wrap my-1"> v-if="validatePrepTime && validatePerformTime"
<v-icon :large="!small" :dense="small" left color="primary"> vertical
class="mx-4"
/>
<div
v-if="validatePerformTime"
class="d-flex flex-no-wrap my-1 align-center"
>
<v-icon
:size="small ? 'small' : 'large'"
left
color="primary"
>
{{ $globals.icons.potSteam }} {{ $globals.icons.potSteam }}
</v-icon> </v-icon>
<p class="my-0"><span class="font-weight-bold">{{ validatePerformTime.name }}</span><br>{{ validatePerformTime.value }}</p> <p class="my-0">
<span class="font-weight-bold opacity-80">{{ validatePerformTime.name }}</span><br>{{ validatePerformTime.value }}
</p>
</div> </div>
</v-row> </v-row>
</div> </div>
@ -36,9 +79,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api"; export default defineNuxtComponent({
export default defineComponent({
props: { props: {
prepTime: { prepTime: {
type: String, type: String,
@ -54,7 +95,7 @@ export default defineComponent({
}, },
color: { color: {
type: String, type: String,
default: "accent custom-transparent" default: "accent custom-transparent",
}, },
small: { small: {
type: Boolean, type: Boolean,
@ -62,14 +103,14 @@ export default defineComponent({
}, },
}, },
setup(props) { setup(props) {
const { i18n } = useContext(); const i18n = useI18n();
function isEmpty(str: string | null) { function isEmpty(str: string | null) {
return !str || str.length === 0; return !str || str.length === 0;
} }
const showCards = computed(() => { const showCards = computed(() => {
return [props.prepTime, props.totalTime, props.performTime].some((x) => !isEmpty(x)); return [props.prepTime, props.totalTime, props.performTime].some(x => !isEmpty(x));
}); });
const validateTotalTime = computed(() => { const validateTotalTime = computed(() => {

View file

@ -4,41 +4,50 @@
<v-spacer /> <v-spacer />
<v-col class="text-right"> <v-col class="text-right">
<!-- Filters --> <!-- Filters -->
<v-menu offset-y bottom left nudge-bottom="3" :close-on-content-click="false"> <v-menu
<template #activator="{ on, attrs }"> offset-y
<v-badge :content="filterBadgeCount" :value="filterBadgeCount" bordered overlap> bottom
<v-btn fab small color="info" v-bind="attrs" v-on="on"> start
nudge-bottom="3"
:close-on-content-click="false"
>
<template #activator="{ props }">
<v-badge
:content="filterBadgeCount"
:model-value="filterBadgeCount > 0"
bordered
>
<v-btn
class="rounded-circle"
size="small"
color="info"
v-bind="props"
icon
>
<v-icon> {{ $globals.icons.filter }} </v-icon> <v-icon> {{ $globals.icons.filter }} </v-icon>
</v-btn> </v-btn>
</v-badge> </v-badge>
</template> </template>
<v-card> <v-card>
<v-list> <v-list>
<v-list-item @click="reverseSort"> <v-list-item
<v-icon left> :prepend-icon="preferences.orderDirection === 'asc' ? $globals.icons.sortCalendarDescending : $globals.icons.sortCalendarAscending"
{{ :title="preferences.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
preferences.orderDirection === "asc" ? @click="reverseSort"
$globals.icons.sortCalendarDescending : $globals.icons.sortCalendarAscending />
}}
</v-icon>
<v-list-item-title>
{{ preferences.orderDirection === "asc" ? $tc("general.sort-descending") : $tc("general.sort-ascending") }}
</v-list-item-title>
</v-list-item>
<v-divider /> <v-divider />
<v-list-item class="pa-0">
<v-list class="py-0" style="width: 100%;">
<v-list-item <v-list-item
v-for="option, idx in eventTypeFilterState" v-for="option, idx in eventTypeFilterState"
:key="idx" :key="idx"
> >
<v-checkbox <v-checkbox
:input-value="option.checked" :model-value="option.checked"
color="primary"
readonly readonly
@click="toggleEventTypeOption(option.value)" @click="toggleEventTypeOption(option.value)"
> >
<template #label> <template #label>
<v-icon left> <v-icon start>
{{ option.icon }} {{ option.icon }}
</v-icon> </v-icon>
{{ option.label }} {{ option.label }}
@ -46,8 +55,6 @@
</v-checkbox> </v-checkbox>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-list-item>
</v-list>
</v-card> </v-card>
</v-menu> </v-menu>
</v-col> </v-col>
@ -61,7 +68,10 @@
class="px-1" class="px-1"
:style="maxHeight ? `max-height: ${maxHeight}; overflow-y: auto;` : ''" :style="maxHeight ? `max-height: ${maxHeight}; overflow-y: auto;` : ''"
> >
<v-timeline :dense="$vuetify.breakpoint.smAndDown" class="timeline"> <v-timeline
:dense="$vuetify.display.smAndDown"
class="timeline"
>
<RecipeTimelineItem <RecipeTimelineItem
v-for="(event, index) in timelineEvents" v-for="(event, index) in timelineEvents"
:key="event.id" :key="event.id"
@ -73,33 +83,41 @@
/> />
</v-timeline> </v-timeline>
</div> </div>
<v-card v-else-if="!loading" class="mt-2"> <v-card
v-else-if="!loading"
class="mt-2"
>
<v-card-title class="justify-center pa-9"> <v-card-title class="justify-center pa-9">
{{ $t("recipe.timeline-no-events-found-try-adjusting-filters") }} {{ $t("recipe.timeline-no-events-found-try-adjusting-filters") }}
</v-card-title> </v-card-title>
</v-card> </v-card>
<div v-if="loading" class="mb-3 text-center"> <div
<AppLoader :loading="loading" :waiting-text="$tc('general.loading-events')" /> v-if="loading"
class="mb-3 text-center"
>
<AppLoader
:loading="loading"
:waiting-text="$t('general.loading-events')"
/>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, onMounted, ref, useAsync, useContext } from "@nuxtjs/composition-api";
import { useThrottleFn, whenever } from "@vueuse/core"; import { useThrottleFn, whenever } from "@vueuse/core";
import RecipeTimelineItem from "./RecipeTimelineItem.vue" import RecipeTimelineItem from "./RecipeTimelineItem.vue";
import { useTimelinePreferences } from "~/composables/use-users/preferences"; import { useTimelinePreferences } from "~/composables/use-users/preferences";
import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events"; import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events";
import { useAsyncKey } from "~/composables/use-utils"; import { useAsyncKey } from "~/composables/use-utils";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe"; import type { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe";
export default defineComponent({ export default defineNuxtComponent({
components: { RecipeTimelineItem }, components: { RecipeTimelineItem },
props: { props: {
value: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@ -114,12 +132,12 @@ export default defineComponent({
showRecipeCards: { showRecipeCards: {
type: Boolean, type: Boolean,
default: false, default: false,
} },
}, },
setup(props) { setup(props) {
const api = useUserApi(); const api = useUserApi();
const { i18n } = useContext(); const i18n = useI18n();
const preferences = useTimelinePreferences(); const preferences = useTimelinePreferences();
const { eventTypeOptions } = useTimelineEventTypes(); const { eventTypeOptions } = useTimelineEventTypes();
const loading = ref(true); const loading = ref(true);
@ -133,11 +151,11 @@ export default defineComponent({
const recipes = new Map<string, Recipe>(); const recipes = new Map<string, Recipe>();
const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length); const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length);
const eventTypeFilterState = computed(() => { const eventTypeFilterState = computed(() => {
return eventTypeOptions.value.map(option => { return eventTypeOptions.value.map((option) => {
return { return {
...option, ...option,
checked: preferences.value.types.includes(option.value), checked: preferences.value.types.includes(option.value),
} };
}); });
}); });
@ -161,10 +179,10 @@ export default defineComponent({
}; };
whenever( whenever(
() => props.value, () => props.modelValue,
() => { () => {
initializeTimelineEvents(); initializeTimelineEvents();
} },
); );
// Preferences // Preferences
@ -185,7 +203,8 @@ export default defineComponent({
const index = preferences.value.types.indexOf(option); const index = preferences.value.types.indexOf(option);
if (index === -1) { if (index === -1) {
preferences.value.types.push(option); preferences.value.types.push(option);
} else { }
else {
preferences.value.types.splice(index, 1); preferences.value.types.splice(index, 1);
} }
@ -194,7 +213,7 @@ export default defineComponent({
// Timeline Actions // Timeline Actions
async function updateTimelineEvent(index: number) { async function updateTimelineEvent(index: number) {
const event = timelineEvents.value[index] const event = timelineEvents.value[index];
const payload: RecipeTimelineEventUpdate = { const payload: RecipeTimelineEventUpdate = {
subject: event.subject, subject: event.subject,
eventMessage: event.eventMessage, eventMessage: event.eventMessage,
@ -223,35 +242,35 @@ export default defineComponent({
async function getRecipe(recipeId: string): Promise<Recipe | null> { async function getRecipe(recipeId: string): Promise<Recipe | null> {
const { data } = await api.recipes.getOne(recipeId); const { data } = await api.recipes.getOne(recipeId);
return data return data;
}; };
async function updateRecipes(events: RecipeTimelineEventOut[]) { async function updateRecipes(events: RecipeTimelineEventOut[]) {
const recipePromises: Promise<Recipe | null>[] = []; const recipePromises: Promise<Recipe | null>[] = [];
const seenRecipeIds: string[] = []; const seenRecipeIds: string[] = [];
events.forEach(event => { events.forEach((event) => {
if (seenRecipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) { if (seenRecipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
return; return;
} }
seenRecipeIds.push(event.recipeId); seenRecipeIds.push(event.recipeId);
recipePromises.push(getRecipe(event.recipeId)); recipePromises.push(getRecipe(event.recipeId));
}) });
const results = await Promise.all(recipePromises); const results = await Promise.all(recipePromises);
results.forEach(result => { results.forEach((result) => {
if (result && result.id) { if (result && result.id) {
recipes.set(result.id, result); recipes.set(result.id, result);
} }
}) });
} }
async function scrollTimelineEvents() { async function scrollTimelineEvents() {
const orderBy = "timestamp"; const orderBy = "timestamp";
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc"; const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
// eslint-disable-next-line quotes
const eventTypeValue = `["${preferences.value.types.join('", "')}"]`; const eventTypeValue = `["${preferences.value.types.join("\", \"")}"]`;
const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}` const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`;
const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter }); const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter });
page.value += 1; page.value += 1;
@ -290,7 +309,7 @@ export default defineComponent({
} }
const infiniteScroll = useThrottleFn(() => { const infiniteScroll = useThrottleFn(() => {
useAsync(async () => { useAsyncData(useAsyncKey(), async () => {
if (!hasMore.value || loading.value) { if (!hasMore.value || loading.value) {
return; return;
} }
@ -298,7 +317,7 @@ export default defineComponent({
loading.value = true; loading.value = true;
await scrollTimelineEvents(); await scrollTimelineEvents();
loading.value = false; loading.value = false;
}, useAsyncKey()); });
}, 500); }, 500);
// preload events // preload events
@ -310,7 +329,7 @@ export default defineComponent({
// if the inner element is scrollable, let its scroll event handle the infiniteScroll // if the inner element is scrollable, let its scroll event handle the infiniteScroll
const timelineContainerElement = document.getElementById("timeline-container"); const timelineContainerElement = document.getElementById("timeline-container");
if (timelineContainerElement) { if (timelineContainerElement) {
const { clientHeight, scrollHeight } = timelineContainerElement const { clientHeight, scrollHeight } = timelineContainerElement;
// if scrollHeight == clientHeight, the element is not scrollable, so we need to look at the global position // if scrollHeight == clientHeight, the element is not scrollable, so we need to look at the global position
// if scrollHeight > clientHeight, it is scrollable and we don't need to do anything here // if scrollHeight > clientHeight, it is scrollable and we don't need to do anything here
@ -324,8 +343,8 @@ export default defineComponent({
infiniteScroll(); infiniteScroll();
} }
}; };
} },
) );
return { return {
deleteTimelineEvent, deleteTimelineEvent,

View file

@ -1,32 +1,48 @@
<template> <template>
<v-tooltip bottom nudge-right="50" :color="buttonStyle ? 'info' : 'secondary'"> <v-tooltip
<template #activator="{ on, attrs }"> bottom
nudge-right="50"
:color="buttonStyle ? 'info' : 'secondary'"
>
<template #activator="{ props }">
<v-btn <v-btn
small icon
:variant="buttonStyle ? 'flat' : undefined"
:rounded="buttonStyle ? 'circle' : undefined"
size="small"
:color="buttonStyle ? 'info' : 'secondary'" :color="buttonStyle ? 'info' : 'secondary'"
:fab="buttonStyle" :fab="buttonStyle"
class="ml-1" v-bind="{ ...props, ...$attrs }"
v-bind="attrs"
v-on="on"
@click.prevent="toggleTimeline" @click.prevent="toggleTimeline"
> >
<v-icon :small="!buttonStyle" :color="buttonStyle ? 'white' : 'secondary'"> <v-icon
:size="!buttonStyle ? undefined : 'x-large'"
:color="buttonStyle ? 'white' : 'secondary'"
>
{{ $globals.icons.timelineText }} {{ $globals.icons.timelineText }}
</v-icon> </v-icon>
</v-btn> </v-btn>
<BaseDialog v-model="showTimeline" :title="timelineAttrs.title" :icon="$globals.icons.timelineText" width="70%"> <BaseDialog
<RecipeTimeline v-model="showTimeline" :query-filter="timelineAttrs.queryFilter" max-height="60vh" /> v-model="showTimeline"
:title="timelineAttrs.title"
:icon="$globals.icons.timelineText"
width="70%"
>
<RecipeTimeline
v-model="showTimeline"
:query-filter="timelineAttrs.queryFilter"
max-height="60vh"
/>
</BaseDialog> </BaseDialog>
</template> </template>
<span>{{ $t('recipe.open-timeline') }}</span> <span>{{ $t('recipe.open-timeline') }}</span>
</v-tooltip> </v-tooltip>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import RecipeTimeline from "./RecipeTimeline.vue"; import RecipeTimeline from "./RecipeTimeline.vue";
export default defineComponent({
export default defineNuxtComponent({
components: { RecipeTimeline }, components: { RecipeTimeline },
props: { props: {
@ -45,23 +61,24 @@ export default defineComponent({
}, },
setup(props) { setup(props) {
const { $vuetify, i18n } = useContext(); const i18n = useI18n();
const { smAndDown } = useDisplay();
const showTimeline = ref(false); const showTimeline = ref(false);
function toggleTimeline() { function toggleTimeline() {
showTimeline.value = !showTimeline.value; showTimeline.value = !showTimeline.value;
} }
const timelineAttrs = computed(() => { const timelineAttrs = computed(() => {
let title = i18n.tc("recipe.timeline") let title = i18n.t("recipe.timeline");
if ($vuetify.breakpoint.smAndDown) { if (smAndDown.value) {
title += ` ${props.recipeName}` title += ` ${props.recipeName}`;
} }
return { return {
title, title,
queryFilter: `recipe.slug="${props.slug}"`, queryFilter: `recipe.slug="${props.slug}"`,
} };
}) });
return { showTimeline, timelineAttrs, toggleTimeline }; return { showTimeline, timelineAttrs, toggleTimeline };
}, },

View file

@ -2,58 +2,69 @@
<div class="text-center"> <div class="text-center">
<BaseDialog <BaseDialog
v-model="recipeEventEditDialog" v-model="recipeEventEditDialog"
:title="$tc('recipe.edit-timeline-event')" :title="$t('recipe.edit-timeline-event')"
:icon="$globals.icons.edit" :icon="$globals.icons.edit"
:submit-text="$tc('general.save')" can-submit
@submit="$emit('update')" :submit-text="$t('general.save')"
@submit="submitEdit"
> >
<v-card-text> <v-card-text>
<v-form ref="domMadeThisForm"> <v-form ref="domEditEventForm">
<v-text-field <v-text-field v-model="localEvent.subject" :label="$t('general.subject')" />
v-model="event.subject" <v-textarea v-model="localEvent.eventMessage" :label="$t('general.message')" rows="4" />
:label="$tc('general.subject')"
/>
<v-textarea
v-model="event.eventMessage"
:label="$tc('general.message')"
rows="4"
/>
</v-form> </v-form>
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<BaseDialog <BaseDialog
v-model="recipeEventDeleteDialog" v-model="recipeEventDeleteDialog"
:title="$tc('events.delete-event')" :title="$t('events.delete-event')"
color="error" color="error"
:icon="$globals.icons.alertCircle" :icon="$globals.icons.alertCircle"
can-confirm
@confirm="$emit('delete')" @confirm="$emit('delete')"
> >
<v-card-text> <v-card-text>
{{ $t("events.event-delete-confirmation") }} {{ $t('events.event-delete-confirmation') }}
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<v-menu <v-menu
offset-y offset-y
left start
:bottom="!menuTop" :bottom="!props.menuTop"
:nudge-bottom="!menuTop ? '5' : '0'" :nudge-bottom="!props.menuTop ? '5' : '0'"
:top="menuTop" :top="props.menuTop"
:nudge-top="menuTop ? '5' : '0'" :nudge-top="props.menuTop ? '5' : '0'"
allow-overflow allow-overflow
close-delay="125" close-delay="125"
:open-on-hover="!useMobileFormat" :open-on-hover="!props.useMobileFormat"
content-class="d-print-none" content-class="d-print-none"
> >
<template #activator="{ on, attrs }"> <template #activator="{ props: btnProps }">
<v-btn :fab="fab" :x-small="fab" :elevation="elevation" :color="color" :icon="!fab" v-bind="attrs" v-on="on" @click.prevent> <v-btn
:class="{ 'rounded-circle': props.fab }"
:x-small="props.fab"
:elevation="props.elevation ?? undefined"
:color="props.color"
:icon="!props.fab"
v-bind="btnProps"
@click.prevent
>
<v-icon>{{ icon }}</v-icon> <v-icon>{{ icon }}</v-icon>
</v-btn> </v-btn>
</template> </template>
<v-list dense> <v-list density="compact">
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)"> <v-list-item
<v-list-item-icon> v-for="(item, index) in menuItems"
<v-icon :color="item.color"> {{ item.icon }} </v-icon> :key="index"
</v-list-item-icon> @click="contextMenuEventHandler(item.event)"
>
<template #prepend>
<v-icon :color="item.color">
{{ item.icon }}
</v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title> <v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item> </v-list-item>
</v-list> </v-list>
@ -61,10 +72,9 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api"; import { useI18n, useNuxtApp } from "#imports";
import { VForm } from "~/types/vuetify"; import type { RecipeTimelineEventOut } from "~/lib/api/types/recipe";
import { RecipeTimelineEventOut } from "~/lib/api/types/recipe";
export interface TimelineContextMenuIncludes { export interface TimelineContextMenuIncludes {
edit: boolean; edit: boolean;
@ -78,129 +88,90 @@ export interface ContextMenuItem {
event: string; event: string;
} }
export default defineComponent({ const props = defineProps<{
props: { useItems?: TimelineContextMenuIncludes;
useItems: { appendItems?: ContextMenuItem[];
type: Object as () => TimelineContextMenuIncludes, leadingItems?: ContextMenuItem[];
default: () => ({ menuTop?: boolean;
edit: true, fab?: boolean;
delete: true, elevation?: number | null;
}), color?: string;
}, event: RecipeTimelineEventOut;
// Append items are added at the end of the useItems list menuIcon?: string | null;
appendItems: { useMobileFormat?: boolean;
type: Array as () => ContextMenuItem[], }>();
default: () => [],
},
// 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,
}
},
setup(props, context) {
const domEditEventForm = ref<VForm>();
const state = reactive({
recipeEventEditDialog: false,
recipeEventDeleteDialog: false,
loading: false,
menuItems: [] as ContextMenuItem[],
});
const { i18n, $globals } = useContext(); const emit = defineEmits(["delete", "update"]);
// =========================================================================== const domEditEventForm = ref();
// Context Menu Setup const recipeEventEditDialog = ref(false);
const recipeEventDeleteDialog = ref(false);
const loading = ref(false);
const i18n = useI18n();
const { $globals } = useNuxtApp();
const defaultItems: { [key: string]: ContextMenuItem } = { const defaultItems: { [key: string]: ContextMenuItem } = {
edit: { edit: {
title: i18n.tc("general.edit"), title: i18n.t("general.edit"),
icon: $globals.icons.edit, icon: $globals.icons.edit,
color: undefined, color: undefined,
event: "edit", event: "edit",
}, },
delete: { delete: {
title: i18n.tc("general.delete"), title: i18n.t("general.delete"),
icon: $globals.icons.delete, icon: $globals.icons.delete,
color: "error", color: "error",
event: "delete", event: "delete",
}, },
}; };
// Get Default Menu Items Specified in Props const menuItems = computed(() => {
for (const [key, value] of Object.entries(props.useItems)) { const items: ContextMenuItem[] = [];
const useItems = props.useItems ?? { edit: true, delete: true };
for (const [key, value] of Object.entries(useItems)) {
if (value) { if (value) {
const item = defaultItems[key]; const item = defaultItems[key];
if (item) { if (item) items.push(item);
state.menuItems.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;
} }
// 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) { function contextMenuEventHandler(eventKey: string) {
const handler = eventHandlers[eventKey]; if (eventKey === "edit") {
openEditDialog();
if (handler && typeof handler === "function") { loading.value = false;
handler();
state.loading = false;
return; return;
} }
if (eventKey === "delete") {
context.emit(eventKey); openDeleteDialog();
state.loading = false; loading.value = false;
return;
}
emit(eventKey as "delete" | "update");
loading.value = false;
}
function submitEdit() {
emit("update", { ...localEvent.value });
recipeEventEditDialog.value = false;
} }
return {
...toRefs(state),
contextMenuEventHandler,
domEditEventForm,
icon,
};
},
});
</script> </script>

View file

@ -1,19 +1,16 @@
<template> <template>
<v-timeline-item <v-timeline-item :class="attrs.class" fill-dot :small="attrs.small" :icon="icon" dot-color="primary">
:class="attrs.class"
fill-dot
:small="attrs.small"
:icon="icon"
>
<template v-if="!useMobileFormat" #opposite> <template v-if="!useMobileFormat" #opposite>
<v-chip v-if="event.timestamp" label large> <v-chip v-if="event.timestamp" label large>
<v-icon class="mr-1"> {{ $globals.icons.calendar }} </v-icon> <v-icon class="mr-1">
{{ $globals.icons.calendar }}
</v-icon>
{{ new Date(event.timestamp).toLocaleDateString($i18n.locale) }} {{ new Date(event.timestamp).toLocaleDateString($i18n.locale) }}
</v-chip> </v-chip>
</template> </template>
<v-card <v-card
hover hover
:to="$listeners.selected || !recipe ? undefined : `/g/${groupSlug}/r/${recipe.slug}`" :to="$attrs.selected || !recipe ? undefined : `/g/${groupSlug}/r/${recipe.slug}`"
class="elevation-12" class="elevation-12"
@click="$emit('selected')" @click="$emit('selected')"
> >
@ -28,18 +25,17 @@
{{ new Date(event.timestamp || "").toLocaleDateString($i18n.locale) }} {{ new Date(event.timestamp || "").toLocaleDateString($i18n.locale) }}
</v-chip> </v-chip>
</v-col> </v-col>
<v-col v-else cols="9" style="margin: auto; text-align: center;"> <v-col v-else cols="9" style="margin: auto; text-align: center">
{{ event.subject }} {{ event.subject }}
</v-col> </v-col>
<v-spacer /> <v-spacer />
<v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0 pt-0"> <v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0 pt-0">
<RecipeTimelineContextMenu <RecipeTimelineContextMenu
v-if="$auth.user && $auth.user.id == event.userId && event.eventType != 'system'" v-if="currentUser && currentUser.id == event.userId && event.eventType != 'system'"
:menu-top="false" :menu-top="false"
:event="event" :event="event"
:menu-icon="$globals.icons.dotsVertical" :menu-icon="$globals.icons.dotsVertical"
:use-mobile-format="useMobileFormat" :use-mobile-format="useMobileFormat"
fab
color="transparent" color="transparent"
:elevation="0" :elevation="0"
:card-menu="false" :card-menu="false"
@ -54,7 +50,7 @@
</v-row> </v-row>
</v-card-title> </v-card-title>
<v-card-text v-if="showRecipeCards && recipe" class="background"> <v-card-text v-if="showRecipeCards && recipe" class="background">
<v-row :class="useMobileFormat ? 'py-3 mx-0' : 'py-3 mx-0'" style="max-width: 100%;"> <v-row :class="useMobileFormat ? 'py-3 mx-0' : 'py-3 mx-0'" style="max-width: 100%">
<v-col align-self="center" class="pa-0"> <v-col align-self="center" class="pa-0">
<RecipeCardMobile <RecipeCardMobile
:vertical="useMobileFormat" :vertical="useMobileFormat"
@ -81,7 +77,7 @@
:height="hideImage ? undefined : 'auto'" :height="hideImage ? undefined : 'auto'"
:max-height="attrs.image.maxHeight" :max-height="attrs.image.maxHeight"
contain contain
:class=attrs.image.class :class="attrs.image.class"
@error="hideImage = true" @error="hideImage = true"
/> />
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''"> <div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
@ -95,16 +91,15 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref, useContext, useRoute } from "@nuxtjs/composition-api";
import RecipeCardMobile from "./RecipeCardMobile.vue"; import RecipeCardMobile from "./RecipeCardMobile.vue";
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue"; import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
import { useStaticRoutes } from "~/composables/api"; import { useStaticRoutes } from "~/composables/api";
import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events"; import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events";
import { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe" import type { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue"; import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import SafeMarkdown from "~/components/global/SafeMarkdown.vue"; import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
export default defineComponent({ export default defineNuxtComponent({
components: { RecipeCardMobile, RecipeTimelineContextMenu, UserAvatar, SafeMarkdown }, components: { RecipeCardMobile, RecipeTimelineContextMenu, UserAvatar, SafeMarkdown },
props: { props: {
@ -119,20 +114,23 @@ export default defineComponent({
showRecipeCards: { showRecipeCards: {
type: Boolean, type: Boolean,
default: false, default: false,
}
}, },
},
emits: ["selected", "update", "delete"],
setup(props) { setup(props) {
const { $auth, $globals, $vuetify } = useContext(); const { $vuetify, $globals } = useNuxtApp();
const { recipeTimelineEventImage } = useStaticRoutes(); const { recipeTimelineEventImage } = useStaticRoutes();
const { eventTypeOptions } = useTimelineEventTypes(); const { eventTypeOptions } = useTimelineEventTypes();
const timelineEvents = ref([] as RecipeTimelineEventOut[]); const timelineEvents = ref([] as RecipeTimelineEventOut[]);
const { user: currentUser } = useMealieAuth();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
const useMobileFormat = computed(() => { const useMobileFormat = computed(() => {
return $vuetify.breakpoint.smAndDown; return $vuetify.display.smAndDown.value;
}); });
const attrs = computed(() => { const attrs = computed(() => {
@ -146,9 +144,9 @@ export default defineComponent({
}, },
image: { image: {
maxHeight: "250", maxHeight: "250",
class: "my-3" class: "my-3",
}, },
} };
} }
else { else {
return { return {
@ -160,14 +158,14 @@ export default defineComponent({
}, },
image: { image: {
maxHeight: "300", maxHeight: "300",
class: "mb-5" class: "mb-5",
}, },
};
} }
} });
})
const icon = computed(() => { const icon = computed(() => {
const option = eventTypeOptions.value.find((option) => option.value === props.event.eventType); const option = eventTypeOptions.value.find(option => option.value === props.event.eventType);
return option ? option.icon : $globals.icons.informationVariant; return option ? option.icon : $globals.icons.informationVariant;
}); });
@ -178,7 +176,7 @@ export default defineComponent({
} }
return recipeTimelineEventImage(props.event.recipeId, props.event.id); return recipeTimelineEventImage(props.event.recipeId, props.event.id);
}) });
return { return {
attrs, attrs,
@ -188,6 +186,7 @@ export default defineComponent({
hideImage, hideImage,
timelineEvents, timelineEvents,
useMobileFormat, useMobileFormat,
currentUser,
}; };
}, },
}); });

View file

@ -1,24 +1,34 @@
<template> <template>
<div v-if="scaledAmount" class="d-flex align-center"> <div
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger;"> v-if="scaledAmount"
<v-icon x-large left color="primary"> class="d-flex align-center"
>
<v-row
no-gutters
class="d-flex flex-wrap align-center"
style="font-size: larger;"
>
<v-icon
size="x-large"
start
color="primary"
>
{{ $globals.icons.bread }} {{ $globals.icons.bread }}
</v-icon> </v-icon>
<p class="my-0"> <p class="my-0 opacity-80">
<span class="font-weight-bold">{{ $i18n.tc("recipe.yield") }}</span><br> <span class="font-weight-bold">{{ $t("recipe.yield") }}</span><br>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="scaledAmount"></span> {{ text }} <span v-html="scaledAmount" /> {{ text }}
</p> </p>
</v-row> </v-row>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed } from "@nuxtjs/composition-api";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount"; import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
yieldQuantity: { yieldQuantity: {
type: Number, type: Number,
@ -34,11 +44,10 @@ export default defineComponent({
}, },
color: { color: {
type: String, type: String,
default: "accent custom-transparent" default: "accent custom-transparent",
}, },
}, },
setup(props) { setup(props) {
function sanitizeHTML(rawHtml: string) { function sanitizeHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, { return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true }, USE_PROFILES: { html: true },

View file

@ -1,56 +1,131 @@
<template> <template>
<div> <div>
<v-menu v-model="state.menu" offset-y bottom nudge-bottom="3" :close-on-content-click="false"> <v-menu
<template #activator="{ on, attrs }"> v-model="state.menu"
<v-badge :value="selected.length > 0" small overlap color="primary" :content="selected.length"> offset-y
<v-btn small color="accent" dark v-bind="attrs" v-on="on"> bottom
<slot></slot> nudge-bottom="3"
:close-on-content-click="false"
>
<template #activator="{ props }">
<v-badge
:model-value="selected.length > 0"
size="small"
color="primary"
:content="selected.length"
>
<v-btn
size="small"
color="accent"
dark
v-bind="props"
>
<slot />
</v-btn> </v-btn>
</v-badge> </v-badge>
</template> </template>
<v-card width="400"> <v-card width="400">
<v-card-text> <v-card-text>
<v-text-field v-model="state.search" class="mb-2" hide-details dense :label="$tc('search.search')" clearable /> <v-text-field
<div class="d-flex py-4"> v-model="state.search"
class="mb-2"
hide-details
density="comfortable"
:variant="'underlined'"
:label="$t('search.search')"
clearable
/>
<div class="d-flex py-4 px-1">
<v-switch <v-switch
v-if="requireAll != undefined" v-if="requireAll != undefined"
v-model="requireAllValue" v-model="requireAllValue"
dense density="compact"
small size="small"
hide-details hide-details
class="my-auto" class="my-auto"
:label="`${requireAll ? $tc('search.has-all') : $tc('search.has-any')}`" color="primary"
:label="`${requireAll ? $t('search.has-all') : $t('search.has-any')}`"
/> />
<v-spacer /> <v-spacer />
<v-btn <v-btn
small size="small"
color="accent" color="accent"
class="mr-2 my-auto" class="mr-2 my-auto"
@click="clearSelection" @click="clearSelection"
> >
{{ $tc("search.clear-selection") }} {{ $t("search.clear-selection") }}
</v-btn> </v-btn>
</div> </div>
<v-card v-if="filtered.length > 0" flat outlined> <v-card
<v-radio-group v-model="selectedRadio" class="ma-0 pa-0"> v-if="filtered.length > 0"
<v-virtual-scroll :items="filtered" height="300" item-height="51"> flat
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 }"> <template #default="{ item }">
<v-list-item :key="item.id" dense :value="item"> <v-list-item
<v-list-item-action> :key="item.id"
<v-radio v-if="radio" :value="item" @click="handleRadioClick(item)" /> :value="item"
<v-checkbox v-else v-model="selected" :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> </v-list-item-action>
<v-list-item-content> </template>
<v-list-item-title> {{ item.name }} </v-list-item-title>
</v-list-item-content>
</v-list-item> </v-list-item>
<v-divider></v-divider> <v-divider />
</template> </template>
</v-virtual-scroll> </v-virtual-scroll>
</v-radio-group> </v-radio-group>
<!-- checkbox filters -->
<v-row v-else class="mt-1">
<v-virtual-scroll
:items="filtered"
height="300"
item-height="51"
>
<template #default="{ item }">
<v-list-item
:key="item.id"
:value="item"
:title="item.name"
>
<template #prepend>
<v-list-item-action start>
<v-checkbox-btn
v-model="selected"
:value="item"
color="primary"
/>
</v-list-item-action>
</template>
</v-list-item>
<v-divider />
</template>
</v-virtual-scroll>
</v-row>
</v-card> </v-card>
<div v-else> <div v-else>
<v-alert type="info" text> {{ $tc('search.no-results') }} </v-alert> <v-alert
type="info"
:text="$t('search.no-results')"
/>
</div> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>
@ -59,20 +134,18 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, computed } from "@nuxtjs/composition-api";
export interface SelectableItem { export interface SelectableItem {
id: string; id: string;
name: string; name: string;
} }
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
items: { items: {
type: Array as () => SelectableItem[], type: Array as () => SelectableItem[],
required: true, required: true,
}, },
value: { modelValue: {
type: Array as () => any[], type: Array as () => any[],
required: true, required: true,
}, },
@ -85,6 +158,7 @@ export default defineComponent({
default: false, default: false,
}, },
}, },
emits: ["update:requireAll", "update:modelValue"],
setup(props, context) { setup(props, context) {
const state = reactive({ const state = reactive({
search: "", search: "",
@ -99,16 +173,16 @@ export default defineComponent({
}); });
const selected = computed({ const selected = computed({
get: () => props.value as SelectableItem[], get: () => props.modelValue as SelectableItem[],
set: (value) => { set: (value) => {
context.emit("input", value); context.emit("update:modelValue", value);
}, },
}); });
const selectedRadio = computed({ const selectedRadio = computed({
get: () => (selected.value.length > 0 ? selected.value[0] : null), get: () => (selected.value.length > 0 ? selected.value[0] : null),
set: (value) => { set: (value) => {
context.emit("input", value ? [value] : []); context.emit("update:modelValue", value ? [value] : []);
}, },
}); });
@ -117,9 +191,19 @@ export default defineComponent({
return props.items; return props.items;
} }
return props.items.filter((item) => item.name.toLowerCase().includes(state.search.toLowerCase())); return props.items.filter(item => item.name.toLowerCase().includes(state.search.toLowerCase()));
}); });
const handleCheckboxClick = (item: SelectableItem) => {
console.log(selected.value, item);
if (selected.value.includes(item)) {
selected.value = selected.value.filter(i => i !== item);
}
else {
selected.value.push(item);
}
};
const handleRadioClick = (item: SelectableItem) => { const handleRadioClick = (item: SelectableItem) => {
if (selectedRadio.value === item) { if (selectedRadio.value === item) {
selectedRadio.value = null; selectedRadio.value = null;
@ -138,6 +222,7 @@ export default defineComponent({
selected, selected,
selectedRadio, selectedRadio,
filtered, filtered,
handleCheckboxClick,
handleRadioClick, handleRadioClick,
clearSelection, clearSelection,
}; };

View file

@ -1,5 +1,11 @@
<template> <template>
<v-chip v-bind="$attrs" label :color="label.color || undefined" :text-color="textColor"> <v-chip
v-bind="$attrs"
label
variant="flat"
:color="label.color || undefined"
:text-color="textColor"
>
<span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis;"> <span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis;">
{{ label.name }} {{ label.name }}
</span> </span>
@ -7,12 +13,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import { getTextColor } from "~/composables/use-text-color"; import { getTextColor } from "~/composables/use-text-color";
import { MultiPurposeLabelSummary } from "~/lib/api/types/recipe"; import type { MultiPurposeLabelSummary } from "~/lib/api/types/recipe";
export default defineNuxtComponent({
export default defineComponent({
props: { props: {
label: { label: {
type: Object as () => MultiPurposeLabelSummary, type: Object as () => MultiPurposeLabelSummary,

View file

@ -6,12 +6,24 @@
{{ $globals.icons.tags }} {{ $globals.icons.tags }}
</v-icon> </v-icon>
</span> </span>
{{ value.label.name }} {{ modelValue.label.name }}
</div> </div>
<div style="min-width: 72px" class="ml-auto text-right"> <div
<v-menu offset-x left min-width="125px"> style="min-width: 72px"
<template #activator="{ on, attrs }"> class="ml-auto text-right"
<v-btn small class="ml-2 handle" icon v-bind="attrs" v-on="on"> >
<v-menu
offset-x
start
min-width="125px"
>
<template #activator="{ props }">
<v-btn
size="small"
class="ml-2 handle"
icon
v-bind="props"
>
<v-icon> <v-icon>
{{ $globals.icons.arrowUpDown }} {{ $globals.icons.arrowUpDown }}
</v-icon> </v-icon>
@ -23,22 +35,21 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api"; import type { ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/household";
import { ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/household";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: Object as () => ShoppingListMultiPurposeLabelOut, type: Object as () => ShoppingListMultiPurposeLabelOut,
required: true, required: true,
}, },
useColor: { useColor: {
type: Boolean, type: Boolean,
default: false, default: false,
} },
}, },
setup(props, context) { setup(props, context) {
const labelColor = ref<string | undefined>(props.useColor ? props.value.label.color : undefined); const labelColor = ref<string | undefined>(props.useColor ? props.modelValue.label.color : undefined);
function contextHandler(event: string) { function contextHandler(event: string) {
context.emit(event); context.emit(event);

View file

@ -1,41 +1,74 @@
<template> <template>
<v-container v-if="!edit" class="pa-0"> <v-container
<v-row no-gutters class="flex-nowrap align-center"> v-if="!edit"
class="pa-0"
>
<v-row
no-gutters
class="flex-nowrap align-center"
>
<v-col :cols="itemLabelCols"> <v-col :cols="itemLabelCols">
<v-checkbox <v-checkbox
v-model="listItem.checked" v-model="listItem.checked"
class="mt-0" class="mt-0"
color="null" color="null"
hide-details hide-details
dense density="compact"
:label="listItem.note" :label="listItem.note!"
@change="$emit('checked', listItem)" @change="$emit('checked', listItem)"
> >
<template #label> <template #label>
<div :class="listItem.checked ? 'strike-through' : ''"> <div :class="listItem.checked ? 'strike-through' : ''">
<RecipeIngredientListItem :ingredient="listItem" :disable-amount="!(listItem.isFood || listItem.quantity !== 1)" /> <RecipeIngredientListItem
:ingredient="listItem"
:disable-amount="!(listItem.isFood || listItem.quantity !== 1)"
/>
</div> </div>
</template> </template>
</v-checkbox> </v-checkbox>
</v-col> </v-col>
<v-spacer /> <v-spacer />
<v-col v-if="label && showLabel" cols="3" class="text-right"> <v-col
<MultiPurposeLabel :label="label" small /> v-if="label && showLabel"
cols="3"
class="text-right"
>
<MultiPurposeLabel
:label="label"
size="small"
/>
</v-col> </v-col>
<v-col cols="auto" class="text-right"> <v-col
<div v-if="!listItem.checked" style="min-width: 72px"> cols="auto"
<v-menu offset-x left min-width="125px"> class="text-right"
<template #activator="{ on, attrs }"> >
<div
v-if="!listItem.checked"
style="min-width: 72px"
>
<v-menu
offset-x
start
min-width="125px"
>
<template #activator="{ props }">
<v-tooltip <v-tooltip
v-if="recipeList && recipeList.length" v-if="recipeList && recipeList.length"
open-delay="200" open-delay="200"
transition="slide-x-reverse-transition" transition="slide-x-reverse-transition"
dense density="compact"
right right
content-class="text-caption" content-class="text-caption"
> >
<template #activator="{ on: onBtn, attrs: attrsBtn }"> <template #activator="{ props: tooltipProps }">
<v-btn small class="ml-2" icon v-bind="attrsBtn" v-on="onBtn" @click="displayRecipeRefs = !displayRecipeRefs"> <v-btn
size="small"
variant="text"
class="ml-2"
icon
v-bind="tooltipProps"
@click="displayRecipeRefs = !displayRecipeRefs"
>
<v-icon> <v-icon>
{{ $globals.icons.potSteam }} {{ $globals.icons.potSteam }}
</v-icon> </v-icon>
@ -44,43 +77,91 @@
<span>Toggle Recipes</span> <span>Toggle Recipes</span>
</v-tooltip> </v-tooltip>
<!-- Dummy button so the spacing is consistent when labels are enabled --> <!-- Dummy button so the spacing is consistent when labels are enabled -->
<v-btn v-else small class="ml-2" icon disabled> <v-btn
</v-btn> v-else
size="small"
variant="text"
class="ml-2"
icon
disabled
/>
<v-btn small class="ml-2 handle" icon v-bind="attrs" v-on="on"> <v-btn
size="small"
variant="text"
class="ml-2 handle"
icon
v-bind="props"
>
<v-icon> <v-icon>
{{ $globals.icons.arrowUpDown }} {{ $globals.icons.arrowUpDown }}
</v-icon> </v-icon>
</v-btn> </v-btn>
<v-btn small class="ml-2" icon @click="toggleEdit(true)"> <v-btn
size="small"
variant="text"
class="ml-2"
icon
@click="toggleEdit(true)"
>
<v-icon> <v-icon>
{{ $globals.icons.edit }} {{ $globals.icons.edit }}
</v-icon> </v-icon>
</v-btn> </v-btn>
</template> </template>
<v-list dense> <v-list density="compact">
<v-list-item v-for="action in contextMenu" :key="action.event" dense @click="contextHandler(action.event)"> <v-list-item
<v-list-item-title>{{ action.text }}</v-list-item-title> v-for="action in contextMenu"
:key="action.event"
density="compact"
@click="contextHandler(action.event)"
>
<v-list-item-title>
{{ action.text }}
</v-list-item-title>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
</div> </div>
</v-col> </v-col>
</v-row> </v-row>
<v-row v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs" no-gutters class="mb-2"> <v-row
<v-col cols="auto" style="width: 100%;"> v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs"
<RecipeList :recipes="recipeList" :list-item="listItem" :disabled="$nuxt.isOffline" small tile /> no-gutters
class="mb-2"
>
<v-col
cols="auto"
style="width: 100%;"
>
<RecipeList
:recipes="recipeList"
:list-item="listItem"
:disabled="isOffline"
size="small"
tile
/>
</v-col> </v-col>
</v-row> </v-row>
<v-row v-if="listItem.checked" no-gutters class="mb-2"> <v-row
v-if="listItem.checked"
no-gutters
class="mb-2"
>
<v-col cols="auto"> <v-col cols="auto">
<div class="text-caption font-weight-light font-italic"> <div class="text-caption font-weight-light font-italic">
{{ $t("shopping-list.completed-on", {date: new Date(listItem.updatedAt || "").toLocaleDateString($i18n.locale)}) }} {{ $t("shopping-list.completed-on", {
date: new Date(listItem.updatedAt
|| "").toLocaleDateString($i18n.locale) })
}}
</div> </div>
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
<div v-else class="mb-1 mt-6"> <div
v-else
class="mb-1 mt-6"
>
<ShoppingListItemEditor <ShoppingListItemEditor
v-model="localListItem" v-model="localListItem"
:labels="labels" :labels="labels"
@ -95,13 +176,13 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition-api"; import { useOnline } from "@vueuse/core";
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue"; import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
import MultiPurposeLabel from "./MultiPurposeLabel.vue"; import MultiPurposeLabel from "./MultiPurposeLabel.vue";
import { ShoppingListItemOut } from "~/lib/api/types/household"; import type { ShoppingListItemOut } from "~/lib/api/types/household";
import { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels"; import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels";
import { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe"; import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue"; import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
interface actions { interface actions {
@ -109,10 +190,10 @@ interface actions {
event: string; event: string;
} }
export default defineComponent({ export default defineNuxtComponent({
components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeList, RecipeIngredientListItem }, components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeList, RecipeIngredientListItem },
props: { props: {
value: { modelValue: {
type: Object as () => ShoppingListItemOut, type: Object as () => ShoppingListItemOut,
required: true, required: true,
}, },
@ -137,10 +218,12 @@ export default defineComponent({
default: undefined, default: undefined,
}, },
}, },
emits: ["checked", "update:modelValue", "save", "delete"],
setup(props, context) { setup(props, context) {
const { i18n } = useContext(); const i18n = useI18n();
const displayRecipeRefs = ref(false); const displayRecipeRefs = ref(false);
const itemLabelCols = ref<string>(props.value.checked ? "auto" : props.showLabel ? "4" : "6"); const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : props.showLabel ? "4" : "6");
const isOffline = computed(() => useOnline().value === false);
const contextMenu: actions[] = [ const contextMenu: actions[] = [
{ {
@ -154,15 +237,15 @@ export default defineComponent({
]; ];
// copy prop value so a refresh doesn't interrupt the user // copy prop value so a refresh doesn't interrupt the user
const localListItem = ref(Object.assign({}, props.value)); const localListItem = ref(Object.assign({}, props.modelValue));
const listItem = computed({ const listItem = computed({
get: () => { get: () => {
return props.value; return props.modelValue;
}, },
set: (val) => { set: (val) => {
// keep local copy in sync // keep local copy in sync
localListItem.value = val; localListItem.value = val;
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
const edit = ref(false); const edit = ref(false);
@ -173,7 +256,7 @@ export default defineComponent({
if (val) { if (val) {
// update local copy of item with the current value // update local copy of item with the current value
localListItem.value = props.value; localListItem.value = props.modelValue;
} }
edit.value = val; edit.value = val;
@ -182,7 +265,8 @@ export default defineComponent({
function contextHandler(event: string) { function contextHandler(event: string) {
if (event === "edit") { if (event === "edit") {
toggleEdit(true); toggleEdit(true);
} else { }
else {
context.emit(event); context.emit(event);
} }
} }
@ -205,9 +289,7 @@ export default defineComponent({
* or the label of the food applied. * or the label of the food applied.
*/ */
const label = computed<MultiPurposeLabelSummary | undefined>(() => { const label = computed<MultiPurposeLabelSummary | undefined>(() => {
// @ts-ignore - it _might_ exists
if (listItem.value.label) { if (listItem.value.label) {
// @ts-ignore - it _might_ exists
return listItem.value.label as MultiPurposeLabelSummary; return listItem.value.label as MultiPurposeLabelSummary;
} }
@ -225,7 +307,7 @@ export default defineComponent({
} }
listItem.value.recipeReferences.forEach((ref) => { listItem.value.recipeReferences.forEach((ref) => {
const recipe = props.recipes.get(ref.recipeId) const recipe = props.recipes.get(ref.recipeId);
if (recipe) { if (recipe) {
recipeList.push(recipe); recipeList.push(recipe);
} }
@ -247,6 +329,7 @@ export default defineComponent({
label, label,
recipeList, recipeList,
toggleEdit, toggleEdit,
isOffline,
}; };
}, },
}); });

View file

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<v-card outlined> <v-card variant="outlined">
<v-card-text class="pb-3 pt-1"> <v-card-text class="pb-3 pt-1">
<div v-if="listItem.isFood" class="d-md-flex align-center mb-2" style="gap: 20px"> <div v-if="listItem.isFood" class="d-md-flex align-center mb-2" style="gap: 20px">
<div> <div>
@ -8,21 +8,22 @@
</div> </div>
<InputLabelType <InputLabelType
v-model="listItem.unit" v-model="listItem.unit"
v-model:item-id="listItem.unitId!"
:items="units" :items="units"
:item-id.sync="listItem.unitId"
:label="$t('general.units')" :label="$t('general.units')"
:icon="$globals.icons.units" :icon="$globals.icons.units"
create
@create="createAssignUnit" @create="createAssignUnit"
/> />
<InputLabelType <InputLabelType
v-model="listItem.food" v-model="listItem.food"
v-model:item-id="listItem.foodId!"
:items="foods" :items="foods"
:item-id.sync="listItem.foodId"
:label="$t('shopping-list.food')" :label="$t('shopping-list.food')"
:icon="$globals.icons.foods" :icon="$globals.icons.foods"
create
@create="createAssignFood" @create="createAssignFood"
/> />
</div> </div>
<div class="d-md-flex align-center" style="gap: 20px"> <div class="d-md-flex align-center" style="gap: 20px">
<div v-if="!listItem.isFood"> <div v-if="!listItem.isFood">
@ -36,17 +37,17 @@
auto-grow auto-grow
autofocus autofocus
@keypress="handleNoteKeyPress" @keypress="handleNoteKeyPress"
></v-textarea> />
</div> </div>
<div class="d-flex flex-wrap align-end" style="gap: 20px"> <div class="d-flex flex-wrap align-end" style="gap: 20px">
<div class="d-flex align-end"> <div class="d-flex align-end">
<div style="max-width: 300px" class="mt-3 mr-auto"> <div style="max-width: 300px" class="mt-3 mr-auto">
<InputLabelType <InputLabelType
v-model="listItem.label" v-model="listItem.label"
v-model:item-id="listItem.labelId!"
:items="labels" :items="labels"
:item-id.sync="listItem.labelId"
:label="$t('shopping-list.label')" :label="$t('shopping-list.label')"
width="250"
/> />
</div> </div>
@ -54,11 +55,11 @@
v-if="listItem.recipeReferences && listItem.recipeReferences.length > 0" v-if="listItem.recipeReferences && listItem.recipeReferences.length > 0"
open-on-hover open-on-hover
offset-y offset-y
left start
top top
> >
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-icon class="mt-auto" icon v-bind="attrs" color="warning" v-on="on"> <v-icon class="mt-auto" :icon="$globals.icons.alert" v-bind="props" color="warning">
{{ $globals.icons.alert }} {{ $globals.icons.alert }}
</v-icon> </v-icon>
</template> </template>
@ -71,10 +72,10 @@
</div> </div>
<BaseButton <BaseButton
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId" v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
small size="small"
color="info" color="info"
:icon="$globals.icons.tagArrowRight" :icon="$globals.icons.tagArrowRight"
:text="$tc('shopping-list.save-label')" :text="$t('shopping-list.save-label')"
class="mt-2 align-items-flex-start" class="mt-2 align-items-flex-start"
@click="assignLabelToFood" @click="assignLabelToFood"
/> />
@ -84,11 +85,15 @@
<v-card-actions class="ma-0 pt-0 pb-1 justify-end"> <v-card-actions class="ma-0 pt-0 pb-1 justify-end">
<BaseButtonGroup <BaseButtonGroup
:buttons="[ :buttons="[
...(allowDelete ? [{ ...(allowDelete
? [
{
icon: $globals.icons.delete, icon: $globals.icons.delete,
text: $t('general.delete'), text: $t('general.delete'),
event: 'delete', event: 'delete',
}] : []), },
]
: []),
{ {
icon: $globals.icons.close, icon: $globals.icons.close,
text: $t('general.cancel'), text: $t('general.cancel'),
@ -116,15 +121,14 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, watch } from "@nuxtjs/composition-api"; import type { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/household";
import { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/household"; import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
import { MultiPurposeLabelOut } from "~/lib/api/types/labels"; import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store"; import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
value: { modelValue: {
type: Object as () => ShoppingListItemCreate | ShoppingListItemOut, type: Object as () => ShoppingListItemCreate | ShoppingListItemOut,
required: true, required: true,
}, },
@ -146,6 +150,7 @@ export default defineComponent({
default: true, default: true,
}, },
}, },
emits: ["update:modelValue", "save", "cancel", "delete"],
setup(props, context) { setup(props, context) {
const foodStore = useFoodStore(); const foodStore = useFoodStore();
const foodData = useFoodData(); const foodData = useFoodData();
@ -155,25 +160,25 @@ export default defineComponent({
const listItem = computed({ const listItem = computed({
get: () => { get: () => {
return props.value; return props.modelValue;
}, },
set: (val) => { set: (val) => {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
watch( watch(
() => props.value.food, () => props.modelValue.food,
(newFood) => { (newFood) => {
// @ts-ignore our logic already assumes there's a label attribute, even if TS doesn't think there is
listItem.value.label = newFood?.label || null; listItem.value.label = newFood?.label || null;
listItem.value.labelId = listItem.value.label?.id || null; listItem.value.labelId = listItem.value.label?.id || null;
} },
); );
async function createAssignFood(val: string) { async function createAssignFood(val: string) {
// keep UI reactive // keep UI reactive
listItem.value.food ? listItem.value.food.name = val : listItem.value.food = { name: val }; // eslint-disable-next-line @typescript-eslint/no-unused-expressions
listItem.value.food ? (listItem.value.food.name = val) : (listItem.value.food = { name: val });
foodData.data.name = val; foodData.data.name = val;
const newFood = await foodStore.actions.createOne(foodData.data); const newFood = await foodStore.actions.createOne(foodData.data);
@ -186,7 +191,8 @@ export default defineComponent({
async function createAssignUnit(val: string) { async function createAssignUnit(val: string) {
// keep UI reactive // keep UI reactive
listItem.value.unit ? listItem.value.unit.name = val : listItem.value.unit = { name: val }; // eslint-disable-next-line @typescript-eslint/no-unused-expressions
listItem.value.unit ? (listItem.value.unit.name = val) : (listItem.value.unit = { name: val });
unitData.data.name = val; unitData.data.name = val;
const newUnit = await unitStore.actions.createOne(unitData.data); const newUnit = await unitStore.actions.createOne(unitData.data);
@ -203,7 +209,6 @@ export default defineComponent({
} }
listItem.value.food.labelId = listItem.value.labelId; listItem.value.food.labelId = listItem.value.labelId;
// @ts-ignore the food will have an id, even though TS says it might not
await foodStore.actions.updateOne(listItem.value.food); await foodStore.actions.updateOne(listItem.value.food);
} }
@ -222,6 +227,6 @@ export default defineComponent({
this.$emit("save"); this.$emit("save");
} }
}, },
} },
}); });
</script> </script>

View file

@ -4,12 +4,29 @@
:disabled="!user || !tooltip" :disabled="!user || !tooltip"
right right
> >
<template #activator="{ on, attrs }"> <template #activator="{ props }">
<v-list-item-avatar v-if="list" v-bind="attrs" v-on="on"> <v-avatar
<v-img :src="imageURL" :alt="userId" @load="error = false" @error="error = true"> </v-img> v-if="list"
</v-list-item-avatar> v-bind="props"
<v-avatar v-else :size="size" v-bind="attrs" v-on="on"> >
<v-img :src="imageURL" :alt="userId" @load="error = false" @error="error = true"> </v-img> <v-img
:src="imageURL"
:alt="userId"
@load="error = false"
@error="error = true"
/>
</v-avatar>
<v-avatar
v-else
:size="size"
v-bind="props"
>
<v-img
:src="imageURL"
:alt="userId"
@load="error = false"
@error="error = true"
/>
</v-avatar> </v-avatar>
</template> </template>
<span v-if="user"> <span v-if="user">
@ -19,11 +36,9 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, toRefs, reactive, useContext, computed } from "@nuxtjs/composition-api";
import { useUserStore } from "~/composables/store/use-user-store"; import { useUserStore } from "~/composables/store/use-user-store";
import { UserOut } from "~/lib/api/types/user";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
userId: { userId: {
type: String, type: String,
@ -40,22 +55,22 @@ export default defineComponent({
tooltip: { tooltip: {
type: Boolean, type: Boolean,
default: true, default: true,
} },
}, },
setup(props) { setup(props) {
const state = reactive({ const state = reactive({
error: false, error: false,
}); });
const { $auth } = useContext(); const $auth = useMealieAuth();
const { store: users } = useUserStore(); const { store: users } = useUserStore();
const user = computed(() => { const user = computed(() => {
return users.value.find((user) => user.id === props.userId); return users.value.find(user => user.id === props.userId);
}) });
const imageURL = computed(() => { const imageURL = computed(() => {
// TODO Setup correct user type for $auth.user // Note: $auth.user is a ref now
const authUser = $auth.user as unknown as UserOut | null; const authUser = $auth.user.value;
const key = authUser?.cacheKey ?? ""; const key = authUser?.cacheKey ?? "";
return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`; return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`;
}); });

View file

@ -1,50 +1,63 @@
<template> <template>
<BaseDialog <BaseDialog
v-model="inviteDialog" v-model="inviteDialog"
:title="$tc('profile.get-invite-link')" :title="$t('profile.get-invite-link')"
:icon="$globals.icons.accountPlusOutline" :icon="$globals.icons.accountPlusOutline"
color="primary"> color="primary"
>
<v-container> <v-container>
<v-form class="mt-5"> <v-form class="mt-5">
<v-select <v-select
v-if="groups && groups.length" v-if="groups && groups.length"
v-model="selectedGroup" v-model="selectedGroup"
:items="groups" :items="groups"
item-text="name" item-title="name"
item-value="id" item-value="id"
:return-object="false" :return-object="false"
filled variant="filled"
:label="$tc('group.user-group')" :label="$t('group.user-group')"
:rules="[validators.required]" /> :rules="[validators.required]"
/>
<v-select <v-select
v-if="households && households.length" v-if="households && households.length"
v-model="selectedHousehold" v-model="selectedHousehold"
:items="filteredHouseholds" :items="filteredHouseholds"
item-text="name" item-value="id" item-title="name"
:return-object="false" filled item-value="id"
:label="$tc('household.user-household')" :return-object="false"
:rules="[validators.required]" /> variant="filled"
:label="$t('household.user-household')"
:rules="[validators.required]"
/>
<v-row> <v-row>
<v-col cols="9"> <v-col cols="9">
<v-text-field <v-text-field
:label="$tc('profile.invite-link')" :label="$t('profile.invite-link')"
type="text" readonly filled type="text"
:value="generatedSignupLink" /> readonly
variant="filled"
:value="generatedSignupLink"
/>
</v-col> </v-col>
<v-col cols="3" class="pl-1 mt-3"> <v-col
cols="3"
class="pl-1 mt-3"
>
<AppButtonCopy <AppButtonCopy
:icon="false" :icon="false"
color="info" color="info"
:copy-text="generatedSignupLink" :copy-text="generatedSignupLink"
:disabled="generatedSignupLink" /> :disabled="generatedSignupLink"
/>
</v-col> </v-col>
</v-row> </v-row>
<v-text-field <v-text-field
v-model="sendTo" v-model="sendTo"
:label="$t('user.email')" :label="$t('user.email')"
:rules="[validators.email]" :rules="[validators.email]"
outlined variant="outlined"
@keydown.enter="sendInvite" /> @keydown.enter="sendInvite"
/>
</v-form> </v-form>
</v-container> </v-container>
<template #custom-card-action> <template #custom-card-action>
@ -52,7 +65,8 @@
:disabled="!validEmail" :disabled="!validEmail"
:loading="loading" :loading="loading"
:icon="$globals.icons.email" :icon="$globals.icons.email"
@click="sendInvite"> @click="sendInvite"
>
{{ $t("group.invite") }} {{ $t("group.invite") }}
</BaseButton> </BaseButton>
</template> </template>
@ -60,7 +74,6 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext, ref, toRefs, reactive } from "@nuxtjs/composition-api";
import { watchEffect } from "vue"; import { watchEffect } from "vue";
import { useUserApi } from "@/composables/api"; import { useUserApi } from "@/composables/api";
import BaseDialog from "~/components/global/BaseDialog.vue"; import BaseDialog from "~/components/global/BaseDialog.vue";
@ -68,12 +81,12 @@ import AppButtonCopy from "~/components/global/AppButtonCopy.vue";
import BaseButton from "~/components/global/BaseButton.vue"; import BaseButton from "~/components/global/BaseButton.vue";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { GroupInDB } from "~/lib/api/types/user"; import type { GroupInDB } from "~/lib/api/types/user";
import { HouseholdInDB } from "~/lib/api/types/household"; import type { HouseholdInDB } from "~/lib/api/types/household";
import { useGroups } from "~/composables/use-groups"; import { useGroups } from "~/composables/use-groups";
import { useAdminHouseholds } from "~/composables/use-households"; import { useAdminHouseholds } from "~/composables/use-households";
export default defineComponent({ export default defineNuxtComponent({
name: "UserInviteDialog", name: "UserInviteDialog",
components: { components: {
BaseDialog, BaseDialog,
@ -81,15 +94,17 @@ export default defineComponent({
BaseButton, BaseButton,
}, },
props: { props: {
value: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const { $auth, i18n } = useContext(); const i18n = useI18n();
const $auth = useMealieAuth();
const isAdmin = computed(() => $auth.user?.admin); const isAdmin = computed(() => $auth.user.value?.admin);
const token = ref(""); const token = ref("");
const selectedGroup = ref<string | null>(null); const selectedGroup = ref<string | null>(null);
const selectedHousehold = ref<string | null>(null); const selectedHousehold = ref<string | null>(null);
@ -98,7 +113,7 @@ export default defineComponent({
const api = useUserApi(); const api = useUserApi();
const fetchGroupsAndHouseholds = () => { const fetchGroupsAndHouseholds = () => {
if (isAdmin) { if (isAdmin.value) {
const groupsResponse = useGroups(); const groupsResponse = useGroups();
const householdsResponse = useAdminHouseholds(); const householdsResponse = useAdminHouseholds();
watchEffect(() => { watchEffect(() => {
@ -110,10 +125,10 @@ export default defineComponent({
const inviteDialog = computed<boolean>({ const inviteDialog = computed<boolean>({
get() { get() {
return props.value; return props.modelValue;
}, },
set(val) { set(val) {
context.emit("input", val); context.emit("update:modelValue", val);
}, },
}); });
@ -156,9 +171,10 @@ export default defineComponent({
}); });
if (data && data.success) { if (data && data.success) {
alert.success(i18n.tc("profile.email-sent")); alert.success(i18n.t("profile.email-sent"));
} else { }
alert.error(i18n.tc("profile.error-sending-email")); else {
alert.error(i18n.t("profile.error-sending-email"));
} }
state.loading = false; state.loading = false;
inviteDialog.value = false; inviteDialog.value = false;
@ -191,10 +207,11 @@ export default defineComponent({
households, households,
fetchGroupsAndHouseholds, fetchGroupsAndHouseholds,
...toRefs(state), ...toRefs(state),
isAdmin,
}; };
}, },
watch: { watch: {
value: { modelValue: {
immediate: false, immediate: false,
handler(val) { handler(val) {
if (val && !this.isAdmin) { if (val && !this.isAdmin) {

View file

@ -3,8 +3,8 @@
<div style="flex-basis: 500px"> <div style="flex-basis: 500px">
<strong> {{ $t("user.password-strength", { strength: pwStrength.strength.value }) }}</strong> <strong> {{ $t("user.password-strength", { strength: pwStrength.strength.value }) }}</strong>
<v-progress-linear <v-progress-linear
:value="pwStrength.score.value" v-model="pwStrength.score.value"
class="rounded-lg" rounded
:color="pwStrength.color.value" :color="pwStrength.color.value"
height="15" height="15"
/> />
@ -12,28 +12,11 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent, toRef, useContext } from "@nuxtjs/composition-api";
import { usePasswordStrength } from "~/composables/use-passwords"; import { usePasswordStrength } from "~/composables/use-passwords";
export default defineComponent({ const modelValue = defineModel<string>({ default: "" });
props: { const i18n = useI18n();
value: {
type: String,
default: "",
},
},
setup(props) {
const asRef = toRef(props, "value");
const { i18n } = useContext();
const pwStrength = usePasswordStrength(asRef, i18n); const pwStrength = usePasswordStrength(modelValue, i18n);
return {
pwStrength,
};
},
});
</script> </script>
<style scoped></style>

View file

@ -1,43 +1,65 @@
<template> <template>
<v-card outlined nuxt :to="link.to" height="100%" class="d-flex flex-column"> <v-card
<div v-if="$vuetify.breakpoint.smAndDown" class="pa-2 mx-auto"> variant="outlined"
<v-img max-width="150px" max-height="125" :src="image" /> style="border-color: lightgrey;"
:to="link.to"
height="100%"
class="d-flex flex-column mt-4"
>
<div
v-if="$vuetify.display.smAndDown"
class="pa-2 mx-auto"
>
<v-img
width="150px"
height="125"
:src="image"
/>
</div> </div>
<div class="d-flex justify-space-between"> <div class="d-flex justify-space-between">
<div> <div>
<v-card-title class="headline pb-0"> <v-card-title class="text-subtitle-1 pb-0">
<slot name="title"> </slot> <slot name="title" />
</v-card-title> </v-card-title>
<div class="d-flex justify-center align-center"> <div class="d-flex justify-center align-center">
<v-card-text class="d-flex flex-row mb-auto"> <v-card-text class="d-flex flex-row mb-auto">
<slot name="default"></slot> <slot name="default" />
</v-card-text> </v-card-text>
</div> </div>
</div> </div>
<div v-if="$vuetify.breakpoint.mdAndUp" class="py-2 px-10 my-auto"> <div
<v-img max-width="150px" max-height="125" :src="image"></v-img> v-if="$vuetify.display.mdAndUp"
class="py-2 px-10 my-auto"
>
<v-img
width="150px"
height="125"
:src="image"
/>
</div> </div>
</div> </div>
<v-divider class="mt-auto"></v-divider> <v-spacer />
<v-divider />
<v-card-actions> <v-card-actions>
<v-btn text color="info" :to="link.to"> <v-btn
variant="text"
color="info"
:to="link.to"
>
{{ link.text }} {{ link.text }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from "@nuxtjs/composition-api";
interface LinkProp { interface LinkProp {
text: string; text: string;
url?: string; url?: string;
to: string; to: string;
} }
export default defineComponent({ const props = defineProps({
props: {
link: { link: {
type: Object as () => LinkProp, type: Object as () => LinkProp,
required: true, required: true,
@ -47,9 +69,7 @@ export default defineComponent({
required: false, required: false,
default: "", default: "",
}, },
},
setup() {
return {};
},
}); });
console.log("Props", props);
</script> </script>

View file

@ -1,17 +1,25 @@
<template> <template>
<div> <div>
<v-card-title> <v-card-title>
<v-icon large class="mr-3"> {{ $globals.icons.user }}</v-icon> <v-icon
size="large"
class="mr-3"
>
{{ $globals.icons.user }}
</v-icon>
<span class="headline"> {{ $t("user-registration.account-details") }}</span> <span class="headline"> {{ $t("user-registration.account-details") }}</span>
</v-card-title> </v-card-title>
<v-divider /> <v-divider />
<v-card-text> <v-card-text>
<v-form ref="domAccountForm" @submit.prevent> <v-form
ref="domAccountForm"
@submit.prevent
>
<v-text-field <v-text-field
v-model="accountDetails.username.value" v-model="accountDetails.username.value"
autofocus autofocus
v-bind="inputAttrs" v-bind="inputAttrs"
:label="$tc('user.username')" :label="$t('user.username')"
:prepend-icon="$globals.icons.user" :prepend-icon="$globals.icons.user"
:rules="[validators.required]" :rules="[validators.required]"
:error-messages="usernameErrorMessages" :error-messages="usernameErrorMessages"
@ -20,7 +28,7 @@
<v-text-field <v-text-field
v-model="accountDetails.fullName.value" v-model="accountDetails.fullName.value"
v-bind="inputAttrs" v-bind="inputAttrs"
:label="$tc('user.full-name')" :label="$t('user.full-name')"
:prepend-icon="$globals.icons.user" :prepend-icon="$globals.icons.user"
:rules="[validators.required]" :rules="[validators.required]"
/> />
@ -28,7 +36,7 @@
v-model="accountDetails.email.value" v-model="accountDetails.email.value"
v-bind="inputAttrs" v-bind="inputAttrs"
:prepend-icon="$globals.icons.email" :prepend-icon="$globals.icons.email"
:label="$tc('user.email')" :label="$t('user.email')"
:rules="[validators.required, validators.email]" :rules="[validators.required, validators.email]"
:error-messages="emailErrorMessages" :error-messages="emailErrorMessages"
@blur="validateEmail" @blur="validateEmail"
@ -37,32 +45,32 @@
v-model="credentials.password1.value" v-model="credentials.password1.value"
v-bind="inputAttrs" v-bind="inputAttrs"
:type="pwFields.inputType.value" :type="pwFields.inputType.value"
:append-icon="pwFields.passwordIcon.value" :append-inner-icon="pwFields.passwordIcon.value"
:prepend-icon="$globals.icons.lock" :prepend-icon="$globals.icons.lock"
:label="$tc('user.password')" :label="$t('user.password')"
:rules="[validators.required, validators.minLength(8), validators.maxLength(258)]" :rules="[validators.required, validators.minLength(8), validators.maxLength(258)]"
@click:append="pwFields.togglePasswordShow" @click:append-inner="pwFields.togglePasswordShow"
/> />
<UserPasswordStrength :value="credentials.password1.value" /> <UserPasswordStrength v-model="credentials.password1.value" />
<v-text-field <v-text-field
v-model="credentials.password2.value" v-model="credentials.password2.value"
v-bind="inputAttrs" v-bind="inputAttrs"
:type="pwFields.inputType.value" :type="pwFields.inputType.value"
:append-icon="pwFields.passwordIcon.value" :append-inner-icon="pwFields.passwordIcon.value"
:prepend-icon="$globals.icons.lock" :prepend-icon="$globals.icons.lock"
:label="$tc('user.confirm-password')" :label="$t('user.confirm-password')"
:rules="[validators.required, credentials.passwordMatch]" :rules="[validators.required, credentials.passwordMatch]"
@click:append="pwFields.togglePasswordShow" @click:append-inner="pwFields.togglePasswordShow"
/> />
<div class="px-2"> <div class="px-2">
<v-checkbox <v-checkbox
v-model="accountDetails.advancedOptions.value" v-model="accountDetails.advancedOptions.value"
:label="$tc('user.enable-advanced-content')" :label="$t('user.enable-advanced-content')"
/> />
<p class="text-caption mt-n4"> <p class="text-caption mt-n4">
{{ $tc("user.enable-advanced-content-description") }} {{ $t("user.enable-advanced-content-description") }}
</p> </p>
</div> </div>
</v-form> </v-form>
@ -71,7 +79,6 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { useDark } from "@vueuse/core"; import { useDark } from "@vueuse/core";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form"; import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
@ -79,16 +86,19 @@ import { usePasswordField } from "~/composables/use-passwords";
import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue"; import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue";
const inputAttrs = { const inputAttrs = {
filled: true,
rounded: true, rounded: true,
validateOnBlur: true, validateOnBlur: true,
class: "rounded-lg", class: "rounded-lg pb-1",
variant: "solo-filled" as any,
}; };
export default defineComponent({ export default defineNuxtComponent({
components: { UserPasswordStrength }, components: { UserPasswordStrength },
layout: "blank",
setup() { setup() {
definePageMeta({
layout: "blank",
});
const isDark = useDark(); const isDark = useDark();
const langDialog = ref(false); const langDialog = ref(false);

View file

@ -1,41 +1,85 @@
<template> <template>
<v-app dark> <v-app dark>
<NuxtPwaManifest />
<TheSnackbar /> <TheSnackbar />
<AppHeader>
<v-btn
icon
@click.stop="sidebar = !sidebar"
>
<v-icon> {{ $globals.icons.menu }}</v-icon>
</v-btn>
</AppHeader>
<AppSidebar <AppSidebar
v-model="sidebar" v-model="sidebar"
absolute absolute
:top-link="topLinks" :top-link="topLinks"
:secondary-links="cookbookLinks || []" :secondary-links="cookbookLinks || []"
:bottom-links="isAdmin ? bottomLinks : []" :bottom-links="bottomLinks"
>
<v-menu
offset-y
nudge-bottom="5"
close-delay="50"
nudge-right="15"
>
<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"
> >
<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">
{{ $globals.icons.createAlt }} {{ $globals.icons.createAlt }}
</v-icon> </v-icon>
{{ $t("general.create") }} {{ $t("general.create") }}
</v-btn> </v-btn>
</template> </template>
<v-list dense class="my-0 py-0"> <v-list
density="comfortable"
class="mb-0 mt-1 py-0"
variant="flat"
>
<template v-for="(item, index) in createLinks"> <template v-for="(item, index) in createLinks">
<div v-if="!item.hide" :key="item.title"> <div
<v-divider v-if="item.insertDivider" :key="index" class="mx-2"></v-divider> v-if="!item.hide"
<v-list-item v-if="!item.restricted || isOwnGroup" :key="item.title" :to="item.to" exact> :key="item.title"
<v-list-item-avatar> >
<v-icon> <v-divider
{{ item.icon }} v-if="item.insertDivider"
</v-icon> :key="index"
</v-list-item-avatar> class="mx-2"
<v-list-item-content> />
<v-list-item-title> <v-list-item
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 }} {{ item.title }}
</v-list-item-title> </v-list-item-title>
<v-list-item-subtitle v-if="item.subtitle"> <v-list-item-subtitle class="font-weight-medium" style="font-size: small;">
{{ item.subtitle }} {{ item.subtitle }}
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item-content>
</v-list-item> </v-list-item>
</div> </div>
</template> </template>
@ -43,68 +87,64 @@
</v-menu> </v-menu>
<template #bottom> <template #bottom>
<v-list-item @click.stop="languageDialog = true"> <v-list-item @click.stop="languageDialog = true">
<v-list-item-icon> <template #prepend>
<v-icon>{{ $globals.icons.translate }}</v-icon> <v-icon>{{ $globals.icons.translate }}</v-icon>
</v-list-item-icon> </template>
<v-list-item-content>
<v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title> <v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
<LanguageDialog v-model="languageDialog" /> <LanguageDialog v-model="languageDialog" />
</v-list-item-content>
</v-list-item> </v-list-item>
<v-list-item @click="toggleDark"> <v-list-item @click="toggleDark">
<v-list-item-icon> <template #prepend>
<v-icon> <v-icon>
{{ $vuetify.theme.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }} {{ $vuetify.theme.current.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
</v-icon> </v-icon>
</v-list-item-icon> </template>
<v-list-item-title> <v-list-item-title>
{{ $vuetify.theme.dark ? $t("settings.theme.light-mode") : $t("settings.theme.dark-mode") }} {{ $vuetify.theme.current.dark ? $t("settings.theme.light-mode") : $t("settings.theme.dark-mode") }}
</v-list-item-title> </v-list-item-title>
</v-list-item> </v-list-item>
</template> </template>
</AppSidebar> </AppSidebar>
<v-main class="pt-16">
<AppHeader>
<v-btn icon @click.stop="sidebar = !sidebar">
<v-icon> {{ $globals.icons.menu }}</v-icon>
</v-btn>
</AppHeader>
<v-main>
<v-scroll-x-transition> <v-scroll-x-transition>
<Nuxt /> <div>
<NuxtPage />
</div>
</v-scroll-x-transition> </v-scroll-x-transition>
</v-main> </v-main>
</v-app> </v-app>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, onMounted, ref, useContext, useRoute } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue"; import type { SideBarLink } from "~/types/application-types";
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
import { SideBarLink } from "~/types/application-types";
import LanguageDialog from "~/components/global/LanguageDialog.vue";
import TheSnackbar from "@/components/Layout/LayoutParts/TheSnackbar.vue";
import { useAppInfo } from "~/composables/api"; import { useAppInfo } from "~/composables/api";
import { useCookbooks, usePublicCookbooks } from "~/composables/use-group-cookbooks";
import { useCookbookPreferences } from "~/composables/use-users/preferences"; import { useCookbookPreferences } from "~/composables/use-users/preferences";
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
import { useHouseholdStore, usePublicHouseholdStore } from "~/composables/store/use-household-store"; import { useHouseholdStore, usePublicHouseholdStore } from "~/composables/store/use-household-store";
import { useToggleDarkMode } from "~/composables/use-utils"; import { useToggleDarkMode } from "~/composables/use-utils";
import { ReadCookBook } from "~/lib/api/types/cookbook"; import type { ReadCookBook } from "~/lib/api/types/cookbook";
import { HouseholdSummary } from "~/lib/api/types/household"; import type { HouseholdSummary } from "~/lib/api/types/household";
export default defineNuxtComponent({
export default defineComponent({
components: { AppHeader, AppSidebar, LanguageDialog, TheSnackbar },
setup() { setup() {
const { $globals, $auth, $vuetify, i18n } = useContext(); const i18n = useI18n();
const { $globals, $vuetify } = useNuxtApp();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const isAdmin = computed(() => $auth.user?.admin); const isAdmin = computed(() => $auth.user.value?.admin);
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { cookbooks } = isOwnGroup.value ? useCookbooks() : usePublicCookbooks(groupSlug.value || "");
const cookbookPreferences = useCookbookPreferences(); const cookbookPreferences = useCookbookPreferences();
const { store: cookbooks, actions: cookbooksActions } = isOwnGroup.value ? useCookbookStore() : usePublicCookbookStore(groupSlug.value || "");
onMounted(() => {
if (!cookbooks.value.length) {
cookbooksActions.refresh();
}
});
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value || ""); const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value || "");
const householdsById = computed(() => { const householdsById = computed(() => {
@ -121,10 +161,9 @@ export default defineComponent({
const languageDialog = ref<boolean>(false); const languageDialog = ref<boolean>(false);
const sidebar = ref<boolean | null>(null); const sidebar = ref<boolean>(false);
onMounted(() => { onMounted(() => {
sidebar.value = !$vuetify.breakpoint.md; sidebar.value = $vuetify.display.mdAndUp.value;
}); });
function cookbookAsLink(cookbook: ReadCookBook): SideBarLink { function cookbookAsLink(cookbook: ReadCookBook): SideBarLink {
@ -137,16 +176,13 @@ export default defineComponent({
}; };
} }
const currentUserHouseholdId = computed(() => $auth.user?.householdId); const currentUserHouseholdId = computed(() => $auth.user.value?.householdId);
const cookbookLinks = computed<SideBarLink[]>(() => { const cookbookLinks = computed<SideBarLink[]>(() => {
if (!cookbooks.value) { const sortedCookbooks = [...cookbooks.value].sort((a, b) => (a.position || 0) - (b.position || 0));
return [];
}
cookbooks.value.sort((a, b) => (a.position || 0) - (b.position || 0));
const ownLinks: SideBarLink[] = []; const ownLinks: SideBarLink[] = [];
const links: SideBarLink[] = []; const links: SideBarLink[] = [];
const cookbooksByHousehold = cookbooks.value.reduce((acc, cookbook) => { const cookbooksByHousehold = sortedCookbooks.reduce((acc, cookbook) => {
const householdName = householdsById.value[cookbook.householdId]?.name || ""; const householdName = householdsById.value[cookbook.householdId]?.name || "";
if (!acc[householdName]) { if (!acc[householdName]) {
acc[householdName] = []; acc[householdName] = [];
@ -156,9 +192,13 @@ export default defineComponent({
}, {} as Record<string, ReadCookBook[]>); }, {} as Record<string, ReadCookBook[]>);
Object.entries(cookbooksByHousehold).forEach(([householdName, cookbooks]) => { Object.entries(cookbooksByHousehold).forEach(([householdName, cookbooks]) => {
if (!cookbooks.length) {
return;
}
if (cookbooks[0].householdId === currentUserHouseholdId.value) { if (cookbooks[0].householdId === currentUserHouseholdId.value) {
ownLinks.push(...cookbooks.map(cookbookAsLink)); ownLinks.push(...cookbooks.map(cookbookAsLink));
} else { }
else {
links.push({ links.push({
key: householdName, key: householdName,
icon: $globals.icons.book, icon: $globals.icons.book,
@ -170,19 +210,20 @@ export default defineComponent({
}); });
links.sort((a, b) => a.title.localeCompare(b.title)); links.sort((a, b) => a.title.localeCompare(b.title));
if ($auth.user && cookbookPreferences.value.hideOtherHouseholds) { if ($auth.user.value && cookbookPreferences.value.hideOtherHouseholds) {
return ownLinks; return ownLinks;
} else { }
else {
return [...ownLinks, ...links]; return [...ownLinks, ...links];
} }
}); });
const createLinks = computed<SideBarLink[]>(() => [ const createLinks = computed(() => [
{ {
insertDivider: false, insertDivider: false,
icon: $globals.icons.link, icon: $globals.icons.link,
title: i18n.tc("general.import"), title: i18n.t("general.import"),
subtitle: i18n.tc("new-recipe.import-by-url"), subtitle: i18n.t("new-recipe.import-by-url"),
to: `/g/${groupSlug.value}/r/create/url`, to: `/g/${groupSlug.value}/r/create/url`,
restricted: true, restricted: true,
hide: false, hide: false,
@ -190,8 +231,8 @@ export default defineComponent({
{ {
insertDivider: false, insertDivider: false,
icon: $globals.icons.fileImage, icon: $globals.icons.fileImage,
title: i18n.tc("recipe.create-from-image"), title: i18n.t("recipe.create-from-image"),
subtitle: i18n.tc("recipe.create-recipe-from-an-image"), subtitle: i18n.t("recipe.create-recipe-from-an-image"),
to: `/g/${groupSlug.value}/r/create/image`, to: `/g/${groupSlug.value}/r/create/image`,
restricted: true, restricted: true,
hide: !showImageImport.value, hide: !showImageImport.value,
@ -199,81 +240,85 @@ export default defineComponent({
{ {
insertDivider: true, insertDivider: true,
icon: $globals.icons.edit, icon: $globals.icons.edit,
title: i18n.tc("general.create"), title: i18n.t("general.create"),
subtitle: i18n.tc("new-recipe.create-manually"), subtitle: i18n.t("new-recipe.create-manually"),
to: `/g/${groupSlug.value}/r/create/new`, to: `/g/${groupSlug.value}/r/create/new`,
restricted: true, restricted: true,
hide: false, hide: false,
}, },
]); ]);
const bottomLinks = computed<SideBarLink[]>(() => [ const bottomLinks = computed<SideBarLink[]>(() =>
isAdmin.value
? [
{ {
icon: $globals.icons.cog, icon: $globals.icons.cog,
title: i18n.tc("general.settings"), title: i18n.t("general.settings"),
to: "/admin/site-settings", to: "/admin/site-settings",
restricted: true, restricted: true,
}, },
]); ]
: [],
);
const topLinks = computed<SideBarLink[]>(() => [ const topLinks = computed<SideBarLink[]>(() => [
{ {
icon: $globals.icons.silverwareForkKnife, icon: $globals.icons.silverwareForkKnife,
to: `/g/${groupSlug.value}`, to: `/g/${groupSlug.value}`,
title: i18n.tc("general.recipes"), title: i18n.t("general.recipes"),
restricted: false, restricted: false,
}, },
{ {
icon: $globals.icons.search, icon: $globals.icons.search,
to: `/g/${groupSlug.value}/recipes/finder`, to: `/g/${groupSlug.value}/recipes/finder`,
title: i18n.tc("recipe-finder.recipe-finder"), title: i18n.t("recipe-finder.recipe-finder"),
restricted: false, restricted: false,
}, },
{ {
icon: $globals.icons.calendarMultiselect, icon: $globals.icons.calendarMultiselect,
title: i18n.tc("meal-plan.meal-planner"), title: i18n.t("meal-plan.meal-planner"),
to: "/household/mealplan/planner/view", to: "/household/mealplan/planner/view",
restricted: true, restricted: true,
}, },
{ {
icon: $globals.icons.formatListCheck, icon: $globals.icons.formatListCheck,
title: i18n.tc("shopping-list.shopping-lists"), title: i18n.t("shopping-list.shopping-lists"),
to: "/shopping-lists", to: "/shopping-lists",
restricted: true, restricted: true,
}, },
{ {
icon: $globals.icons.timelineText, icon: $globals.icons.timelineText,
title: i18n.tc("recipe.timeline"), title: i18n.t("recipe.timeline"),
to: `/g/${groupSlug.value}/recipes/timeline`, to: `/g/${groupSlug.value}/recipes/timeline`,
restricted: true, restricted: true,
}, },
{ {
icon: $globals.icons.book, icon: $globals.icons.book,
to: `/g/${groupSlug.value}/cookbooks`, to: `/g/${groupSlug.value}/cookbooks`,
title: i18n.tc("cookbook.cookbooks"), title: i18n.t("cookbook.cookbooks"),
restricted: true, restricted: true,
}, },
{ {
icon: $globals.icons.organizers, icon: $globals.icons.organizers,
title: i18n.tc("general.organizers"), title: i18n.t("general.organizers"),
restricted: true, restricted: true,
children: [ children: [
{ {
icon: $globals.icons.categories, icon: $globals.icons.categories,
to: `/g/${groupSlug.value}/recipes/categories`, to: `/g/${groupSlug.value}/recipes/categories`,
title: i18n.tc("sidebar.categories"), title: i18n.t("sidebar.categories"),
restricted: true, restricted: true,
}, },
{ {
icon: $globals.icons.tags, icon: $globals.icons.tags,
to: `/g/${groupSlug.value}/recipes/tags`, to: `/g/${groupSlug.value}/recipes/tags`,
title: i18n.tc("sidebar.tags"), title: i18n.t("sidebar.tags"),
restricted: true, restricted: true,
}, },
{ {
icon: $globals.icons.potSteam, icon: $globals.icons.potSteam,
to: `/g/${groupSlug.value}/recipes/tools`, to: `/g/${groupSlug.value}/recipes/tools`,
title: i18n.tc("tool.tools"), title: i18n.t("tool.tools"),
restricted: true, restricted: true,
}, },
], ],
@ -286,7 +331,6 @@ export default defineComponent({
createLinks, createLinks,
bottomLinks, bottomLinks,
topLinks, topLinks,
isAdmin,
isOwnGroup, isOwnGroup,
languageDialog, languageDialog,
toggleDark, toggleDark,

View file

@ -1,8 +1,25 @@
<template> <template>
<v-footer color="primary" padless app> <v-footer
<v-row justify="center" align="center" dense no-gutters> color="primary"
<v-col class="py-2 text-center white--text" cols="12"> padless
<v-btn color="white" icon href="https://github.com/hay-kot/mealie" target="_blank"> app
>
<v-row
justify="center"
align="center"
dense
no-gutters
>
<v-col
class="py-2 text-center white--text"
cols="12"
>
<v-btn
color="white"
icon
href="https://github.com/hay-kot/mealie"
target="_blank"
>
<v-icon> <v-icon>
{{ $globals.icons.github }} {{ $globals.icons.github }}
</v-icon> </v-icon>
@ -14,9 +31,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api"; export default defineNuxtComponent({
export default defineComponent({
setup() { setup() {
return {}; return {};
}, },

View file

@ -1,46 +1,83 @@
<template> <template>
<v-app-bar clipped-left dense app color="primary" dark class="d-print-none"> <v-app-bar
clipped-left
density="compact"
app
color="primary"
dark
class="d-print-none"
>
<slot /> <slot />
<router-link :to="routerLink"> <router-link :to="routerLink">
<v-btn icon> <v-btn
icon
color="white"
>
<v-icon size="40"> {{ $globals.icons.primary }} </v-icon> <v-icon size="40"> {{ $globals.icons.primary }} </v-icon>
</v-btn> </v-btn>
</router-link> </router-link>
<div btn class="pl-2"> <div
<v-toolbar-title style="cursor: pointer" @click="$router.push(routerLink)"> Mealie </v-toolbar-title> btn
class="pl-2"
>
<v-toolbar-title
style="cursor: pointer"
@click="$router.push(routerLink)"
>
Mealie
</v-toolbar-title>
</div> </div>
<RecipeDialogSearch ref="domSearchDialog" /> <RecipeDialogSearch ref="domSearchDialog" />
<v-spacer></v-spacer> <v-spacer />
<!-- Navigation Menu --> <!-- Navigation Menu -->
<template v-if="menu"> <template v-if="menu">
<div v-if="!$vuetify.breakpoint.xs" style="max-width: 500px" @click="activateSearch"> <v-responsive
v-if="!xs"
max-width="250"
@click="activateSearch"
>
<v-text-field <v-text-field
readonly readonly
class="mt-6 rounded-xl" class="mt-1"
rounded rounded
dark variant="solo-filled"
solo density="compact"
dense
flat flat
:prepend-inner-icon="$globals.icons.search" :prepend-inner-icon="$globals.icons.search"
background-color="primary darken-1" bg-color="primary-darken-1"
color="white"
:placeholder="$t('search.search-hint')" :placeholder="$t('search.search-hint')"
/>
</v-responsive>
<v-btn
v-else
icon
@click="activateSearch"
> >
</v-text-field>
</div>
<v-btn v-else icon @click="activateSearch">
<v-icon> {{ $globals.icons.search }}</v-icon> <v-icon> {{ $globals.icons.search }}</v-icon>
</v-btn> </v-btn>
<v-btn v-if="loggedIn" :text="$vuetify.breakpoint.smAndUp" :icon="$vuetify.breakpoint.xs" @click="logout()"> <v-btn
<v-icon :left="$vuetify.breakpoint.smAndUp">{{ $globals.icons.logout }}</v-icon> v-if="loggedIn"
{{ $vuetify.breakpoint.smAndUp ? $t("user.logout") : "" }} :variant="smAndUp ? 'text' : undefined"
:icon="xs"
@click="logout()"
>
<v-icon :start="smAndUp">
{{ $globals.icons.logout }}
</v-icon>
{{ smAndUp ? $t("user.logout") : "" }}
</v-btn> </v-btn>
<v-btn v-else text nuxt to="/login"> <v-btn
<v-icon left>{{ $globals.icons.user }}</v-icon> v-else
variant="text"
nuxt
to="/login"
>
<v-icon start>
{{ $globals.icons.user }}
</v-icon>
{{ $t("user.login") }} {{ $t("user.login") }}
</v-btn> </v-btn>
</template> </template>
@ -48,11 +85,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, useContext, useRoute, useRouter } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue"; import RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue";
export default defineComponent({ export default defineNuxtComponent({
components: { RecipeDialogSearch }, components: { RecipeDialogSearch },
props: { props: {
menu: { menu: {
@ -61,11 +97,11 @@ export default defineComponent({
}, },
}, },
setup() { setup() {
const { $auth } = useContext(); const $auth = useMealieAuth();
const { loggedIn } = useLoggedInState(); const { loggedIn } = useLoggedInState();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const { xs, smAndUp } = useDisplay();
const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/"); const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/");
const domSearchDialog = ref<InstanceType<typeof RecipeDialogSearch> | null>(null); const domSearchDialog = ref<InstanceType<typeof RecipeDialogSearch> | null>(null);
@ -91,7 +127,12 @@ export default defineComponent({
}); });
async function logout() { async function logout() {
await $auth.logout().then(() => router.push("/login?direct=1")) try {
await $auth.signOut({ callbackUrl: "/login?direct=1" });
}
catch (e) {
console.error(e);
}
} }
return { return {
@ -100,7 +141,14 @@ export default defineComponent({
routerLink, routerLink,
loggedIn, loggedIn,
logout, logout,
xs, smAndUp,
}; };
}, },
}); });
</script> </script>
<style scoped>
.v-toolbar {
z-index: 1010 !important;
}
</style>

View file

@ -1,66 +1,52 @@
<template> <template>
<v-navigation-drawer v-model="drawer" class="d-flex flex-column d-print-none" clipped app width="240px"> <v-navigation-drawer v-model="showDrawer" class="d-flex flex-column d-print-none position-fixed">
<!-- User Profile --> <!-- User Profile -->
<template v-if="loggedIn"> <template v-if="loggedIn">
<v-list-item two-line :to="userProfileLink" exact> <v-list-item lines="two" :to="userProfileLink" exact>
<UserAvatar list :user-id="$auth.user.id" :tooltip="false" /> <div class="d-flex align-center ga-2">
<UserAvatar list :user-id="sessionUser.id" :tooltip="false" />
<v-list-item-content> <div class="d-flex flex-column justify-start">
<v-list-item-title class="pr-2"> {{ $auth.user.fullName }}</v-list-item-title> <v-list-item-title class="pr-2 pl-1">
<v-list-item-subtitle> {{ sessionUser.fullName }}
<v-btn v-if="isOwnGroup" class="px-2 pa-0" text :to="userFavoritesLink" small> </v-list-item-title>
<v-icon left small> <v-list-item-subtitle class="opacity-100">
<v-btn v-if="isOwnGroup" class="px-2 pa-0" variant="text" :to="userFavoritesLink" size="small">
<v-icon start size="small">
{{ $globals.icons.heart }} {{ $globals.icons.heart }}
</v-icon> </v-icon>
{{ $t("user.favorite-recipes") }} {{ $t("user.favorite-recipes") }}
</v-btn> </v-btn>
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item-content> </div>
</div>
</v-list-item> </v-list-item>
<v-divider></v-divider> <v-divider />
</template> </template>
<slot></slot> <slot />
<!-- Primary Links --> <!-- Primary Links -->
<template v-if="topLink"> <template v-if="topLink">
<v-list nav dense> <v-list v-model:selected="secondarySelected" nav density="comfortable" color="primary">
<template v-for="nav in topLink"> <template v-for="nav in topLink">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title"> <div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
<!-- Multi Items --> <!-- Multi Items -->
<v-list-group <v-list-group v-if="nav.children" :key="(nav.key || nav.title) + 'multi-item'"
v-if="nav.children" v-model="dropDowns[nav.title]" color="primary" :prepend-icon="nav.icon" :fluid="true">
:key="(nav.key || nav.title) + 'multi-item'" <template #activator="{ props }">
v-model="dropDowns[nav.title]" <v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
color="primary"
:prepend-icon="nav.icon"
>
<template #activator>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</template> </template>
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to" class="ml-2"> <v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to"
<v-list-item-icon> :prepend-icon="child.icon" :title="child.title" class="ml-4" />
<v-icon>{{ child.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ child.title }}</v-list-item-title>
</v-list-item>
</v-list-group> </v-list-group>
<!-- Single Item --> <!-- Single Item -->
<v-list-item-group <template v-else>
v-else <v-list-item :key="(nav.key || nav.title) + 'single-item'" exact link :to="nav.to"
:key="(nav.key || nav.title) + 'single-item'" :prepend-icon="nav.icon" :title="nav.title" />
v-model="secondarySelected" </template>
color="primary"
>
<v-list-item exact link :to="nav.to">
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</v-list-item-group>
</div> </div>
</template> </template>
</v-list> </v-list>
@ -68,39 +54,28 @@
<!-- Secondary Links --> <!-- Secondary Links -->
<template v-if="secondaryLinks.length > 0"> <template v-if="secondaryLinks.length > 0">
<v-divider class="mt-2"></v-divider> <v-divider class="mt-2" />
<v-list nav dense exact> <v-list v-model:selected="secondarySelected" nav density="compact" exact>
<template v-for="nav in secondaryLinks"> <template v-for="nav in secondaryLinks">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title"> <div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
<!-- Multi Items --> <!-- Multi Items -->
<v-list-group <v-list-group v-if="nav.children" :key="(nav.key || nav.title) + 'multi-item'"
v-if="nav.children" v-model="dropDowns[nav.title]" color="primary" :prepend-icon="nav.icon" fluid>
:key="(nav.key || nav.title) + 'multi-item'" <template #activator="{ props }">
v-model="dropDowns[nav.title]" <v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
color="primary"
:prepend-icon="nav.icon"
>
<template #activator>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</template> </template>
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to" class="ml-2"> <v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to"
<v-list-item-icon> class="ml-2" :prepend-icon="child.icon" :title="child.title" />
<v-icon>{{ child.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ child.title }}</v-list-item-title>
</v-list-item>
</v-list-group> </v-list-group>
<!-- Single Item --> <!-- Single Item -->
<v-list-item-group v-else :key="(nav.key || nav.title) + 'single-item'" v-model="secondarySelected" color="primary"> <v-list-item v-else :key="(nav.key || nav.title) + 'single-item'" exact link :to="nav.to">
<v-list-item exact link :to="nav.to"> <template #prepend>
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon> <v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon> </template>
<v-list-item-title>{{ nav.title }}</v-list-item-title> <v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item> </v-list-item>
</v-list-item-group>
</div> </div>
</template> </template>
</v-list> </v-list>
@ -108,46 +83,39 @@
<!-- Bottom Navigation Links --> <!-- Bottom Navigation Links -->
<template v-if="bottomLinks" #append> <template v-if="bottomLinks" #append>
<v-list nav dense> <v-list v-model:selected="bottomSelected" nav density="compact">
<v-list-item-group v-model="bottomSelected" color="primary">
<template v-for="nav in bottomLinks"> <template v-for="nav in bottomLinks">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title"> <div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
<v-list-item <v-list-item :key="nav.key || nav.title" exact link :to="nav.to" :href="nav.href"
:key="nav.key || nav.title" :target="nav.href ? '_blank' : null">
exact <template #prepend>
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-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon> </template>
<v-list-item-title>{{ nav.title }}</v-list-item-title> <v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item> </v-list-item>
</div> </div>
</template> </template>
</v-list-item-group> <slot name="bottom" />
<slot name="bottom"></slot>
</v-list> </v-list>
</template> </template>
</v-navigation-drawer> </v-navigation-drawer>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, toRefs, useContext, watch } from "@nuxtjs/composition-api"; import { useWindowSize } from "@vueuse/core";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { SidebarLinks } from "~/types/application-types"; import type { SidebarLinks } from "~/types/application-types";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue"; import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
export default defineComponent({ export default defineNuxtComponent({
components: { components: {
UserAvatar, UserAvatar,
}, },
props: { props: {
value: { modelValue: {
type: Boolean, type: Boolean,
default: null, required: false,
default: false,
}, },
user: { user: {
type: Object, type: Object,
@ -165,31 +133,16 @@ export default defineComponent({
bottomLinks: { bottomLinks: {
type: Array as () => SidebarLinks, type: Array as () => SidebarLinks,
required: false, required: false,
default: null, default: () => ([]),
}, },
}, },
emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
// V-Model Support const $auth = useMealieAuth();
const drawer = computed({
get: () => {
return props.value;
},
set: (val) => {
if (window.innerWidth < 760 && state.hasOpenedBefore === false) {
state.hasOpenedBefore = true;
val = false;
context.emit("input", val);
} else {
context.emit("input", val);
}
},
});
const { $auth } = useContext();
const { loggedIn, isOwnGroup } = useLoggedInState(); const { loggedIn, isOwnGroup } = useLoggedInState();
const userFavoritesLink = computed(() => $auth.user ? `/user/${$auth.user.id}/favorites` : undefined); const userFavoritesLink = computed(() => $auth.user.value ? `/user/${$auth.user.value.id}/favorites` : undefined);
const userProfileLink = computed(() => $auth.user ? "/user/profile" : undefined); const userProfileLink = computed(() => $auth.user.value ? "/user/profile" : undefined);
const state = reactive({ const state = reactive({
dropDowns: {} as Record<string, boolean>, dropDowns: {} as Record<string, boolean>,
@ -198,12 +151,31 @@ export default defineComponent({
bottomSelected: null as string[] | null, bottomSelected: null as string[] | null,
hasOpenedBefore: false as boolean, hasOpenedBefore: false as boolean,
}); });
// model to control the drawer
const showDrawer = computed({
get: () => props.modelValue,
set: value => context.emit("update:modelValue", value),
});
watch(showDrawer, () => {
if (window.innerWidth < 760 && state.hasOpenedBefore === false) {
state.hasOpenedBefore = true;
}
});
const { width: wWidth } = useWindowSize();
watch(wWidth, (w) => {
if (w > 760) {
showDrawer.value = true;
}
else {
showDrawer.value = false;
}
});
const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || []), ...(props.bottomLinks || [])]); const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || []), ...(props.bottomLinks || [])]);
function initDropdowns() { function initDropdowns() {
allLinks.value.forEach((link) => { allLinks.value.forEach((link) => {
state.dropDowns[link.title] = link.childrenStartExpanded || false; state.dropDowns[link.title] = link.childrenStartExpanded || false;
}) });
} }
watch( watch(
() => allLinks, () => allLinks,
@ -212,22 +184,23 @@ export default defineComponent({
}, },
{ {
deep: true, deep: true,
} },
); );
return { return {
...toRefs(state), ...toRefs(state),
userFavoritesLink, userFavoritesLink,
userProfileLink, userProfileLink,
drawer, showDrawer,
loggedIn, loggedIn,
isOwnGroup, isOwnGroup,
sessionUser: $auth.user,
}; };
}, },
}); });
</script> </script>
<style> <style scoped>
@media print { @media print {
.no-print { .no-print {
display: none; display: none;

View file

@ -1,57 +1,75 @@
<template> <template>
<div class="text-center"> <div class="text-center">
<v-snackbar v-model="toastAlert.open" top :color="toastAlert.color" timeout="2000" @input="toastAlert.open = false"> <v-snackbar
<v-icon dark left> v-model="toastAlert.open"
{{ icon }} location="top"
</v-icon> :color="toastAlert.color"
timeout="2000"
>
<v-icon
v-if="icon"
dark
start
:icon="icon"
/>
{{ toastAlert.title }} {{ toastAlert.title }}
{{ toastAlert.text }} {{ toastAlert.text }}
<template #action="{ attrs }"> <template #actions>
<v-btn text v-bind="attrs" @click="toastAlert.open = false"> {{ $t('general.close') }} </v-btn> <v-btn
variant="text"
@click="toastAlert.open = false"
>
{{ $t('general.close') }}
</v-btn>
</template> </template>
</v-snackbar> </v-snackbar>
<v-snackbar <v-snackbar
v-model="toastLoading.open"
content-class="py-2" content-class="py-2"
dense density="compact"
bottom location="bottom"
right
:value="toastLoading.open"
:timeout="-1" :timeout="-1"
:color="toastLoading.color" :color="toastLoading.color"
@input="toastLoading.open = false"
> >
<div class="d-flex flex-column align-center justify-start" @click="toastLoading.open = false"> <div
class="d-flex flex-column align-center justify-start"
@click="toastLoading.open = false"
>
<div class="mb-2 mt-0 text-subtitle-1 text-center"> <div class="mb-2 mt-0 text-subtitle-1 text-center">
{{ toastLoading.text }} {{ toastLoading.text }}
</div> </div>
<v-progress-linear indeterminate color="white darken-2"></v-progress-linear> <v-progress-linear
indeterminate
color="white-darken-2"
/>
</div> </div>
</v-snackbar> </v-snackbar>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api"; import { useNuxtApp } from "#app";
import { toastAlert, toastLoading } from "~/composables/use-toast"; import { toastAlert, toastLoading } from "~/composables/use-toast";
export default defineComponent({ export default {
setup() { setup() {
const { $globals } = useNuxtApp();
const icon = computed(() => { const icon = computed(() => {
switch (toastAlert.color) { switch (toastAlert.color) {
case "error": case "error":
return "mdi-alert"; return $globals.icons.alertOutline;
case "success": case "success":
return "mdi-check-bold"; return $globals.icons.checkBold;
case "info": case "info":
return "mdi-information-outline"; return $globals.icons.informationOutline;
default: default:
return "mdi-alert"; return $globals.icons.alertOutline;
} }
}); });
return { icon, toastAlert, toastLoading }; return { icon, toastAlert, toastLoading };
}, },
}); };
</script> </script>

View file

@ -1,19 +1,17 @@
<template> <template>
<div scoped-slot></div> <div scoped-slot />
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, useContext } from "@nuxtjs/composition-api";
/** /**
* Renderless component that only renders if the user is logged in. * Renderless component that only renders if the user is logged in.
* and has advanced options toggled. * and has advanced options toggled.
*/ */
export default defineComponent({ export default defineNuxtComponent({
setup(_, ctx) { setup(_, ctx) {
const { $auth } = useContext(); const $auth = useMealieAuth();
const r = $auth?.user?.advanced || false; const r = $auth.user.value?.advanced || false;
return () => { return () => {
return r ? ctx.slots.default?.() : null; return r ? ctx.slots.default?.() : null;

View file

@ -2,32 +2,33 @@
<v-tooltip <v-tooltip
ref="copyToolTip" ref="copyToolTip"
v-model="show" v-model="show"
:color="copied? 'success lighten-1' : 'red lighten-1'" :color="copied? 'success-lighten-1' : 'red-lighten-1'"
top top
:open-on-hover="false" :open-on-hover="false"
:open-on-click="true" :open-on-click="true"
close-delay="500" close-delay="500"
transition="slide-y-transition" transition="slide-y-transition"
> >
<template #activator="{ on }"> <template #activator="{ props }">
<v-btn <v-btn
variant="flat"
:icon="icon" :icon="icon"
:color="color" :color="color"
retain-focus-on-click retain-focus-on-click
:class="btnClass" :class="btnClass"
:disabled="copyText !== '' ? false : true" :disabled="copyText !== '' ? false : true"
@click=" v-bind="props"
on.click; @click="textToClipboard()"
textToClipboard();
"
@blur="on.blur"
> >
<v-icon>{{ $globals.icons.contentCopy }}</v-icon> <v-icon>{{ $globals.icons.contentCopy }}</v-icon>
{{ icon ? "" : $t("general.copy") }} {{ icon ? "" : $t("general.copy") }}
</v-btn> </v-btn>
</template> </template>
<span> <span>
<v-icon left dark> <v-icon
start
dark
>
{{ $globals.icons.clipboardCheck }} {{ $globals.icons.clipboardCheck }}
</v-icon> </v-icon>
<slot v-if="!isSupported"> {{ $t("general.your-browser-does-not-support-clipboard") }} </slot> <slot v-if="!isSupported"> {{ $t("general.your-browser-does-not-support-clipboard") }} </slot>
@ -37,11 +38,9 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { useClipboard } from "@vueuse/core" import { useClipboard } from "@vueuse/core";
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { VTooltip } from "~/types/vuetify";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
copyText: { copyText: {
type: String, type: String,
@ -61,7 +60,7 @@ export default defineComponent({
}, },
}, },
setup(props) { setup(props) {
const { copy, copied, isSupported } = useClipboard() const { copy, copied, isSupported } = useClipboard();
const show = ref(false); const show = ref(false);
const copyToolTip = ref<VTooltip | null>(null); const copyToolTip = ref<VTooltip | null>(null);
@ -73,7 +72,7 @@ export default defineComponent({
if (isSupported.value) { if (isSupported.value) {
await copy(props.copyText); await copy(props.copyText);
if (copied.value) { if (copied.value) {
console.log(`Copied\n${props.copyText}`) console.log(`Copied\n${props.copyText}`);
} }
else { else {
console.warn("Copy failed: ", copied.value); console.warn("Copy failed: ", copied.value);

View file

@ -1,9 +1,24 @@
<template> <template>
<v-form ref="file"> <v-form ref="file">
<input ref="uploader" class="d-none" type="file" :accept="accept" @change="onFileChanged" /> <input
ref="uploader"
class="d-none"
type="file"
:accept="accept"
@change="onFileChanged"
>
<slot v-bind="{ isSelecting, onButtonClick }"> <slot v-bind="{ isSelecting, onButtonClick }">
<v-btn :loading="isSelecting" :small="small" :color="color" :text="textBtn" :disabled="disabled" @click="onButtonClick"> <v-btn
<v-icon left> {{ effIcon }}</v-icon> :loading="isSelecting"
:small="small"
:color="color"
:variant="textBtn ? 'text' : 'elevated'"
:disabled="disabled"
@click="onButtonClick"
>
<v-icon start>
{{ effIcon }}
</v-icon>
{{ text ? text : defaultText }} {{ text ? text : defaultText }}
</v-btn> </v-btn>
</slot> </slot>
@ -11,12 +26,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
const UPLOAD_EVENT = "uploaded"; const UPLOAD_EVENT = "uploaded";
export default defineComponent({ export default defineNuxtComponent({
props: { props: {
small: { small: {
type: Boolean, type: Boolean,
@ -57,14 +71,15 @@ export default defineComponent({
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false, default: false,
} },
}, },
setup(props, context) { setup(props, context) {
const file = ref<File | null>(null); const file = ref<File | null>(null);
const uploader = ref<HTMLInputElement | null>(null); const uploader = ref<HTMLInputElement | null>(null);
const isSelecting = ref(false); const isSelecting = ref(false);
const { i18n, $globals } = useContext(); const i18n = useI18n();
const { $globals } = useNuxtApp();
const effIcon = props.icon ? props.icon : $globals.icons.upload; const effIcon = props.icon ? props.icon : $globals.icons.upload;
const defaultText = i18n.t("general.upload"); const defaultText = i18n.t("general.upload");
@ -82,12 +97,16 @@ export default defineComponent({
const formData = new FormData(); const formData = new FormData();
formData.append(props.fileName, file.value); formData.append(props.fileName, file.value);
try {
const response = await api.upload.file(props.url, formData); const response = await api.upload.file(props.url, formData);
if (response) { if (response) {
context.emit(UPLOAD_EVENT, response); context.emit(UPLOAD_EVENT, response);
} }
}
catch (e) {
console.error(e);
context.emit(UPLOAD_EVENT, null);
}
isSelecting.value = false; isSelecting.value = false;
} }
} }
@ -107,7 +126,7 @@ export default defineComponent({
() => { () => {
isSelecting.value = false; isSelecting.value = false;
}, },
{ once: true } { once: true },
); );
uploader.value?.click(); uploader.value?.click();
} }

View file

@ -1,19 +1,36 @@
<template> <template>
<div class="mx-auto my-3 justify-center" style="display: flex;"> <div
class="mx-auto my-3 justify-center"
style="display: flex;"
>
<div style="display: inline;"> <div style="display: inline;">
<v-progress-circular :width="size.width" :size="size.size" color="primary lighten-2" indeterminate> <v-progress-circular
:width="size.width"
:size="size.size"
color="primary-lighten-2"
indeterminate
>
<div class="text-center"> <div class="text-center">
<v-icon :size="size.icon" color="primary lighten-2"> <v-icon
:size="size.icon"
color="primary-lighten-2"
>
{{ $globals.icons.primary }} {{ $globals.icons.primary }}
</v-icon> </v-icon>
<div v-if="large" class="text-small"> <div
v-if="large"
class="text-small"
>
<slot> <slot>
{{ (small || tiny) ? "" : waitingText }} {{ (small || tiny) ? "" : waitingText }}
</slot> </slot>
</div> </div>
</div> </div>
</v-progress-circular> </v-progress-circular>
<div v-if="!large" class="text-small"> <div
v-if="!large"
class="text-small"
>
<slot> <slot>
{{ (small || tiny) ? "" : waitingTextCalculated }} {{ (small || tiny) ? "" : waitingTextCalculated }}
</slot> </slot>
@ -23,9 +40,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api"; export default defineNuxtComponent({
export default defineComponent({
props: { props: {
loading: { loading: {
type: Boolean, type: Boolean,
@ -50,7 +65,7 @@ export default defineComponent({
waitingText: { waitingText: {
type: String, type: String,
default: undefined, default: undefined,
} },
}, },
setup(props) { setup(props) {
const size = computed(() => { const size = computed(() => {
@ -67,7 +82,8 @@ export default defineComponent({
icon: 30, icon: 30,
size: 50, size: 50,
}; };
} else if (props.large) { }
else if (props.large) {
return { return {
width: 4, width: 4,
icon: 120, icon: 120,
@ -81,7 +97,7 @@ export default defineComponent({
}; };
}); });
const { i18n } = useContext(); const i18n = useI18n();
const waitingTextCalculated = props.waitingText == null ? i18n.t("general.loading-recipes") : props.waitingText; const waitingTextCalculated = props.waitingText == null ? i18n.t("general.loading-recipes") : props.waitingText;
return { return {

Some files were not shown because too many files have changed in this diff Show more