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
687 changed files with 57482 additions and 716946 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

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v5.0.0
hooks:
- id: check-yaml
exclude: "mkdocs.yml"
@@ -12,7 +12,7 @@ repos:
exclude: ^tests/data/
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.9
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,8 +70,7 @@ tasks:
dev:generate:
desc: run code generators
cmds:
- poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
- task: docs:gen
- poetry run python dev/code-generation/main.py
- task: py:format
dev:services:
@@ -244,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

@@ -35,7 +35,7 @@ conventional_commits = true
filter_unconventional = true
# regex for preprocessing the commit messages
commit_preprocessors = [
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/mealie-recipes/mealie/issues/${2}))"},
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/hay-kot/mealie/issues/${2}))"},
]
# regex for parsing and grouping commits
commit_parsers = [

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,18 +173,12 @@ def inject_nuxt_values():
all_langs = []
for match in locales_dir.glob("*.json"):
match_data = LOCALE_DATA.get(match.stem)
match_dir = match_data.dir if match_data else "ltr"
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},'
lang_string = f'{{ code: "{match.stem}", file: "{match.name}" }},'
all_langs.append(lang_string)
all_langs.sort()
all_date_locales.sort()
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():
@@ -207,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

@@ -8,8 +8,8 @@ from utils import log
# ============================================================
template = """// This Code is auto generated by gen_ts_types.py
{% for name in global %}import type {{ name }} from "@/components/global/{{ name }}.vue";
{% endfor %}{% for name in layout %}import type {{ name }} from "@/components/layout/{{ name }}.vue";
{% for name in global %}import {{ name }} from "@/components/global/{{ name }}.vue";
{% endfor %}{% for name in layout %}import {{ name }} from "@/components/layout/{{ name }}.vue";
{% endfor %}
declare module "vue" {
export interface GlobalComponents {

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

@@ -44,6 +44,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 cup unsalted butter, cut into cubes",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "ea3b6702-9532-4fbc-a40b-f99917831c26",
@@ -53,6 +54,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 cup light brown sugar",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "c5bbfefb-1e23-4ffd-af88-c0363a0fae82",
@@ -62,6 +64,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1/2 cup granulated white sugar",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "034f481b-c426-4a17-b983-5aea9be4974b",
@@ -71,6 +74,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "2 large eggs",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "37c1f796-3bdb-4856-859f-dbec90bc27e4",
@@ -80,6 +84,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "2 tsp vanilla extract",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "85561ace-f249-401d-834c-e600a2f6280e",
@@ -89,6 +94,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1/2 cup creamy peanut butter",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "ac91bda0-e8a8-491a-976a-ae4e72418cfd",
@@ -98,6 +104,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 tsp cornstarch",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "4d1256b3-115e-4475-83cd-464fbc304cb0",
@@ -107,6 +114,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 tsp baking soda",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "64627441-39f9-4ee3-8494-bafe36451d12",
@@ -116,6 +124,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1/2 tsp salt",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "7ae212d0-3cd1-44b0-899e-ec5bd91fd384",
@@ -125,6 +134,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 cup cake flour",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "06967994-8548-4952-a8cc-16e8db228ebd",
@@ -134,6 +144,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "2 cups all-purpose flour",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "bdb33b23-c767-4465-acf8-3b8e79eb5691",
@@ -143,6 +154,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "2 cups peanut butter chips",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "12ba0af8-affd-4fb2-9cca-6f1b3e8d3aef",
@@ -152,6 +164,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1½ cups Reese's Pieces candies",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "4bdc0598-a3eb-41ee-8af0-4da9348fbfe2",
@@ -208,6 +221,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"showAssets": False,
"landscapeView": False,
"disableComments": False,
"disableAmount": True,
"locked": False,
},
"assets": [],

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,8 +1,7 @@
###############################################
# Frontend Build
###############################################
FROM node:20@sha256:572a90df10a58ebb7d3f223d661d964a6c2383a9c2b5763162b4f631c53dc56a \
AS frontend-builder
FROM node:16 AS frontend-builder
WORKDIR /frontend
@@ -21,8 +20,7 @@ RUN yarn generate
###############################################
# Base Image - Python
###############################################
FROM python:3.12-slim@sha256:2267adc248a477c1f1a852a07a5a224d42abe54c28aafa572efa157dfb001bba \
AS python-base
FROM python:3.12-slim as python-base
ENV MEALIE_HOME="/app"
@@ -121,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
@@ -134,7 +132,7 @@ RUN apt-get update \
gosu \
iproute2 \
libldap-common \
libldap2 \
libldap-2.5 \
&& rm -rf /var/lib/apt/lists/*
# create directory used for Docker Secrets

View File

@@ -13,14 +13,14 @@ Steps:
#### 1. Get your API Token
Create an API token from Mealie's User Settings page (https://docs.mealie.io/documentation/getting-started/api-usage/#getting-a-token)
Create an API token from Mealie's User Settings page (https://hay-kot.github.io/mealie/documentation/users-groups/user-settings/#api-key-generation)
#### 2. Create Home Assistant Sensors
Create REST sensors in home assistant to get the details of today's meal.
We will create sensors to get the name and ID of the first meal in today's meal plan (note that this may not be what is wanted if there is more than one meal planned for the day). We need the ID as well as the name to be able to retrieve the image for the meal.
Make sure the url and port (`http://mealie:9000`) matches your installation's address and _API_ port.
Make sure the url and port (`http://mealie:9000` ) matches your installation's address and _API_ port.
```yaml
rest:

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.2`
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

@@ -1,8 +1,5 @@
# Installing with PostgreSQL
!!! Warning
When upgrading postgresql major versions, manual steps are required [Postgres#37](https://github.com/docker-library/postgres/issues/37).
PostgreSQL might be considered if you need to support many concurrent users. In addition, some features are only enabled on PostgreSQL, such as fuzzy search.
**For Environment Variable Configuration, see** [Backend Configuration](./backend-config.md)
@@ -10,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.2 # (3)
image: ghcr.io/mealie-recipes/mealie:v2.8.0 # (3)
container_name: mealie
restart: always
ports:
@@ -41,7 +38,7 @@ services:
postgres:
container_name: postgres
image: postgres:17
image: postgres:15
restart: always
volumes:
- mealie-pgdata:/var/lib/postgresql/data
@@ -49,7 +46,6 @@ services:
POSTGRES_PASSWORD: mealie
POSTGRES_USER: mealie
PGUSER: mealie
POSTGRES_DB: mealie
healthcheck:
test: ["CMD", "pg_isready"]
interval: 30s

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

View File

@@ -2,3 +2,6 @@
## Feature Requests
[Please request new features on Github](https://github.com/mealie-recipes/mealie/discussions/new?category=feature-request)
## Progress
See the [Github Projects page](https://github.com/users/hay-kot/projects/2) to see what is currently being worked on

File diff suppressed because one or more lines are too long

View File

@@ -351,7 +351,7 @@
<!-- Custom narrow footer -->
<div class="md-footer-meta__inner md-grid">
<div class="md-footer-social">
<a class="md-footer-social__link" href="https://github.com/mealie-recipes/mealie" rel="noopener" target="_blank"
<a class="md-footer-social__link" href="https://github.com/hay-kot/mealie" rel="noopener" target="_blank"
title="github.com">
<svg style="width: 32px; height: 32px" viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
<path

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>
@@ -44,54 +20,74 @@
</div>
</template>
<script setup lang="ts">
<script lang="ts">
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 type { ReadCookBook } from "~/lib/api/types/cookbook";
import { FieldDefinition } from "~/composables/use-query-filter-builder";
const modelValue = defineModel<ReadCookBook>({ required: true });
const i18n = useI18n();
const cookbook = toRef(modelValue);
function handleInput(value: string | undefined) {
cookbook.value.queryFilterString = value || "";
}
export default defineComponent({
components: { QueryFilterBuilder },
props: {
cookbook: {
type: Object as () => ReadCookBook,
required: true,
},
actions: {
type: Object as () => any,
required: true,
},
},
setup(props) {
const { i18n } = useContext();
const fieldDefs: FieldDefinition[] = [
function handleInput(value: string | undefined) {
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 {
handleInput,
fieldDefs,
};
},
});
</script>

View File

@@ -7,56 +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"
/>
<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>
<!-- 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"
/>
</div>
<div v-if="book.description" class="subtitle-1 text-grey-lighten-1 mb-2">
</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>
@@ -64,49 +52,53 @@
</div>
</template>
<script setup 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 { ReadCookBook } 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";
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 book = getOne(slug);
const tab = ref(null);
const book = getOne(slug);
const isOwnHousehold = computed(() => {
if (!($auth.user.value && book.value?.householdId)) {
const isOwnHousehold = computed(() => {
if (!($auth.user && book.value?.householdId)) {
return false;
}
return $auth.user.value.householdId === book.value.householdId;
});
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
return $auth.user.householdId === book.value.householdId;
})
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
const dialogStates = reactive({
const dialogStates = reactive({
edit: false,
});
});
const editTarget = ref<ReadCookBook | null>(null);
function handleEditCookbook() {
const editTarget = ref<RecipeCookBook | null>(null);
function handleEditCookbook() {
dialogStates.edit = true;
editTarget.value = book.value;
}
}
async function editCookbook() {
async function editCookbook() {
if (!editTarget.value) {
return;
}
@@ -114,17 +106,38 @@ async function editCookbook() {
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 {
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;
}
}
useSeoMeta({
useMeta(() => {
return {
title: book?.value?.name || "Cookbook",
});
</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,46 +7,55 @@
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 setup lang="ts">
<script lang="ts">
import { defineComponent, useContext } from "@nuxtjs/composition-api";
import { parseISO, formatDistanceToNow } from "date-fns";
import type { GroupDataExport } from "~/lib/api/types/group";
import { GroupDataExport } from "~/lib/api/types/group";
export default defineComponent({
props: {
exports: {
type: Array as () => GroupDataExport[],
required: true,
},
},
setup() {
const { i18n } = useContext();
defineProps<{
exports: GroupDataExport[];
}>();
const headers = [
{ text: i18n.t("export.export"), value: "name" },
{ text: i18n.t("export.file-name"), value: "filename" },
{ text: i18n.t("export.size"), value: "size" },
{ text: i18n.t("export.link-expires"), value: "expires" },
{ text: "", value: "actions" },
];
const i18n = useI18n();
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" },
];
function getTimeToExpire(timeString: string) {
function getTimeToExpire(timeString: string) {
const expiresAt = parseISO(timeString);
return formatDistanceToNow(expiresAt, {
addSuffix: false,
});
}
}
function downloadData(_: any) {
function downloadData(_: any) {
console.log("Downloading data...");
}
}
return {
downloadData,
headers,
getTimeToExpire,
};
},
});
</script>

View File

@@ -1,18 +1,36 @@
<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 setup lang="ts">
import type { ReadGroupPreferences } from "~/lib/api/types/user";
<script lang="ts">
import { defineComponent, computed } from "@nuxtjs/composition-api";
const preferences = defineModel<ReadGroupPreferences>({ required: true });
export default defineComponent({
props: {
value: {
type: Object,
required: true,
},
},
setup(props, context) {
const preferences = computed({
get() {
return props.value;
},
set(val) {
context.emit("input", val);
},
});
return {
preferences,
};
},
});
</script>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,91 @@
<template>
<v-select
v-model="selected"
:items="households"
:label="label"
:hint="description"
:persistent-hint="!!description"
item-text="name"
:multiple="multiselect"
:prepend-inner-icon="$globals.icons.household"
return-object
>
<template #selection="data">
<v-chip
:key="data.index"
class="ma-1"
:input-value="data.selected"
small
close
label
color="accent"
dark
@click:close="removeByIndex(data.index)"
>
{{ 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 {
id: string;
name: string;
}
export default defineComponent({
props: {
value: {
type: Array as () => HouseholdLike[],
required: true,
},
multiselect: {
type: Boolean,
default: false,
},
description: {
type: String,
default: "",
},
},
setup(props, context) {
const selected = computed({
get: () => props.value,
set: (val) => {
context.emit("input", val);
},
});
onMounted(() => {
if (selected.value === undefined) {
selected.value = [];
}
});
const { i18n } = useContext();
const label = computed(
() => props.multiselect ? i18n.tc("household.households") : i18n.tc("household.household")
);
const { store: households } = useHouseholdStore();
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}
return {
selected,
label,
households,
removeByIndex,
};
},
});
</script>

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: activatorProps }">
<v-btn
:class="{ 'rounded-circle': fab }"
:size="fab ? 'small' : undefined"
:color="color"
:icon="!fab"
variant="text"
dark
v-bind="activatorProps"
@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>
@@ -50,10 +35,11 @@
</div>
</template>
<script setup lang="ts">
import type { Recipe } from "~/lib/api/types/recipe";
<script lang="ts">
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,75 +50,77 @@ export interface ContextMenuItem {
isPublic: boolean;
}
interface Props {
recipes?: Recipe[];
menuTop?: boolean;
fab?: boolean;
color?: string;
menuIcon?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
recipes: () => [],
menuTop: true,
fab: false,
color: "primary",
menuIcon: null,
});
export default defineComponent({
components: {
RecipeDialogAddToShoppingList,
},
props: {
recipes: {
type: Array as () => Recipe[],
default: () => [],
},
menuTop: {
type: Boolean,
default: true,
},
fab: {
type: Boolean,
default: false,
},
color: {
type: String,
default: "primary",
},
menuIcon: {
type: String,
default: null,
},
},
setup(props, context) {
const { $globals, i18n } = useContext();
const api = useUserApi();
const emit = defineEmits<{
[key: string]: [];
}>();
const { mdAndUp } = useDisplay();
const i18n = useI18n();
const { $globals } = useNuxtApp();
const api = useUserApi();
const state = reactive({
const state = reactive({
loading: false,
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",
isPublic: false,
},
],
});
});
const { shoppingListDialog, menuItems } = toRefs(state);
const icon = props.menuIcon || $globals.icons.dotsVertical;
const icon = props.menuIcon || $globals.icons.dotsVertical;
const shoppingLists = ref<ShoppingListSummary[]>();
const recipesWithScales = computed(() => {
const shoppingLists = ref<ShoppingListSummary[]>();
const recipesWithScales = computed(() => {
return props.recipes.map((recipe) => {
return {
scale: 1,
...recipe,
};
});
});
})
})
async function getShoppingLists() {
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> } = {
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
shoppingList: () => {
getShoppingLists();
state.shoppingListDialog = true;
},
};
};
function contextMenuEventHandler(eventKey: string) {
function contextMenuEventHandler(eventKey: string) {
const handler = eventHandlers[eventKey];
if (handler && typeof handler === "function") {
@@ -141,7 +129,17 @@ function contextMenuEventHandler(eventKey: string) {
return;
}
emit(eventKey);
context.emit(eventKey);
state.loading = false;
}
}
return {
...toRefs(state),
contextMenuEventHandler,
icon,
recipesWithScales,
shoppingLists,
}
},
})
</script>

View File

@@ -1,122 +1,164 @@
<template>
<div>
<div
class="d-md-flex"
style="gap: 10px"
>
<v-select
v-model="day"
:items="MEAL_DAY_OPTIONS"
:label="$t('meal-plan.rule-day')"
/>
<v-select
v-model="entryType"
: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">
<QueryFilterBuilder
:field-defs="fieldDefs"
:initial-query-filter="props.queryFilter"
:initial-query-filter="queryFilter"
@input="handleQueryFilterInput"
/>
</div>
<!-- TODO: proper pluralization of inputDay -->
{{ $t('meal-plan.this-rule-will-apply', {
dayCriteria: day === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [day]),
mealTypeCriteria: entryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [entryType]),
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 setup lang="ts">
<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";
interface Props {
queryFilter?: QueryFilterJSON | null;
showHelp?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
queryFilter: null,
showHelp: false,
});
export default defineComponent({
components: {
QueryFilterBuilder,
},
props: {
day: {
type: String,
default: "unset",
},
entryType: {
type: String,
default: "unset",
},
queryFilterString: {
type: String,
default: "",
},
queryFilter: {
type: Object as () => QueryFilterJSON,
default: null,
},
showHelp: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const { i18n } = useContext();
const day = defineModel<string>("day", { default: "unset" });
const entryType = defineModel<string>("entryType", { default: "unset" });
const queryFilterString = defineModel<string>("queryFilterString", { default: "" });
const MEAL_TYPE_OPTIONS = [
{ 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 i18n = useI18n();
const MEAL_DAY_OPTIONS = [
{ 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 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" },
];
const inputDay = computed({
get: () => {
return props.day;
},
set: (val) => {
context.emit("update:day", val);
},
});
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" },
];
const inputEntryType = computed({
get: () => {
return props.entryType;
},
set: (val) => {
context.emit("update:entry-type", val);
},
});
function handleQueryFilterInput(value: string | undefined) {
console.warn("handleQueryFilterInput called with value:", value);
queryFilterString.value = value || "";
}
const inputQueryFilterString = computed({
get: () => {
return props.queryFilterString;
},
set: (val) => {
context.emit("update:query-filter-string", val);
},
});
const fieldDefs: FieldDefinition[] = [
function handleQueryFilterInput(value: string | undefined) {
inputQueryFilterString.value = 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: "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",
},
];
];
return {
MEAL_TYPE_OPTIONS,
MEAL_DAY_OPTIONS,
inputDay,
inputEntryType,
inputQueryFilterString,
handleQueryFilterInput,
fieldDefs,
};
},
});
</script>

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-text-field
v-model="scheduledTime"
type="time"
clearable
variant="underlined"
/>
<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',
},
]"
@@ -50,25 +33,24 @@
</div>
</template>
<script setup lang="ts">
import type { ReadWebhook } from "~/lib/api/types/household";
<script lang="ts">
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
import { ReadWebhook } from "~/lib/api/types/household";
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
const props = defineProps<{
webhook: ReadWebhook;
}>();
export default defineComponent({
props: {
webhook: {
type: Object as () => ReadWebhook,
required: true,
},
},
emits: ["delete", "save", "test"],
setup(props, { emit }) {
const itemUTC = ref<string>(props.webhook.scheduledTime);
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
const emit = defineEmits<{
delete: [id: string];
save: [webhook: ReadWebhook];
test: [id: string];
}>();
const i18n = useI18n();
const itemUTC = ref<string>(props.webhook.scheduledTime);
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
const scheduledTime = computed({
const scheduledTime = computed({
get() {
return itemLocal.value;
},
@@ -76,17 +58,27 @@ const scheduledTime = computed({
itemUTC.value = timeLocalToUTC(v);
itemLocal.value = v;
},
});
});
const webhookCopy = ref({ ...props.webhook });
const webhookCopy = ref({ ...props.webhook });
function handleSave() {
function handleSave() {
webhookCopy.value.scheduledTime = itemLocal.value;
emit("save", webhookCopy.value);
}
}
// Set page title using useSeoMeta
useSeoMeta({
title: i18n.t("settings.webhooks.webhooks"),
return {
webhookCopy,
scheduledTime,
handleSave,
itemUTC,
itemLocal,
};
},
head() {
return {
title: this.$t("settings.webhooks.webhooks") as string,
};
},
});
</script>

View File

@@ -1,8 +1,13 @@
<template>
<div v-if="preferences">
<BaseCardSectionTitle :title="$t('household.household-preferences')" />
<BaseCardSectionTitle class="mt-10" :title="$tc('household.household-preferences')"></BaseCardSectionTitle>
<div class="mb-6">
<v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
<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") }}
@@ -11,7 +16,12 @@
</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" />
<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") }}
@@ -22,17 +32,20 @@
v-model="preferences.firstDayOfWeek"
:prepend-icon="$globals.icons.calendarWeekBegin"
:items="allDays"
item-title="name"
item-text="name"
item-value="value"
:label="$t('settings.first-day-of-week')"
variant="underlined"
flat
/>
<BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')" />
<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 density="compact" :label="p.label" color="primary" />
<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>
@@ -41,47 +54,60 @@
</div>
</template>
<script setup lang="ts">
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
<script lang="ts">
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
import { ReadHouseholdPreferences } from "~/lib/api/types/household";
const preferences = defineModel<ReadHouseholdPreferences>({ required: true });
const i18n = useI18n();
export default defineComponent({
props: {
value: {
type: Object,
required: true,
},
},
setup(props, context) {
const { i18n } = useContext();
type Preference = {
type Preference = {
key: keyof ReadHouseholdPreferences;
label: string;
description: string;
};
}
const recipePreferences: Preference[] = [
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"),
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.t("group.show-nutrition-information"),
description: i18n.t("group.show-nutrition-information-description"),
label: i18n.tc("group.show-nutrition-information"),
description: i18n.tc("group.show-nutrition-information-description"),
},
{
key: "recipeShowAssets",
label: i18n.t("group.show-recipe-assets"),
description: i18n.t("group.show-recipe-assets-description"),
label: i18n.tc("group.show-recipe-assets"),
description: i18n.tc("group.show-recipe-assets-description"),
},
{
key: "recipeLandscapeView",
label: i18n.t("group.default-to-landscape-view"),
description: i18n.t("group.default-to-landscape-view-description"),
label: i18n.tc("group.default-to-landscape-view"),
description: i18n.tc("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"),
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 = [
const allDays = [
{
name: i18n.t("general.sunday"),
value: 0,
@@ -110,7 +136,24 @@ const allDays = [
name: i18n.t("general.saturday"),
value: 6,
},
];
];
const preferences = computed({
get() {
return props.value;
},
set(val) {
context.emit("input", val);
},
});
return {
allDays,
preferences,
recipePreferences,
};
},
});
</script>
<style lang="css">

View File

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

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="$tc('recipe.delete-recipe')"
color="error"
:icon="$globals.icons.alertCircle"
@confirm="emitDelete()"
>
<BaseDialog v-model="deleteDialog" :title="$t('recipe.delete-recipe')" color="error"
:icon="$globals.icons.alertCircle" can-confirm @confirm="emitDelete()">
<v-card-text>
{{ $t("recipe.delete-confirmation") }}
</v-card-text>
</BaseDialog>
<v-spacer />
<v-spacer></v-spacer>
<div v-if="!open" class="custom-btn-group ma-1">
<RecipeFavoriteBadge v-if="loggedIn" 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" location="bottom" color="info">
<template #activator="{ props: tooltipProps }">
<v-btn
icon
variant="flat"
rounded="circle"
size="small"
color="info"
class="ml-1"
v-bind="tooltipProps"
@click="$emit('edit', true)"
>
<v-icon size="x-large">
{{ $globals.icons.edit }}
</v-icon>
<v-tooltip v-if="canEdit" bottom color="info">
<template #activator="{ on, attrs }">
<v-btn fab small class="ml-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
<v-icon> {{ $globals.icons.edit }} </v-icon>
</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,57 +66,71 @@
<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 setup lang="ts">
<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";
interface Props {
recipe: Recipe;
slug: string;
recipeScale?: number;
open: boolean;
name: string;
loggedIn?: boolean;
recipeId: string;
canEdit?: boolean;
}
withDefaults(defineProps<Props>(), {
recipeScale: 1,
loggedIn: false,
canEdit: false,
});
export default defineComponent({
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
props: {
recipe: {
required: true,
type: Object as () => Recipe,
},
slug: {
required: true,
type: String,
},
recipeScale: {
type: Number,
default: 1,
},
open: {
required: true,
type: Boolean,
},
name: {
required: true,
type: String,
},
loggedIn: {
type: Boolean,
default: false,
},
recipeId: {
required: true,
type: String,
},
canEdit: {
type: Boolean,
default: false,
},
},
setup(_, context) {
const deleteDialog = ref(false);
const emit = defineEmits(["print", "input", "delete", "close", "edit"]);
const deleteDialog = ref(false);
const i18n = useI18n();
const { $globals } = useNuxtApp();
const editorButtons = [
const { i18n, $globals } = useContext();
const editorButtons = [
{
text: i18n.t("general.delete"),
icon: $globals.icons.delete,
@@ -145,27 +155,36 @@ const editorButtons = [
event: SAVE_EVENT,
color: "success",
},
];
];
function emitHandler(event: string) {
function emitHandler(event: string) {
switch (event) {
case CLOSE_EVENT:
emit("close");
emit("input", false);
context.emit(CLOSE_EVENT);
context.emit("input", false);
break;
case DELETE_EVENT:
deleteDialog.value = true;
break;
default:
emit(event as any);
context.emit(event);
break;
}
}
}
function emitDelete() {
emit("delete");
emit("input", false);
}
function emitDelete() {
context.emit(DELETE_EVENT);
context.emit("input", false);
}
return {
deleteDialog,
editorButtons,
emitHandler,
emitDelete,
};
},
});
</script>
<style scoped>
@@ -190,13 +209,9 @@ function emitDelete() {
.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 location="bottom">
<template #activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps">
<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>
</div>
</template>
</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,12 +77,15 @@
</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({
export default defineComponent({
props: {
slug: {
type: String,
required: true,
@@ -127,29 +94,30 @@ const props = defineProps({
type: String,
required: true,
},
value: {
type: Array as () => RecipeAsset[],
required: true,
},
edit: {
type: Boolean,
default: true,
},
});
},
setup(props, context) {
const api = useUserApi();
const model = defineModel<RecipeAsset[]>({ required: true });
const api = useUserApi();
const state = reactive({
const state = reactive({
newAssetDialog: false,
fileObject: {} as File,
newAsset: {
name: "",
icon: "mdi-file",
},
});
});
const i18n = useI18n();
const { $globals } = useNuxtApp();
const { $globals, i18n, req } = useContext();
const iconOptions = [
const iconOptions = [
{
name: "mdi-file",
title: i18n.t("asset.file"),
@@ -175,32 +143,32 @@ const iconOptions = [
title: i18n.t("asset.recipe"),
icon: $globals.icons.primary,
},
];
];
const serverBase = useRequestURL().origin;
const serverBase = detectServerBaseUrl(req);
function getIconDefinition(icon: string) {
return iconOptions.find(item => item.name === icon) || iconOptions[0];
}
function getIconDefinition(icon: string) {
return iconOptions.find((item) => item.name === icon) || iconOptions[0];
}
const { recipeAssetPath } = useStaticRoutes();
function assetURL(assetName: string) {
const { recipeAssetPath } = useStaticRoutes();
function assetURL(assetName: string) {
return recipeAssetPath(props.recipeId, assetName);
}
}
function assetEmbed(name: string) {
function assetEmbed(name: string) {
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%"> </img>`;
}
}
function setFileObject(fileObject: File) {
function setFileObject(fileObject: File) {
state.fileObject = fileObject;
}
}
function validFields() {
function validFields() {
return state.newAsset.name.length > 0 && state.fileObject.name.length > 0;
}
}
async function addAsset() {
async function addAsset() {
if (!validFields()) {
alert.error(i18n.t("asset.error-submitting-form") as string);
return;
@@ -212,10 +180,21 @@ async function addAsset() {
file: state.fileObject,
extension: state.fileObject.name.split(".").pop() || "",
});
if (data) {
model.value = [...model.value, data];
}
context.emit("input", [...props.value, data]);
state.newAsset = { name: "", icon: "mdi-file" };
state.fileObject = {} as File;
}
}
return {
state,
addAsset,
assetURL,
assetEmbed,
getIconDefinition,
iconOptions,
setFileObject,
};
},
});
</script>

View File

@@ -1,15 +1,10 @@
<template>
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
<div>
<v-hover
v-slot="{ isHovering, props: hoverProps }"
:open-delay="50"
>
<v-lazy>
<v-hover v-slot="{ hover }" :open-delay="50">
<v-card
v-bind="hoverProps"
:class="{ 'on-hover': isHovering }"
:class="{ 'on-hover': hover }"
:style="{ cursor }"
:elevation="isHovering ? 12 : 2"
:elevation="hover ? 12 : 2"
:to="recipeRoute"
:min-height="imageHeight + 75"
@click.self="$emit('click')"
@@ -19,15 +14,11 @@
:height="imageHeight"
:slug="slug"
:recipe-id="recipeId"
size="small"
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%"
>
<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" />
@@ -36,47 +27,24 @@
</div>
</v-expand-transition>
</RecipeCardImage>
<v-card-title class="mb-n3 px-4">
<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 -->
<v-card-actions v-if="showRecipeContent" class="px-1">
<RecipeFavoriteBadge v-if="isOwnGroup" class="absolute" :recipe-id="recipeId" show-always />
<RecipeRating
class="ml-n2"
:model-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"
color="grey darken-2"
:slug="slug"
:name="name"
:recipe-id="recipeId"
@@ -94,13 +62,14 @@
/>
</v-card-actions>
</slot>
<slot />
<slot></slot>
</v-card>
</v-hover>
</div>
</v-lazy>
</template>
<script setup lang="ts">
<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";
@@ -108,41 +77,68 @@ import RecipeCardImage from "./RecipeCardImage.vue";
import RecipeRating from "./RecipeRating.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
interface Props {
name: string;
slug: string;
description?: string | null;
rating?: number;
ratingColor?: string;
image?: string;
tags?: Array<any>;
recipeId: string;
imageHeight?: number;
}
const props = withDefaults(defineProps<Props>(), {
description: null,
rating: 0,
ratingColor: "secondary",
image: "abc123",
tags: () => [],
imageHeight: 200,
});
export default defineComponent({
components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage },
props: {
name: {
type: String,
required: true,
},
slug: {
type: String,
required: true,
},
description: {
type: String,
default: null,
},
rating: {
type: Number,
required: false,
default: 0,
},
ratingColor: {
type: String,
default: "secondary",
},
image: {
type: String,
required: false,
default: "abc123",
},
tags: {
type: Array,
default: () => [],
},
recipeId: {
required: true,
type: String,
},
imageHeight: {
type: Number,
default: 200,
},
},
setup(props) {
const { $auth } = useContext();
const { isOwnGroup } = useLoggedInState();
defineEmits<{
click: [];
delete: [slug: string];
}>();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => {
const route = useRoute();
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,
showRecipeContent,
cursor,
};
},
});
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
</script>
<style>
@@ -163,11 +159,10 @@ const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
overflow: hidden;
text-overflow: ellipsis;
}
.descriptionWrapper {
.descriptionWrapper{
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 8;
line-clamp: 8;
overflow: hidden;
}
</style>

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,69 +9,76 @@
@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 setup lang="ts">
import { useStaticRoutes } from "~/composables/api";
<script lang="ts">
import { computed, defineComponent, ref, watch } from "@nuxtjs/composition-api";
import { useStaticRoutes, useUserApi } from "~/composables/api";
interface Props {
tiny?: boolean | null;
small?: boolean | null;
large?: boolean | null;
iconSize?: number | string;
slug?: string | null;
recipeId: string;
imageVersion?: string | null;
height?: number | string;
}
const props = withDefaults(defineProps<Props>(), {
tiny: null,
small: null,
large: null,
iconSize: 100,
slug: null,
imageVersion: null,
height: "100%",
});
export default defineComponent({
props: {
tiny: {
type: Boolean,
default: null,
},
small: {
type: Boolean,
default: null,
},
large: {
type: Boolean,
default: null,
},
iconSize: {
type: [Number, String],
default: 100,
},
slug: {
type: String,
default: null,
},
recipeId: {
type: String,
required: true,
},
imageVersion: {
type: String,
default: null,
},
height: {
type: [Number, String],
default: "fill-height",
},
},
setup(props) {
const api = useUserApi();
defineEmits<{
click: [];
}>();
const { recipeImage, recipeSmallImage, recipeTinyImage } = useStaticRoutes();
const { recipeImage, recipeSmallImage, recipeTinyImage } = useStaticRoutes();
const fallBackImage = ref(false);
const imageSize = computed(() => {
const fallBackImage = ref(false);
const imageSize = computed(() => {
if (props.tiny) return "tiny";
if (props.small) return "small";
if (props.large) return "large";
return "large";
});
});
watch(
watch(
() => props.recipeId,
() => {
fallBackImage.value = false;
},
);
}
);
function getImage(recipeId: string) {
function getImage(recipeId: string) {
switch (imageSize.value) {
case "tiny":
return recipeTinyImage(recipeId, props.imageVersion);
@@ -81,7 +87,16 @@ function getImage(recipeId: string) {
case "large":
return recipeImage(recipeId, props.imageVersion);
}
}
}
return {
api,
fallBackImage,
imageSize,
getImage,
};
},
});
</script>
<style scoped>

View File

@@ -1,100 +1,57 @@
<template>
<div :style="`height: ${height}px;`">
<div :style="`height: ${height}`">
<v-expand-transition>
<v-card
:ripple="false"
:class="[
isFlat ? 'mx-auto flat' : 'mx-auto',
{ 'disable-highlight': disableHighlight },
]"
: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"
/>
</slot>
</template>
<div class="pl-4 d-flex flex-column justify-space-between align-stretch pr-2">
<v-list-item-title class="mt-3 mb-1 text-top text-truncate w-100">
{{ name }}
</v-list-item-title>
<v-list-item-subtitle class="ma-0 text-top">
<SafeMarkdown v-if="description" :source="description" />
<p v-else>
<br>
<br>
<br>
</p>
</v-list-item-subtitle>
<div
class="d-flex flex-nowrap justify-start ma-0 pt-2 pb-0"
style="overflow-x: hidden; overflow-y: hidden; white-space: nowrap;"
>
<RecipeChips
:truncate="true"
:items="tags"
:title="false"
:limit="2"
small
url-prefix="tags"
v-bind="$attrs"
/>
</v-list-item-avatar>
</slot>
<v-list-item-content class="py-0">
<v-list-item-title class="mt-1 mb-1 text-top">{{ name }}</v-list-item-title>
<v-list-item-subtitle class="ma-0 text-top">
<SafeMarkdown :source="description" />
</v-list-item-subtitle>
<div class="d-flex flex-wrap justify-start ma-0">
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" v-on="$listeners" />
</div>
</div>
<div class="d-flex flex-wrap justify-end align-center">
<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 -->
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :recipe-id="recipeId" show-always />
<RecipeRating
v-if="showRecipeContent"
:class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']"
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'"
:value="rating"
:recipe-id="recipeId"
:slug="slug"
small
: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 -->
@@ -104,7 +61,6 @@
:menu-icon="$globals.icons.dotsHorizontal"
:name="name"
:recipe-id="recipeId"
class="ml-auto"
:use-items="{
delete: false,
edit: false,
@@ -117,8 +73,9 @@
}"
@deleted="$emit('delete', slug)"
/>
</v-card-actions>
</slot>
</div>
</v-list-item-content>
</v-list-item>
<slot />
</v-card>
@@ -126,7 +83,8 @@
</div>
</template>
<script setup lang="ts">
<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";
@@ -134,50 +92,85 @@ import RecipeRating from "./RecipeRating.vue";
import RecipeChips from "./RecipeChips.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
interface Props {
name: string;
slug: string;
description: string;
rating?: number;
image?: string;
tags?: Array<any>;
recipeId: string;
vertical?: boolean;
isFlat?: boolean;
height?: number;
disableHighlight?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
rating: 0,
image: "abc123",
tags: () => [],
vertical: false,
isFlat: false,
height: 150,
disableHighlight: false,
});
export default defineComponent({
components: {
RecipeFavoriteBadge,
RecipeContextMenu,
RecipeRating,
RecipeCardImage,
RecipeChips,
},
props: {
name: {
type: String,
required: true,
},
slug: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
rating: {
type: Number,
default: 0,
},
image: {
type: String,
required: false,
default: "abc123",
},
tags: {
type: Array,
default: () => [],
},
recipeId: {
type: String,
required: true,
},
vertical: {
type: Boolean,
default: false,
},
isFlat: {
type: Boolean,
default: false,
},
height: {
type: [Number, String],
default: 150,
},
imageHeight: {
type: [Number, String],
default: "fill-height",
},
},
setup(props) {
const { $auth } = useContext();
const { isOwnGroup } = useLoggedInState();
defineEmits<{
selected: [];
delete: [slug: string];
}>();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => {
const route = useRoute();
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,
showRecipeContent,
cursor,
};
},
});
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
</script>
<style scoped>
:deep(.v-list-item__prepend) {
height: 100%;
}
<style>
.v-mobile-img {
padding-top: 0;
padding-bottom: 0;
@@ -205,13 +198,8 @@ const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
align-self: start !important;
}
.flat,
.theme--dark .flat {
box-shadow: none !important;
background-color: transparent !important;
}
.disable-highlight :deep(.v-card__overlay) {
opacity: 0 !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: activatorProps }">
<v-btn
variant="text"
:icon="$vuetify.display.xs"
v-bind="activatorProps"
: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>
<v-icon left>
{{ $globals.icons.orderAlphabeticalAscending }}
</v-icon>
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
</div>
</v-list-item>
<v-list-item @click="sortRecipes(EVENTS.rating)">
<div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
<v-icon left>
{{ $globals.icons.star }}
</v-icon>
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title>
</div>
</v-list-item>
<v-list-item @click="sortRecipes(EVENTS.created)">
<div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
<v-icon left>
{{ $globals.icons.newBox }}
</v-icon>
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title>
</div>
</v-list-item>
<v-list-item @click="sortRecipes(EVENTS.updated)">
<div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
<v-icon left>
{{ $globals.icons.update }}
</v-icon>
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
</div>
</v-list-item>
<v-list-item @click="sortRecipes(EVENTS.lastMade)">
<div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
<v-icon left>
{{ $globals.icons.chefHat }}
</v-icon>
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
</div>
</v-list-item>
</v-list>
</v-menu>
<ContextMenu
v-if="!$vuetify.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,137 +72,154 @@
<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"
>
<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!"
: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'"
>
<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!"
: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 setup lang="ts">
<script lang="ts">
import {
computed,
defineComponent,
onMounted,
reactive,
ref,
toRefs,
useAsync,
useContext,
useRoute,
useRouter,
watch,
} from "@nuxtjs/composition-api";
import { useThrottleFn } from "@vueuse/core";
import 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";
interface Props {
disableToolbar?: boolean;
disableSort?: boolean;
icon?: string | null;
title?: string | null;
singleColumn?: boolean;
recipes?: Recipe[];
query?: RecipeSearchQuery | null;
}
const props = withDefaults(defineProps<Props>(), {
disableToolbar: false,
disableSort: false,
icon: null,
title: null,
singleColumn: false,
recipes: () => [],
query: null,
});
export default defineComponent({
components: {
RecipeCard,
RecipeCardMobile,
},
props: {
disableToolbar: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: null,
},
title: {
type: String,
default: null,
},
singleColumn: {
type: Boolean,
default: false,
},
recipes: {
type: Array as () => Recipe[],
default: () => [],
},
query: {
type: Object as () => RecipeSearchQuery,
default: null,
},
},
setup(props, context) {
const preferences = useUserSortPreferences();
const emit = defineEmits<{
replaceRecipes: [recipes: Recipe[]];
appendRecipes: [recipes: Recipe[]];
}>();
const { $vuetify } = useNuxtApp();
const preferences = useUserSortPreferences();
const EVENTS = {
const EVENTS = {
az: "az",
rating: "rating",
created: "created",
updated: "updated",
lastMade: "lastMade",
shuffle: "shuffle",
};
};
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => {
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
});
const { $auth, $globals, $vuetify } = useContext();
const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => {
return $vuetify.breakpoint.smAndDown || preferences.value.useMobileCards;
});
const displayTitleIcon = computed(() => {
const displayTitleIcon = computed(() => {
return props.icon || $globals.icons.tags;
});
});
const sortLoading = ref(false);
const state = reactive({
sortLoading: false,
});
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 page = ref(1);
const perPage = 32;
const hasMore = ref(true);
const ready = ref(false);
const loading = ref(false);
const page = ref(1);
const perPage = 32;
const hasMore = ref(true);
const ready = ref(false);
const loading = ref(false);
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const router = useRouter();
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const router = useRouter();
const queryFilter = computed(() => {
return props.query?.queryFilter || null;
const queryFilter = computed(() => {
return props.query.queryFilter || null;
// TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade)
@@ -251,9 +233,9 @@ const queryFilter = computed(() => {
// } else {
// return orderByFilter;
// }
});
});
async function fetchRecipes(pageCount = 1) {
async function fetchRecipes(pageCount = 1) {
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
return await fetchMore(
@@ -266,28 +248,28 @@ async function fetchRecipes(pageCount = 1) {
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
queryFilter.value,
);
}
}
onMounted(async () => {
onMounted(async () => {
await initRecipes();
ready.value = true;
});
});
let lastQuery: string | undefined = JSON.stringify(props.query);
watch(
let lastQuery: string | undefined = JSON.stringify(props.query);
watch(
() => props.query,
async (newValue: RecipeSearchQuery | undefined | null) => {
const newValueString = JSON.stringify(newValue);
async (newValue: RecipeSearchQuery | undefined) => {
const newValueString = JSON.stringify(newValue)
if (lastQuery !== newValueString) {
lastQuery = newValueString;
ready.value = false;
await initRecipes();
ready.value = true;
}
},
);
}
);
async function initRecipes() {
async function initRecipes() {
page.value = 1;
hasMore.value = true;
@@ -301,10 +283,11 @@ async function initRecipes() {
// since we doubled the first call, we also need to advance the page
page.value = page.value + 1;
emit(REPLACE_RECIPES_EVENT, newRecipes);
}
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
}
const infiniteScroll = useThrottleFn(async () => {
const infiniteScroll = useThrottleFn(() => {
useAsync(async () => {
if (!hasMore.value || loading.value) {
return;
}
@@ -317,14 +300,16 @@ const infiniteScroll = useThrottleFn(async () => {
hasMore.value = false;
}
if (newRecipes.length) {
emit(APPEND_RECIPES_EVENT, newRecipes);
context.emit(APPEND_RECIPES_EVENT, newRecipes);
}
loading.value = false;
}, 500);
}, useAsyncKey());
}, 500);
async function sortRecipes(sortType: string) {
if (sortLoading.value || loading.value) {
function sortRecipes(sortType: string) {
if (state.sortLoading || loading.value) {
return;
}
@@ -333,14 +318,13 @@ async function sortRecipes(sortType: string) {
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;
@@ -353,7 +337,7 @@ async function sortRecipes(sortType: string) {
$globals.icons.sortAlphabeticalAscending,
$globals.icons.sortAlphabeticalDescending,
"asc",
false,
false
);
break;
case EVENTS.rating:
@@ -365,7 +349,7 @@ async function sortRecipes(sortType: string) {
$globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending,
"desc",
false,
false
);
break;
case EVENTS.updated:
@@ -377,7 +361,7 @@ async function sortRecipes(sortType: string) {
$globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending,
"desc",
true,
true
);
break;
default:
@@ -385,33 +369,51 @@ async function sortRecipes(sortType: string) {
return;
}
useAsync(async () => {
// reset pagination
page.value = 1;
hasMore.value = true;
sortLoading.value = true;
state.sortLoading = true;
loading.value = true;
// fetch new recipes
const newRecipes = await fetchRecipes();
emit(REPLACE_RECIPES_EVENT, newRecipes);
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
sortLoading.value = false;
state.sortLoading = false;
loading.value = false;
}
}, useAsyncKey());
}
async function navigateRandom() {
async function navigateRandom() {
const recipe = await getRandom(props.query, queryFilter.value);
if (!recipe?.slug) {
return;
}
router.push(`/g/${groupSlug.value}/r/${recipe.slug}`);
}
}
function toggleMobileCards() {
function toggleMobileCards() {
preferences.value.useMobileCards = !preferences.value.useMobileCards;
}
}
return {
...toRefs(state),
displayTitleIcon,
EVENTS,
infiniteScroll,
ready,
loading,
navigateRandom,
preferences,
sortRecipes,
toggleMobileCards,
useMobileCards,
};
},
});
</script>
<style>

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)"
@@ -23,38 +17,66 @@
</div>
</template>
<script setup lang="ts">
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
<script lang="ts">
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
export type UrlPrefixParam = "tags" | "categories" | "tools";
interface Props {
truncate?: boolean;
items?: RecipeCategory[] | RecipeTag[] | RecipeTool[];
title?: boolean;
urlPrefix?: UrlPrefixParam;
limit?: number;
small?: boolean;
maxWidth?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
truncate: false,
items: () => [],
title: false,
urlPrefix: "categories",
limit: 999,
small: false,
maxWidth: null,
});
export default defineComponent({
props: {
truncate: {
type: Boolean,
default: false,
},
items: {
type: Array as () => RecipeCategory[] | RecipeTag[] | RecipeTool[],
default: () => [],
},
title: {
type: Boolean,
default: false,
},
urlPrefix: {
type: String as () => UrlPrefixParam,
default: "categories",
},
limit: {
type: Number,
default: 999,
},
small: {
type: Boolean,
default: false,
},
maxWidth: {
type: String,
default: null,
},
},
setup(props) {
const { $auth } = useContext();
defineEmits(["item-selected"]);
function truncateText(text: string, length = 20, clamp = "...") {
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "")
const baseRecipeRoute = computed<string>(() => {
return `/g/${groupSlug.value}`
});
function truncateText(text: string, length = 20, clamp = "...") {
if (!props.truncate) return text;
const node = document.createElement("div");
node.innerHTML = text;
const content = node.textContent || "";
return content.length > length ? content.slice(0, length) + clamp : content;
}
}
return {
baseRecipeRoute,
truncateText,
};
},
});
</script>
<style></style>

View File

@@ -8,16 +8,10 @@
:title="$t('recipe.delete-recipe')"
color="error"
:icon="$globals.icons.alertCircle"
can-confirm
@confirm="deleteRecipe()"
>
<v-card-text>
<template v-if="isAdminAndNotOwner">
{{ $t("recipe.admin-delete-confirmation") }}
</template>
<template v-else>
{{ $t("recipe.delete-confirmation") }}
</template>
</v-card-text>
</BaseDialog>
<BaseDialog
@@ -25,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
@@ -43,7 +36,6 @@
:title="$t('recipe.add-recipe-to-mealplan')"
color="primary"
:icon="$globals.icons.calendar"
can-confirm
@confirm="addRecipeToPlan()"
>
<v-card-text>
@@ -55,21 +47,22 @@
max-width="290px"
min-width="auto"
>
<template #activator="{ props: activatorProps }">
<template #activator="{ on, attrs }">
<v-text-field
v-model="newMealdateString"
v-model="newMealdate"
:label="$t('general.date')"
:prepend-icon="$globals.icons.calendar"
v-bind="activatorProps"
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
@@ -77,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
@@ -90,67 +81,55 @@
/>
<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: activatorProps }">
<v-btn
icon
:variant="fab ? 'flat' : undefined"
:rounded="fab ? 'circle' : undefined"
:size="fab ? 'small' : undefined"
:color="fab ? 'info' : 'secondary'"
:fab="fab"
v-bind="activatorProps"
@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>
<v-list-item-title>{{ $tc("recipe.recipe-actions") }}</v-list-item-title>
</template>
<v-list dense class="ma-0 pa-0">
<v-list-item
v-for="(action, index) in recipeActions"
:key="index"
class="pl-6"
@click="executeRecipeAction(action)"
>
<template #prepend>
<v-icon color="undefined">
{{ $globals.icons.linkVariantPlus }}
</v-icon>
</template>
<v-list-item-title>
{{ action.title }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-list-group>
</div>
</v-list>
</v-menu>
</div>
</template>
<script setup lang="ts">
<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,22 +164,16 @@ export interface ContextMenuItem {
isPublic: boolean;
}
interface Props {
useItems?: ContextMenuIncludes;
appendItems?: ContextMenuItem[];
leadingItems?: ContextMenuItem[];
menuTop?: boolean;
fab?: boolean;
color?: string;
slug: string;
menuIcon?: string | null;
name: string;
recipe?: Recipe;
recipeId: string;
recipeScale?: number;
}
const props = withDefaults(defineProps<Props>(), {
useItems: () => ({
export default defineComponent({
components: {
RecipeDialogAddToShoppingList,
RecipeDialogPrintPreferences,
RecipeDialogShare,
},
props: {
useItems: {
type: Object as () => ContextMenuIncludes,
default: () => ({
delete: true,
edit: true,
download: true,
@@ -213,217 +185,225 @@ const props = withDefaults(defineProps<Props>(), {
share: true,
recipeActions: true,
}),
appendItems: () => [],
leadingItems: () => [],
menuTop: true,
fab: false,
color: "primary",
menuIcon: null,
recipe: undefined,
recipeScale: 1,
});
},
// Append items are added at the end of the useItems list
appendItems: {
type: Array as () => ContextMenuItem[],
default: () => [],
},
// Append items are added at the beginning of the useItems list
leadingItems: {
type: Array as () => ContextMenuItem[],
default: () => [],
},
menuTop: {
type: Boolean,
default: true,
},
fab: {
type: Boolean,
default: false,
},
color: {
type: String,
default: "primary",
},
slug: {
type: String,
required: true,
},
menuIcon: {
type: String,
default: null,
},
name: {
required: true,
type: String,
},
recipe: {
type: Object as () => Recipe,
default: undefined,
},
recipeId: {
required: true,
type: String,
},
recipeScale: {
type: Number,
default: 1,
},
},
setup(props, context) {
const api = useUserApi();
const emit = defineEmits<{
[key: string]: any;
delete: [slug: string];
}>();
const state = reactive({
printPreferencesDialog: false,
shareDialog: false,
recipeDeleteDialog: false,
mealplannerDialog: false,
shoppingListDialog: false,
recipeDuplicateDialog: false,
recipeName: props.name,
loading: false,
menuItems: [] as ContextMenuItem[],
newMealdate: "",
newMealType: "dinner" as PlanEntryType,
pickerMenu: false,
});
const api = useUserApi();
const { i18n, $auth, $globals } = useContext();
const { household } = useHouseholdSelf();
const { isOwnGroup } = useLoggedInState();
const printPreferencesDialog = ref(false);
const shareDialog = ref(false);
const recipeDeleteDialog = ref(false);
const mealplannerDialog = ref(false);
const shoppingListDialog = ref(false);
const recipeDuplicateDialog = ref(false);
const recipeName = ref(props.name);
const loading = ref(false);
const menuItems = ref<ContextMenuItem[]>([]);
const newMealdate = ref(new Date());
const newMealType = ref<PlanEntryType>("dinner");
const pickerMenu = ref(false);
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const newMealdateString = computed(() => {
// Format the date to YYYY-MM-DD in the same timezone as newMealdate
const year = newMealdate.value.getFullYear();
const month = String(newMealdate.value.getMonth() + 1).padStart(2, "0");
const day = String(newMealdate.value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
});
const i18n = useI18n();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { household } = useHouseholdSelf();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => {
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
});
// ===========================================================================
// Context Menu Setup
// ===========================================================================
// Context Menu Setup
const defaultItems: { [key: string]: ContextMenuItem } = {
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",
isPublic: false,
},
};
// Add leading and Appending Items
menuItems.value = [...menuItems.value, ...props.leadingItems, ...props.appendItems];
const icon = props.menuIcon || $globals.icons.dotsVertical;
// ===========================================================================
// 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 isAdminAndNotOwner = computed(() => {
return (
$auth.user.value?.admin
&& $auth.user.value?.id !== recipeRef.value?.userId
);
});
const canDelete = computed(() => {
const user = $auth.user.value;
const recipe = recipeRef.value;
return user && recipe && (user.admin || user.id === recipe.userId);
});
// Get Default Menu Items Specified in Props
for (const [key, value] of Object.entries(props.useItems)) {
if (!value) continue;
// Skip delete if not allowed
if (key === "delete" && !canDelete.value) continue;
};
// Get Default Menu Items Specified in Props
for (const [key, value] of Object.entries(props.useItems)) {
if (value) {
const item = defaultItems[key];
if (item && (item.isPublic || isOwnGroup.value)) {
menuItems.value.push(item);
state.menuItems.push(item);
}
}
}
}
async function getShoppingLists() {
// Add leading and Appending Items
state.menuItems = [...state.menuItems, ...props.leadingItems, ...props.appendItems];
const icon = props.menuIcon || $globals.icons.dotsVertical;
// ===========================================================================
// Context Menu Event Handler
const shoppingLists = ref<ShoppingListSummary[]>();
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" });
if (data) {
shoppingLists.value = data.items ?? [];
}
}
}
async function refreshRecipe() {
async function refreshRecipe() {
const { data } = await api.recipes.getOne(props.slug);
if (data) {
recipeRef.value = data;
}
}
}
const router = useRouter();
const groupRecipeActionsStore = useGroupRecipeActions();
const router = useRouter();
const groupRecipeActionsStore = useGroupRecipeActions();
async function executeRecipeAction(action: GroupRecipeActionOut) {
if (!props.recipe) return;
async function executeRecipeAction(action: GroupRecipeActionOut) {
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"));
}
}
}
}
async function deleteRecipe() {
async function deleteRecipe() {
const { data } = await api.recipes.deleteOne(props.slug);
if (data?.slug) {
router.push(`/g/${groupSlug.value}`);
}
emit("delete", props.slug);
}
context.emit("delete", props.slug);
}
const download = useDownloader();
const download = useAxiosDownloader();
async function handleDownloadEvent() {
async function handleDownloadEvent() {
const { data } = await api.recipes.getZipToken(props.slug);
if (data) {
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
}
}
}
async function addRecipeToPlan() {
async function addRecipeToPlan() {
const { response } = await api.mealplans.createOne({
date: newMealdateString.value,
entryType: newMealType.value,
date: state.newMealdate,
entryType: state.newMealType,
title: "",
text: "",
recipeId: props.recipeId,
@@ -431,38 +411,36 @@ async function addRecipeToPlan() {
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);
}
}
}
async function duplicateRecipe() {
const { data } = await api.recipes.duplicateOne(props.slug, recipeName.value);
async function duplicateRecipe() {
const { data } = await api.recipes.duplicateOne(props.slug, state.recipeName);
if (data && data.slug) {
router.push(`/g/${groupSlug.value}/r/${data.slug}`);
}
}
}
// 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> } = {
// Note: Print is handled as an event in the parent component
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
delete: () => {
recipeDeleteDialog.value = true;
state.recipeDeleteDialog = true;
},
edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"),
download: handleDownloadEvent,
duplicate: () => {
recipeDuplicateDialog.value = true;
state.recipeDuplicateDialog = true;
},
mealplanner: () => {
mealplannerDialog.value = true;
state.mealplannerDialog = true;
},
printPreferences: async () => {
if (!recipeRef.value) {
await refreshRecipe();
}
printPreferencesDialog.value = true;
state.printPreferencesDialog = true;
},
shoppingList: () => {
const promises: Promise<void>[] = [getShoppingLists()];
@@ -470,28 +448,43 @@ const eventHandlers: { [key: string]: () => void | Promise<any> } = {
promises.push(refreshRecipe());
}
Promise.allSettled(promises).then(() => {
shoppingListDialog.value = true;
});
Promise.allSettled(promises).then(() => { state.shoppingListDialog = true });
},
share: () => {
shareDialog.value = true;
state.shareDialog = true;
},
};
};
function contextMenuEventHandler(eventKey: string) {
function contextMenuEventHandler(eventKey: string) {
const handler = eventHandlers[eventKey];
if (handler && typeof handler === "function") {
handler();
loading.value = false;
state.loading = false;
return;
}
emit(eventKey);
loading.value = false;
}
context.emit(eventKey);
state.loading = false;
}
const planTypeOptions = usePlanTypeOptions();
const recipeActions = groupRecipeActionsStore.recipeActions;
const planTypeOptions = usePlanTypeOptions();
return {
...toRefs(state),
recipeRef,
recipeRefWithScale,
executeRecipeAction,
recipeActions: groupRecipeActionsStore.recipeActions,
shoppingLists,
duplicateRecipe,
contextMenuEventHandler,
deleteRecipe,
addRecipeToPlan,
icon,
planTypeOptions,
firstDayOfWeek,
};
},
});
</script>

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="[
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
},
]" @delete="deleteAlias(i)" />
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>
@@ -33,56 +45,65 @@
</div>
</template>
<script setup lang="ts">
<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;
}
interface Props {
data: IngredientFood | IngredientUnit;
}
const props = defineProps<Props>();
const emit = defineEmits<{
submit: [aliases: GenericAlias[]];
cancel: [];
}>();
// V-Model Support
const dialog = defineModel<boolean>({ default: false });
function createAlias() {
aliases.value.push({
name: "",
export default defineComponent({
props: {
value: {
type: Boolean,
default: false,
},
data: {
type: Object as () => IngredientFood | IngredientUnit,
required: true,
},
},
setup(props, context) {
// V-Model Support
const dialog = computed({
get: () => {
return props.value;
},
set: (val) => {
context.emit("input", val);
},
});
}
function deleteAlias(index: number) {
function createAlias() {
aliases.value.push({
"name": "",
})
}
function deleteAlias(index: number) {
aliases.value.splice(index, 1);
}
}
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
function initAliases() {
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
function initAliases() {
aliases.value = [...props.data.aliases || []];
if (!aliases.value.length) {
createAlias();
}
}
}
initAliases();
whenever(
() => dialog.value,
initAliases();
whenever(
() => props.value,
() => {
initAliases();
},
);
)
function saveAliases() {
function saveAliases() {
const seenAliasNames: string[] = [];
const keepAliases: GenericAlias[] = [];
aliases.value.forEach((alias) => {
@@ -90,7 +111,9 @@ function saveAliases() {
!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)
) {
@@ -99,9 +122,20 @@ function saveAliases() {
keepAliases.push(alias);
seenAliasNames.push(alias.name);
});
})
aliases.value = keepAliases;
emit("submit", keepAliases);
}
context.emit("submit", keepAliases);
}
return {
aliases,
createAlias,
dialog,
deleteAlias,
saveAliases,
validators,
}
},
});
</script>

View File

@@ -3,72 +3,60 @@
v-model="selected"
item-key="id"
show-select
:sort-by="sortBy"
sort-by="dateAdded"
sort-desc
:headers="headers"
:items="recipes"
:items-per-page="15"
class="elevation-0"
:loading="loading"
return-object
@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 setup lang="ts">
<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 = "input";
interface ShowHeaders {
id: boolean;
@@ -82,114 +70,143 @@ interface ShowHeaders {
dateAdded: boolean;
}
interface Props {
loading?: boolean;
recipes?: Recipe[];
showHeaders?: ShowHeaders;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
recipes: () => [],
showHeaders: () => ({
export default defineComponent({
components: { RecipeChip, UserAvatar },
props: {
value: {
type: Array,
required: false,
default: () => [],
},
loading: {
type: Boolean,
required: false,
default: false,
},
recipes: {
type: Array as () => Recipe[],
default: () => [],
},
showHeaders: {
type: Object as () => ShowHeaders,
required: false,
default: () => {
return {
id: true,
owner: false,
tags: true,
categories: true,
tools: true,
recipeServings: true,
recipeYieldQuantity: true,
recipeYield: true,
dateAdded: true,
}),
});
};
},
},
},
setup(props, context) {
const { $auth, i18n } = useContext();
const groupSlug = $auth.user?.groupSlug;
const router = useRouter();
function setValue(value: Recipe[]) {
context.emit(INPUT_EVENT, value);
}
defineEmits<{
click: [];
}>();
const selected = defineModel<Recipe[]>({ default: () => [] });
const i18n = useI18n();
const $auth = useMealieAuth();
const groupSlug = $auth.user.value?.groupSlug;
const router = useRouter();
// Initialize sort state with default sorting by dateAdded descending
const sortBy = ref([{ key: "dateAdded", order: "desc" as const }]);
const headers = computed(() => {
const hdrs: Array<{ title: string; value: string; align?: "center" | "start" | "end"; sortable?: boolean }> = [];
const headers = computed(() => {
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;
});
});
function formatDate(date: string) {
function formatDate(date: string) {
try {
return i18n.d(Date.parse(date), "medium");
}
catch {
} catch {
return "";
}
}
}
// ============
// Group Members
const api = useUserApi();
const members = ref<UserSummary[]>([]);
// ============
// Group Members
const api = useUserApi();
const members = ref<UserSummary[]>([]);
async function refreshMembers() {
async function refreshMembers() {
const { data } = await api.groups.fetchMembers();
if (data) {
members.value = data.items;
}
}
}
function filterItems(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
function filterItems(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
if (!groupSlug || !item.id) {
return;
}
router.push(`/g/${groupSlug}?${itemType}=${item.id}`);
}
}
onMounted(() => {
onMounted(() => {
refreshMembers();
});
});
function getMember(id: string) {
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 {
groupSlug,
setValue,
headers,
formatDate,
members,
getMember,
filterItems,
};
},
data() {
return {
selected: [],
};
},
watch: {
value(val) {
this.selected = val;
},
},
});
</script>

View File

@@ -1,16 +1,9 @@
<template>
<div v-if="dialog">
<BaseDialog
v-if="shoppingListDialog && ready"
v-model="dialog"
:title="$t('recipe.add-to-list')"
:icon="$globals.icons.cartCheck"
>
<BaseDialog v-if="shoppingListDialog && ready" v-model="dialog" :title="$t('recipe.add-to-list')" :icon="$globals.icons.cartCheck">
<v-container v-if="!shoppingListChoices.length">
<BasePageTitle>
<template #title>
{{ $t('shopping-list.no-shopping-lists-found') }}
</template>
<template #title>{{ $t('shopping-list.no-shopping-lists-found') }}</template>
</BasePageTitle>
</v-container>
<v-card-text>
@@ -28,78 +21,49 @@
</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>
<BaseDialog
v-if="shoppingListIngredientDialog"
v-model="dialog"
:title="selectedShoppingList?.name || $t('recipe.add-to-list')"
: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,20 +73,17 @@
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]
@@ -130,23 +91,18 @@
.ingredients[i]
.checked"
>
<v-container class="pa-0 ma-0">
<v-row no-gutters>
<v-checkbox
hide-details
:model-value="ingredientData.checked"
class="pt-0 my-auto py-auto mr-2"
:input-value="ingredientData.checked"
class="pt-0 my-auto py-auto"
color="secondary"
density="compact"
/>
<div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
<v-list-item-content :key="ingredientData.ingredient.quantity">
<RecipeIngredientListItem
:ingredient="ingredientData.ingredient"
:scale="recipeSection.recipeScale"
/>
</div>
</v-row>
</v-container>
:disable-amount="ingredientData.disableAmount"
:scale="recipeSection.recipeScale" />
</v-list-item-content>
</v-list-item>
</div>
</div>
@@ -158,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',
},
]"
@@ -175,14 +131,15 @@
</div>
</template>
<script setup lang="ts">
<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;
@@ -191,6 +148,7 @@ export interface RecipeWithScale extends Recipe {
export interface ShoppingListIngredient {
checked: boolean;
ingredient: RecipeIngredient;
disableAmount: boolean;
}
export interface ShoppingListIngredientSection {
@@ -205,61 +163,70 @@ export interface ShoppingListRecipeIngredientSection {
ingredientSections: ShoppingListIngredientSection[];
}
interface Props {
recipes?: RecipeWithScale[];
shoppingLists?: ShoppingListSummary[];
}
const props = withDefaults(defineProps<Props>(), {
recipes: undefined,
shoppingLists: () => [],
});
export default defineComponent({
components: {
RecipeIngredientListItem,
},
props: {
value: {
type: Boolean,
default: false,
},
recipes: {
type: Array as () => RecipeWithScale[],
default: undefined,
},
shoppingLists: {
type: Array as () => ShoppingListSummary[],
default: () => [],
},
},
setup(props, context) {
const { $auth, i18n } = useContext();
const api = useUserApi();
const preferences = useShoppingListPreferences();
const ready = ref(false);
const dialog = defineModel<boolean>({ default: false });
// v-model support
const dialog = computed({
get: () => {
return props.value;
},
set: (val) => {
context.emit("input", val);
initState();
},
});
const i18n = useI18n();
const $auth = useMealieAuth();
const api = useUserApi();
const preferences = useShoppingListPreferences();
const ready = ref(false);
const state = reactive({
const state = reactive({
shoppingListDialog: true,
shoppingListIngredientDialog: false,
shoppingListShowAllToggled: false,
});
});
const { shoppingListDialog, shoppingListIngredientDialog, shoppingListShowAllToggled: _shoppingListShowAllToggled } = toRefs(state);
const userHousehold = computed(() => {
return $auth.user?.householdSlug || "";
});
const userHousehold = computed(() => {
return $auth.user.value?.householdSlug || "";
});
const shoppingListChoices = computed(() => {
return props.shoppingLists.filter((list) => preferences.value.viewAllLists || list.userId === $auth.user?.id);
});
const shoppingListChoices = computed(() => {
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
});
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
watchEffect(
watchEffect(
() => {
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
selectedShoppingList.value = shoppingListChoices.value[0];
openShoppingListIngredientDialog(selectedShoppingList.value);
}
else {
} else {
ready.value = true;
}
},
);
);
watch(dialog, (val) => {
if (!val) {
initState();
}
});
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
for (const recipe of recipes) {
if (!recipe.slug) {
@@ -267,10 +234,8 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
}
if (recipeSectionMap.has(recipe.slug)) {
const existingSection = recipeSectionMap.get(recipe.slug);
if (existingSection) {
existingSection.recipeScale += recipe.scale;
}
// @ts-ignore not undefined, see above
recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
continue;
}
@@ -282,8 +247,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
recipe.id = data.id || "";
recipe.name = data.name || "";
recipe.recipeIngredient = data.recipeIngredient;
}
else if (!recipe.recipeIngredient.length) {
} else if (!recipe.recipeIngredient.length) {
continue;
}
@@ -292,7 +256,8 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
return {
checked: !householdsWithFood.includes(userHousehold.value),
ingredient: ing,
};
disableAmount: recipe.settings?.disableAmount || false,
}
});
let currentTitle = "";
@@ -335,23 +300,23 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
recipeName: recipe.name,
recipeScale: recipe.scale,
ingredientSections: shoppingListIngredientSections,
});
})
}
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
}
}
function initState() {
function initState() {
state.shoppingListDialog = true;
state.shoppingListIngredientDialog = false;
state.shoppingListShowAllToggled = false;
recipeIngredientSections.value = [];
selectedShoppingList.value = null;
}
}
initState();
initState();
async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
if (!props.recipes?.length) {
return;
}
@@ -360,13 +325,13 @@ async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
await consolidateRecipesIntoSections(props.recipes);
state.shoppingListDialog = false;
state.shoppingListIngredientDialog = true;
}
}
function setShowAllToggled() {
function setShowAllToggled() {
state.shoppingListShowAllToggled = true;
}
}
function bulkCheckIngredients(value = true) {
function bulkCheckIngredients(value = true) {
recipeIngredientSections.value.forEach((recipeSection) => {
recipeSection.ingredientSections.forEach((ingSection) => {
ingSection.ingredients.forEach((ing) => {
@@ -374,9 +339,9 @@ function bulkCheckIngredients(value = true) {
});
});
});
}
}
async function addRecipesToList() {
async function addRecipesToList() {
if (!selectedShoppingList.value) {
return;
}
@@ -401,18 +366,34 @@ async function addRecipesToList() {
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;
dialog.value = false;
}
}
return {
dialog,
preferences,
ready,
shoppingListChoices,
...toRefs(state),
addRecipesToList,
bulkCheckIngredients,
openShoppingListIngredientDialog,
setShowAllToggled,
recipeIngredientSections,
selectedShoppingList,
}
},
})
</script>
<style scoped lang="css">

View File

@@ -1,163 +1,142 @@
<template>
<div class="text-center">
<v-dialog
v-model="dialog"
width="800"
>
<template #activator="{ props: activatorProps }">
<BaseButton
v-bind="activatorProps"
@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-textarea>
<v-divider />
<template
v-for="(util) in utilities"
:key="util.id"
>
<v-list-item
density="compact"
class="py-1"
>
<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>
</div>
</template>
<script setup lang="ts">
interface Props {
inputTextProp?: string;
}
const props = withDefaults(defineProps<Props>(), {
inputTextProp: "",
});
<script lang="ts">
import { reactive, toRefs, defineComponent, useContext } from "@nuxtjs/composition-api";
export default defineComponent({
props: {
inputTextProp: {
type: String,
required: false,
default: "",
},
},
setup(props, context) {
const state = reactive({
dialog: false,
inputText: props.inputTextProp,
});
const emit = defineEmits<{
"bulk-data": [data: string[]];
}>();
function splitText() {
return state.inputText.split("\n").filter((line) => !(line === "\n" || !line));
}
const dialog = ref(false);
const inputText = ref(props.inputTextProp);
function splitText() {
return inputText.value.split("\n").filter(line => !(line === "\n" || !line));
}
function removeFirstCharacter() {
inputText.value = splitText()
.map(line => line.substring(1))
function removeFirstCharacter() {
state.inputText = splitText()
.map((line) => line.substring(1))
.join("\n");
}
}
const numberedLineRegex = /\d+[.):] /gm;
const numberedLineRegex = /\d+[.):] /gm;
function splitByNumberedLine() {
function splitByNumberedLine() {
// Split inputText by numberedLineRegex
const matches = inputText.value.match(numberedLineRegex);
const matches = state.inputText.match(numberedLineRegex);
matches?.forEach((match, idx) => {
const replaceText = idx === 0 ? "" : "\n";
inputText.value = inputText.value.replace(match, replaceText);
state.inputText = state.inputText.replace(match, replaceText);
});
}
}
function trimAllLines() {
function trimAllLines() {
const splitLines = splitText();
splitLines.forEach((element: string, index: number) => {
splitLines[index] = element.trim();
});
inputText.value = splitLines.join("\n");
}
state.inputText = splitLines.join("\n");
}
function save() {
emit("bulk-data", splitText());
dialog.value = false;
}
function save() {
context.emit("bulk-data", splitText());
state.dialog = false;
}
const i18n = useI18n();
const { i18n } = useContext();
const utilities = [
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,
},
];
];
return {
utilities,
splitText,
trimAllLines,
removeFirstCharacter,
splitByNumberedLine,
save,
...toRefs(state),
};
},
});
</script>

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,40 +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
color="primary"
: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
color="primary"
: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
color="primary"
:label="$t('recipe.nutrition')"
/>
</v-row>
<v-row no-gutters />
</v-col>
</v-row>
</v-container>
@@ -80,25 +47,50 @@
class="print-preview"
style="overflow-y: auto;"
>
<RecipePrintView :recipe="recipe" />
<RecipePrintView :recipe="recipe"/>
</v-card>
</div>
</BaseDialog>
</template>
<script setup lang="ts">
import type { Recipe } from "~/lib/api/types/recipe";
<script lang="ts">
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";
interface Props {
recipe?: NoUndefinedField<Recipe>;
}
withDefaults(defineProps<Props>(), {
recipe: undefined,
export default defineComponent({
components: {
RecipePrintView,
},
props: {
value: {
type: Boolean,
default: false,
},
recipe: {
type: Object as () => Recipe,
default: undefined,
},
},
setup(props, context) {
const preferences = useUserPrintPreferences();
// V-Model Support
const dialog = computed({
get: () => {
return props.value;
},
set: (val) => {
context.emit("input", val);
},
});
return {
dialog,
ImagePosition,
preferences,
}
}
});
const dialog = defineModel<boolean>({ default: false });
const preferences = useUserPrintPreferences();
</script>

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