feat: Migrate to Nuxt 3 framework (#5184)
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Docker Nightly Production / Backend Server Tests (push) Waiting to run
Docker Nightly Production / Frontend Tests (push) Waiting to run
Docker Nightly Production / Build Package (push) Waiting to run
Docker Nightly Production / Build Tagged Release (push) Blocked by required conditions
Docker Nightly Production / Notify Discord (push) Blocked by required conditions
Release Drafter / ✏️ Draft release (push) Waiting to run

Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Hoa (Kyle) Trinh 2025-06-20 00:09:12 +07:00 committed by GitHub
parent 89ab7fac25
commit c24d532608
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
403 changed files with 23959 additions and 19557 deletions

View file

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

View file

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

View file

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

View file

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

5
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,19 +13,18 @@
</template>
<script lang="ts">
import { defineComponent, toRef, useContext } from "@nuxtjs/composition-api";
import { usePasswordStrength } from "~/composables/use-passwords";
export default defineComponent({
export default defineNuxtComponent({
props: {
value: {
modelValue: {
type: String,
default: "",
},
},
setup(props) {
const asRef = toRef(props, "value");
const { i18n } = useContext();
const asRef = toRef(props, "modelValue");
const i18n = useI18n();
const pwStrength = usePasswordStrength(asRef, i18n);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,17 +1,25 @@
<template>
<v-toolbar color="transparent" flat>
<BaseButton color="null" rounded secondary @click="$router.go(-1)">
<template #icon> {{ $globals.icons.arrowLeftBold }}</template>
<v-toolbar
color="transparent"
flat
>
<BaseButton
color="null"
rounded
secondary
@click="$router.go(-1)"
>
<template #icon>
{{ $globals.icons.arrowLeftBold }}
</template>
{{ $t('general.back') }}
</BaseButton>
<slot></slot>
<slot />
</v-toolbar>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
export default defineNuxtComponent({
props: {
back: {
type: Boolean,

View file

@ -1,49 +1,64 @@
<template>
<v-card :color="color" :dark="dark" flat :width="width" class="my-2">
<v-card
:color="color"
:dark="dark"
flat
:width="width"
class="my-2"
>
<v-row>
<v-col v-for="(inputField, index) in items" :key="index" class="py-0" cols="12" sm="12">
<v-divider v-if="inputField.section" class="my-2" />
<v-card-title v-if="inputField.section" class="pl-0">
<v-col
v-for="(inputField, index) in items"
:key="index"
class="py-0"
cols="12"
sm="12"
>
<v-divider
v-if="inputField.section"
class="my-2"
/>
<v-card-title
v-if="inputField.section"
class="pl-0"
>
{{ inputField.section }}
</v-card-title>
<v-card-text v-if="inputField.sectionDetails" class="pl-0 mt-0 pt-0">
<v-card-text
v-if="inputField.sectionDetails"
class="pl-0 mt-0 pt-0"
>
{{ inputField.sectionDetails }}
</v-card-text>
<!-- Check Box -->
<v-checkbox
v-if="inputField.type === fieldTypes.BOOLEAN"
v-model="value[inputField.varName]"
class="my-0 py-0"
v-model="modelValue[inputField.varName]"
:name="inputField.varName"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
@change="emitBlur"
>
:hint="inputField.hint"
hide-details="auto"
density="comfortable"
@change="emitBlur">
<template #label>
<div>
<v-card-text class="text-body-1 my-0 py-0">
{{ inputField.label }}
</v-card-text>
<v-card-text v-if="inputField.hint" class="text-caption my-0 py-0">
{{ inputField.hint }}
</v-card-text>
</div>
</template>
</v-checkbox>
<span class="ml-4">
{{ inputField.label }}
</span>
</template>
</v-checkbox>
<!-- Text Field -->
<v-text-field
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
v-model="value[inputField.varName]"
v-model="modelValue[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
filled
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
rounded
class="rounded-lg"
variant="solo-filled"
flat
:autofocus="index === 0"
dense
density="comfortable"
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
@ -55,15 +70,15 @@
<!-- Text Area -->
<v-textarea
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
v-model="value[inputField.varName]"
v-model="modelValue[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
filled
rounded
variant="solo-filled"
flat
class="rounded-lg"
rows="3"
auto-grow
dense
density="comfortable"
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
@ -75,42 +90,53 @@
<!-- Option Select -->
<v-select
v-else-if="inputField.type === fieldTypes.SELECT"
v-model="value[inputField.varName]"
v-model="modelValue[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
filled
rounded
variant="solo-filled"
flat
class="rounded-lg"
:prepend-icon="inputField.icons ? value[inputField.varName] : null"
:prepend-icon="inputField.icons ? modelValue[inputField.varName] : null"
:label="inputField.label"
:name="inputField.varName"
:items="inputField.options"
:item-text="inputField.itemText"
:item-title="inputField.itemText"
:item-value="inputField.itemValue"
:return-object="false"
:hint="inputField.hint"
density="comfortable"
persistent-hint
lazy-validation
@blur="emitBlur"
>
<template #item="{ item }">
<v-list-item-content>
<v-list-item-title>{{ item.text }}</v-list-item-title>
<v-list-item-subtitle>{{ item.description }}</v-list-item-subtitle>
</v-list-item-content>
<div>
<v-list-item-title>{{ item.raw.text }}</v-list-item-title>
<v-list-item-subtitle>{{ item.raw.description }}</v-list-item-subtitle>
</div>
</template>
</v-select>
<!-- Color Picker -->
<div v-else-if="inputField.type === fieldTypes.COLOR" class="d-flex" style="width: 100%">
<div
v-else-if="inputField.type === fieldTypes.COLOR"
class="d-flex"
style="width: 100%"
>
<v-menu offset-y>
<template #activator="{ on }">
<v-btn class="my-2 ml-auto" style="min-width: 200px" :color="value[inputField.varName]" dark v-on="on">
<template #activator="{ props: templateProps }">
<v-btn
class="my-2 ml-auto"
style="min-width: 200px"
:color="modelValue[inputField.varName]"
dark
v-bind="templateProps"
>
{{ inputField.label }}
</v-btn>
</template>
<v-color-picker
v-model="value[inputField.varName]"
v-model="modelValue[inputField.varName]"
value="#7417BE"
hide-canvas
hide-inputs
@ -122,21 +148,34 @@
</div>
<div v-else-if="inputField.type === fieldTypes.OBJECT">
<auto-form v-model="value[inputField.varName]" :color="color" :items="inputField.items" @blur="emitBlur" />
<auto-form
v-model="modelValue[inputField.varName]"
:color="color"
:items="inputField.items"
@blur="emitBlur"
/>
</div>
<!-- List Type -->
<div v-else-if="inputField.type === fieldTypes.LIST">
<div v-for="(item, idx) in value[inputField.varName]" :key="idx">
<div
v-for="(item, idx) in modelValue[inputField.varName]"
:key="idx"
>
<p>
{{ inputField.label }} {{ idx + 1 }}
<span>
<BaseButton class="ml-5" x-small delete @click="removeByIndex(value[inputField.varName], idx)" />
<BaseButton
class="ml-5"
x-small
delete
@click="removeByIndex(modelValue[inputField.varName], idx)"
/>
</span>
</p>
<v-divider class="mb-5 mx-2" />
<auto-form
v-model="value[inputField.varName][idx]"
v-model="modelValue[inputField.varName][idx]"
:color="color"
:items="inputField.items"
@blur="emitBlur"
@ -144,7 +183,10 @@
</div>
<v-card-actions>
<v-spacer />
<BaseButton small @click="value[inputField.varName].push(getTemplate(inputField.items))">
<BaseButton
small
@click="modelValue[inputField.varName].push(getTemplate(inputField.items))"
>
{{ $t("general.new") }}
</BaseButton>
</v-card-actions>
@ -154,111 +196,96 @@
</v-card>
</template>
<script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
<script lang="ts" setup>
import { validators } from "@/composables/use-validators";
import { fieldTypes } from "@/composables/forms";
import { AutoFormItems } from "~/types/auto-forms";
import type { AutoFormItems } from "~/types/auto-forms";
const BLUR_EVENT = "blur";
type ValidatorKey = keyof typeof validators;
export default defineComponent({
name: "AutoForm",
props: {
value: {
default: null,
type: [Object, Array],
},
updateMode: {
default: false,
type: Boolean,
},
items: {
default: null,
type: Array as () => AutoFormItems,
},
width: {
type: [Number, String],
default: "max",
},
globalRules: {
default: null,
type: Array as () => string[],
},
color: {
default: null,
type: String,
},
dark: {
default: false,
type: Boolean,
},
disabledFields: {
default: null,
type: Array as () => string[],
},
readonlyFields: {
default: null,
type: Array as () => string[],
},
// Use defineModel for v-model
const modelValue = defineModel<[object, Array<any>]>();
const props = defineProps({
updateMode: {
default: false,
type: Boolean,
},
setup(props, context) {
function rulesByKey(keys?: ValidatorKey[] | null) {
if (keys === undefined || keys === null) {
return [];
}
const list = [] as ((v: string) => boolean | string)[];
keys.forEach((key) => {
const split = key.split(":");
const validatorKey = split[0] as ValidatorKey;
if (validatorKey in validators) {
if (split.length === 1) {
// @ts-ignore- validators[validatorKey] is a function
list.push(validators[validatorKey]);
} else {
// @ts-ignore - validators[validatorKey] is a function
list.push(validators[validatorKey](split[1]));
}
}
});
return list;
}
const defaultRules = computed(() => rulesByKey(props.globalRules as ValidatorKey[]));
function removeByIndex(list: never[], index: number) {
// Removes the item at the index
list.splice(index, 1);
}
function getTemplate(item: AutoFormItems) {
const obj = {} as { [key: string]: string };
item.forEach((field) => {
obj[field.varName] = "";
});
return obj;
}
function emitBlur() {
context.emit(BLUR_EVENT, props.value);
}
return {
rulesByKey,
defaultRules,
removeByIndex,
getTemplate,
emitBlur,
fieldTypes,
validators,
};
items: {
default: null,
type: Array as () => AutoFormItems,
},
width: {
type: [Number, String],
default: "max",
},
globalRules: {
default: null,
type: Array as () => string[],
},
color: {
default: null,
type: String,
},
dark: {
default: false,
type: Boolean,
},
disabledFields: {
default: null,
type: Array as () => string[],
},
readonlyFields: {
default: null,
type: Array as () => string[],
},
});
const emit = defineEmits(["blur", "update:modelValue"]);
function rulesByKey(keys?: ValidatorKey[] | null) {
if (keys === undefined || keys === null) {
return [];
}
const list = [] as ((v: string) => boolean | string)[];
keys.forEach((key) => {
const split = key.split(":");
const validatorKey = split[0] as ValidatorKey;
if (validatorKey in validators) {
if (split.length === 1) {
list.push(validators[validatorKey]);
}
else {
list.push(validators[validatorKey](split[1]));
}
}
});
return list;
}
const defaultRules = computed(() => rulesByKey(props.globalRules as ValidatorKey[]));
function removeByIndex(list: never[], index: number) {
// Removes the item at the index
list.splice(index, 1);
}
function getTemplate(item: AutoFormItems) {
const obj = {} as { [key: string]: string };
item.forEach((field) => {
obj[field.varName] = "";
});
return obj;
}
function emitBlur() {
emit(BLUR_EVENT, modelValue.value);
}
</script>
<style lang="scss" scoped></style>

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