Compare commits

..

1 Commits

Author SHA1 Message Date
Kuchenpirat
df4fd7ed7f fix: build pull request image only if source is in mealie repo 2025-04-08 18:44:31 +02:00
617 changed files with 52044 additions and 711145 deletions

View File

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

View File

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

View File

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

View File

@@ -1,114 +0,0 @@
name: Automatic Locale Sync
on:
schedule:
# Run every Sunday at 2 AM UTC
- cron: "0 2 * * 0"
workflow_dispatch:
# Allow manual triggering from the GitHub UI
permissions:
contents: write # To checkout, commit, and push changes
pull-requests: write # To create pull requests
jobs:
sync-locales:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
- name: Check venv cache
id: cache-validate
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
run: |
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
rm test.py
continue-on-error: true
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
poetry install
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
- name: Run locale generation
run: |
cd dev/code-generation
poetry run python main.py locales
env:
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
- name: Check for changes
id: changes
run: |
if git diff --quiet; then
echo "has_changes=false" >> $GITHUB_OUTPUT
else
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
- name: Commit and create PR
if: steps.changes.outputs.has_changes == 'true'
run: |
# Configure git
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
# Use the current branch as the base
BASE_BRANCH="${{ github.ref_name }}"
echo "Using base branch: $BASE_BRANCH"
# Create a new branch from the base branch
BRANCH_NAME="auto-locale-sync-$(date +%Y%m%d-%H%M%S)"
git checkout -b "$BRANCH_NAME"
# Add and commit changes
git add .
git commit -m "chore: automatic locale sync"
# Push the branch
git push origin "$BRANCH_NAME"
sleep 2
# Create PR using GitHub CLI with explicit repository
gh pr create \
--repo "${{ github.repository }}" \
--title "chore: automatic locale sync" \
--base "$BASE_BRANCH" \
--head "$BRANCH_NAME" \
--body "## Summary
Automatically generated locale updates from the weekly sync job.
## Changes
- Updated frontend locale files
- Generated from latest translation sources" \
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: No changes detected
if: steps.changes.outputs.has_changes == 'false'
run: echo "No locale changes detected, skipping PR creation"

View File

@@ -17,7 +17,7 @@ jobs:
name: Build Package
uses: ./.github/workflows/build-package.yml
with:
tag: ${{ github.event.release.tag_name }}
tag: release
publish:
permissions:

View File

@@ -16,13 +16,12 @@ jobs:
with:
stale-issue-label: 'stale'
exempt-issue-labels: 'pinned,security,early-stages,bug: confirmed,feedback,task'
stale-issue-message: 'This issue has been automatically marked as stale because it has been open 90 days with no activity.'
days-before-issue-stale: 90
# This stops an issue from ever getting closed automatically.
days-before-issue-close: -1
stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.'
days-before-issue-stale: 30
days-before-issue-close: 5
stale-pr-label: 'stale'
stale-pr-message: 'This PR has been automatically marked as stale because it has been open 90 days with no activity.'
days-before-pr-stale: 90
stale-pr-message: 'This PR is stale because it has been open 45 days with no activity.'
days-before-pr-stale: 45
# This stops a PR from ever getting closed automatically.
days-before-pr-close: -1
# If an issue/PR has a milestone, it's exempt from being marked as stale.

View File

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

5
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -70,7 +70,7 @@ tasks:
dev:generate:
desc: run code generators
cmds:
- poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
- poetry run python dev/code-generation/main.py
- task: py:format
dev:services:
@@ -243,7 +243,7 @@ tasks:
desc: runs the frontend server
dir: frontend
cmds:
- yarn run dev --no-fork
- yarn run dev
docker:build-from-package:
desc: Builds the Docker image from the existing Python package in dist/

View File

@@ -1,4 +1,3 @@
import os
import pathlib
from dataclasses import dataclass
from pathlib import Path
@@ -14,7 +13,7 @@ from mealie.schema._mealie import MealieModel
BASE = pathlib.Path(__file__).parent.parent.parent
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") or os.environ.get("CROWDIN_API_KEY", "")
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY")
@dataclass
@@ -24,22 +23,19 @@ class LocaleData:
LOCALE_DATA: dict[str, LocaleData] = {
"en-US": LocaleData(name="American English"),
"en-GB": LocaleData(name="British English"),
"af-ZA": LocaleData(name="Afrikaans (Afrikaans)"),
"ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"),
"bg-BG": LocaleData(name="Български (Bulgarian)"),
"ca-ES": LocaleData(name="Català (Catalan)"),
"cs-CZ": LocaleData(name="Čeština (Czech)"),
"da-DK": LocaleData(name="Dansk (Danish)"),
"de-DE": LocaleData(name="Deutsch (German)"),
"el-GR": LocaleData(name="Ελληνικά (Greek)"),
"en-GB": LocaleData(name="British English"),
"en-US": LocaleData(name="American English"),
"es-ES": LocaleData(name="Español (Spanish)"),
"et-EE": LocaleData(name="Eesti (Estonian)"),
"fi-FI": LocaleData(name="Suomi (Finnish)"),
"fr-BE": LocaleData(name="Belge (Belgian)"),
"fr-CA": LocaleData(name="Français canadien (Canadian French)"),
"fr-FR": LocaleData(name="Français (French)"),
"fr-BE": LocaleData(name="Belge (Belgian)"),
"gl-ES": LocaleData(name="Galego (Galician)"),
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
"hr-HR": LocaleData(name="Hrvatski (Croatian)"),
@@ -57,7 +53,6 @@ LOCALE_DATA: dict[str, LocaleData] = {
"pt-PT": LocaleData(name="Português (Portuguese)"),
"ro-RO": LocaleData(name="Română (Romanian)"),
"ru-RU": LocaleData(name="Pусский (Russian)"),
"sk-SK": LocaleData(name="Slovenčina (Slovak)"),
"sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
"sr-SP": LocaleData(name="српски (Serbian)"),
"sv-SE": LocaleData(name="Svenska (Swedish)"),
@@ -76,7 +71,7 @@ export const LOCALES = [{% for locale in locales %}
progress: {{ locale.progress }},
dir: "{{ locale.dir }}",
},{% endfor %}
];
]
"""
@@ -98,8 +93,8 @@ class CrowdinApi:
project_id = "451976"
api_key = API_KEY
def __init__(self, api_key: str | None):
self.api_key = api_key or API_KEY
def __init__(self, api_key: str):
api_key = api_key
@property
def headers(self) -> dict:
@@ -161,13 +156,12 @@ PROJECT_DIR = Path(__file__).parent.parent.parent
datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats"
locales_dir = PROJECT_DIR / "frontend" / "lang" / "messages"
nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.ts"
i18n_config = PROJECT_DIR / "frontend" / "i18n.config.ts"
nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.js"
reg_valid = PROJECT_DIR / "mealie" / "schema" / "_mealie" / "validators.py"
"""
This snippet walks the message and dat locales directories and generates the import information
for the nuxt.config.ts file and automatically injects it into the nuxt.config.ts file. Note that
for the nuxt.config.js file and automatically injects it into the nuxt.config.js file. Note that
the code generation ID is hardcoded into the script and required in the nuxt config.
"""
@@ -179,12 +173,12 @@ def inject_nuxt_values():
all_langs = []
for match in locales_dir.glob("*.json"):
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}" }},'
lang_string = f'{{ code: "{match.stem}", file: "{match.name}" }},'
all_langs.append(lang_string)
log.debug(f"injecting locales into nuxt config -> {nuxt_config}")
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)
inject_inline(i18n_config, CodeKeys.nuxt_local_dates, all_date_locales)
inject_inline(nuxt_config, CodeKeys.nuxt_local_dates, all_date_locales)
def inject_registration_validation_values():
@@ -201,7 +195,7 @@ def inject_registration_validation_values():
def generate_locales_ts_file():
api = CrowdinApi(None)
api = CrowdinApi("")
models = api.get_languages()
tmpl = Template(LOCALE_TEMPLATE)
rendered = tmpl.render(locales=models)

View File

@@ -1,4 +1,3 @@
import argparse
from pathlib import Path
import gen_py_pytest_data_paths
@@ -12,39 +11,15 @@ CWD = Path(__file__).parent
def main():
parser = argparse.ArgumentParser(description="Run code generators")
parser.add_argument(
"generators",
nargs="*",
help="Specific generators to run (schema, types, locales, data-paths, routes). If none specified, all will run.", # noqa: E501 - long line
)
args = parser.parse_args()
items = [
(gen_py_schema_exports.main, "schema exports"),
(gen_ts_types.main, "frontend types"),
(gen_ts_locales.main, "locales"),
(gen_py_pytest_data_paths.main, "test data paths"),
(gen_py_pytest_routes.main, "pytest routes"),
]
# Define all available generators
all_generators = {
"schema": (gen_py_schema_exports.main, "schema exports"),
"types": (gen_ts_types.main, "frontend types"),
"locales": (gen_ts_locales.main, "locales"),
"data-paths": (gen_py_pytest_data_paths.main, "test data paths"),
"routes": (gen_py_pytest_routes.main, "pytest routes"),
}
# Determine which generators to run
if args.generators:
# Validate requested generators
invalid_generators = [g for g in args.generators if g not in all_generators]
if invalid_generators:
log.error(f"Invalid generator(s): {', '.join(invalid_generators)}")
log.info(f"Available generators: {', '.join(all_generators.keys())}")
return
generators_to_run = [(all_generators[g][0], all_generators[g][1]) for g in args.generators]
else:
# Run all generators (default behavior)
generators_to_run = list(all_generators.values())
# Run the selected generators
for func, name in generators_to_run:
for func, name in items:
log.info(f"Generating {name}...")
func()

View File

@@ -1,4 +1,5 @@
import logging
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
@@ -34,7 +35,7 @@ class CodeSlicer:
start: int
end: int
indentation: str | None
indentation: str
text: list[str]
_next_line = None
@@ -46,24 +47,15 @@ class CodeSlicer:
def push_line(self, string: str) -> None:
self._next_line = self._next_line or self.start + 1
self.text.insert(self._next_line, (self.indentation or "") + string + "\n")
self.text.insert(self._next_line, self.indentation + string + "\n")
self._next_line += 1
def get_indentation_of_string(line: str) -> str:
# Extract everything before the comment
if "//" in line:
indentation = line.split("//")[0]
elif "#" in line:
indentation = line.split("#")[0]
else:
indentation = line
# Keep only the whitespace, remove any non-whitespace characters
return "".join(c for c in indentation if c.isspace())
def get_indentation_of_string(line: str, comment_char: str = "//|#") -> str:
return re.sub(rf"{comment_char}.*", "", line).removesuffix("\n")
def find_start_end(file_text: list[str], gen_id: str) -> tuple[int, int, str | None]:
def find_start_end(file_text: list[str], gen_id: str) -> tuple[int, int, str]:
start = None
end = None
indentation = None

View File

@@ -0,0 +1,24 @@
![Recipe Image](../../images/{{ recipe.slug }}/original.jpg)
# {{ recipe.name }}
{{ recipe.description }}
## Ingredients
{% for ingredient in recipe.recipeIngredient %}
- [ ] {{ ingredient }} {% endfor %}
## Instructions
{% for step in recipe.recipeInstructions %}
- [ ] {{ step.text }} {% endfor %}
{% for note in recipe.notes %}
**{{ note.title }}:** {{ note.text }}
{% endfor %}
---
Tags: {{ recipe.tags }}
Categories: {{ recipe.categories }}
Original URL: {{ recipe.orgURL }}

View File

@@ -1,75 +0,0 @@
import glob
import json
import pathlib
def get_seed_locale_names() -> set[str]:
"""Find all locales in the seed/resources/ folder
Returns:
A set of every file name where there's both a seed label and seed food file
"""
LABELS_PATH = "/workspaces/mealie/mealie/repos/seed/resources/labels/locales/"
FOODS_PATH = "/workspaces/mealie/mealie/repos/seed/resources/foods/locales/"
label_locales = glob.glob("*.json", root_dir=LABELS_PATH)
foods_locales = glob.glob("*.json", root_dir=FOODS_PATH)
# ensure that a locale has both a label and a food seed file
return set(label_locales).intersection(foods_locales)
def get_labels_from_file(locale: str) -> list[str]:
"""Query a locale to get all of the labels so that they can be added to the new foods seed format
Returns:
All of the labels found within the seed file for a given locale
"""
locale_path = pathlib.Path("/workspaces/mealie/mealie/repos/seed/resources/labels/locales/" + locale)
label_names = [label["name"] for label in json.loads(locale_path.read_text(encoding="utf-8"))]
return label_names
def transform_foods(locale: str):
"""
Convert the current food seed file for a locale into a new format which maps each food to a label
Existing format of foods seed file is a dictionary where each key is a food name and the values are a dictionary
of attributes such as name and plural_name
New format maps each food to a label. The top-level dictionary has each key as a label e.g. "Fruits".
Each label key as a value that is a dictionary with an element called "foods"
"Foods" is a dictionary of each food for that label, with a key of the english food name e.g. "baking-soda"
and a value of attributes, including the translated name of the item e.g. "bicarbonate of soda" for en-GB.
"""
locale_path = pathlib.Path("/workspaces/mealie/mealie/repos/seed/resources/foods/locales/" + locale)
with open(locale_path, encoding="utf-8") as infile:
data = json.load(infile)
first_value = next(iter(data.values()))
if isinstance(first_value, dict) and "foods" in first_value:
# Locale is already in the new format, skipping transformation
return
transformed_data = {"": {"foods": dict(data.items())}}
# Seeding for labels now pulls from the foods file and parses the labels from there (as top-level keys),
# thus we need to add all of the existing labels to the new food seed file and give them an empty foods dictionary
label_names = get_labels_from_file(locale)
for label in label_names:
transformed_data[label] = {"foods": {}}
with open(locale_path, "w", encoding="utf-8") as outfile:
json.dump(transformed_data, outfile, indent=4, ensure_ascii=False)
def main():
for locale in get_seed_locale_names():
transform_foods(locale)
if __name__ == "__main__":
main()

View File

@@ -1,7 +1,7 @@
###############################################
# Frontend Build
###############################################
FROM node:20 AS frontend-builder
FROM node:16 AS frontend-builder
WORKDIR /frontend
@@ -20,7 +20,7 @@ RUN yarn generate
###############################################
# Base Image - Python
###############################################
FROM python:3.12-slim AS python-base
FROM python:3.12-slim as python-base
ENV MEALIE_HOME="/app"
@@ -119,7 +119,7 @@ RUN . $VENV_PATH/bin/activate \
###############################################
# Production Image
###############################################
FROM python-base AS production
FROM python-base as production
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
ENV PRODUCTION=true
ENV TESTING=false

View File

@@ -1,27 +0,0 @@
!!! info
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
An easy way to add recipes to Mealie from an Apple device is via an Apple Shortcut. This is a short guide to install an configure a shortcut able to add recipes via a link or image(s).
*Note: if adding via images make sure to enable [Mealie's openai integration](https://docs.mealie.io/documentation/getting-started/installation/open-ai/)*
## Javascript can only be run via Shortcuts on the Safari browser on MacOS and iOS. If you do not use Safari you may skip this section
Some sites have begun blocking AI scraping bots, inadvertently blocking the recipe scraping library Mealie uses as well. To circumvent this, the shortcut uses javascript to capture the raw html loaded in the browser and sends that to mealie when possible.
**iOS**
Settings app -> apps -> Shortcuts -> Advanced -> Allow Running Scripts
**MacOS**
Shortcuts app -> Settings (CMD ,) -> Advanced -> Allow Running Scripts
## Initial setup
An API key is needed to authenticate with mealie. To create an api key for a user, navigate to http://YOUR_MEALIE_URL/user/profile/api-tokens. Alternatively you can create a key via the mealie home page by clicking the user's profile pic in the top left -> Api Tokens
The shortcut can be installed via **[This link](https://www.icloud.com/shortcuts/52834724050b42aebe0f2efd8d067360)**. Upon install, replace "MEALIE_API_KEY" with the API key generated previously and "MEALIE_URI" with the full URL used to access your mealie instance e.g. "http://10.0.0.5:9000" or "https://mealie.domain.com".
## Using the shortcut
Once installed, the shortcut will automatically appear as an option when sharing an image or webpage. It can also be useful to add the shortcut to the home screen of your device. If selected from the home screen or shortcuts app, a menu will appear with prompts to import via **taking photo(s)**, **selecting photo(s)**, **scanning a URL**, or **pasting a URL**.
*Note: despite the mealie API being able to accept multiple recipe images for import it is currently impossible to send multiple files in 1 web request via Shortcuts. Instead, the shortcut combines the images into a singular, vertically-concatenated image to send to mealie. This can result in slightly less-accurate text recognition.*

View File

@@ -52,8 +52,6 @@ Before you can start using OIDC Authentication, you must first configure a new c
Take the client id and your discovery URL and update your environment variables to include the required OIDC variables described in [Installation - Backend Configuration](../installation/backend-config.md#openid-connect-oidc).
You might also want to set ALLOW_PASSWORD_LOGIN to false, to hide the username+password inputs, if you want to allow logins only via OIDC.
### Groups
There are two (optional) [environment variables](../installation/backend-config.md#openid-connect-oidc) that can control which of the users in your IdP can log in to Mealie and what permissions they will have. Keep in mind that these groups **do not necessarily correspond to groups in Mealie**. The groups claim is configurable via the `OIDC_GROUPS_CLAIM` environment variable. The groups should be **defined in your IdP** and be returned in the configured claim value.

View File

@@ -36,10 +36,6 @@ Before you can start using OIDC Authentication, you must first configure a new c
http://localhost:9091/login
https://mealie.example.com/login
If you are hosting Mealie behind a reverse proxy (nginx, Caddy, ...) to terminate TLS, make sure to start Mealie's Gunicorn server
with `--forwarded-allow-ips=<ip-of-proxy>`, otherwise the `X-Forwarded-*` headers will be ignored and the generated OIDC redirect
URI will use the wrong scheme (http instead of https). This will lead to authentication errors with strict OIDC providers.
3. Configure origins
If your identity provider enforces CORS on any endpoints, you will need to specify your Mealie URL as an Allowed Origin.

View File

@@ -16,7 +16,6 @@
| API_DOCS | True | Turns on/off access to the API documentation locally |
| TZ | UTC | Must be set to get correct date/time on the server |
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
| ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication |
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
@@ -156,6 +155,8 @@ Setting the following environmental variables will change the theme of the front
### Docker Secrets
### Docker Secrets
> <super>&dagger;</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger
> symbol next to them support the Docker Compose secrets pattern, below.
[Docker Compose secrets][docker-secrets] can be used to secure sensitive information regarding the Mealie implementation

View File

@@ -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:v3.0.1`
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.8.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

View File

@@ -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:v3.0.1 # (3)
image: ghcr.io/mealie-recipes/mealie:v2.8.0 # (3)
container_name: mealie
restart: always
ports:

View File

@@ -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:v3.0.1 # (3)
image: ghcr.io/mealie-recipes/mealie:v2.8.0 # (3)
container_name: mealie
restart: always
ports:

View File

@@ -90,7 +90,7 @@ nav:
- Bulk Url Import: "documentation/community-guide/bulk-url-import.md"
- Home Assistant: "documentation/community-guide/home-assistant.md"
- Import Bookmarklet: "documentation/community-guide/import-recipe-bookmarklet.md"
- iOS Shortcut: "documentation/community-guide/ios-shortcut.md"
- iOS Shortcuts: "documentation/community-guide/ios.md"
- Reverse Proxy (SWAG): "documentation/community-guide/swag.md"
- API Reference: "api/redoc.md"

74
frontend/.eslintrc.js Normal file
View File

@@ -0,0 +1,74 @@
module.exports = {
root: true,
env: {
browser: true,
node: true,
},
parser: "vue-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
requireConfigFile: false,
tsConfigRootDir: __dirname,
project: ["./tsconfig.json"],
extraFileExtensions: [".vue"],
},
extends: [
"@nuxtjs/eslint-config-typescript",
"plugin:nuxt/recommended",
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
// "plugin:prettier/recommended",
"prettier",
],
// Re-add once we use nuxt bridge
// See https://v3.nuxtjs.org/getting-started/bridge#update-nuxtconfig
ignorePatterns: ["nuxt.config.js", "lib/api/types/**/*.ts"],
plugins: ["prettier"],
// add your custom rules here
rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
quotes: ["error", "double"],
"vue/component-name-in-template-casing": ["error", "PascalCase"],
camelcase: 0,
"vue/singleline-html-element-content-newline": "off",
"vue/multiline-html-element-content-newline": "off",
"vue/no-mutating-props": "off",
"vue/no-v-text-v-html-on-component": "warn",
"vue/no-v-for-template-key-on-child": "off",
"vue/valid-v-slot": [
"error",
{
allowModifiers: true,
},
],
"@typescript-eslint/ban-ts-comment": [
"error",
{
"ts-ignore": "allow-with-description",
},
],
"no-restricted-imports": [
"error",
{ paths: ["@vue/reactivity", "@vue/runtime-dom", "@vue/composition-api", "vue-demi"] },
],
// TODO Gradually activate all rules
// Allow Promise in onMounted
"@typescript-eslint/no-misused-promises": [
"error",
{
checksVoidReturn: {
arguments: false,
},
},
],
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-explicit-any": "off",
},
};

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,110 +1,74 @@
<template>
<div v-if="model.length > 0 || edit">
<div v-if="value.length > 0 || edit">
<v-card class="mt-4">
<v-card-title class="py-2">
{{ $t("asset.assets") }}
</v-card-title>
<v-divider class="mx-2" />
<v-list
v-if="model.length > 0"
:flat="!edit"
>
<v-list-item
v-for="(item, i) in model"
:key="i"
>
<template #prepend>
<div class="ma-auto">
<v-tooltip bottom>
<template #activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps">
{{ getIconDefinition(item.icon).icon }}
</v-icon>
</template>
<span>{{ getIconDefinition(item.icon).title }}</span>
</v-tooltip>
</div>
</template>
<v-list-item-title class="pl-2">
{{ item.name }}
</v-list-item-title>
<v-divider class="mx-2"></v-divider>
<v-list v-if="value.length > 0" :flat="!edit">
<v-list-item v-for="(item, i) in value" :key="i">
<v-list-item-icon class="ma-auto">
<v-tooltip bottom>
<template #activator="{ on, attrs }">
<v-icon v-bind="attrs" v-on="on">
{{ getIconDefinition(item.icon).icon }}
</v-icon>
</template>
<span>{{ getIconDefinition(item.icon).title }}</span>
</v-tooltip>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="pl-2">
{{ item.name }}
</v-list-item-title>
</v-list-item-content>
<v-list-item-action>
<v-btn
v-if="!edit"
color="primary"
icon
:href="assetURL(item.fileName ?? '')"
target="_blank"
top
>
<v-btn v-if="!edit" color="primary" icon :href="assetURL(item.fileName)" target="_blank" top>
<v-icon> {{ $globals.icons.download }} </v-icon>
</v-btn>
<div v-else>
<v-btn
color="error"
icon
top
@click="model.splice(i, 1)"
>
<v-btn color="error" icon top @click="value.splice(i, 1)">
<v-icon>{{ $globals.icons.delete }}</v-icon>
</v-btn>
<AppButtonCopy
color=""
:copy-text="assetEmbed(item.fileName ?? '')"
/>
<AppButtonCopy color="" :copy-text="assetEmbed(item.fileName)" />
</div>
</v-list-item-action>
</v-list-item>
</v-list>
</v-card>
<div class="d-flex ml-auto mt-2">
<v-spacer />
<v-spacer></v-spacer>
<BaseDialog
v-model="state.newAssetDialog"
:title="$t('asset.new-asset')"
:title="$tc('asset.new-asset')"
:icon="getIconDefinition(state.newAsset.icon).icon"
can-submit
@submit="addAsset"
>
<template #activator>
<BaseButton
v-if="edit"
size="small"
create
@click="state.newAssetDialog = true"
/>
<BaseButton v-if="edit" small create @click="state.newAssetDialog = true" />
</template>
<v-card-text class="pt-4">
<v-text-field
v-model="state.newAsset.name"
density="compact"
:label="$t('general.name')"
/>
<v-text-field v-model="state.newAsset.name" dense :label="$t('general.name')"></v-text-field>
<div class="d-flex justify-space-between">
<v-select
v-model="state.newAsset.icon"
density="compact"
dense
:prepend-icon="getIconDefinition(state.newAsset.icon).icon"
:items="iconOptions"
item-title="title"
item-text="title"
item-value="name"
class="mr-2"
>
<template #item="{ item }">
<v-avatar>
<v-list-item-avatar>
<v-icon class="mr-auto">
{{ item.raw.icon }}
{{ item.icon }}
</v-icon>
</v-avatar>
</v-list-item-avatar>
{{ item.title }}
</template>
</v-select>
<AppButtonUpload
:post="false"
file-name="file"
:text-btn="false"
@uploaded="setFileObject"
/>
<AppButtonUpload :post="false" file-name="file" :text-btn="false" @uploaded="setFileObject" />
</div>
{{ state.fileObject.name }}
</v-card-text>
@@ -113,109 +77,124 @@
</div>
</template>
<script setup lang="ts">
<script lang="ts">
import { defineComponent, reactive, useContext } from "@nuxtjs/composition-api";
import { useStaticRoutes, useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import type { RecipeAsset } from "~/lib/api/types/recipe";
import { detectServerBaseUrl } from "~/composables/use-utils";
import { RecipeAsset } from "~/lib/api/types/recipe";
const props = defineProps({
slug: {
type: String,
required: true,
export default defineComponent({
props: {
slug: {
type: String,
required: true,
},
recipeId: {
type: String,
required: true,
},
value: {
type: Array as () => RecipeAsset[],
required: true,
},
edit: {
type: Boolean,
default: true,
},
},
recipeId: {
type: String,
required: true,
},
edit: {
type: Boolean,
default: true,
setup(props, context) {
const api = useUserApi();
const state = reactive({
newAssetDialog: false,
fileObject: {} as File,
newAsset: {
name: "",
icon: "mdi-file",
},
});
const { $globals, i18n, req } = useContext();
const iconOptions = [
{
name: "mdi-file",
title: i18n.t("asset.file"),
icon: $globals.icons.file,
},
{
name: "mdi-file-pdf-box",
title: i18n.t("asset.pdf"),
icon: $globals.icons.filePDF,
},
{
name: "mdi-file-image",
title: i18n.t("asset.image"),
icon: $globals.icons.fileImage,
},
{
name: "mdi-code-json",
title: i18n.t("asset.code"),
icon: $globals.icons.codeJson,
},
{
name: "mdi-silverware-fork-knife",
title: i18n.t("asset.recipe"),
icon: $globals.icons.primary,
},
];
const serverBase = detectServerBaseUrl(req);
function getIconDefinition(icon: string) {
return iconOptions.find((item) => item.name === icon) || iconOptions[0];
}
const { recipeAssetPath } = useStaticRoutes();
function assetURL(assetName: string) {
return recipeAssetPath(props.recipeId, assetName);
}
function assetEmbed(name: string) {
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%"> </img>`;
}
function setFileObject(fileObject: File) {
state.fileObject = fileObject;
}
function validFields() {
return state.newAsset.name.length > 0 && state.fileObject.name.length > 0;
}
async function addAsset() {
if (!validFields()) {
alert.error(i18n.t("asset.error-submitting-form") as string);
return;
}
const { data } = await api.recipes.createAsset(props.slug, {
name: state.newAsset.name,
icon: state.newAsset.icon,
file: state.fileObject,
extension: state.fileObject.name.split(".").pop() || "",
});
context.emit("input", [...props.value, data]);
state.newAsset = { name: "", icon: "mdi-file" };
state.fileObject = {} as File;
}
return {
state,
addAsset,
assetURL,
assetEmbed,
getIconDefinition,
iconOptions,
setFileObject,
};
},
});
const model = defineModel<RecipeAsset[]>({ required: true });
const api = useUserApi();
const state = reactive({
newAssetDialog: false,
fileObject: {} as File,
newAsset: {
name: "",
icon: "mdi-file",
},
});
const i18n = useI18n();
const { $globals } = useNuxtApp();
const iconOptions = [
{
name: "mdi-file",
title: i18n.t("asset.file"),
icon: $globals.icons.file,
},
{
name: "mdi-file-pdf-box",
title: i18n.t("asset.pdf"),
icon: $globals.icons.filePDF,
},
{
name: "mdi-file-image",
title: i18n.t("asset.image"),
icon: $globals.icons.fileImage,
},
{
name: "mdi-code-json",
title: i18n.t("asset.code"),
icon: $globals.icons.codeJson,
},
{
name: "mdi-silverware-fork-knife",
title: i18n.t("asset.recipe"),
icon: $globals.icons.primary,
},
];
const serverBase = useRequestURL().origin;
function getIconDefinition(icon: string) {
return iconOptions.find(item => item.name === icon) || iconOptions[0];
}
const { recipeAssetPath } = useStaticRoutes();
function assetURL(assetName: string) {
return recipeAssetPath(props.recipeId, assetName);
}
function assetEmbed(name: string) {
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%"> </img>`;
}
function setFileObject(fileObject: File) {
state.fileObject = fileObject;
}
function validFields() {
return state.newAsset.name.length > 0 && state.fileObject.name.length > 0;
}
async function addAsset() {
if (!validFields()) {
alert.error(i18n.t("asset.error-submitting-form") as string);
return;
}
const { data } = await api.recipes.createAsset(props.slug, {
name: state.newAsset.name,
icon: state.newAsset.icon,
file: state.fileObject,
extension: state.fileObject.name.split(".").pop() || "",
});
if (data) {
model.value = [...model.value, data];
}
state.newAsset = { name: "", icon: "mdi-file" };
state.fileObject = {} as File;
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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