mirror of
https://github.com/hay-kot/mealie.git
synced 2025-08-23 14:55:24 -07:00
Merge branch 'mealie_messages_image' of https://github.com/amishabagri/mealie into mealie_messages_image
This commit is contained in:
commit
43856709a8
174 changed files with 3837 additions and 1591 deletions
|
@ -1,8 +1,8 @@
|
|||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.224.2/containers/python-3/.devcontainer/base.Dockerfile
|
||||
|
||||
# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster
|
||||
ARG VARIANT="3.10-bullseye"
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT}
|
||||
ARG VARIANT="3.12-bullseye"
|
||||
FROM mcr.microsoft.com/devcontainers/python:${VARIANT}
|
||||
|
||||
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
|
||||
ARG NODE_VERSION="none"
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
// Update 'VARIANT' to pick a Python version: 3, 3.10, 3.9, 3.8, 3.7, 3.6
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||
"VARIANT": "3.10-bullseye",
|
||||
"VARIANT": "3.12-bullseye",
|
||||
// Options
|
||||
"NODE_VERSION": "16"
|
||||
}
|
||||
|
|
2
.github/workflows/partial-backend.yml
vendored
2
.github/workflows/partial-backend.yml
vendored
|
@ -47,7 +47,7 @@ jobs:
|
|||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
|
|
2
.github/workflows/scheduled-checks.yml
vendored
2
.github/workflows/scheduled-checks.yml
vendored
|
@ -18,7 +18,7 @@ jobs:
|
|||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set PY
|
||||
shell: bash
|
||||
|
|
|
@ -12,7 +12,7 @@ repos:
|
|||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.7.3
|
||||
rev: v0.8.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
|
|
@ -151,7 +151,7 @@ tasks:
|
|||
py:migrate:
|
||||
desc: generates a new database migration file e.g. task py:migrate -- "add new column"
|
||||
cmds:
|
||||
- poetry run alembic revision --autogenerate -m "{{ .CLI_ARGS }}"
|
||||
- poetry run alembic --config mealie/alembic/alembic.ini revision --autogenerate -m "{{ .CLI_ARGS }}"
|
||||
- task: py:format
|
||||
|
||||
ui:build:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Template
|
||||
|
@ -64,7 +65,112 @@ def generate_global_components_types() -> None:
|
|||
# Pydantic To Typescript Generator
|
||||
|
||||
|
||||
def generate_typescript_types() -> None:
|
||||
def generate_typescript_types() -> None: # noqa: C901
|
||||
def contains_number(s: str) -> bool:
|
||||
return bool(re.search(r"\d", s))
|
||||
|
||||
def remove_numbers(s: str) -> str:
|
||||
return re.sub(r"\d", "", s)
|
||||
|
||||
def extract_type_name(line: str) -> str:
|
||||
# Looking for "export type EnumName = enumVal1 | enumVal2 | ..."
|
||||
if not (line.startswith("export type") and "=" in line):
|
||||
return ""
|
||||
|
||||
return line.split(" ")[2]
|
||||
|
||||
def extract_property_type_name(line: str) -> str:
|
||||
# Looking for " fieldName: FieldType;" or " fieldName: FieldType & string;"
|
||||
if not (line.startswith(" ") and ":" in line):
|
||||
return ""
|
||||
|
||||
return line.split(":")[1].strip().split(";")[0]
|
||||
|
||||
def extract_interface_name(line: str) -> str:
|
||||
# Looking for "export interface InterfaceName {"
|
||||
if not (line.startswith("export interface") and "{" in line):
|
||||
return ""
|
||||
|
||||
return line.split(" ")[2]
|
||||
|
||||
def is_comment_line(line: str) -> bool:
|
||||
s = line.strip()
|
||||
return s.startswith("/*") or s.startswith("*")
|
||||
|
||||
def clean_output_file(file: Path) -> None:
|
||||
"""
|
||||
json2ts generates duplicate types off of our enums and appends a number to the end of the type name.
|
||||
Our Python code (hopefully) doesn't have any duplicate enum names, or types with numbers in them,
|
||||
so we can safely remove the numbers.
|
||||
|
||||
To do this, we read the output line-by-line and replace any type names that contain numbers with
|
||||
the same type name, but without the numbers.
|
||||
|
||||
Note: the issue arrises from the JSON package json2ts, not the Python package pydantic2ts,
|
||||
otherwise we could just fix pydantic2ts.
|
||||
"""
|
||||
|
||||
# First pass: build a map of type names to their numberless counterparts and lines to skip
|
||||
replacement_map = {}
|
||||
lines_to_skip = set()
|
||||
wait_for_semicolon = False
|
||||
wait_for_close_bracket = False
|
||||
skip_comments = False
|
||||
with open(file) as f:
|
||||
for i, line in enumerate(f.readlines()):
|
||||
if wait_for_semicolon:
|
||||
if ";" in line:
|
||||
wait_for_semicolon = False
|
||||
lines_to_skip.add(i)
|
||||
continue
|
||||
if wait_for_close_bracket:
|
||||
if "}" in line:
|
||||
wait_for_close_bracket = False
|
||||
lines_to_skip.add(i)
|
||||
continue
|
||||
|
||||
if type_name := extract_type_name(line):
|
||||
if not contains_number(type_name):
|
||||
continue
|
||||
|
||||
replacement_map[type_name] = remove_numbers(type_name)
|
||||
if ";" not in line:
|
||||
wait_for_semicolon = True
|
||||
lines_to_skip.add(i)
|
||||
|
||||
elif type_name := extract_interface_name(line):
|
||||
if not contains_number(type_name):
|
||||
continue
|
||||
|
||||
replacement_map[type_name] = remove_numbers(type_name)
|
||||
if "}" not in line:
|
||||
wait_for_close_bracket = True
|
||||
lines_to_skip.add(i)
|
||||
|
||||
elif skip_comments and is_comment_line(line):
|
||||
lines_to_skip.add(i)
|
||||
|
||||
# we've passed the opening comments and empty line at the header
|
||||
elif not skip_comments and not line.strip():
|
||||
skip_comments = True
|
||||
|
||||
# Second pass: rewrite or remove lines as needed.
|
||||
# We have to do two passes here because definitions don't always appear in the same order as their usage.
|
||||
lines = []
|
||||
with open(file) as f:
|
||||
for i, line in enumerate(f.readlines()):
|
||||
if i in lines_to_skip:
|
||||
continue
|
||||
|
||||
if type_name := extract_property_type_name(line):
|
||||
if type_name in replacement_map:
|
||||
line = line.replace(type_name, replacement_map[type_name])
|
||||
|
||||
lines.append(line)
|
||||
|
||||
with open(file, "w") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
def path_to_module(path: Path):
|
||||
str_path: str = str(path)
|
||||
|
||||
|
@ -98,9 +204,10 @@ def generate_typescript_types() -> None:
|
|||
try:
|
||||
path_as_module = path_to_module(module)
|
||||
generate_typescript_defs(path_as_module, str(out_path), exclude=("MealieModel")) # type: ignore
|
||||
except Exception as e:
|
||||
clean_output_file(out_path)
|
||||
except Exception:
|
||||
failed_modules.append(module)
|
||||
log.error(f"Module Error: {e}")
|
||||
log.exception(f"Module Error: {module}")
|
||||
|
||||
log.debug("\n📁 Skipped Directories:")
|
||||
for skipped_dir in skipped_dirs:
|
||||
|
|
|
@ -17,7 +17,7 @@ RUN yarn generate
|
|||
###############################################
|
||||
# Base Image - Python
|
||||
###############################################
|
||||
FROM python:3.10-slim as python-base
|
||||
FROM python:3.12-slim as python-base
|
||||
|
||||
ENV MEALIE_HOME="/app"
|
||||
|
||||
|
@ -109,10 +109,6 @@ COPY --from=crfpp /usr/local/bin/crf_test /usr/local/bin/crf_test
|
|||
COPY ./mealie $MEALIE_HOME/mealie
|
||||
COPY ./poetry.lock ./pyproject.toml $MEALIE_HOME/
|
||||
|
||||
# Alembic
|
||||
COPY ./alembic $MEALIE_HOME/alembic
|
||||
COPY ./alembic.ini $MEALIE_HOME/
|
||||
|
||||
# venv already has runtime deps installed we get a quicker install
|
||||
WORKDIR $MEALIE_HOME
|
||||
RUN . $VENV_PATH/bin/activate && poetry install -E pgsql --only main
|
||||
|
|
|
@ -32,7 +32,7 @@ Make sure the VSCode Dev Containers extension is installed, then select "Dev Con
|
|||
|
||||
### Prerequisites
|
||||
|
||||
- [Python 3.10](https://www.python.org/downloads/)
|
||||
- [Python 3.12](https://www.python.org/downloads/)
|
||||
- [Poetry](https://python-poetry.org/docs/#installation)
|
||||
- [Node v16.x](https://nodejs.org/en/)
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)
|
||||
|
|
|
@ -24,7 +24,7 @@ Make sure the url and port (`http://mealie:9000` ) matches your installation's a
|
|||
|
||||
```yaml
|
||||
rest:
|
||||
- resource: "http://mealie:9000/api/groups/mealplans/today"
|
||||
- resource: "http://mealie:9000/api/households/mealplans/today"
|
||||
method: GET
|
||||
headers:
|
||||
Authorization: Bearer <<API_TOKEN>>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many Identity Providers (IdP), including:
|
||||
|
||||
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
||||
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/)
|
||||
- [Authelia](https://www.authelia.com/integration/openid-connect/mealie/)
|
||||
- [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc)
|
||||
- [Okta](https://www.okta.com/openid-connect/)
|
||||
|
||||
|
|
|
@ -79,7 +79,7 @@ Mealie's Recipe Steps and other fields support markdown syntax and therefore sup
|
|||
If your account has been locked by bad password attempts, you can use an administrator account to unlock another account. Alternatively, you can unlock all accounts via a script within the container.
|
||||
|
||||
```shell
|
||||
docker exec -it mealie-next bash
|
||||
docker exec -it mealie bash
|
||||
|
||||
python /app/mealie/scripts/reset_locked_users.py
|
||||
```
|
||||
|
@ -89,7 +89,7 @@ python /app/mealie/scripts/reset_locked_users.py
|
|||
You can change your password by going to the user profile page and clicking the "Change Password" button. Alternatively you can use the following script to change your password via the CLI if you are locked out of your account.
|
||||
|
||||
```shell
|
||||
docker exec -it mealie-next bash
|
||||
docker exec -it mealie bash
|
||||
|
||||
python /app/mealie/scripts/change_password.py
|
||||
```
|
||||
|
|
|
@ -95,7 +95,7 @@ Use this only when mealie is run without a webserver or reverse proxy.
|
|||
For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
|
||||
|
||||
| Variables | Default | Description |
|
||||
| ------------------------------------------------- | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
|---------------------------------------------------|:-------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| OIDC_AUTH_ENABLED | False | Enables authentication via OpenID Connect |
|
||||
| OIDC_SIGNUP_ENABLED | True | Enables new users to be created when signing in for the first time with OIDC |
|
||||
| OIDC_CONFIGURATION_URL | None | The URL to the OIDC configuration of your provider. This is usually something like https://auth.example.com/.well-known/openid-configuration |
|
||||
|
@ -107,6 +107,7 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
|
|||
| OIDC_PROVIDER_NAME | OAuth | The provider name is shown in SSO login button. "Login with <OIDC_PROVIDER_NAME\>" |
|
||||
| OIDC_REMEMBER_ME | False | Because redirects bypass the login screen, you cant extend your session by clicking the "Remember Me" checkbox. By setting this value to true, a session will be extended as if "Remember Me" was checked |
|
||||
| OIDC_USER_CLAIM | email | This is the claim which Mealie will use to look up an existing user by (e.g. "email", "preferred_username") |
|
||||
| OIDC_NAME_CLAIM | name | This is the claim which Mealie will use for the users Full Name |
|
||||
| OIDC_GROUPS_CLAIM | groups | Optional if not using `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. This is the claim Mealie will request from your IdP and will use to compare to `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP` to allow the user to log in to Mealie or is set as an admin. **Your IdP must be configured to grant this claim** |
|
||||
| OIDC_SCOPES_OVERRIDE | None | Advanced configuration used to override the scopes requested from the IdP. **Most users won't need to change this**. At a minimum, 'openid profile email' are required. |
|
||||
| OIDC_TLS_CACERTFILE | None | File path to Certificate Authority used to verify server certificate (e.g. `/path/to/ca.crt`) |
|
||||
|
|
|
@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
|||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||
|
||||
1. Take a backup just in case!
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.2.0`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.3.0`
|
||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||
4. Restart the container
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
|||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.2.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.3.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
@ -24,8 +24,6 @@ services:
|
|||
PUID: 1000
|
||||
PGID: 1000
|
||||
TZ: America/Anchorage
|
||||
MAX_WORKERS: 1
|
||||
WEB_CONCURRENCY: 1
|
||||
BASE_URL: https://mealie.yourdomain.com
|
||||
# Database Settings
|
||||
DB_ENGINE: postgres
|
||||
|
|
|
@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
|||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.2.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.3.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
@ -28,8 +28,6 @@ services:
|
|||
PUID: 1000
|
||||
PGID: 1000
|
||||
TZ: America/Anchorage
|
||||
MAX_WORKERS: 1
|
||||
WEB_CONCURRENCY: 1
|
||||
BASE_URL: https://mealie.yourdomain.com
|
||||
|
||||
volumes:
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -48,3 +48,11 @@
|
|||
.v-card__title {
|
||||
word-break: normal !important;
|
||||
}
|
||||
|
||||
.text-hide-overflow {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
|
|
@ -253,7 +253,7 @@
|
|||
</v-container>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-container fluid class="d-flex justify-end pa-0">
|
||||
<v-container fluid class="d-flex justify-end pa-0 mx-2">
|
||||
<v-checkbox
|
||||
v-model="showAdvanced"
|
||||
hide-details
|
||||
|
@ -431,6 +431,7 @@ export default defineComponent({
|
|||
state.qfValid = !!qf;
|
||||
|
||||
context.emit("input", qf || undefined);
|
||||
context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
|
||||
},
|
||||
{
|
||||
deep: true
|
||||
|
@ -543,6 +544,32 @@ export default defineComponent({
|
|||
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
|
||||
}
|
||||
|
||||
function buildQueryFilterJSON(): QueryFilterJSON {
|
||||
const parts = fields.value.map((field) => {
|
||||
const part: QueryFilterJSONPart = {
|
||||
attributeName: field.name,
|
||||
leftParenthesis: field.leftParenthesis,
|
||||
rightParenthesis: field.rightParenthesis,
|
||||
logicalOperator: field.logicalOperator?.value,
|
||||
relationalOperator: field.relationalOperatorValue?.value,
|
||||
};
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
part.value = field.values.map((value) => value.toString());
|
||||
} else if (field.type === "boolean") {
|
||||
part.value = field.value ? "true" : "false";
|
||||
} else {
|
||||
part.value = (field.value || "").toString();
|
||||
}
|
||||
|
||||
return part;
|
||||
});
|
||||
|
||||
const qfJSON = { parts } as QueryFilterJSON;
|
||||
console.debug(`Built query filter JSON: ${JSON.stringify(qfJSON)}`);
|
||||
return qfJSON;
|
||||
}
|
||||
|
||||
|
||||
const attrs = computed(() => {
|
||||
const baseColMaxWidth = 55;
|
||||
|
|
|
@ -51,8 +51,6 @@
|
|||
<v-text-field
|
||||
v-model="newMealdate"
|
||||
:label="$t('general.date')"
|
||||
:hint="$t('recipe.date-format-hint')"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="attrs"
|
||||
readonly
|
||||
|
|
|
@ -63,6 +63,8 @@ interface ShowHeaders {
|
|||
tags: boolean;
|
||||
categories: boolean;
|
||||
tools: boolean;
|
||||
recipeServings: boolean;
|
||||
recipeYieldQuantity: boolean;
|
||||
recipeYield: boolean;
|
||||
dateAdded: boolean;
|
||||
}
|
||||
|
@ -93,6 +95,8 @@ export default defineComponent({
|
|||
owner: false,
|
||||
tags: true,
|
||||
categories: true,
|
||||
recipeServings: true,
|
||||
recipeYieldQuantity: true,
|
||||
recipeYield: true,
|
||||
dateAdded: true,
|
||||
};
|
||||
|
@ -127,8 +131,14 @@ export default defineComponent({
|
|||
if (props.showHeaders.tools) {
|
||||
hdrs.push({ text: i18n.t("tool.tools"), value: "tools" });
|
||||
}
|
||||
if (props.showHeaders.recipeServings) {
|
||||
hdrs.push({ text: i18n.t("recipe.servings"), value: "recipeServings" });
|
||||
}
|
||||
if (props.showHeaders.recipeYieldQuantity) {
|
||||
hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYieldQuantity" });
|
||||
}
|
||||
if (props.showHeaders.recipeYield) {
|
||||
hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYield" });
|
||||
hdrs.push({ text: i18n.t("recipe.yield-text"), value: "recipeYield" });
|
||||
}
|
||||
if (props.showHeaders.dateAdded) {
|
||||
hdrs.push({ text: i18n.t("general.date-added"), value: "dateAdded" });
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
<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>
|
||||
<v-card-text>
|
||||
<v-card
|
||||
v-for="list in shoppingListChoices"
|
||||
|
|
|
@ -86,12 +86,6 @@
|
|||
</BaseDialog>
|
||||
</div>
|
||||
<div>
|
||||
<div class="d-flex justify-center flex-wrap">
|
||||
<BaseButton :small="$vuetify.breakpoint.smAndDown" @click="madeThisDialog = true">
|
||||
<template #icon> {{ $globals.icons.chefHat }} </template>
|
||||
{{ $t('recipe.made-this') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="d-flex justify-center flex-wrap">
|
||||
<v-chip
|
||||
label
|
||||
|
@ -105,6 +99,12 @@
|
|||
{{ $t('recipe.last-made-date', { date: value ? new Date(value).toLocaleDateString($i18n.locale) : $t("general.never") } ) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="d-flex justify-center flex-wrap mt-1">
|
||||
<BaseButton :small="$vuetify.breakpoint.smAndDown" @click="madeThisDialog = true">
|
||||
<template #icon> {{ $globals.icons.chefHat }} </template>
|
||||
{{ $t('recipe.made-this') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -125,7 +125,7 @@ export default defineComponent({
|
|||
},
|
||||
recipe: {
|
||||
type: Object as () => Recipe,
|
||||
default: null,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
|
|
|
@ -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-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
|
||||
<RecipePageHeader
|
||||
:recipe="recipe"
|
||||
:recipe-scale="scale"
|
||||
|
@ -21,10 +21,10 @@
|
|||
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" />
|
||||
<RecipePageTitleContent :recipe="recipe" :landscape="landscape" />
|
||||
<RecipePageIngredientEditor v-if="isEditForm" :recipe="recipe" />
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape" />
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
|
||||
|
||||
<!--
|
||||
This section contains the 2 column layout for the recipe steps and other content.
|
||||
|
@ -76,7 +76,7 @@
|
|||
<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" :landscape="landscape" />
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
|
||||
</div>
|
||||
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" :is-cook-mode="isCookMode" />
|
||||
<v-divider></v-divider>
|
||||
|
@ -95,7 +95,7 @@
|
|||
</v-sheet>
|
||||
<v-sheet v-show="isCookMode && hasLinkedIngredients">
|
||||
<div class="mt-2 px-2 px-md-4">
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape"/>
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale"/>
|
||||
</div>
|
||||
<RecipePageInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
|
@ -154,7 +154,7 @@ import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredien
|
|||
import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue";
|
||||
import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
|
||||
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
|
||||
import RecipePageTitleContent from "./RecipePageParts/RecipePageTitleContent.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";
|
||||
|
@ -185,7 +185,7 @@ export default defineComponent({
|
|||
RecipePageHeader,
|
||||
RecipePrintContainer,
|
||||
RecipePageComments,
|
||||
RecipePageTitleContent,
|
||||
RecipePageInfoEditor,
|
||||
RecipePageEditorToolbar,
|
||||
RecipePageIngredientEditor,
|
||||
RecipePageOrganizers,
|
||||
|
@ -195,7 +195,7 @@ export default defineComponent({
|
|||
RecipeNotes,
|
||||
RecipePageInstructions,
|
||||
RecipePageFooter,
|
||||
RecipeIngredients
|
||||
RecipeIngredients,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
|
|
|
@ -1,46 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-end flex-wrap align-stretch">
|
||||
<v-card v-if="!landscape" width="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">
|
||||
{{ recipe.name }}
|
||||
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
|
||||
</v-card-title>
|
||||
<v-divider class="my-2"></v-divider>
|
||||
<SafeMarkdown :source="recipe.description" />
|
||||
<v-divider></v-divider>
|
||||
<div v-if="isOwnGroup" class="d-flex justify-center mt-5">
|
||||
<RecipeLastMade
|
||||
v-model="recipe.lastMade"
|
||||
:recipe="recipe"
|
||||
class="d-flex justify-center flex-wrap"
|
||||
:class="true ? undefined : 'force-bottom'"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex justify-center mt-5">
|
||||
<RecipeTimeCard
|
||||
class="d-flex justify-center flex-wrap"
|
||||
:class="true ? undefined : 'force-bottom'"
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-img
|
||||
:key="imageKey"
|
||||
:max-width="landscape ? null : '50%'"
|
||||
min-height="50"
|
||||
:height="hideImage ? undefined : imageHeight"
|
||||
:src="recipeImageUrl"
|
||||
class="d-print-none"
|
||||
@error="hideImage = true"
|
||||
>
|
||||
</v-img>
|
||||
</div>
|
||||
<v-divider></v-divider>
|
||||
<RecipePageInfoCard :recipe="recipe" :recipe-scale="recipeScale" :landscape="landscape" />
|
||||
<v-divider />
|
||||
<RecipeActionMenu
|
||||
:recipe="recipe"
|
||||
:slug="recipe.slug"
|
||||
|
@ -65,10 +26,8 @@
|
|||
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useRecipePermissions } from "~/composables/recipes";
|
||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
||||
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
|
||||
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
|
@ -76,10 +35,8 @@ import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
|||
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeTimeCard,
|
||||
RecipePageInfoCard,
|
||||
RecipeActionMenu,
|
||||
RecipeRating,
|
||||
RecipeLastMade,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-end flex-wrap align-stretch">
|
||||
<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">
|
||||
{{ recipe.name }}
|
||||
<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" />
|
||||
<v-divider />
|
||||
<v-container class="d-flex flex-row flex-wrap justify-center align-center">
|
||||
<div class="mx-5">
|
||||
<v-row no-gutters class="mb-1">
|
||||
<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"
|
||||
:scale="recipeScale"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12" class="d-flex flex-wrap justify-center">
|
||||
<RecipeLastMade
|
||||
v-if="isOwnGroup"
|
||||
:value="recipe.lastMade"
|
||||
:recipe="recipe"
|
||||
:class="true ? undefined : 'force-bottom'"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
<div class="mx-5">
|
||||
<RecipeTimeCard
|
||||
stacked
|
||||
container-class="d-flex flex-wrap justify-center"
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
/>
|
||||
</div>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<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({
|
||||
components: {
|
||||
RecipeRating,
|
||||
RecipeLastMade,
|
||||
RecipeTimeCard,
|
||||
RecipeYield,
|
||||
RecipePageInfoCardImage,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
recipeScale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
landscape: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { $vuetify } = useContext();
|
||||
const useMobile = computed(() => $vuetify.breakpoint.smAndDown);
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
return {
|
||||
isOwnGroup,
|
||||
useMobile,
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,69 @@
|
|||
<template>
|
||||
<v-img
|
||||
:key="imageKey"
|
||||
:max-width="maxWidth"
|
||||
min-height="50"
|
||||
:height="hideImage ? undefined : imageHeight"
|
||||
:src="recipeImageUrl"
|
||||
class="d-print-none"
|
||||
@error="hideImage = true"
|
||||
/>
|
||||
</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 { 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({
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
maxWidth: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { $vuetify } = useContext();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const { user } = usePageUser();
|
||||
|
||||
const recipeHousehold = ref<HouseholdSummary>();
|
||||
if (user) {
|
||||
const userApi = useUserApi();
|
||||
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
|
||||
recipeHousehold.value = data || undefined;
|
||||
});
|
||||
}
|
||||
|
||||
const hideImage = ref(false);
|
||||
const imageHeight = computed(() => {
|
||||
return $vuetify.breakpoint.xs ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
recipeImageUrl,
|
||||
imageKey,
|
||||
hideImage,
|
||||
imageHeight,
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,107 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-text-field
|
||||
v-model="recipe.name"
|
||||
class="my-3"
|
||||
:label="$t('recipe.recipe-name')"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<v-container class="ma-0 pa-0">
|
||||
<v-row>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
v-model="recipeServings"
|
||||
type="number"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
dense
|
||||
:label="$t('recipe.servings')"
|
||||
@input="validateInput($event, 'recipeServings')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
v-model="recipeYieldQuantity"
|
||||
type="number"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
dense
|
||||
:label="$t('recipe.yield')"
|
||||
@input="validateInput($event, 'recipeYieldQuantity')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="recipe.recipeYield"
|
||||
dense
|
||||
:label="$t('recipe.yield-text')"
|
||||
/>
|
||||
</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>
|
||||
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
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 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -5,50 +5,32 @@
|
|||
<RecipeScaleEditButton
|
||||
v-model.number="scaleValue"
|
||||
v-bind="attrs"
|
||||
:recipe-yield="recipe.recipeYield"
|
||||
:scaled-yield="scaledYield"
|
||||
:basic-yield-num="basicYieldNum"
|
||||
:recipe-servings="recipeServings"
|
||||
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
|
||||
v-on="on"
|
||||
/>
|
||||
</template>
|
||||
<span> {{ $t("recipe.edit-scale") }} </span>
|
||||
</v-tooltip>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<RecipeRating
|
||||
v-if="landscape && $vuetify.breakpoint.smAndUp"
|
||||
:key="recipe.slug"
|
||||
v-model="recipe.rating"
|
||||
:recipe-id="recipe.id"
|
||||
:slug="recipe.slug"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref } from "@nuxtjs/composition-api";
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
|
||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { useExtractRecipeYield, findMatch } from "~/composables/recipe-page/use-extract-recipe-yield";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeScaleEditButton,
|
||||
RecipeRating,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
landscape: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
|
@ -57,6 +39,10 @@ export default defineComponent({
|
|||
setup(props, { emit }) {
|
||||
const { isEditMode } = usePageState(props.recipe.slug);
|
||||
|
||||
const recipeServings = computed<number>(() => {
|
||||
return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
|
||||
});
|
||||
|
||||
const scaleValue = computed<number>({
|
||||
get() {
|
||||
return props.scale;
|
||||
|
@ -66,17 +52,9 @@ export default defineComponent({
|
|||
},
|
||||
});
|
||||
|
||||
const scaledYield = computed(() => {
|
||||
return useExtractRecipeYield(props.recipe.recipeYield, scaleValue.value);
|
||||
});
|
||||
|
||||
const match = findMatch(props.recipe.recipeYield);
|
||||
const basicYieldNum = ref<number |null>(match ? match[1] : null);
|
||||
|
||||
return {
|
||||
recipeServings,
|
||||
scaleValue,
|
||||
scaledYield,
|
||||
basicYieldNum,
|
||||
isEditMode,
|
||||
};
|
||||
},
|
||||
|
|
|
@ -1,92 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<template v-if="!isEditMode && landscape">
|
||||
<v-card-title class="px-0 py-2 ma-0 headline">
|
||||
{{ recipe.name }}
|
||||
</v-card-title>
|
||||
<SafeMarkdown :source="recipe.description" />
|
||||
<div v-if="isOwnGroup" class="pb-2 d-flex justify-center flex-wrap">
|
||||
<RecipeLastMade
|
||||
v-model="recipe.lastMade"
|
||||
:recipe="recipe"
|
||||
class="d-flex justify-center flex-wrap"
|
||||
:class="true ? undefined : 'force-bottom'"
|
||||
/>
|
||||
</div>
|
||||
<div class="pb-2 d-flex justify-center flex-wrap">
|
||||
<RecipeTimeCard
|
||||
class="d-flex justify-center flex-wrap"
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
/>
|
||||
<RecipeRating
|
||||
v-if="$vuetify.breakpoint.smAndDown"
|
||||
:key="recipe.slug"
|
||||
v-model="recipe.rating"
|
||||
:recipe-id="recipe.id"
|
||||
:slug="recipe.slug"
|
||||
/>
|
||||
</div>
|
||||
<v-divider></v-divider>
|
||||
</template>
|
||||
<template v-else-if="isEditMode">
|
||||
<v-text-field
|
||||
v-model="recipe.name"
|
||||
class="my-3"
|
||||
:label="$t('recipe.recipe-name')"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<v-text-field v-model="recipe.recipeYield" dense :label="$t('recipe.servings')" />
|
||||
<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>
|
||||
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeRating,
|
||||
RecipeTimeCard,
|
||||
RecipeLastMade,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
landscape: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { user } = usePageUser();
|
||||
const { imageKey, isEditMode } = usePageState(props.recipe.slug);
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
return {
|
||||
user,
|
||||
imageKey,
|
||||
validators,
|
||||
isEditMode,
|
||||
isOwnGroup,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -18,7 +18,24 @@
|
|||
</v-icon>
|
||||
{{ recipe.name }}
|
||||
</v-card-title>
|
||||
<RecipeTimeCard :prep-time="recipe.prepTime" :total-time="recipe.totalTime" :perform-time="recipe.performTime" color="white" />
|
||||
<div v-if="recipeYield" class="d-flex justify-space-between align-center px-4 pb-2">
|
||||
<v-chip
|
||||
:small="$vuetify.breakpoint.smAndDown"
|
||||
label
|
||||
>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="recipeYield"></span>
|
||||
</v-chip>
|
||||
</div>
|
||||
<RecipeTimeCard
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
color="white"
|
||||
/>
|
||||
<v-card-text v-if="preferences.showDescription" class="px-0">
|
||||
<SafeMarkdown :source="recipe.description" />
|
||||
</v-card-text>
|
||||
|
@ -30,9 +47,6 @@
|
|||
<!-- Ingredients -->
|
||||
<section>
|
||||
<v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title>
|
||||
<div class="font-italic px-0 py-0">
|
||||
<SafeMarkdown :source="recipe.recipeYield" />
|
||||
</div>
|
||||
<div
|
||||
v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
||||
:key="`ingredient-section-${sectionIndex}`"
|
||||
|
@ -111,7 +125,8 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
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";
|
||||
|
@ -119,6 +134,7 @@ import { 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 = {
|
||||
|
@ -151,13 +167,39 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n } = useContext();
|
||||
const preferences = useUserPrintPreferences();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const {labels} = useNutritionLabels();
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
|
||||
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 : "";
|
||||
})
|
||||
|
||||
const yieldDisplay = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
|
||||
return scaledAmountDisplay ? i18n.t("recipe.serves-amount", { amount: scaledAmountDisplay }) as string : "";
|
||||
});
|
||||
|
||||
const recipeYield = computed(() => {
|
||||
if (servingsDisplay.value && yieldDisplay.value) {
|
||||
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
|
||||
} else {
|
||||
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
|
||||
}
|
||||
})
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
|
@ -258,6 +300,7 @@ export default defineComponent({
|
|||
parseIngredientText,
|
||||
preferences,
|
||||
recipeImageUrl,
|
||||
recipeYield,
|
||||
ingredientSections,
|
||||
instructionSections,
|
||||
};
|
||||
|
|
|
@ -1,16 +1,13 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="yieldDisplay">
|
||||
<div class="text-center d-flex align-center">
|
||||
<div>
|
||||
<v-menu v-model="menu" :disabled="!editScale" offset-y top nudge-top="6" :close-on-content-click="false">
|
||||
<v-menu v-model="menu" :disabled="!canEditScale" offset-y top nudge-top="6" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-card class="pa-1 px-2" dark color="secondary darken-1" small v-bind="attrs" v-on="on">
|
||||
<span v-if="!recipeYield"> x {{ scale }} </span>
|
||||
<div v-else-if="!numberParsed && recipeYield">
|
||||
<span v-if="numerator === 1"> {{ recipeYield }} </span>
|
||||
<span v-else> {{ numerator }}x {{ scaledYield }} </span>
|
||||
</div>
|
||||
<span v-else> {{ scaledYield }} </span>
|
||||
<v-icon small class="mr-2">{{ $globals.icons.edit }}</v-icon>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="yieldDisplay"></span>
|
||||
|
||||
</v-card>
|
||||
</template>
|
||||
|
@ -20,7 +17,7 @@
|
|||
</v-card-title>
|
||||
<v-card-text class="mt-n5">
|
||||
<div class="mt-4 d-flex align-center">
|
||||
<v-text-field v-model="numerator" type="number" :min="0" hide-spin-buttons />
|
||||
<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">
|
||||
|
@ -37,7 +34,7 @@
|
|||
</v-menu>
|
||||
</div>
|
||||
<BaseButtonGroup
|
||||
v-if="editScale"
|
||||
v-if="canEditScale"
|
||||
class="pl-2"
|
||||
:large="false"
|
||||
:buttons="[
|
||||
|
@ -53,41 +50,36 @@
|
|||
event: 'increment',
|
||||
},
|
||||
]"
|
||||
@decrement="numerator--"
|
||||
@increment="numerator++"
|
||||
@decrement="recalculateScale(yieldQuantity - 1)"
|
||||
@increment="recalculateScale(yieldQuantity + 1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed, watch } from "@nuxtjs/composition-api";
|
||||
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
recipeYield: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
scaledYield: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
basicYieldNum: {
|
||||
value: {
|
||||
type: Number,
|
||||
default: null,
|
||||
required: true,
|
||||
},
|
||||
recipeServings: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
editScale: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const { i18n } = useContext();
|
||||
const menu = ref<boolean>(false);
|
||||
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
|
||||
|
||||
const scale = computed({
|
||||
get: () => props.value,
|
||||
|
@ -97,24 +89,54 @@ export default defineComponent({
|
|||
},
|
||||
});
|
||||
|
||||
const numerator = ref<number>(props.basicYieldNum != null ? parseFloat(props.basicYieldNum.toFixed(3)) : 1);
|
||||
const denominator = props.basicYieldNum != null ? parseFloat(props.basicYieldNum.toFixed(32)) : 1;
|
||||
const numberParsed = !!props.basicYieldNum;
|
||||
function recalculateScale(newYield: number) {
|
||||
if (isNaN(newYield) || newYield <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
watch(() => numerator.value, () => {
|
||||
scale.value = parseFloat((numerator.value / denominator).toFixed(32));
|
||||
if (props.recipeServings <= 0) {
|
||||
scale.value = 1;
|
||||
} else {
|
||||
scale.value = newYield / props.recipeServings;
|
||||
}
|
||||
}
|
||||
|
||||
const recipeYieldAmount = computed(() => {
|
||||
return useScaledAmount(props.recipeServings, scale.value);
|
||||
});
|
||||
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
|
||||
const yieldDisplay = computed(() => {
|
||||
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
|
||||
const yieldQuantityEditorValue = ref(recipeYieldAmount.value.scaledAmount);
|
||||
watch(
|
||||
() => menu.value,
|
||||
() => {
|
||||
if (!menu.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
yieldQuantityEditorValue.value = recipeYieldAmount.value.scaledAmount;
|
||||
}
|
||||
)
|
||||
|
||||
const disableDecrement = computed(() => {
|
||||
return numerator.value <= 1;
|
||||
return recipeYieldAmount.value.scaledAmount <= 1;
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
menu,
|
||||
canEditScale,
|
||||
scale,
|
||||
numerator,
|
||||
recalculateScale,
|
||||
yieldDisplay,
|
||||
yieldQuantity,
|
||||
yieldQuantityEditorValue,
|
||||
disableDecrement,
|
||||
numberParsed,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
118
frontend/components/Domain/Recipe/RecipeSuggestion.vue
Normal file
118
frontend/components/Domain/Recipe/RecipeSuggestion.vue
Normal file
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<v-container class="elevation-3">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12">
|
||||
<RecipeCardMobile
|
||||
:name="recipe.name"
|
||||
:description="recipe.description"
|
||||
:slug="recipe.slug"
|
||||
:rating="recipe.rating"
|
||||
:image="recipe.image"
|
||||
:recipe-id="recipe.id"
|
||||
/>
|
||||
</v-col>
|
||||
<div v-for="(organizer, idx) in missingOrganizers" :key="idx">
|
||||
<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-card-text>
|
||||
<v-chip
|
||||
v-for="item in organizer.items"
|
||||
:key="item.item.id"
|
||||
label
|
||||
color="secondary custom-transparent"
|
||||
class="mr-2 my-1"
|
||||
>
|
||||
<v-checkbox dark :ripple="false" @click="handleCheckbox(item)">
|
||||
<template #label>
|
||||
{{ organizer.getLabel(item.item) }}
|
||||
</template>
|
||||
</v-checkbox>
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-col>
|
||||
</div>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</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";
|
||||
|
||||
interface Organizer {
|
||||
type: "food" | "tool";
|
||||
item: IngredientFood | RecipeTool;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: { RecipeCardMobile },
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => RecipeSummary,
|
||||
required: true,
|
||||
},
|
||||
missingFoods: {
|
||||
type: Array as () => IngredientFood[] | null,
|
||||
default: null,
|
||||
},
|
||||
missingTools: {
|
||||
type: Array as () => RecipeTool[] | null,
|
||||
default: null,
|
||||
},
|
||||
disableCheckbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { $globals } = useContext();
|
||||
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);
|
||||
}) : [],
|
||||
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);
|
||||
}) : [],
|
||||
getLabel: (item: RecipeTool) => item.name,
|
||||
}
|
||||
])
|
||||
|
||||
function handleCheckbox(organizer: Organizer) {
|
||||
if (props.disableCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
organizer.selected = !organizer.selected;
|
||||
if (organizer.selected) {
|
||||
context.emit(`add-${organizer.type}`, organizer.item);
|
||||
}
|
||||
else {
|
||||
context.emit(`remove-${organizer.type}`, organizer.item);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
missingOrganizers,
|
||||
handleCheckbox,
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -1,19 +1,41 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-chip
|
||||
v-for="(time, index) in allTimes"
|
||||
:key="index"
|
||||
:small="$vuetify.breakpoint.smAndDown"
|
||||
label
|
||||
:color="color"
|
||||
class="ma-1"
|
||||
>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.clockOutline }}
|
||||
</v-icon>
|
||||
{{ time.name }} |
|
||||
{{ time.value }}
|
||||
</v-chip>
|
||||
<div v-if="stacked">
|
||||
<v-container>
|
||||
<v-row v-for="(time, index) in allTimes" :key="`${index}-stacked`" no-gutters>
|
||||
<v-col cols="12" :class="containerClass">
|
||||
<v-chip
|
||||
:small="$vuetify.breakpoint.smAndDown"
|
||||
label
|
||||
:color="color"
|
||||
class="ma-1"
|
||||
>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.clockOutline }}
|
||||
</v-icon>
|
||||
{{ time.name }} |
|
||||
{{ time.value }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</div>
|
||||
<div v-else>
|
||||
<v-container :class="containerClass">
|
||||
<v-chip
|
||||
v-for="(time, index) in allTimes"
|
||||
:key="index"
|
||||
:small="$vuetify.breakpoint.smAndDown"
|
||||
label
|
||||
:color="color"
|
||||
class="ma-1"
|
||||
>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.clockOutline }}
|
||||
</v-icon>
|
||||
{{ time.name }} |
|
||||
{{ time.value }}
|
||||
</v-chip>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -22,6 +44,10 @@ import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
|||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
stacked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
prepTime: {
|
||||
type: String,
|
||||
default: null,
|
||||
|
@ -38,6 +64,10 @@ export default defineComponent({
|
|||
type: String,
|
||||
default: "accent custom-transparent"
|
||||
},
|
||||
containerClass: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n } = useContext();
|
||||
|
|
69
frontend/components/Domain/Recipe/RecipeYield.vue
Normal file
69
frontend/components/Domain/Recipe/RecipeYield.vue
Normal file
|
@ -0,0 +1,69 @@
|
|||
<template>
|
||||
<div v-if="displayText" class="d-flex justify-space-between align-center">
|
||||
<v-chip
|
||||
:small="$vuetify.breakpoint.smAndDown"
|
||||
label
|
||||
:color="color"
|
||||
>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="displayText"></span>
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
yieldQuantity: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
yield: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "accent custom-transparent"
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n } = useContext();
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (!(props.yieldQuantity || props.yield)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
|
||||
|
||||
return i18n.t("recipe.yields-amount-with-text", {
|
||||
amount: scaledAmountDisplay,
|
||||
text: sanitizeHTML(props.yield),
|
||||
}) as string;
|
||||
});
|
||||
|
||||
return {
|
||||
displayText,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -221,7 +221,13 @@ export default defineComponent({
|
|||
icon: $globals.icons.silverwareForkKnife,
|
||||
to: `/g/${groupSlug.value}`,
|
||||
title: i18n.tc("general.recipes"),
|
||||
restricted: true,
|
||||
restricted: false,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.search,
|
||||
to: `/g/${groupSlug.value}/recipes/finder`,
|
||||
title: i18n.tc("recipe-finder.recipe-finder"),
|
||||
restricted: false,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.calendarMultiselect,
|
||||
|
|
|
@ -45,11 +45,13 @@
|
|||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<slot name="custom-card-action"></slot>
|
||||
<BaseButton v-if="$listeners.delete" delete secondary @click="deleteEvent" />
|
||||
<BaseButton
|
||||
v-if="$listeners.confirm"
|
||||
:color="color"
|
||||
type="submit"
|
||||
:disabled="submitDisabled"
|
||||
@click="
|
||||
$emit('confirm');
|
||||
dialog = false;
|
||||
|
@ -60,8 +62,12 @@
|
|||
</template>
|
||||
{{ $t("general.confirm") }}
|
||||
</BaseButton>
|
||||
<slot name="custom-card-action"></slot>
|
||||
<BaseButton v-if="$listeners.submit" type="submit" :disabled="submitDisabled" @click="submitEvent">
|
||||
<BaseButton
|
||||
v-if="$listeners.submit"
|
||||
type="submit"
|
||||
:disabled="submitDisabled"
|
||||
@click="submitEvent"
|
||||
>
|
||||
{{ submitText }}
|
||||
<template v-if="submitIcon" #icon>
|
||||
{{ submitIcon }}
|
||||
|
|
|
@ -99,6 +99,8 @@ export interface TableHeaders {
|
|||
value: string;
|
||||
show: boolean;
|
||||
align?: string;
|
||||
sortable?: boolean;
|
||||
sort?: (a: any, b: any) => number;
|
||||
}
|
||||
|
||||
export interface BulkAction {
|
||||
|
|
|
@ -48,3 +48,20 @@ export default defineComponent({
|
|||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
::v-deep table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
::v-deep th, ::v-deep td {
|
||||
border: 1px solid;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
::v-deep th {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,111 +0,0 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { useExtractRecipeYield } from "./use-extract-recipe-yield";
|
||||
|
||||
describe("test use extract recipe yield", () => {
|
||||
test("when text empty return empty", () => {
|
||||
const result = useExtractRecipeYield(null, 1);
|
||||
expect(result).toStrictEqual("");
|
||||
});
|
||||
|
||||
test("when text matches nothing return text", () => {
|
||||
const val = "this won't match anything";
|
||||
const result = useExtractRecipeYield(val, 1);
|
||||
expect(result).toStrictEqual(val);
|
||||
|
||||
const resultScaled = useExtractRecipeYield(val, 5);
|
||||
expect(resultScaled).toStrictEqual(val);
|
||||
});
|
||||
|
||||
test("when text matches a mixed fraction, return a scaled fraction", () => {
|
||||
const val = "10 1/2 units";
|
||||
const result = useExtractRecipeYield(val, 1);
|
||||
expect(result).toStrictEqual(val);
|
||||
|
||||
const resultScaled = useExtractRecipeYield(val, 3);
|
||||
expect(resultScaled).toStrictEqual("31 1/2 units");
|
||||
|
||||
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
|
||||
expect(resultScaledPartial).toStrictEqual("26 1/4 units");
|
||||
|
||||
const resultScaledInt = useExtractRecipeYield(val, 4);
|
||||
expect(resultScaledInt).toStrictEqual("42 units");
|
||||
});
|
||||
|
||||
test("when text matches a fraction, return a scaled fraction", () => {
|
||||
const val = "1/3 plates";
|
||||
const result = useExtractRecipeYield(val, 1);
|
||||
expect(result).toStrictEqual(val);
|
||||
|
||||
const resultScaled = useExtractRecipeYield(val, 2);
|
||||
expect(resultScaled).toStrictEqual("2/3 plates");
|
||||
|
||||
const resultScaledInt = useExtractRecipeYield(val, 3);
|
||||
expect(resultScaledInt).toStrictEqual("1 plates");
|
||||
|
||||
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
|
||||
expect(resultScaledPartial).toStrictEqual("5/6 plates");
|
||||
|
||||
const resultScaledMixed = useExtractRecipeYield(val, 4);
|
||||
expect(resultScaledMixed).toStrictEqual("1 1/3 plates");
|
||||
});
|
||||
|
||||
test("when text matches a decimal, return a scaled, rounded decimal", () => {
|
||||
const val = "1.25 parts";
|
||||
const result = useExtractRecipeYield(val, 1);
|
||||
expect(result).toStrictEqual(val);
|
||||
|
||||
const resultScaled = useExtractRecipeYield(val, 2);
|
||||
expect(resultScaled).toStrictEqual("2.5 parts");
|
||||
|
||||
const resultScaledInt = useExtractRecipeYield(val, 4);
|
||||
expect(resultScaledInt).toStrictEqual("5 parts");
|
||||
|
||||
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
|
||||
expect(resultScaledPartial).toStrictEqual("3.125 parts");
|
||||
|
||||
const roundedVal = "1.33333333333333333333 parts";
|
||||
const resultScaledRounded = useExtractRecipeYield(roundedVal, 2);
|
||||
expect(resultScaledRounded).toStrictEqual("2.667 parts");
|
||||
});
|
||||
|
||||
test("when text matches an int, return a scaled int", () => {
|
||||
const val = "5 bowls";
|
||||
const result = useExtractRecipeYield(val, 1);
|
||||
expect(result).toStrictEqual(val);
|
||||
|
||||
const resultScaled = useExtractRecipeYield(val, 2);
|
||||
expect(resultScaled).toStrictEqual("10 bowls");
|
||||
|
||||
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
|
||||
expect(resultScaledPartial).toStrictEqual("12.5 bowls");
|
||||
|
||||
const resultScaledLarge = useExtractRecipeYield(val, 10);
|
||||
expect(resultScaledLarge).toStrictEqual("50 bowls");
|
||||
});
|
||||
|
||||
test("when text contains an invalid fraction, return the original string", () => {
|
||||
const valDivZero = "3/0 servings";
|
||||
const resultDivZero = useExtractRecipeYield(valDivZero, 3);
|
||||
expect(resultDivZero).toStrictEqual(valDivZero);
|
||||
|
||||
const valDivZeroMixed = "2 4/0 servings";
|
||||
const resultDivZeroMixed = useExtractRecipeYield(valDivZeroMixed, 6);
|
||||
expect(resultDivZeroMixed).toStrictEqual(valDivZeroMixed);
|
||||
});
|
||||
|
||||
test("when text contains a weird or small fraction, return the original string", () => {
|
||||
const valWeird = "2323231239087/134527431962272135 servings";
|
||||
const resultWeird = useExtractRecipeYield(valWeird, 5);
|
||||
expect(resultWeird).toStrictEqual(valWeird);
|
||||
|
||||
const valSmall = "1/20230225 lovable servings";
|
||||
const resultSmall = useExtractRecipeYield(valSmall, 12);
|
||||
expect(resultSmall).toStrictEqual(valSmall);
|
||||
});
|
||||
|
||||
test("when text contains multiple numbers, the first is parsed as the servings amount", () => {
|
||||
const val = "100 sets of 55 bowls";
|
||||
const result = useExtractRecipeYield(val, 3);
|
||||
expect(result).toStrictEqual("300 sets of 55 bowls");
|
||||
})
|
||||
});
|
|
@ -1,132 +0,0 @@
|
|||
import { useFraction } from "~/composables/recipes";
|
||||
|
||||
const matchMixedFraction = /(?:\d*\s\d*\d*|0)\/\d*\d*/;
|
||||
const matchFraction = /(?:\d*\d*|0)\/\d*\d*/;
|
||||
const matchDecimal = /(\d+.\d+)|(.\d+)/;
|
||||
const matchInt = /\d+/;
|
||||
|
||||
|
||||
|
||||
function extractServingsFromMixedFraction(fractionString: string): number | undefined {
|
||||
const mixedSplit = fractionString.split(/\s/);
|
||||
const wholeNumber = parseInt(mixedSplit[0]);
|
||||
const fraction = mixedSplit[1];
|
||||
|
||||
const fractionSplit = fraction.split("/");
|
||||
const numerator = parseInt(fractionSplit[0]);
|
||||
const denominator = parseInt(fractionSplit[1]);
|
||||
|
||||
if (denominator === 0) {
|
||||
return undefined; // if the denominator is zero, just give up
|
||||
}
|
||||
else {
|
||||
return wholeNumber + (numerator / denominator);
|
||||
}
|
||||
}
|
||||
|
||||
function extractServingsFromFraction(fractionString: string): number | undefined {
|
||||
const fractionSplit = fractionString.split("/");
|
||||
const numerator = parseInt(fractionSplit[0]);
|
||||
const denominator = parseInt(fractionSplit[1]);
|
||||
|
||||
if (denominator === 0) {
|
||||
return undefined; // if the denominator is zero, just give up
|
||||
}
|
||||
else {
|
||||
return numerator / denominator;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function findMatch(yieldString: string): [matchString: string, servings: number, isFraction: boolean] | null {
|
||||
if (!yieldString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mixedFractionMatch = yieldString.match(matchMixedFraction);
|
||||
if (mixedFractionMatch?.length) {
|
||||
const match = mixedFractionMatch[0];
|
||||
const servings = extractServingsFromMixedFraction(match);
|
||||
|
||||
// if the denominator is zero, return no match
|
||||
if (servings === undefined) {
|
||||
return null;
|
||||
} else {
|
||||
return [match, servings, true];
|
||||
}
|
||||
}
|
||||
|
||||
const fractionMatch = yieldString.match(matchFraction);
|
||||
if (fractionMatch?.length) {
|
||||
const match = fractionMatch[0]
|
||||
const servings = extractServingsFromFraction(match);
|
||||
|
||||
// if the denominator is zero, return no match
|
||||
if (servings === undefined) {
|
||||
return null;
|
||||
} else {
|
||||
return [match, servings, true];
|
||||
}
|
||||
}
|
||||
|
||||
const decimalMatch = yieldString.match(matchDecimal);
|
||||
if (decimalMatch?.length) {
|
||||
const match = decimalMatch[0];
|
||||
return [match, parseFloat(match), false];
|
||||
}
|
||||
|
||||
const intMatch = yieldString.match(matchInt);
|
||||
if (intMatch?.length) {
|
||||
const match = intMatch[0];
|
||||
return [match, parseInt(match), false];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatServings(servings: number, scale: number, isFraction: boolean): string {
|
||||
const val = servings * scale;
|
||||
if (Number.isInteger(val)) {
|
||||
return val.toString();
|
||||
} else if (!isFraction) {
|
||||
return (Math.round(val * 1000) / 1000).toString();
|
||||
}
|
||||
|
||||
// convert val into a fraction string
|
||||
const { frac } = useFraction();
|
||||
|
||||
let valString = "";
|
||||
const fraction = frac(val, 10, true);
|
||||
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
valString += fraction[0];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
valString += ` ${fraction[1]}/${fraction[2]}`;
|
||||
}
|
||||
|
||||
return valString.trim();
|
||||
}
|
||||
|
||||
|
||||
export function useExtractRecipeYield(yieldString: string | null, scale: number): string {
|
||||
if (!yieldString) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const match = findMatch(yieldString);
|
||||
if (!match) {
|
||||
return yieldString;
|
||||
}
|
||||
|
||||
const [matchString, servings, isFraction] = match;
|
||||
|
||||
const formattedServings = formatServings(servings, scale, isFraction);
|
||||
if (!formattedServings) {
|
||||
return yieldString // this only happens with very weird or small fractions
|
||||
} else {
|
||||
return yieldString.replace(matchString, formatServings(servings, scale, isFraction));
|
||||
}
|
||||
}
|
68
frontend/composables/recipes/use-scaled-amount.test.ts
Normal file
68
frontend/composables/recipes/use-scaled-amount.test.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { useScaledAmount } from "./use-scaled-amount";
|
||||
|
||||
describe("test use recipe yield", () => {
|
||||
function asFrac(numerator: number, denominator: number): string {
|
||||
return `<sup>${numerator}</sup><span>⁄</span><sub>${denominator}</sub>`;
|
||||
}
|
||||
|
||||
test("base case", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3);
|
||||
expect(scaledAmount).toStrictEqual(3);
|
||||
expect(scaledAmountDisplay).toStrictEqual("3");
|
||||
});
|
||||
|
||||
test("base case scaled", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3, 2);
|
||||
expect(scaledAmount).toStrictEqual(6);
|
||||
expect(scaledAmountDisplay).toStrictEqual("6");
|
||||
});
|
||||
|
||||
test("zero scale", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3, 0);
|
||||
expect(scaledAmount).toStrictEqual(0);
|
||||
expect(scaledAmountDisplay).toStrictEqual("");
|
||||
});
|
||||
|
||||
test("zero quantity", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0);
|
||||
expect(scaledAmount).toStrictEqual(0);
|
||||
expect(scaledAmountDisplay).toStrictEqual("");
|
||||
});
|
||||
|
||||
test("basic fraction", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0.5);
|
||||
expect(scaledAmount).toStrictEqual(0.5);
|
||||
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 2));
|
||||
});
|
||||
|
||||
test("mixed fraction", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.5);
|
||||
expect(scaledAmount).toStrictEqual(1.5);
|
||||
expect(scaledAmountDisplay).toStrictEqual(`1${asFrac(1, 2)}`);
|
||||
});
|
||||
|
||||
test("mixed fraction scaled", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.5, 9);
|
||||
expect(scaledAmount).toStrictEqual(13.5);
|
||||
expect(scaledAmountDisplay).toStrictEqual(`13${asFrac(1, 2)}`);
|
||||
});
|
||||
|
||||
test("small scale", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1, 0.125);
|
||||
expect(scaledAmount).toStrictEqual(0.125);
|
||||
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 8));
|
||||
});
|
||||
|
||||
test("small qty", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0.125);
|
||||
expect(scaledAmount).toStrictEqual(0.125);
|
||||
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 8));
|
||||
});
|
||||
|
||||
test("rounded decimal", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.3344559997);
|
||||
expect(scaledAmount).toStrictEqual(1.334);
|
||||
expect(scaledAmountDisplay).toStrictEqual(`1${asFrac(1, 3)}`);
|
||||
});
|
||||
});
|
32
frontend/composables/recipes/use-scaled-amount.ts
Normal file
32
frontend/composables/recipes/use-scaled-amount.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { useFraction } from "~/composables/recipes";
|
||||
|
||||
function formatQuantity(val: number): string {
|
||||
if (Number.isInteger(val)) {
|
||||
return val.toString();
|
||||
}
|
||||
|
||||
const { frac } = useFraction();
|
||||
|
||||
let valString = "";
|
||||
const fraction = frac(val, 10, true);
|
||||
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
valString += fraction[0];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
valString += `<sup>${fraction[1]}</sup><span>⁄</span><sub>${fraction[2]}</sub>`;
|
||||
}
|
||||
|
||||
return valString.trim();
|
||||
}
|
||||
|
||||
export function useScaledAmount(amount: number, scale = 1) {
|
||||
const scaledAmount = Number(((amount || 0) * scale).toFixed(3));
|
||||
const scaledAmountDisplay = scaledAmount ? formatQuantity(scaledAmount) : "";
|
||||
|
||||
return {
|
||||
scaledAmount,
|
||||
scaledAmountDisplay,
|
||||
};
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { Ref, useContext } from "@nuxtjs/composition-api";
|
||||
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
|
||||
import { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe";
|
||||
import { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
|
||||
export interface UserPrintPreferences {
|
||||
imagePosition: string;
|
||||
|
@ -49,6 +50,17 @@ export interface UserCookbooksPreferences {
|
|||
hideOtherHouseholds: boolean;
|
||||
}
|
||||
|
||||
export interface UserRecipeFinderPreferences {
|
||||
foodIds: string[];
|
||||
toolIds: string[];
|
||||
queryFilter: string;
|
||||
queryFilterJSON: QueryFilterJSON;
|
||||
maxMissingFoods: number;
|
||||
maxMissingTools: number;
|
||||
includeFoodsOnHand: boolean;
|
||||
includeToolsOnHand: boolean;
|
||||
}
|
||||
|
||||
export function useUserMealPlanPreferences(): Ref<UserMealPlanPreferences> {
|
||||
const fromStorage = useLocalStorage(
|
||||
"meal-planner-preferences",
|
||||
|
@ -171,3 +183,24 @@ export function useCookbookPreferences(): Ref<UserCookbooksPreferences> {
|
|||
|
||||
return fromStorage;
|
||||
}
|
||||
|
||||
export function useRecipeFinderPreferences(): Ref<UserRecipeFinderPreferences> {
|
||||
const fromStorage = useLocalStorage(
|
||||
"recipe-finder-preferences",
|
||||
{
|
||||
foodIds: [],
|
||||
toolIds: [],
|
||||
queryFilter: "",
|
||||
queryFilterJSON: { parts: [] } as QueryFilterJSON,
|
||||
maxMissingFoods: 20,
|
||||
maxMissingTools: 20,
|
||||
includeFoodsOnHand: true,
|
||||
includeToolsOnHand: true,
|
||||
},
|
||||
{ mergeDefaults: true }
|
||||
// we cast to a Ref because by default it will return an optional type ref
|
||||
// but since we pass defaults we know all properties are set.
|
||||
) as unknown as Ref<UserRecipeFinderPreferences>;
|
||||
|
||||
return fromStorage;
|
||||
}
|
||||
|
|
|
@ -279,7 +279,8 @@
|
|||
"admin-group-management": "Admin Group Management",
|
||||
"admin-group-management-text": "Changes to this group will be reflected immediately.",
|
||||
"group-id-value": "Group Id: {0}",
|
||||
"total-households": "Total Households"
|
||||
"total-households": "Total Households",
|
||||
"you-must-select-a-group-before-selecting-a-household": "You must select a group before selecting a household"
|
||||
},
|
||||
"household": {
|
||||
"household": "Household",
|
||||
|
@ -520,6 +521,7 @@
|
|||
"save-recipe-before-use": "Save recipe before use",
|
||||
"section-title": "Section Title",
|
||||
"servings": "Servings",
|
||||
"serves-amount": "Serves {amount}",
|
||||
"share-recipe-message": "I wanted to share my {0} recipe with you.",
|
||||
"show-nutrition-values": "Show Nutrition Values",
|
||||
"sodium-content": "Sodium",
|
||||
|
@ -548,6 +550,8 @@
|
|||
"failed-to-add-recipe-to-mealplan": "Failed to add recipe to mealplan",
|
||||
"failed-to-add-to-list": "Failed to add to list",
|
||||
"yield": "Yield",
|
||||
"yields-amount-with-text": "Yields {amount} {text}",
|
||||
"yield-text": "Yield Text",
|
||||
"quantity": "Quantity",
|
||||
"choose-unit": "Choose Unit",
|
||||
"press-enter-to-create": "Press Enter to Create",
|
||||
|
@ -643,7 +647,9 @@
|
|||
"recipe-debugger-use-openai-description": "Use OpenAI to parse the results instead of relying on the scraper library. When creating a recipe via URL, this is done automatically if the scraper library fails, but you may test it manually here.",
|
||||
"debug": "Debug",
|
||||
"tree-view": "Tree View",
|
||||
"recipe-servings": "Recipe Servings",
|
||||
"recipe-yield": "Recipe Yield",
|
||||
"recipe-yield-text": "Recipe Yield Text",
|
||||
"unit": "Unit",
|
||||
"upload-image": "Upload image",
|
||||
"screen-awake": "Keep Screen Awake",
|
||||
|
@ -668,6 +674,23 @@
|
|||
"reset-servings-count": "Reset Servings Count",
|
||||
"not-linked-ingredients": "Additional Ingredients"
|
||||
},
|
||||
"recipe-finder": {
|
||||
"recipe-finder": "Recipe Finder",
|
||||
"recipe-finder-description": "Search for recipes based on ingredients you have on hand. You can also filter by tools you have available, and set a maximum number of missing ingredients or tools.",
|
||||
"selected-ingredients": "Selected Ingredients",
|
||||
"no-ingredients-selected": "No ingredients selected",
|
||||
"missing": "Missing",
|
||||
"no-recipes-found": "No recipes found",
|
||||
"no-recipes-found-description": "Try adding more ingredients to your search or adjusting your filters",
|
||||
"include-ingredients-on-hand": "Include Ingredients On Hand",
|
||||
"include-tools-on-hand": "Include Tools On Hand",
|
||||
"max-missing-ingredients": "Max Missing Ingredients",
|
||||
"max-missing-tools": "Max Missing Tools",
|
||||
"selected-tools": "Selected Tools",
|
||||
"other-filters": "Other Filters",
|
||||
"ready-to-make": "Ready to Make",
|
||||
"almost-ready-to-make": "Almost Ready to Make"
|
||||
},
|
||||
"search": {
|
||||
"advanced-search": "Advanced Search",
|
||||
"and": "and",
|
||||
|
@ -870,7 +893,8 @@
|
|||
"you-are-offline-description": "Not all features are available while offline. You can still add, modify, and remove items, but you will not be able to sync your changes to the server until you are back online.",
|
||||
"are-you-sure-you-want-to-check-all-items": "Are you sure you want to check all items?",
|
||||
"are-you-sure-you-want-to-uncheck-all-items": "Are you sure you want to uncheck all items?",
|
||||
"are-you-sure-you-want-to-delete-checked-items": "Are you sure you want to delete all checked items?"
|
||||
"are-you-sure-you-want-to-delete-checked-items": "Are you sure you want to delete all checked items?",
|
||||
"no-shopping-lists-found": "No Shopping Lists Found"
|
||||
},
|
||||
"sidebar": {
|
||||
"all-recipes": "All Recipes",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
|
||||
import { route } from "../../base";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { Recipe, RecipeSuggestionQuery, RecipeSuggestionResponse } from "~/lib/api/types/recipe";
|
||||
import { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated";
|
||||
import { RecipeSearchQuery } from "../../user/recipes/recipe";
|
||||
|
||||
|
@ -23,4 +23,10 @@ export class PublicRecipeApi extends BaseCRUDAPIReadOnly<Recipe> {
|
|||
async search(rsq: RecipeSearchQuery) {
|
||||
return await this.requests.get<PaginationData<Recipe>>(route(routes.recipesGroupSlug(this.groupSlug), rsq));
|
||||
}
|
||||
|
||||
async getSuggestions(q: RecipeSuggestionQuery, foods: string[] | null = null, tools: string[]| null = null) {
|
||||
return await this.requests.get<RecipeSuggestionResponse>(
|
||||
route(`${this.baseRoute}/suggestions`, { ...q, foods, tools })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -126,6 +126,8 @@ export interface RecipeSummary {
|
|||
name?: string | null;
|
||||
slug?: string;
|
||||
image?: unknown;
|
||||
recipeServings?: number;
|
||||
recipeYieldQuantity?: number;
|
||||
recipeYield?: string | null;
|
||||
totalTime?: string | null;
|
||||
prepTime?: string | null;
|
||||
|
@ -160,6 +162,7 @@ export interface RecipeTool {
|
|||
name: string;
|
||||
slug: string;
|
||||
onHand?: boolean;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface CustomPageImport {
|
||||
name: string;
|
||||
|
|
|
@ -15,7 +15,7 @@ export interface CreateCookBook {
|
|||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
queryFilterString: string;
|
||||
queryFilterString?: string;
|
||||
}
|
||||
export interface ReadCookBook {
|
||||
name: string;
|
||||
|
@ -23,11 +23,11 @@ export interface ReadCookBook {
|
|||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
queryFilterString: string;
|
||||
queryFilterString?: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
id: string;
|
||||
queryFilter: QueryFilterJSON;
|
||||
queryFilter?: QueryFilterJSON;
|
||||
}
|
||||
export interface QueryFilterJSON {
|
||||
parts?: QueryFilterJSONPart[];
|
||||
|
@ -47,11 +47,11 @@ export interface RecipeCookBook {
|
|||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
queryFilterString: string;
|
||||
queryFilterString?: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
id: string;
|
||||
queryFilter: QueryFilterJSON;
|
||||
queryFilter?: QueryFilterJSON;
|
||||
recipes: RecipeSummary[];
|
||||
}
|
||||
export interface RecipeSummary {
|
||||
|
@ -62,6 +62,8 @@ export interface RecipeSummary {
|
|||
name?: string | null;
|
||||
slug?: string;
|
||||
image?: unknown;
|
||||
recipeServings?: number;
|
||||
recipeYieldQuantity?: number;
|
||||
recipeYield?: string | null;
|
||||
totalTime?: string | null;
|
||||
prepTime?: string | null;
|
||||
|
@ -104,7 +106,7 @@ export interface SaveCookBook {
|
|||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
queryFilterString: string;
|
||||
queryFilterString?: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
}
|
||||
|
@ -114,7 +116,7 @@ export interface UpdateCookBook {
|
|||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
queryFilterString: string;
|
||||
queryFilterString?: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
id: string;
|
||||
|
|
|
@ -26,12 +26,14 @@ export interface CreateHouseholdPreferences {
|
|||
}
|
||||
export interface CreateInviteToken {
|
||||
uses: number;
|
||||
groupId?: string | null;
|
||||
householdId?: string | null;
|
||||
}
|
||||
export interface CreateWebhook {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
webhookType?: WebhookType;
|
||||
scheduledTime: string;
|
||||
}
|
||||
export interface EmailInitationResponse {
|
||||
|
@ -46,10 +48,6 @@ export interface GroupEventNotifierCreate {
|
|||
name: string;
|
||||
appriseUrl?: string | null;
|
||||
}
|
||||
/**
|
||||
* These events are in-sync with the EventTypes found in the EventBusService.
|
||||
* If you modify this, make sure to update the EventBusService as well.
|
||||
*/
|
||||
export interface GroupEventNotifierOptions {
|
||||
testMessage?: boolean;
|
||||
webhookTask?: boolean;
|
||||
|
@ -204,7 +202,7 @@ export interface ReadWebhook {
|
|||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
webhookType?: WebhookType;
|
||||
scheduledTime: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
|
@ -263,7 +261,7 @@ export interface SaveWebhook {
|
|||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
webhookType?: WebhookType;
|
||||
scheduledTime: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
|
@ -486,9 +484,6 @@ export interface ShoppingListItemUpdate {
|
|||
} | null;
|
||||
recipeReferences?: (ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate)[];
|
||||
}
|
||||
/**
|
||||
* Only used for bulk update operations where the shopping list item id isn't already supplied
|
||||
*/
|
||||
export interface ShoppingListItemUpdateBulk {
|
||||
quantity?: number;
|
||||
unit?: IngredientUnit | CreateIngredientUnit | null;
|
||||
|
@ -509,9 +504,6 @@ export interface ShoppingListItemUpdateBulk {
|
|||
recipeReferences?: (ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate)[];
|
||||
id: string;
|
||||
}
|
||||
/**
|
||||
* Container for bulk shopping list item changes
|
||||
*/
|
||||
export interface ShoppingListItemsCollectionOut {
|
||||
createdItems?: ShoppingListItemOut[];
|
||||
updatedItems?: ShoppingListItemOut[];
|
||||
|
@ -565,6 +557,8 @@ export interface RecipeSummary {
|
|||
name?: string | null;
|
||||
slug?: string;
|
||||
image?: unknown;
|
||||
recipeServings?: number;
|
||||
recipeYieldQuantity?: number;
|
||||
recipeYield?: string | null;
|
||||
totalTime?: string | null;
|
||||
prepTime?: string | null;
|
||||
|
@ -599,6 +593,7 @@ export interface RecipeTool {
|
|||
name: string;
|
||||
slug: string;
|
||||
onHand?: boolean;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface ShoppingListRemoveRecipeParams {
|
||||
recipeDecrementQuantity?: number;
|
||||
|
|
|
@ -12,21 +12,16 @@ export type LogicalOperator = "AND" | "OR";
|
|||
export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
|
||||
export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
export interface CreatePlanEntry {
|
||||
date: string;
|
||||
entryType?: PlanEntryType & string;
|
||||
entryType?: PlanEntryType;
|
||||
title?: string;
|
||||
text?: string;
|
||||
recipeId?: string | null;
|
||||
}
|
||||
export interface CreateRandomEntry {
|
||||
date: string;
|
||||
entryType?: PlanEntryType & string;
|
||||
entryType?: PlanEntryType;
|
||||
}
|
||||
export interface ListItem {
|
||||
title?: string | null;
|
||||
|
@ -35,18 +30,18 @@ export interface ListItem {
|
|||
checked?: boolean;
|
||||
}
|
||||
export interface PlanRulesCreate {
|
||||
day?: PlanRulesDay & string;
|
||||
entryType?: PlanRulesType & string;
|
||||
queryFilterString: string;
|
||||
day?: PlanRulesDay;
|
||||
entryType?: PlanRulesType;
|
||||
queryFilterString?: string;
|
||||
}
|
||||
export interface PlanRulesOut {
|
||||
day?: PlanRulesDay & string;
|
||||
entryType?: PlanRulesType & string;
|
||||
queryFilterString: string;
|
||||
day?: PlanRulesDay;
|
||||
entryType?: PlanRulesType;
|
||||
queryFilterString?: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
id: string;
|
||||
queryFilter: QueryFilterJSON;
|
||||
queryFilter?: QueryFilterJSON;
|
||||
}
|
||||
export interface QueryFilterJSON {
|
||||
parts?: QueryFilterJSONPart[];
|
||||
|
@ -61,21 +56,21 @@ export interface QueryFilterJSONPart {
|
|||
[k: string]: unknown;
|
||||
}
|
||||
export interface PlanRulesSave {
|
||||
day?: PlanRulesDay & string;
|
||||
entryType?: PlanRulesType & string;
|
||||
queryFilterString: string;
|
||||
day?: PlanRulesDay;
|
||||
entryType?: PlanRulesType;
|
||||
queryFilterString?: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
}
|
||||
export interface ReadPlanEntry {
|
||||
date: string;
|
||||
entryType?: PlanEntryType & string;
|
||||
entryType?: PlanEntryType;
|
||||
title?: string;
|
||||
text?: string;
|
||||
recipeId?: string | null;
|
||||
id: number;
|
||||
groupId: string;
|
||||
userId?: string | null;
|
||||
userId: string;
|
||||
householdId: string;
|
||||
recipe?: RecipeSummary | null;
|
||||
}
|
||||
|
@ -87,6 +82,8 @@ export interface RecipeSummary {
|
|||
name?: string | null;
|
||||
slug?: string;
|
||||
image?: unknown;
|
||||
recipeServings?: number;
|
||||
recipeYieldQuantity?: number;
|
||||
recipeYield?: string | null;
|
||||
totalTime?: string | null;
|
||||
prepTime?: string | null;
|
||||
|
@ -125,12 +122,12 @@ export interface RecipeTool {
|
|||
}
|
||||
export interface SavePlanEntry {
|
||||
date: string;
|
||||
entryType?: PlanEntryType & string;
|
||||
entryType?: PlanEntryType;
|
||||
title?: string;
|
||||
text?: string;
|
||||
recipeId?: string | null;
|
||||
groupId: string;
|
||||
userId?: string | null;
|
||||
userId: string;
|
||||
}
|
||||
export interface ShoppingListIn {
|
||||
name: string;
|
||||
|
@ -145,11 +142,11 @@ export interface ShoppingListOut {
|
|||
}
|
||||
export interface UpdatePlanEntry {
|
||||
date: string;
|
||||
entryType?: PlanEntryType & string;
|
||||
entryType?: PlanEntryType;
|
||||
title?: string;
|
||||
text?: string;
|
||||
recipeId?: string | null;
|
||||
id: number;
|
||||
groupId: string;
|
||||
userId?: string | null;
|
||||
userId: string;
|
||||
}
|
||||
|
|
|
@ -6,215 +6,37 @@
|
|||
*/
|
||||
|
||||
export interface OpenAIIngredient {
|
||||
/**
|
||||
*
|
||||
* The input is simply the ingredient string you are processing as-is. It is forbidden to
|
||||
* modify this at all, you must provide the input exactly as you received it.
|
||||
*
|
||||
*/
|
||||
input: string;
|
||||
/**
|
||||
*
|
||||
* This value is a float between 0 - 100, where 100 is full confidence that the result is correct,
|
||||
* and 0 is no confidence that the result is correct. If you're unable to parse anything,
|
||||
* and you put the entire string in the notes, you should return 0 confidence. If you can easily
|
||||
* parse the string into each component, then you should return a confidence of 100. If you have to
|
||||
* guess which part is the unit and which part is the food, your confidence should be lower, such as 60.
|
||||
* Even if there is no unit or note, if you're able to determine the food, you may use a higher confidence.
|
||||
* If the entire ingredient consists of only a food, you can use a confidence of 100.
|
||||
*
|
||||
*/
|
||||
confidence?: number | null;
|
||||
/**
|
||||
*
|
||||
* The numerical representation of how much of this ingredient. For instance, if you receive
|
||||
* "3 1/2 grams of minced garlic", the quantity is "3 1/2". Quantity may be represented as a whole number
|
||||
* (integer), a float or decimal, or a fraction. You should output quantity in only whole numbers or
|
||||
* floats, converting fractions into floats. Floats longer than 10 decimal places should be
|
||||
* rounded to 10 decimal places.
|
||||
*
|
||||
*/
|
||||
quantity?: number | null;
|
||||
/**
|
||||
*
|
||||
* The unit of measurement for this ingredient. For instance, if you receive
|
||||
* "2 lbs chicken breast", the unit is "lbs" (short for "pounds").
|
||||
*
|
||||
*/
|
||||
unit?: string | null;
|
||||
/**
|
||||
*
|
||||
* The actual physical ingredient used in the recipe. For instance, if you receive
|
||||
* "3 cups of onions, chopped", the food is "onions".
|
||||
*
|
||||
*/
|
||||
food?: string | null;
|
||||
/**
|
||||
*
|
||||
* The rest of the text that represents more detail on how to prepare the ingredient.
|
||||
* Anything that is not one of the above should be the note. For instance, if you receive
|
||||
* "one can of butter beans, drained" the note would be "drained". If you receive
|
||||
* "3 cloves of garlic peeled and finely chopped", the note would be "peeled and finely chopped".
|
||||
*
|
||||
*/
|
||||
note?: string | null;
|
||||
}
|
||||
export interface OpenAIIngredients {
|
||||
ingredients?: OpenAIIngredient[];
|
||||
}
|
||||
export interface OpenAIRecipe {
|
||||
/**
|
||||
*
|
||||
* The name or title of the recipe. If you're unable to determine the name of the recipe, you should
|
||||
* make your best guess based upon the ingredients and instructions provided.
|
||||
*
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
*
|
||||
* A long description of the recipe. This should be a string that describes the recipe in a few words
|
||||
* or sentences. If the recipe doesn't have a description, you should return None.
|
||||
*
|
||||
*/
|
||||
description: string | null;
|
||||
/**
|
||||
*
|
||||
* The yield of the recipe. For instance, if the recipe makes 12 cookies, the yield is "12 cookies".
|
||||
* If the recipe makes 2 servings, the yield is "2 servings". Typically yield consists of a number followed
|
||||
* by the word "serving" or "servings", but it can be any string that describes the yield. If the yield
|
||||
* isn't specified, you should return None.
|
||||
*
|
||||
*/
|
||||
recipe_yield?: string | null;
|
||||
/**
|
||||
*
|
||||
* The total time it takes to make the recipe. This should be a string that describes a duration of time,
|
||||
* such as "1 hour and 30 minutes", "90 minutes", or "1.5 hours". If the recipe has multiple times, choose
|
||||
* the longest time. If the recipe doesn't specify a total time or duration, or it specifies a prep time or
|
||||
* perform time but not a total time, you should return None. Do not duplicate times between total time, prep
|
||||
* time and perform time.
|
||||
*
|
||||
*/
|
||||
total_time?: string | null;
|
||||
/**
|
||||
*
|
||||
* The time it takes to prepare the recipe. This should be a string that describes a duration of time,
|
||||
* such as "30 minutes", "1 hour", or "1.5 hours". If the recipe has a total time, the prep time should be
|
||||
* less than the total time. If the recipe doesn't specify a prep time, you should return None. If the recipe
|
||||
* supplies only one time, it should be the total time. Do not duplicate times between total time, prep
|
||||
* time and coperformok time.
|
||||
*
|
||||
*/
|
||||
prep_time?: string | null;
|
||||
/**
|
||||
*
|
||||
* The time it takes to cook the recipe. This should be a string that describes a duration of time,
|
||||
* such as "30 minutes", "1 hour", or "1.5 hours". If the recipe has a total time, the perform time should be
|
||||
* less than the total time. If the recipe doesn't specify a perform time, you should return None. If the
|
||||
* recipe specifies a cook time, active time, or other time besides total or prep, you should use that
|
||||
* time as the perform time. If the recipe supplies only one time, it should be the total time, and not the
|
||||
* perform time. Do not duplicate times between total time, prep time and perform time.
|
||||
*
|
||||
*/
|
||||
perform_time?: string | null;
|
||||
/**
|
||||
*
|
||||
* A list of ingredients used in the recipe. Ingredients should be inserted in the order they appear in the
|
||||
* recipe. If the recipe has no ingredients, you should return an empty list.
|
||||
*
|
||||
* Often times, but not always, ingredients are separated by line breaks. Use these as a guide to
|
||||
* separate ingredients.
|
||||
*
|
||||
*/
|
||||
ingredients?: OpenAIRecipeIngredient[];
|
||||
/**
|
||||
*
|
||||
* A list of ingredients used in the recipe. Ingredients should be inserted in the order they appear in the
|
||||
* recipe. If the recipe has no ingredients, you should return an empty list.
|
||||
*
|
||||
* Often times, but not always, instructions are separated by line breaks and/or separated by paragraphs.
|
||||
* Use these as a guide to separate instructions. They also may be separated by numbers or words, such as
|
||||
* "1.", "2.", "Step 1", "Step 2", "First", "Second", etc.
|
||||
*
|
||||
*/
|
||||
instructions?: OpenAIRecipeInstruction[];
|
||||
/**
|
||||
*
|
||||
* A list of notes found in the recipe. Notes should be inserted in the order they appear in the recipe.
|
||||
* They may appear anywhere on the recipe, though they are typically found under the instructions.
|
||||
*
|
||||
*/
|
||||
notes?: OpenAIRecipeNotes[];
|
||||
}
|
||||
export interface OpenAIRecipeIngredient {
|
||||
/**
|
||||
*
|
||||
* The title of the section of the recipe that the ingredient is found in. Recipes may not specify
|
||||
* ingredient sections, in which case this should be left blank.
|
||||
* Only the first item in the section should have this set,
|
||||
* whereas subsuquent items should have their titles left blank (unless they start a new section).
|
||||
*
|
||||
*/
|
||||
title?: string | null;
|
||||
/**
|
||||
*
|
||||
* The text of the ingredient. This should represent the entire ingredient, such as "1 cup of flour" or
|
||||
* "2 cups of onions, chopped". If the ingredient is completely blank, skip it and do not add the ingredient,
|
||||
* since this field is required.
|
||||
*
|
||||
* If the ingredient has no text, but has a title, include the title on the
|
||||
* next ingredient instead.
|
||||
*
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
export interface OpenAIRecipeInstruction {
|
||||
/**
|
||||
*
|
||||
* The title of the section of the recipe that the instruction is found in. Recipes may not specify
|
||||
* instruction sections, in which case this should be left blank.
|
||||
* Only the first instruction in the section should have this set,
|
||||
* whereas subsuquent instructions should have their titles left blank (unless they start a new section).
|
||||
*
|
||||
*/
|
||||
title?: string | null;
|
||||
/**
|
||||
*
|
||||
* The text of the instruction. This represents one step in the recipe, such as "Preheat the oven to 350",
|
||||
* or "Sauté the onions for 20 minutes". Sometimes steps can be longer, such as "Bring a large pot of lightly
|
||||
* salted water to a boil. Add ditalini pasta and cook for 8 minutes or until al dente; drain.".
|
||||
*
|
||||
* Sometimes, but not always, recipes will include their number in front of the text, such as
|
||||
* "1.", "2.", or "Step 1", "Step 2", or "First", "Second". In the case where they are directly numbered
|
||||
* ("1.", "2.", "Step one", "Step 1", "Step two", "Step 2", etc.), you should not include the number in
|
||||
* the text. However, if they use words ("First", "Second", etc.), then those should be included.
|
||||
*
|
||||
* If the instruction is completely blank, skip it and do not add the instruction, since this field is
|
||||
* required. If the ingredient has no text, but has a title, include the title on the next
|
||||
* instruction instead.
|
||||
*
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
export interface OpenAIRecipeNotes {
|
||||
/**
|
||||
*
|
||||
* The title of the note. Notes may not specify a title, and just have a body of text. In this case,
|
||||
* title should be left blank, and all content should go in the note text. If the note title is just
|
||||
* "note" or "info", you should ignore it and leave the title blank.
|
||||
*
|
||||
*/
|
||||
title?: string | null;
|
||||
/**
|
||||
*
|
||||
* The text of the note. This should represent the entire note, such as "This recipe is great for
|
||||
* a summer picnic" or "This recipe is a family favorite". They may also include additional prep
|
||||
* instructions such as "to make this recipe gluten free, use gluten free flour", or "you may prepare
|
||||
* the dough the night before and refrigerate it until ready to bake".
|
||||
*
|
||||
* If the note is completely blank, skip it and do not add the note, since this field is required.
|
||||
*
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
export interface OpenAIBase {}
|
||||
|
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
export type ExportTypes = "json";
|
||||
export type RegisteredParser = "nlp" | "brute" | "openai";
|
||||
export type OrderByNullPosition = "first" | "last";
|
||||
export type OrderDirection = "asc" | "desc";
|
||||
export type TimelineEventType = "system" | "info" | "comment";
|
||||
export type TimelineEventImage = "has image" | "does not have image";
|
||||
|
||||
|
@ -116,7 +118,7 @@ export interface ExportBase {
|
|||
}
|
||||
export interface ExportRecipes {
|
||||
recipes: string[];
|
||||
exportType?: ExportTypes & string;
|
||||
exportType?: ExportTypes;
|
||||
}
|
||||
export interface IngredientConfidence {
|
||||
average?: number | null;
|
||||
|
@ -150,14 +152,11 @@ export interface MultiPurposeLabelSummary {
|
|||
groupId: string;
|
||||
id: string;
|
||||
}
|
||||
/**
|
||||
* A list of ingredient references.
|
||||
*/
|
||||
export interface IngredientReferences {
|
||||
referenceId?: string | null;
|
||||
}
|
||||
export interface IngredientRequest {
|
||||
parser?: RegisteredParser & string;
|
||||
parser?: RegisteredParser;
|
||||
ingredient: string;
|
||||
}
|
||||
export interface IngredientUnit {
|
||||
|
@ -181,7 +180,7 @@ export interface IngredientUnitAlias {
|
|||
name: string;
|
||||
}
|
||||
export interface IngredientsRequest {
|
||||
parser?: RegisteredParser & string;
|
||||
parser?: RegisteredParser;
|
||||
ingredients: string[];
|
||||
}
|
||||
export interface MergeFood {
|
||||
|
@ -230,6 +229,8 @@ export interface Recipe {
|
|||
name?: string | null;
|
||||
slug?: string;
|
||||
image?: unknown;
|
||||
recipeServings?: number;
|
||||
recipeYieldQuantity?: number;
|
||||
recipeYield?: string | null;
|
||||
totalTime?: string | null;
|
||||
prepTime?: string | null;
|
||||
|
@ -266,9 +267,9 @@ export interface RecipeTool {
|
|||
export interface RecipeStep {
|
||||
id?: string | null;
|
||||
title?: string | null;
|
||||
summary?: string | null;
|
||||
text: string;
|
||||
ingredientReferences?: IngredientReferences[];
|
||||
summary?: string | null;
|
||||
}
|
||||
export interface RecipeAsset {
|
||||
name: string;
|
||||
|
@ -307,6 +308,8 @@ export interface RecipeSummary {
|
|||
name?: string | null;
|
||||
slug?: string;
|
||||
image?: unknown;
|
||||
recipeServings?: number;
|
||||
recipeYieldQuantity?: number;
|
||||
recipeYield?: string | null;
|
||||
totalTime?: string | null;
|
||||
prepTime?: string | null;
|
||||
|
@ -379,6 +382,26 @@ export interface RecipeShareTokenSummary {
|
|||
export interface RecipeSlug {
|
||||
slug: string;
|
||||
}
|
||||
export interface RecipeSuggestionQuery {
|
||||
orderBy?: string | null;
|
||||
orderByNullPosition?: OrderByNullPosition | null;
|
||||
orderDirection?: OrderDirection;
|
||||
queryFilter?: string | null;
|
||||
paginationSeed?: string | null;
|
||||
limit?: number;
|
||||
maxMissingFoods?: number;
|
||||
maxMissingTools?: number;
|
||||
includeFoodsOnHand?: boolean;
|
||||
includeToolsOnHand?: boolean;
|
||||
}
|
||||
export interface RecipeSuggestionResponse {
|
||||
items: RecipeSuggestionResponseItem[];
|
||||
}
|
||||
export interface RecipeSuggestionResponseItem {
|
||||
recipe: RecipeSummary;
|
||||
missingFoods: IngredientFood[];
|
||||
missingTools: RecipeTool[];
|
||||
}
|
||||
export interface RecipeTagResponse {
|
||||
name: string;
|
||||
id: string;
|
||||
|
@ -491,7 +514,7 @@ export interface ScrapeRecipeTest {
|
|||
url: string;
|
||||
useOpenAI?: boolean;
|
||||
}
|
||||
export interface SlugResponse { }
|
||||
export interface SlugResponse {}
|
||||
export interface TagIn {
|
||||
name: string;
|
||||
}
|
||||
|
@ -518,3 +541,10 @@ export interface UnitFoodBase {
|
|||
export interface UpdateImageResponse {
|
||||
image: string;
|
||||
}
|
||||
export interface RequestQuery {
|
||||
orderBy?: string | null;
|
||||
orderByNullPosition?: OrderByNullPosition | null;
|
||||
orderDirection?: OrderDirection;
|
||||
queryFilter?: string | null;
|
||||
paginationSeed?: string | null;
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ export interface ReportCreate {
|
|||
category: ReportCategory;
|
||||
groupId: string;
|
||||
name: string;
|
||||
status?: ReportSummaryStatus & string;
|
||||
status?: ReportSummaryStatus;
|
||||
}
|
||||
export interface ReportEntryCreate {
|
||||
reportId: string;
|
||||
|
@ -35,7 +35,7 @@ export interface ReportOut {
|
|||
category: ReportCategory;
|
||||
groupId: string;
|
||||
name: string;
|
||||
status?: ReportSummaryStatus & string;
|
||||
status?: ReportSummaryStatus;
|
||||
id: string;
|
||||
entries?: ReportEntryOut[];
|
||||
}
|
||||
|
@ -44,6 +44,6 @@ export interface ReportSummary {
|
|||
category: ReportCategory;
|
||||
groupId: string;
|
||||
name: string;
|
||||
status?: ReportSummaryStatus & string;
|
||||
status?: ReportSummaryStatus;
|
||||
id: string;
|
||||
}
|
||||
|
|
|
@ -20,13 +20,13 @@ export interface FileTokenResponse {
|
|||
fileToken: string;
|
||||
}
|
||||
export interface PaginationQuery {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: string | null;
|
||||
orderByNullPosition?: OrderByNullPosition | null;
|
||||
orderDirection?: OrderDirection & string;
|
||||
orderDirection?: OrderDirection;
|
||||
queryFilter?: string | null;
|
||||
paginationSeed?: string | null;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
export interface QueryFilterJSON {
|
||||
parts?: QueryFilterJSONPart[];
|
||||
|
@ -47,6 +47,13 @@ export interface RecipeSearchQuery {
|
|||
requireAllFoods?: boolean;
|
||||
search?: string | null;
|
||||
}
|
||||
export interface RequestQuery {
|
||||
orderBy?: string | null;
|
||||
orderByNullPosition?: OrderByNullPosition | null;
|
||||
orderDirection?: OrderDirection;
|
||||
queryFilter?: string | null;
|
||||
paginationSeed?: string | null;
|
||||
}
|
||||
export interface SuccessResponse {
|
||||
message: string;
|
||||
error?: boolean;
|
||||
|
|
|
@ -69,7 +69,7 @@ export interface ReadWebhook {
|
|||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
webhookType?: WebhookType;
|
||||
scheduledTime: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
|
@ -110,7 +110,7 @@ export interface PrivateUser {
|
|||
username?: string | null;
|
||||
fullName?: string | null;
|
||||
email: string;
|
||||
authMethod?: AuthMethod & string;
|
||||
authMethod?: AuthMethod;
|
||||
admin?: boolean;
|
||||
group: string;
|
||||
household: string;
|
||||
|
@ -175,7 +175,7 @@ export interface CreateWebhook {
|
|||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
webhookType?: WebhookType;
|
||||
scheduledTime: string;
|
||||
}
|
||||
export interface UserBase {
|
||||
|
@ -183,7 +183,7 @@ export interface UserBase {
|
|||
username?: string | null;
|
||||
fullName?: string | null;
|
||||
email: string;
|
||||
authMethod?: AuthMethod & string;
|
||||
authMethod?: AuthMethod;
|
||||
admin?: boolean;
|
||||
group?: string | null;
|
||||
household?: string | null;
|
||||
|
@ -195,10 +195,10 @@ export interface UserBase {
|
|||
}
|
||||
export interface UserIn {
|
||||
id?: string | null;
|
||||
username?: string | null;
|
||||
fullName?: string | null;
|
||||
username: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
authMethod?: AuthMethod & string;
|
||||
authMethod?: AuthMethod;
|
||||
admin?: boolean;
|
||||
group?: string | null;
|
||||
household?: string | null;
|
||||
|
@ -214,7 +214,7 @@ export interface UserOut {
|
|||
username?: string | null;
|
||||
fullName?: string | null;
|
||||
email: string;
|
||||
authMethod?: AuthMethod & string;
|
||||
authMethod?: AuthMethod;
|
||||
admin?: boolean;
|
||||
group: string;
|
||||
household: string;
|
||||
|
|
|
@ -11,6 +11,8 @@ import {
|
|||
UpdateImageResponse,
|
||||
RecipeZipTokenResponse,
|
||||
RecipeLastMade,
|
||||
RecipeSuggestionQuery,
|
||||
RecipeSuggestionResponse,
|
||||
RecipeTimelineEventIn,
|
||||
RecipeTimelineEventOut,
|
||||
RecipeTimelineEventUpdate,
|
||||
|
@ -31,6 +33,7 @@ const prefix = "/api";
|
|||
const routes = {
|
||||
recipesCreate: `${prefix}/recipes/create`,
|
||||
recipesBase: `${prefix}/recipes`,
|
||||
recipesSuggestions: `${prefix}/recipes/suggestions`,
|
||||
recipesTestScrapeUrl: `${prefix}/recipes/test-scrape-url`,
|
||||
recipesCreateUrl: `${prefix}/recipes/create/url`,
|
||||
recipesCreateUrlBulk: `${prefix}/recipes/create/url/bulk`,
|
||||
|
@ -109,6 +112,12 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
|
|||
});
|
||||
}
|
||||
|
||||
async getSuggestions(q: RecipeSuggestionQuery, foods: string[] | null = null, tools: string[]| null = null) {
|
||||
return await this.requests.get<RecipeSuggestionResponse>(
|
||||
route(routes.recipesSuggestions, { ...q, foods, tools })
|
||||
);
|
||||
}
|
||||
|
||||
async createAsset(recipeSlug: string, payload: CreateAsset) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", payload.file);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "mealie",
|
||||
"version": "2.2.0",
|
||||
"version": "2.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nuxt",
|
||||
|
|
|
@ -4,25 +4,29 @@
|
|||
v-model="createDialog"
|
||||
:title="$t('household.create-household')"
|
||||
:icon="$globals.icons.household"
|
||||
@submit="createHousehold(createHouseholdForm.data)"
|
||||
>
|
||||
<template #activator> </template>
|
||||
<v-card-text>
|
||||
<v-select
|
||||
v-if="groups"
|
||||
v-model="createHouseholdForm.data.groupId"
|
||||
:items="groups"
|
||||
rounded
|
||||
class="rounded-lg"
|
||||
item-text="name"
|
||||
item-value="id"
|
||||
:return-object="false"
|
||||
filled
|
||||
:label="$tc('household.household-group')"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<AutoForm v-model="createHouseholdForm.data" :update-mode="updateMode" :items="createHouseholdForm.items" />
|
||||
<v-form ref="refNewHouseholdForm">
|
||||
<v-select
|
||||
v-if="groups"
|
||||
v-model="createHouseholdForm.data.groupId"
|
||||
:items="groups"
|
||||
rounded
|
||||
class="rounded-lg"
|
||||
item-text="name"
|
||||
item-value="id"
|
||||
:return-object="false"
|
||||
filled
|
||||
:label="$tc('household.household-group')"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<AutoForm v-model="createHouseholdForm.data" :update-mode="updateMode" :items="createHouseholdForm.items" />
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<template #custom-card-action>
|
||||
<BaseButton type="submit" @click="handleCreateSubmit"> {{ $t("general.create") }} </BaseButton>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog
|
||||
|
@ -92,12 +96,13 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
|
||||
import { defineComponent, reactive, ref, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
|
||||
import { fieldTypes } from "~/composables/forms";
|
||||
import { useGroups } from "~/composables/use-groups";
|
||||
import { useAdminHouseholds } from "~/composables/use-households";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { HouseholdInDB } from "~/lib/api/types/household";
|
||||
import { VForm } from "~/types/vuetify";
|
||||
|
||||
export default defineComponent({
|
||||
layout: "admin",
|
||||
|
@ -105,10 +110,12 @@ export default defineComponent({
|
|||
const { i18n } = useContext();
|
||||
const { groups } = useGroups();
|
||||
const { households, refreshAllHouseholds, deleteHousehold, createHousehold } = useAdminHouseholds();
|
||||
const refNewHouseholdForm = ref<VForm | null>(null);
|
||||
|
||||
const state = reactive({
|
||||
createDialog: false,
|
||||
confirmDialog: false,
|
||||
loading: false,
|
||||
deleteTarget: 0,
|
||||
search: "",
|
||||
headers: [
|
||||
|
@ -153,14 +160,24 @@ export default defineComponent({
|
|||
router.push(`/admin/manage/households/${item.id}`);
|
||||
}
|
||||
|
||||
async function handleCreateSubmit() {
|
||||
if (!refNewHouseholdForm.value?.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.createDialog = false;
|
||||
await createHousehold(state.createHouseholdForm.data);
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
refNewHouseholdForm,
|
||||
groups,
|
||||
households,
|
||||
validators,
|
||||
refreshAllHouseholds,
|
||||
deleteHousehold,
|
||||
createHousehold,
|
||||
handleCreateSubmit,
|
||||
openDialog,
|
||||
handleRowClick,
|
||||
};
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
<v-card outlined>
|
||||
<v-card-text>
|
||||
<v-select
|
||||
v-if="groups"
|
||||
v-model="selectedGroupId"
|
||||
:items="groups"
|
||||
rounded
|
||||
|
@ -24,8 +23,8 @@
|
|||
:rules="[validators.required]"
|
||||
/>
|
||||
<v-select
|
||||
v-if="households"
|
||||
v-model="newUserData.household"
|
||||
:disabled="!selectedGroupId"
|
||||
:items="households"
|
||||
rounded
|
||||
class="rounded-lg"
|
||||
|
@ -34,6 +33,8 @@
|
|||
:return-object="false"
|
||||
filled
|
||||
:label="$t('household.user-household')"
|
||||
:hint="selectedGroupId ? '' : $tc('group.you-must-select-a-group-before-selecting-a-household')"
|
||||
persistent-hint
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<AutoForm v-model="newUserData" :items="userForm" />
|
||||
|
|
606
frontend/pages/g/_groupSlug/recipes/finder/index.vue
Normal file
606
frontend/pages/g/_groupSlug/recipes/finder/index.vue
Normal file
|
@ -0,0 +1,606 @@
|
|||
<template>
|
||||
<v-container>
|
||||
<BasePageTitle divider>
|
||||
<template #header>
|
||||
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-cookbooks.svg')"></v-img>
|
||||
</template>
|
||||
<template #title> {{ $tc('recipe-finder.recipe-finder') }} </template>
|
||||
{{ $t('recipe-finder.recipe-finder-description') }}
|
||||
</BasePageTitle>
|
||||
<v-container v-if="ready">
|
||||
<v-row>
|
||||
<v-col :cols="useMobile ? 12 : 3">
|
||||
<v-container class="ma-0 pa-0">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12" no-gutters :class="attrs.searchFilter.colClass">
|
||||
<SearchFilter v-if="foods" v-model="selectedFoods" :items="foods" :class="attrs.searchFilter.filterClass">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.foods }}
|
||||
</v-icon>
|
||||
{{ $t("general.foods") }}
|
||||
</SearchFilter>
|
||||
<SearchFilter v-if="tools" v-model="selectedTools" :items="tools" :class="attrs.searchFilter.filterClass">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
{{ $t("tool.tools") }}
|
||||
</SearchFilter>
|
||||
<div :class="attrs.searchFilter.filterClass">
|
||||
<v-badge
|
||||
:value="queryFilterJSON.parts && queryFilterJSON.parts.length"
|
||||
small
|
||||
overlap
|
||||
color="primary"
|
||||
:content="(queryFilterJSON.parts || []).length"
|
||||
>
|
||||
<v-btn
|
||||
small
|
||||
color="accent"
|
||||
dark
|
||||
@click="queryFilterMenu = !queryFilterMenu"
|
||||
>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.filter }}
|
||||
</v-icon>
|
||||
{{ $tc("recipe-finder.other-filters") }}
|
||||
<BaseDialog
|
||||
v-model="queryFilterMenu"
|
||||
:title="$tc('recipe-finder.other-filters')"
|
||||
:icon="$globals.icons.filter"
|
||||
width="100%"
|
||||
max-width="1100px"
|
||||
:submit-disabled="!queryFilterEditorValue"
|
||||
@confirm="saveQueryFilter"
|
||||
>
|
||||
<QueryFilterBuilder
|
||||
:key="queryFilterMenuKey"
|
||||
:initial-query-filter="queryFilterJSON"
|
||||
:field-defs="queryFilterBuilderFields"
|
||||
@input="(value) => queryFilterEditorValue = value"
|
||||
@inputJSON="(value) => queryFilterEditorValueJSON = value"
|
||||
/>
|
||||
<template #custom-card-action>
|
||||
<BaseButton color="error" type="submit" @click="clearQueryFilter">
|
||||
<template #icon>
|
||||
{{ $globals.icons.close }}
|
||||
</template>
|
||||
{{ $t("search.clear-selection") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</v-btn>
|
||||
</v-badge>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<!-- Settings Menu -->
|
||||
<v-row no-gutters class="mb-2">
|
||||
<v-col cols="12" :class="attrs.settings.colClass">
|
||||
<v-menu
|
||||
v-model="settingsMenu"
|
||||
offset-y
|
||||
nudge-bottom="3"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ on, attrs: activatorAttrs}">
|
||||
<v-btn small color="primary" dark v-bind="activatorAttrs" v-on="on">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.cog }}
|
||||
</v-icon>
|
||||
{{ $t("general.settings") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<div>
|
||||
<v-text-field
|
||||
v-model="settings.maxMissingFoods"
|
||||
type="number"
|
||||
hide-details
|
||||
hide-spin-buttons
|
||||
:label="$tc('recipe-finder.max-missing-ingredients')"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="settings.maxMissingTools"
|
||||
type="number"
|
||||
hide-details
|
||||
hide-spin-buttons
|
||||
:label="$tc('recipe-finder.max-missing-tools')"
|
||||
class="mt-4"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<v-checkbox
|
||||
v-if="isOwnGroup"
|
||||
v-model="settings.includeFoodsOnHand"
|
||||
dense
|
||||
small
|
||||
hide-details
|
||||
class="my-auto"
|
||||
:label="$tc('recipe-finder.include-ingredients-on-hand')"
|
||||
/>
|
||||
<v-checkbox
|
||||
v-if="isOwnGroup"
|
||||
v-model="settings.includeToolsOnHand"
|
||||
dense
|
||||
small
|
||||
hide-details
|
||||
class="my-auto"
|
||||
:label="$tc('recipe-finder.include-tools-on-hand')"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row no-gutters class="my-2">
|
||||
<v-col cols="12">
|
||||
<v-divider />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row no-gutters class="mt-5">
|
||||
<v-card-title class="ma-0 pa-0">
|
||||
{{ $tc("recipe-finder.selected-ingredients") }}
|
||||
</v-card-title>
|
||||
<v-container class="ma-0 pa-0" style="max-height: 60vh; overflow-y: auto;">
|
||||
<v-card-text v-if="!selectedFoods.length" class="ma-0 pa-0">
|
||||
{{ $tc("recipe-finder.no-ingredients-selected") }}
|
||||
</v-card-text>
|
||||
<div v-if="useMobile">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12" class="d-flex flex-wrap justify-end">
|
||||
<v-chip
|
||||
v-for="food in selectedFoods"
|
||||
:key="food.id"
|
||||
label
|
||||
class="ma-1"
|
||||
color="accent custom-transparent"
|
||||
close
|
||||
@click:close="removeFood(food)"
|
||||
>
|
||||
<span class="text-hide-overflow">{{ food.pluralName || food.name }}</span>
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
<div v-else>
|
||||
<v-row v-for="food in selectedFoods" :key="food.id" no-gutters class="mb-1">
|
||||
<v-col cols="12">
|
||||
<v-chip
|
||||
label
|
||||
color="accent custom-transparent"
|
||||
close
|
||||
@click:close="removeFood(food)"
|
||||
>
|
||||
<span class="text-hide-overflow">{{ food.pluralName || food.name }}</span>
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-container>
|
||||
</v-row>
|
||||
<v-row v-if="selectedTools.length" no-gutters class="mt-5">
|
||||
<v-card-title class="ma-0 pa-0">
|
||||
{{ $tc("recipe-finder.selected-tools") }}
|
||||
</v-card-title>
|
||||
<v-container class="ma-0 pa-0">
|
||||
<div v-if="useMobile">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12" class="d-flex flex-wrap justify-end">
|
||||
<v-chip
|
||||
v-for="tool in selectedTools"
|
||||
:key="tool.id"
|
||||
label
|
||||
class="ma-1"
|
||||
color="accent custom-transparent"
|
||||
close
|
||||
@click:close="removeTool(tool)"
|
||||
>
|
||||
<span class="text-hide-overflow">{{ tool.name }}</span>
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
<div v-else>
|
||||
<v-row v-for="tool in selectedTools" :key="tool.id" no-gutters class="mb-1">
|
||||
<v-col cols="12">
|
||||
<v-chip
|
||||
label
|
||||
color="accent custom-transparent"
|
||||
close
|
||||
@click:close="removeTool(tool)"
|
||||
>
|
||||
<span class="text-hide-overflow">{{ tool.name }}</span>
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-container>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-col>
|
||||
<v-col :cols="useMobile ? 12 : 9" :style="useMobile ? '' : 'max-height: 70vh; overflow-y: auto'">
|
||||
<v-container
|
||||
v-if="recipeSuggestions.readyToMake.length || recipeSuggestions.missingItems.length"
|
||||
class="ma-0 pa-0"
|
||||
>
|
||||
<v-row v-if="recipeSuggestions.readyToMake.length" dense>
|
||||
<v-col cols="12">
|
||||
<v-card-title :class="attrs.title.class.readyToMake">
|
||||
{{ $tc("recipe-finder.ready-to-make") }}
|
||||
</v-card-title>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-for="(item, idx) in recipeSuggestions.readyToMake"
|
||||
:key="`${idx}-ready`"
|
||||
cols="12"
|
||||
>
|
||||
<v-lazy>
|
||||
<RecipeSuggestion
|
||||
:recipe="item.recipe"
|
||||
:missing-foods="item.missingFoods"
|
||||
:missing-tools="item.missingTools"
|
||||
:disable-checkbox="loading"
|
||||
@add-food="addFood"
|
||||
@remove-food="removeFood"
|
||||
@add-tool="addTool"
|
||||
@remove-tool="removeTool"
|
||||
/>
|
||||
</v-lazy>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="recipeSuggestions.missingItems.length" dense>
|
||||
<v-col cols="12">
|
||||
<v-card-title :class="attrs.title.class.missingItems">
|
||||
{{ $tc("recipe-finder.almost-ready-to-make") }}
|
||||
</v-card-title>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-for="(item, idx) in recipeSuggestions.missingItems"
|
||||
:key="`${idx}-missing`"
|
||||
cols="12"
|
||||
>
|
||||
<v-lazy>
|
||||
<RecipeSuggestion
|
||||
:recipe="item.recipe"
|
||||
:missing-foods="item.missingFoods"
|
||||
:missing-tools="item.missingTools"
|
||||
:disable-checkbox="loading"
|
||||
@add-food="addFood"
|
||||
@remove-food="removeFood"
|
||||
@add-tool="addTool"
|
||||
@remove-tool="removeTool"
|
||||
/>
|
||||
</v-lazy>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<v-container v-else-if="!recipesReady">
|
||||
<v-row>
|
||||
<v-col cols="12" class="d-flex justify-center">
|
||||
<div class="text-center">
|
||||
<AppLoader waiting-text="" />
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<v-container v-else>
|
||||
<v-row>
|
||||
<v-col cols="12" class="d-flex flex-row flex-wrap justify-center">
|
||||
<v-card-title class="ma-0 pa-0">{{ $tc("recipe-finder.no-recipes-found") }}</v-card-title>
|
||||
<v-card-text class="ma-0 pa-0 text-center">
|
||||
{{ $tc("recipe-finder.no-recipes-found-description") }}
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<v-container v-else>
|
||||
<v-row>
|
||||
<v-col cols="12" class="d-flex justify-center">
|
||||
<div class="text-center">
|
||||
<AppLoader waiting-text="" />
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
toRefs,
|
||||
useContext,
|
||||
useRoute,
|
||||
watch
|
||||
} from "@nuxtjs/composition-api";
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { usePublicExploreApi } from "~/composables/api/api-client";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useFoodStore, usePublicFoodStore, useToolStore, usePublicToolStore } from "~/composables/store";
|
||||
import { IngredientFood, RecipeSuggestionQuery, RecipeSuggestionResponseItem, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import RecipeSuggestion from "~/components/Domain/Recipe/RecipeSuggestion.vue";
|
||||
import SearchFilter from "~/components/Domain/SearchFilter.vue";
|
||||
import { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
import { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
import { useRecipeFinderPreferences } from "~/composables/use-users/preferences";
|
||||
|
||||
interface RecipeSuggestions {
|
||||
readyToMake: RecipeSuggestionResponseItem[];
|
||||
missingItems: RecipeSuggestionResponseItem[];
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: { QueryFilterBuilder, RecipeSuggestion, SearchFilter },
|
||||
setup() {
|
||||
const { $auth, $vuetify, i18n } = useContext();
|
||||
const route = useRoute();
|
||||
const useMobile = computed(() => $vuetify.breakpoint.smAndDown);
|
||||
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||
|
||||
const preferences = useRecipeFinderPreferences();
|
||||
const state = reactive({
|
||||
ready: false,
|
||||
loading: false,
|
||||
recipesReady: false,
|
||||
settingsMenu: false,
|
||||
queryFilterMenu: false,
|
||||
queryFilterMenuKey: 0,
|
||||
queryFilterEditorValue: "",
|
||||
queryFilterEditorValueJSON: {},
|
||||
queryFilterJSON: preferences.value.queryFilterJSON,
|
||||
settings: {
|
||||
maxMissingFoods: preferences.value.maxMissingFoods,
|
||||
maxMissingTools: preferences.value.maxMissingTools,
|
||||
includeFoodsOnHand: preferences.value.includeFoodsOnHand,
|
||||
includeToolsOnHand: preferences.value.includeToolsOnHand,
|
||||
queryFilter: preferences.value.queryFilter,
|
||||
limit: 20,
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!isOwnGroup.value) {
|
||||
state.settings.includeFoodsOnHand = false;
|
||||
state.settings.includeToolsOnHand = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => state,
|
||||
(newState) => {
|
||||
preferences.value.queryFilter = newState.settings.queryFilter;
|
||||
preferences.value.queryFilterJSON = newState.queryFilterJSON;
|
||||
preferences.value.maxMissingFoods = newState.settings.maxMissingFoods;
|
||||
preferences.value.maxMissingTools = newState.settings.maxMissingTools;
|
||||
preferences.value.includeFoodsOnHand = newState.settings.includeFoodsOnHand;
|
||||
preferences.value.includeToolsOnHand = newState.settings.includeToolsOnHand;
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
|
||||
const attrs = computed(() => {
|
||||
return {
|
||||
title: {
|
||||
class: {
|
||||
readyToMake: "ma-0 pa-0",
|
||||
missingItems: recipeSuggestions.value.readyToMake.length ? "ma-0 pa-0 mt-5" : "ma-0 pa-0",
|
||||
},
|
||||
},
|
||||
searchFilter: {
|
||||
colClass: useMobile.value ? "d-flex flex-wrap justify-end" : "d-flex flex-wrap justify-start",
|
||||
filterClass: useMobile.value ? "ml-4 mb-2" : "mr-4 mb-2",
|
||||
},
|
||||
settings: {
|
||||
colClass: useMobile.value ? "d-flex flex-wrap justify-end" : "d-flex flex-wrap justify-start",
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
const foodStore = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
|
||||
const selectedFoods = ref<IngredientFood[]>([]);
|
||||
function addFood(food: IngredientFood) {
|
||||
selectedFoods.value.push(food);
|
||||
handleFoodUpdates();
|
||||
}
|
||||
function removeFood(food: IngredientFood) {
|
||||
selectedFoods.value = selectedFoods.value.filter((f) => f.id !== food.id);
|
||||
handleFoodUpdates();
|
||||
}
|
||||
function handleFoodUpdates() {
|
||||
selectedFoods.value.sort((a, b) => (a.pluralName || a.name).localeCompare(b.pluralName || b.name));
|
||||
preferences.value.foodIds = selectedFoods.value.map((food) => food.id);
|
||||
}
|
||||
watch(
|
||||
() => selectedFoods.value,
|
||||
() => {
|
||||
handleFoodUpdates();
|
||||
},
|
||||
)
|
||||
|
||||
const toolStore = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
|
||||
const selectedTools = ref<RecipeTool[]>([]);
|
||||
function addTool(tool: RecipeTool) {
|
||||
selectedTools.value.push(tool);
|
||||
handleToolUpdates();
|
||||
}
|
||||
function removeTool(tool: RecipeTool) {
|
||||
selectedTools.value = selectedTools.value.filter((t) => t.id !== tool.id);
|
||||
handleToolUpdates();
|
||||
}
|
||||
function handleToolUpdates() {
|
||||
selectedTools.value.sort((a, b) => a.name.localeCompare(b.name));
|
||||
preferences.value.toolIds = selectedTools.value.map((tool) => tool.id);
|
||||
}
|
||||
watch(
|
||||
() => selectedTools.value,
|
||||
() => {
|
||||
handleToolUpdates();
|
||||
}
|
||||
)
|
||||
|
||||
async function hydrateFoods() {
|
||||
if (!preferences.value.foodIds.length) {
|
||||
return;
|
||||
}
|
||||
if (!foodStore.store.value.length) {
|
||||
await foodStore.actions.refresh();
|
||||
}
|
||||
|
||||
const foods = preferences.value.foodIds
|
||||
.map((foodId) => foodStore.store.value.find((food) => food.id === foodId))
|
||||
.filter((food) => !!food);
|
||||
|
||||
selectedFoods.value = foods;
|
||||
}
|
||||
|
||||
async function hydrateTools() {
|
||||
if (!preferences.value.toolIds.length) {
|
||||
return;
|
||||
}
|
||||
if (!toolStore.store.value.length) {
|
||||
await toolStore.actions.refresh();
|
||||
}
|
||||
|
||||
const tools = preferences.value.toolIds
|
||||
.map((toolId) => toolStore.store.value.find((tool) => tool.id === toolId))
|
||||
.filter((tool) => !!tool);
|
||||
|
||||
selectedTools.value = tools;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([hydrateFoods(), hydrateTools()]);
|
||||
state.ready = true;
|
||||
if (!selectedFoods.value.length) {
|
||||
state.recipesReady = true;
|
||||
};
|
||||
});
|
||||
|
||||
const recipeResponseItems = ref<RecipeSuggestionResponseItem[]>([]);
|
||||
const recipeSuggestions = computed<RecipeSuggestions>(() => {
|
||||
const readyToMake: RecipeSuggestionResponseItem[] = [];
|
||||
const missingItems: RecipeSuggestionResponseItem[] = [];
|
||||
recipeResponseItems.value.forEach((responseItem) => {
|
||||
if (responseItem.missingFoods.length === 0 && responseItem.missingTools.length === 0) {
|
||||
readyToMake.push(responseItem);
|
||||
} else {
|
||||
missingItems.push(responseItem);
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
readyToMake,
|
||||
missingItems,
|
||||
};
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
[selectedFoods, selectedTools, state.settings], async () => {
|
||||
// don't search for suggestions if no foods are selected
|
||||
if(!selectedFoods.value.length) {
|
||||
recipeResponseItems.value = [];
|
||||
state.recipesReady = true;
|
||||
return;
|
||||
}
|
||||
|
||||
state.loading = true;
|
||||
const { data } = await api.recipes.getSuggestions(
|
||||
{
|
||||
limit: state.settings.limit,
|
||||
queryFilter: state.settings.queryFilter,
|
||||
maxMissingFoods: state.settings.maxMissingFoods,
|
||||
maxMissingTools: state.settings.maxMissingTools,
|
||||
includeFoodsOnHand: state.settings.includeFoodsOnHand,
|
||||
includeToolsOnHand: state.settings.includeToolsOnHand,
|
||||
} as RecipeSuggestionQuery,
|
||||
selectedFoods.value.map((food) => food.id),
|
||||
selectedTools.value.map((tool) => tool.id),
|
||||
);
|
||||
state.loading = false;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
recipeResponseItems.value = data.items;
|
||||
state.recipesReady = true;
|
||||
},
|
||||
{
|
||||
debounce: 500,
|
||||
},
|
||||
);
|
||||
|
||||
const queryFilterBuilderFields: FieldDefinition[] = [
|
||||
{
|
||||
name: "recipe_category.id",
|
||||
label: i18n.tc("category.categories"),
|
||||
type: Organizer.Category,
|
||||
},
|
||||
{
|
||||
name: "tags.id",
|
||||
label: i18n.tc("tag.tags"),
|
||||
type: Organizer.Tag,
|
||||
},
|
||||
{
|
||||
name: "household_id",
|
||||
label: i18n.tc("household.households"),
|
||||
type: Organizer.Household,
|
||||
},
|
||||
];
|
||||
|
||||
function clearQueryFilter() {
|
||||
state.queryFilterEditorValue = "";
|
||||
state.queryFilterEditorValueJSON = { parts: [] } as QueryFilterJSON;
|
||||
state.settings.queryFilter = "";
|
||||
state.queryFilterJSON = { parts: [] } as QueryFilterJSON;
|
||||
state.queryFilterMenu = false;
|
||||
state.queryFilterMenuKey += 1;
|
||||
}
|
||||
|
||||
function saveQueryFilter() {
|
||||
state.settings.queryFilter = state.queryFilterEditorValue || "";
|
||||
state.queryFilterJSON = state.queryFilterEditorValueJSON || { parts: [] } as QueryFilterJSON;
|
||||
state.queryFilterMenu = false;
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
useMobile,
|
||||
attrs,
|
||||
isOwnGroup,
|
||||
foods: foodStore.store,
|
||||
selectedFoods,
|
||||
addFood,
|
||||
removeFood,
|
||||
tools: toolStore.store,
|
||||
selectedTools,
|
||||
addTool,
|
||||
removeTool,
|
||||
recipeSuggestions,
|
||||
queryFilterBuilderFields,
|
||||
clearQueryFilter,
|
||||
saveQueryFilter,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$tc("recipe-finder.recipe-finder"),
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -290,6 +290,7 @@ import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabe
|
|||
import { useLocales } from "~/composables/use-locales";
|
||||
import { useFoodStore, useLabelStore } from "~/composables/store";
|
||||
import { VForm } from "~/types/vuetify";
|
||||
import { MultiPurposeLabelOut } from "~/lib/api/types/labels";
|
||||
|
||||
export default defineComponent({
|
||||
components: { MultiPurposeLabel, RecipeDataAliasManagerDialog },
|
||||
|
@ -325,6 +326,11 @@ export default defineComponent({
|
|||
text: i18n.tc("shopping-list.label"),
|
||||
value: "label",
|
||||
show: true,
|
||||
sort: (label1: MultiPurposeLabelOut | null, label2: MultiPurposeLabelOut | null) => {
|
||||
const label1Name = label1?.name || "";
|
||||
const label2Name = label2?.name || "";
|
||||
return label1Name.localeCompare(label2Name);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: i18n.tc("tool.on-hand"),
|
||||
|
|
|
@ -136,12 +136,6 @@
|
|||
<v-card>
|
||||
<RecipeDataTable v-model="selected" :loading="loading" :recipes="allRecipes" :show-headers="headers" />
|
||||
<v-card-actions class="justify-end">
|
||||
<BaseButton color="info">
|
||||
<template #icon>
|
||||
{{ $globals.icons.database }}
|
||||
</template>
|
||||
{{ $t('general.import') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
color="info"
|
||||
@click="
|
||||
|
@ -218,6 +212,8 @@ export default defineComponent({
|
|||
tags: true,
|
||||
tools: true,
|
||||
categories: true,
|
||||
recipeServings: false,
|
||||
recipeYieldQuantity: false,
|
||||
recipeYield: false,
|
||||
dateAdded: false,
|
||||
});
|
||||
|
@ -228,7 +224,9 @@ export default defineComponent({
|
|||
tags: i18n.t("tag.tags"),
|
||||
categories: i18n.t("recipe.categories"),
|
||||
tools: i18n.t("tool.tools"),
|
||||
recipeYield: i18n.t("recipe.recipe-yield"),
|
||||
recipeServings: i18n.t("recipe.recipe-servings"),
|
||||
recipeYieldQuantity: i18n.t("recipe.recipe-yield"),
|
||||
recipeYield: i18n.t("recipe.recipe-yield-text"),
|
||||
dateAdded: i18n.t("general.date-added"),
|
||||
};
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
)"
|
||||
color="primary"
|
||||
:icon="$globals.icons.foods"
|
||||
:submit-disabled="isCreateDisabled"
|
||||
@submit="
|
||||
if (newMeal.existing) {
|
||||
actions.updateOne(newMeal);
|
||||
|
@ -70,9 +71,10 @@
|
|||
item-text="name"
|
||||
item-value="id"
|
||||
:return-object="false"
|
||||
:rules="[requiredRule]"
|
||||
/>
|
||||
<template v-else>
|
||||
<v-text-field v-model="newMeal.title" :label="$t('meal-plan.meal-title')" />
|
||||
<v-text-field v-model="newMeal.title" :rules="[requiredRule]" :label="$t('meal-plan.meal-title')" />
|
||||
<v-textarea v-model="newMeal.text" rows="2" :label="$t('meal-plan.meal-note')" />
|
||||
</template>
|
||||
</v-card-text>
|
||||
|
@ -253,6 +255,7 @@ export default defineComponent({
|
|||
const api = useUserApi();
|
||||
const { $auth } = useContext();
|
||||
const { household } = useHouseholdSelf();
|
||||
const requiredRule = (value: any) => !!value || "Required."
|
||||
|
||||
const state = ref({
|
||||
dialog: false,
|
||||
|
@ -315,6 +318,14 @@ export default defineComponent({
|
|||
userId: $auth.user?.id || "",
|
||||
});
|
||||
|
||||
const isCreateDisabled = computed(() => {
|
||||
if (dialog.note) {
|
||||
return !newMeal.title.trim();
|
||||
}
|
||||
return !newMeal.recipeId;
|
||||
});
|
||||
|
||||
|
||||
function openDialog(date: Date) {
|
||||
newMeal.date = format(date, "yyyy-MM-dd");
|
||||
state.value.dialog = true;
|
||||
|
@ -373,6 +384,8 @@ export default defineComponent({
|
|||
onMoveCallback,
|
||||
planTypeOptions,
|
||||
getEntryTypeText,
|
||||
requiredRule,
|
||||
isCreateDisabled,
|
||||
|
||||
// Dialog
|
||||
dialog,
|
||||
|
|
|
@ -21,6 +21,12 @@
|
|||
<BaseButton create @click="createDialog = true" />
|
||||
</v-container>
|
||||
|
||||
<v-container v-if="!shoppingListChoices.length">
|
||||
<BasePageTitle>
|
||||
<template #title>{{ $t('shopping-list.no-shopping-lists-found') }}</template>
|
||||
</BasePageTitle>
|
||||
</v-container>
|
||||
|
||||
<section>
|
||||
<v-card
|
||||
v-for="list in shoppingListChoices"
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
script_location = %(here)s
|
||||
|
||||
# template used to generate migration files
|
||||
file_template = %%(year)d-%%(month).2d-%%(day).2d-%%(hour).2d.%%(minute).2d.%%(second).2d_%%(rev)s_%%(slug)s
|
|
@ -1,9 +1,9 @@
|
|||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import context
|
||||
|
||||
import mealie.db.models._all_models # noqa: F401
|
||||
from alembic import context
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.db.models._model_base import SqlAlchemyBase
|
||||
|
|
@ -6,9 +6,9 @@ Create Date: ${create_date}
|
|||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
% if imports:
|
||||
${imports}
|
||||
% endif
|
|
@ -9,10 +9,10 @@ Create Date: 2024-02-23 16:15:07.115641
|
|||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import orm
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
from mealie.core.root_logger import get_logger
|
||||
|
||||
logger = get_logger()
|
|
@ -6,16 +6,16 @@ Create Date: 2024-03-18 02:28:15.896959
|
|||
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import orm
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "d7c6efd2de42"
|
||||
|
@ -34,7 +34,7 @@ def new_user_rating(user_id: Any, recipe_id: Any, rating: float | None = None, i
|
|||
else:
|
||||
id = "%.32x" % uuid4().int # noqa: UP031
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
now = datetime.now(UTC).isoformat()
|
||||
return {
|
||||
"id": id,
|
||||
"user_id": user_id,
|
|
@ -7,9 +7,9 @@ Create Date: 2024-04-07 01:05:20.816270
|
|||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "7788478a0338"
|
|
@ -7,9 +7,8 @@ Create Date: 2024-06-22 10:17:03.323966
|
|||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import orm
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "32d69327997b"
|
|
@ -6,17 +6,17 @@ Create Date: 2024-07-12 16:16:29.973929
|
|||
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from slugify import slugify
|
||||
from sqlalchemy import orm
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
from mealie.core.config import get_app_settings
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
|
@ -89,7 +89,7 @@ def dedupe_cookbook_slugs():
|
|||
def create_household(session: orm.Session, group_id: str) -> str:
|
||||
# create/insert household
|
||||
household_id = generate_id()
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
timestamp = datetime.now(UTC).isoformat()
|
||||
household_data = {
|
||||
"id": household_id,
|
||||
"name": settings.DEFAULT_HOUSEHOLD,
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue