Merge branch 'mealie-next' into feature/cookn-migration

This commit is contained in:
Dallin Miner 2025-08-08 04:49:03 +00:00
commit 15adb0878e
687 changed files with 716607 additions and 58046 deletions

View file

@ -11,7 +11,7 @@
// Use -bullseye variants on local on arm64/Apple Silicon. // Use -bullseye variants on local on arm64/Apple Silicon.
"VARIANT": "3.12-bullseye", "VARIANT": "3.12-bullseye",
// Options // Options
"NODE_VERSION": "16" "NODE_VERSION": "20"
} }
}, },
"mounts": [ "mounts": [
@ -48,12 +48,13 @@
], ],
// Use 'onCreateCommand' to run commands at the end of container creation. // Use 'onCreateCommand' to run commands at the end of container creation.
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
"onCreateCommand": "sudo chown -R vscode:vscode /workspaces/mealie/frontend/node_modules && task setup", "onCreateCommand": "sudo chown -R vscode:vscode /workspaces/mealie/frontend/node_modules /home/vscode/commandhistory && task setup",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode", "remoteUser": "vscode",
"features": { "features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": { "ghcr.io/devcontainers/features/docker-in-docker:2": {
"dockerDashComposeVersion": "v2" "dockerDashComposeVersion": "v2"
} }
} },
"appPort": 3000
} }

View file

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

View file

@ -3,15 +3,8 @@ on:
workflow_call: workflow_call:
jobs: jobs:
build-package:
name: "Build Python package"
uses: ./.github/workflows/partial-package.yml
with:
tag: e2e
test: test:
timeout-minutes: 60 timeout-minutes: 60
needs: build-package
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@ -20,7 +13,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 20
cache: 'yarn' cache: 'yarn'
cache-dependency-path: ./tests/e2e/yarn.lock cache-dependency-path: ./tests/e2e/yarn.lock
- name: Set up Docker Buildx - name: Set up Docker Buildx

114
.github/workflows/locale-sync.yml vendored Normal file
View file

@ -0,0 +1,114 @@
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

@ -18,13 +18,19 @@ concurrency:
jobs: jobs:
backend-tests: backend-tests:
name: "Backend Server Tests" name: "Backend Server Tests"
uses: ./.github/workflows/partial-backend.yml uses: ./.github/workflows/test-backend.yml
frontend-tests: frontend-tests:
name: "Frontend Tests" name: "Frontend Tests"
uses: ./.github/workflows/partial-frontend.yml uses: ./.github/workflows/test-frontend.yml
build-release: build-package:
name: Build Package
uses: ./.github/workflows/build-package.yml
with:
tag: nightly
publish:
permissions: permissions:
contents: read contents: read
packages: write packages: write
@ -35,10 +41,11 @@ jobs:
id-token: write id-token: write
name: Build Tagged Release name: Build Tagged Release
if: github.repository == 'mealie-recipes/mealie' if: github.repository == 'mealie-recipes/mealie'
uses: ./.github/workflows/partial-builder.yml uses: ./.github/workflows/publish.yml
needs: needs:
- frontend-tests - frontend-tests
- backend-tests - backend-tests
- build-package
with: with:
tag: nightly tag: nightly
secrets: secrets:
@ -49,7 +56,7 @@ jobs:
name: Notify Discord name: Notify Discord
if: github.repository == 'mealie-recipes/mealie' if: github.repository == 'mealie-recipes/mealie'
needs: needs:
- build-release - publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Discord notification - name: Discord notification

View file

@ -16,14 +16,7 @@ on:
required: true required: true
jobs: jobs:
build-package:
name: "Build Python package"
uses: ./.github/workflows/partial-package.yml
with:
tag: ${{ inputs.tag }}
publish: publish:
needs: build-package
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository

View file

@ -16,20 +16,16 @@ jobs:
backend-tests: backend-tests:
name: "Backend Server Tests" name: "Backend Server Tests"
uses: ./.github/workflows/partial-backend.yml uses: ./.github/workflows/test-backend.yml
frontend-tests: frontend-tests:
name: "Frontend Tests" name: "Frontend Tests"
uses: ./.github/workflows/partial-frontend.yml uses: ./.github/workflows/test-frontend.yml
container-scanning: container-scanning:
name: "Trivy Container Scanning" name: "Trivy Container Scanning"
uses: ./.github/workflows/partial-trivy-container-scanning.yml uses: ./.github/workflows/partial-trivy-container-scanning.yml
end-to-end:
name: "End-to-End Tests"
uses: ./.github/workflows/e2e.yml
code-ql: code-ql:
name: "CodeQL" name: "CodeQL"
uses: ./.github/workflows/codeql.yml uses: ./.github/workflows/codeql.yml
@ -37,3 +33,33 @@ jobs:
actions: read actions: read
contents: read contents: read
security-events: write security-events: write
build-package:
name: "Build Python package"
uses: ./.github/workflows/build-package.yml
with:
tag: e2e
end-to-end:
name: "End-to-End Tests"
needs: build-package
uses: ./.github/workflows/e2e.yml
publish-image:
name: "Publish PR Image"
if: contains(github.event.pull_request.labels.*.name, 'build-image') && github.repository == 'mealie-recipes/mealie'
permissions:
contents: read
packages: write
# The id-token write permission is needed to connect to Depot.dev
# as part of the partial-builder.yml action. It needs to be declared
# in the parent action, as noted here:
# https://github.com/orgs/community/discussions/76409#discussioncomment-8131390
id-token: write
needs: build-package
uses: ./.github/workflows/publish.yml
with:
tag: pr-${{ github.event.pull_request.number }}
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}

View file

@ -7,13 +7,19 @@ on:
jobs: jobs:
backend-tests: backend-tests:
name: "Backend Server Tests" name: "Backend Server Tests"
uses: ./.github/workflows/partial-backend.yml uses: ./.github/workflows/test-backend.yml
frontend-tests: frontend-tests:
name: "Frontend Tests" name: "Frontend Tests"
uses: ./.github/workflows/partial-frontend.yml uses: ./.github/workflows/test-frontend.yml
build-release: build-package:
name: Build Package
uses: ./.github/workflows/build-package.yml
with:
tag: ${{ github.event.release.tag_name }}
publish:
permissions: permissions:
contents: read contents: read
packages: write packages: write
@ -23,10 +29,11 @@ jobs:
# https://github.com/orgs/community/discussions/76409#discussioncomment-8131390 # https://github.com/orgs/community/discussions/76409#discussioncomment-8131390
id-token: write id-token: write
name: Build Tagged Release name: Build Tagged Release
uses: ./.github/workflows/partial-builder.yml uses: ./.github/workflows/publish.yml
needs: needs:
- backend-tests - backend-tests
- frontend-tests - frontend-tests
- build-package
with: with:
tag: ${{ github.event.release.tag_name }} tag: ${{ github.event.release.tag_name }}
tags: | tags: |
@ -39,7 +46,7 @@ jobs:
notify-discord: notify-discord:
name: Notify Discord name: Notify Discord
needs: needs:
- build-release - publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Discord notification - name: Discord notification
@ -52,7 +59,7 @@ jobs:
update-image-tags: update-image-tags:
name: Update image tag in sample docker-compose files name: Update image tag in sample docker-compose files
needs: needs:
- build-release - publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write

View file

@ -16,12 +16,13 @@ jobs:
with: with:
stale-issue-label: 'stale' stale-issue-label: 'stale'
exempt-issue-labels: 'pinned,security,early-stages,bug: confirmed,feedback,task' 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 not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.' 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: 30 days-before-issue-stale: 90
days-before-issue-close: 5 # This stops an issue from ever getting closed automatically.
days-before-issue-close: -1
stale-pr-label: 'stale' stale-pr-label: 'stale'
stale-pr-message: 'This PR is stale because it has been open 45 days with no activity.' 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: 45 days-before-pr-stale: 90
# This stops a PR from ever getting closed automatically. # This stops a PR from ever getting closed automatically.
days-before-pr-close: -1 days-before-pr-close: -1
# If an issue/PR has a milestone, it's exempt from being marked as stale. # 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 🏗 - name: Setup node env 🏗
uses: actions/setup-node@v4.0.0 uses: actions/setup-node@v4.0.0
with: with:
node-version: 16 node-version: 20
check-latest: true check-latest: true
- name: Get yarn cache directory path 🛠 - name: Get yarn cache directory path 🛠
@ -34,6 +34,10 @@ jobs:
run: yarn run: yarn
working-directory: "frontend" working-directory: "frontend"
- name: Prepare nuxt 🚀
run: yarn nuxt prepare
working-directory: "frontend"
- name: Run linter 👀 - name: Run linter 👀
run: yarn lint run: yarn lint
working-directory: "frontend" working-directory: "frontend"

5
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import argparse
from pathlib import Path from pathlib import Path
import gen_py_pytest_data_paths import gen_py_pytest_data_paths
@ -11,15 +12,39 @@ CWD = Path(__file__).parent
def main(): def main():
items = [ parser = argparse.ArgumentParser(description="Run code generators")
(gen_py_schema_exports.main, "schema exports"), parser.add_argument(
(gen_ts_types.main, "frontend types"), "generators",
(gen_ts_locales.main, "locales"), nargs="*",
(gen_py_pytest_data_paths.main, "test data paths"), help="Specific generators to run (schema, types, locales, data-paths, routes). If none specified, all will run.", # noqa: E501 - long line
(gen_py_pytest_routes.main, "pytest routes"), )
] args = parser.parse_args()
for func, name in items: # 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:
log.info(f"Generating {name}...") log.info(f"Generating {name}...")
func() func()

View file

@ -1,5 +1,4 @@
import logging import logging
import re
import subprocess import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -35,7 +34,7 @@ class CodeSlicer:
start: int start: int
end: int end: int
indentation: str indentation: str | None
text: list[str] text: list[str]
_next_line = None _next_line = None
@ -47,15 +46,24 @@ class CodeSlicer:
def push_line(self, string: str) -> None: def push_line(self, string: str) -> None:
self._next_line = self._next_line or self.start + 1 self._next_line = self._next_line or self.start + 1
self.text.insert(self._next_line, self.indentation + string + "\n") self.text.insert(self._next_line, (self.indentation or "") + string + "\n")
self._next_line += 1 self._next_line += 1
def get_indentation_of_string(line: str, comment_char: str = "//|#") -> str: def get_indentation_of_string(line: str) -> str:
return re.sub(rf"{comment_char}.*", "", line).removesuffix("\n") # 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 find_start_end(file_text: list[str], gen_id: str) -> tuple[int, int, str]: def find_start_end(file_text: list[str], gen_id: str) -> tuple[int, int, str | None]:
start = None start = None
end = None end = None
indentation = None indentation = None

View file

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

View file

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

View file

@ -1,7 +1,7 @@
############################################### ###############################################
# Frontend Build # Frontend Build
############################################### ###############################################
FROM node:16 AS frontend-builder FROM node:20 AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
@ -20,7 +20,7 @@ RUN yarn generate
############################################### ###############################################
# Base Image - Python # Base Image - Python
############################################### ###############################################
FROM python:3.12-slim as python-base FROM python:3.12-slim AS python-base
ENV MEALIE_HOME="/app" ENV MEALIE_HOME="/app"
@ -119,7 +119,7 @@ RUN . $VENV_PATH/bin/activate \
############################################### ###############################################
# Production Image # Production Image
############################################### ###############################################
FROM python-base as production FROM python-base AS production
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie" LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
ENV PRODUCTION=true ENV PRODUCTION=true
ENV TESTING=false ENV TESTING=false
@ -141,6 +141,11 @@ RUN mkdir -p /run/secrets
# Copy venv into image. It contains a fully-installed mealie backend and frontend. # Copy venv into image. It contains a fully-installed mealie backend and frontend.
COPY --from=venv-builder $VENV_PATH $VENV_PATH COPY --from=venv-builder $VENV_PATH $VENV_PATH
# install nltk data for the ingredient parser
ENV NLTK_DATA="/nltk_data/"
RUN mkdir -p $NLTK_DATA
RUN python -m nltk.downloader -d $NLTK_DATA averaged_perceptron_tagger_eng
VOLUME [ "$MEALIE_HOME/data/" ] VOLUME [ "$MEALIE_HOME/data/" ]
ENV APP_PORT=9000 ENV APP_PORT=9000

View file

@ -13,14 +13,14 @@ Steps:
#### 1. Get your API Token #### 1. Get your API 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) Create an API token from Mealie's User Settings page (https://docs.mealie.io/documentation/getting-started/api-usage/#getting-a-token)
#### 2. Create Home Assistant Sensors #### 2. Create Home Assistant Sensors
Create REST sensors in home assistant to get the details of today's meal. 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. 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 ```yaml
rest: rest:

View file

@ -0,0 +1,27 @@
!!! 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

@ -1,82 +0,0 @@
# Using iOS Shortcuts with Mealie
!!! info
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
Don't know what an iOS shortcut is? Neither did I! Experienced iOS users may already be familiar with this utility but for the uninitiated, here is the official Apple explanation:
> A shortcut is a quick way to get one or more tasks done with your apps. The Shortcuts app lets you create your own shortcuts with multiple steps. For example, build a “Surf Time” shortcut that grabs the surf report, gives an ETA to the beach, and launches your surf music playlist.
Basically it is a visual scripting language that lets a user build an automation in a guided fashion. The automation can be [shared with anyone](https://www.icloud.com/shortcuts/94aa272af5ff4d2c8fe5e13a946f89a9) but if it is a user creation, you'll have to jump through a few hoops to make an untrusted automation work on your device.
## Setup Video
The following YouTube video walks through setting up the shortcut in 3 minutes for those who prefer following along visually.
<iframe width="560" height="315" src="https://www.youtube.com/embed/XZk6S1MVUrE?si=HGH07RbK-Ip_1qFz" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
## Guide
### Prerequisites
Before setting up the shortcut, make sure you have the following information ready and easily accessable on your Apple device.
1. The URL of your Mealie instance
2. An API Key for your user
3. A Gemini API Key from [Google AI Studio](https://makersuite.google.com)
!!! note
A Gemini API Key is not required for importing URLs from Safari or your Camera, however you will not be able to take a photo of a recipe and import it without a Gemini key.
Google AI Studio is currently only available in [certain countries and languages](https://ai.google.dev/available_regions). Most notably it is not currently available in Europe.
### Setup
On the Apple device you wish to add the shortcut to, click on [this link](https://www.icloud.com/shortcuts/94aa272af5ff4d2c8fe5e13a946f89a9) to begin the setup of the shortcut.
![screenshot](../../assets/img/ios/setup.png)
Next, you need to replace `url` and `port` with the information for your Mealie instance.
If you have a domain that you use (e.g. `https://mealie.example.com`), put that here. If you just run local, then you need to put in your Mealie instance IP and the port you use (e.g. the default is `9925`).
![screenshot](../../assets/img/ios/url.png)
Next, you need to replace `MEALIE_API_KEY` with your API token.
![screenshot](../../assets/img/ios/api.png)
Finally, replace `GEMINI_API_KEY` with the one you got from [Google AI Studio](https://makersuite.google.com)
![screenshot](../../assets/img/ios/gemini.png)
You may wish to [add the shortcut to your home screen](https://support.apple.com/guide/shortcuts/add-a-shortcut-to-the-home-screen-apd735880972/ios) for easier access.
## Features
- Share a website from Safari with Mealie to import via URL.
- Share a recipe photo from photos to perform OCR and import a physical recipe.
- Trigger the shortcut and take a photo of a physical recipe to import.
- Trigger the shortcut to select a photo from your Photos app to import.
- Trigger the shortcut to take a picture of a URL (like on the bottom of a printed recipe) to import.
## Troubleshooting
Sometimes Gemini will not be able to parse a recipe, and you will get an error. Users have found success with a combination of the following:
1. #### Try Again
Sometimes Gemini returns the wrong information which causes the import to fail. Often, trying again will be successful.
2. #### Photo Quality
Make sure there is no large glare or shadow over the picture, and you have all the text in frame.
3. #### Edit the Photo
Users have found success by cropping the picture to just the recipe card, adding a "mono" filter, and cranking up the exposure before importing.
## History
User [brasilikum](https://github.com/brasilikum) opened an issue on the main repo about how they had created an [iOS shortcut](https://github.com/mealie-recipes/mealie/issues/103) for interested users.
This original method broke after the transition to version 1.X and an issue was raised on [Github](https://github.com/mealie-recipes/mealie/issues/2092) GitHub user [Zippyy](https://github.com/zippyy) has helped to create a working shortcut for version 1.X.
When OCR was removed from Mealie, GitHub user [hunterjm](https://github.com/zippyy) created a new shortcut that uses Apple's built-in OCR and Google Gemini to enhance and replace that functionality.

View file

@ -52,6 +52,8 @@ 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). 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 ### 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. 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,6 +36,10 @@ Before you can start using OIDC Authentication, you must first configure a new c
http://localhost:9091/login http://localhost:9091/login
https://mealie.example.com/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 3. Configure origins
If your identity provider enforces CORS on any endpoints, you will need to specify your Mealie URL as an Allowed Origin. If your identity provider enforces CORS on any endpoints, you will need to specify your Mealie URL as an Allowed Origin.

View file

@ -148,7 +148,7 @@
```shell ```shell
docker exec -it mealie bash docker exec -it mealie bash
python /app/mealie/scripts/reset_locked_users.py python /opt/mealie/lib64/python3.12/site-packages/mealie/scripts/reset_locked_users.py
``` ```
@ -161,7 +161,7 @@
```shell ```shell
docker exec -it mealie bash docker exec -it mealie bash
python /app/mealie/scripts/make_admin.py python /opt/mealie/lib64/python3.12/site-packages/mealie/scripts/make_admin.py
``` ```
@ -174,7 +174,7 @@
```shell ```shell
docker exec -it mealie bash docker exec -it mealie bash
python /app/mealie/scripts/change_password.py python /opt/mealie/lib64/python3.12/site-packages/mealie/scripts/change_password.py
``` ```

View file

@ -16,6 +16,7 @@
| API_DOCS | True | Turns on/off access to the API documentation locally | | 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 | | 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_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_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) | | 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 | | 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 |
@ -108,7 +109,9 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
| OIDC_REMEMBER_ME | False | Because redirects bypass the login screen, you cant extend your session by clicking the "Remember Me" checkbox. By setting this value to true, a session will be extended as if "Remember Me" was checked | | OIDC_REMEMBER_ME | False | Because redirects bypass the login screen, you cant extend your session by clicking the "Remember Me" checkbox. By setting this value to true, a session will be extended as if "Remember Me" was checked |
| OIDC_SIGNING_ALGORITHM | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) | | OIDC_SIGNING_ALGORITHM | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| OIDC_USER_CLAIM | email | This is the claim which Mealie will use to look up an existing user by (e.g. "email", "preferred_username") | | OIDC_USER_CLAIM | email | This is the claim which Mealie will use to look up an existing user by (e.g. "email", "preferred_username") |
| OIDC_NAME_CLAIM | name | This is the claim which Mealie will use for the users Full Name |
| OIDC_GROUPS_CLAIM | groups | Optional if not using `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. This is the claim Mealie will request from your IdP and will use to compare to `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP` to allow the user to log in to Mealie or is set as an admin. **Your IdP must be configured to grant this claim** | | OIDC_GROUPS_CLAIM | groups | Optional if not using `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. This is the claim Mealie will request from your IdP and will use to compare to `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP` to allow the user to log in to Mealie or is set as an admin. **Your IdP must be configured to grant this claim** |
| OIDC_SCOPES_OVERRIDE | None | Advanced configuration used to override the scopes requested from the IdP. **Most users won't need to change this**. At a minimum, 'openid profile email' are required. |
| OIDC_TLS_CACERTFILE | None | File path to Certificate Authority used to verify server certificate (e.g. `/path/to/ca.crt`) | | OIDC_TLS_CACERTFILE | None | File path to Certificate Authority used to verify server certificate (e.g. `/path/to/ca.crt`) |
### OpenAI ### OpenAI
@ -118,13 +121,17 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./open-ai.md). Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./open-ai.md).
For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values as JSON encoded strings (e.g. `OPENAI_CUSTOM_PARAMS='{"k1": "v1", "k2": "v2"}'`) For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values as JSON encoded strings (e.g. `OPENAI_CUSTOM_PARAMS='{"k1": "v1", "k2": "v2"}'`)
| Variables | Default | Description | | Variables | Default | Description |
| ------------------------------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OPENAI_BASE_URL<super>[&dagger;][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform | | OPENAI_BASE_URL<super>[&dagger;][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
| OPENAI_API_KEY<super>[&dagger;][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features | | OPENAI_API_KEY<super>[&dagger;][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features |
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty | | OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs | | OPENAI_CUSTOM_HEADERS | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs | | OPENAI_CUSTOM_PARAMS | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
| OPENAI_REQUEST_TIMEOUT | 60 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
### Theming ### Theming
@ -149,8 +156,6 @@ Setting the following environmental variables will change the theme of the front
### Docker Secrets ### Docker Secrets
### Docker Secrets
> <super>&dagger;</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger > <super>&dagger;</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger
> symbol next to them support the Docker Compose secrets pattern, below. > symbol next to them support the Docker Compose secrets pattern, below.
[Docker Compose secrets][docker-secrets] can be used to secure sensitive information regarding the Mealie implementation [Docker Compose secrets][docker-secrets] can be used to secure sensitive information regarding the Mealie implementation

View file

@ -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: 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! 1. Take a backup just in case!
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.7.1` 2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.0.2`
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. 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 4. Restart the container

View file

@ -1,5 +1,8 @@
# Installing with PostgreSQL # 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. 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) **For Environment Variable Configuration, see** [Backend Configuration](./backend-config.md)
@ -7,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
```yaml ```yaml
services: services:
mealie: mealie:
image: ghcr.io/mealie-recipes/mealie:v2.7.1 # (3) image: ghcr.io/mealie-recipes/mealie:v3.0.2 # (3)
container_name: mealie container_name: mealie
restart: always restart: always
ports: ports:
@ -38,7 +41,7 @@ services:
postgres: postgres:
container_name: postgres container_name: postgres
image: postgres:15 image: postgres:17
restart: always restart: always
volumes: volumes:
- mealie-pgdata:/var/lib/postgresql/data - mealie-pgdata:/var/lib/postgresql/data
@ -46,6 +49,7 @@ services:
POSTGRES_PASSWORD: mealie POSTGRES_PASSWORD: mealie
POSTGRES_USER: mealie POSTGRES_USER: mealie
PGUSER: mealie PGUSER: mealie
POSTGRES_DB: mealie
healthcheck: healthcheck:
test: ["CMD", "pg_isready"] test: ["CMD", "pg_isready"]
interval: 30s interval: 30s

View file

@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
```yaml ```yaml
services: services:
mealie: mealie:
image: ghcr.io/mealie-recipes/mealie:v2.7.1 # (3) image: ghcr.io/mealie-recipes/mealie:v3.0.2 # (3)
container_name: mealie container_name: mealie
restart: always restart: always
ports: ports:

View file

@ -2,6 +2,3 @@
## Feature Requests ## Feature Requests
[Please request new features on Github](https://github.com/mealie-recipes/mealie/discussions/new?category=feature-request) [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 --> <!-- Custom narrow footer -->
<div class="md-footer-meta__inner md-grid"> <div class="md-footer-meta__inner md-grid">
<div class="md-footer-social"> <div class="md-footer-social">
<a class="md-footer-social__link" href="https://github.com/hay-kot/mealie" rel="noopener" target="_blank" <a class="md-footer-social__link" href="https://github.com/mealie-recipes/mealie" rel="noopener" target="_blank"
title="github.com"> title="github.com">
<svg style="width: 32px; height: 32px" viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg"> <svg style="width: 32px; height: 32px" viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
<path <path

View file

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

View file

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

View file

@ -1,378 +0,0 @@
/* 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 { .theme--dark.v-application {
background-color: var(--v-background-base, #1e1e1e) !important; background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
} }
.theme--dark.v-navigation-drawer { .theme--dark.v-navigation-drawer {
background-color: var(--v-background-base, #1e1e1e) !important; background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
} }
.theme--dark.v-card { .theme--dark.v-card {
@ -29,11 +29,11 @@
} }
.left-border { .left-border {
border-left: 5px solid var(--v-primary-base) !important; border-left: 5px solid rgb(var(--v-theme-primary)) !important;
} }
.left-warning-border { .left-warning-border {
border-left: 5px solid var(--v-warning-base) !important; border-left: 5px solid rgb(var(--v-theme-warning)) !important;
} }
.handle { .handle {
@ -56,3 +56,15 @@
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 100%; 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;
}

View file

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

View file

@ -7,44 +7,56 @@
width="100%" width="100%"
max-width="1100px" max-width="1100px"
:icon="$globals.icons.pages" :icon="$globals.icons.pages"
:title="$tc('general.edit')" :title="$t('general.edit')"
:submit-icon="$globals.icons.save" :submit-icon="$globals.icons.save"
:submit-text="$tc('general.save')" :submit-text="$t('general.save')"
:submit-disabled="!editTarget.queryFilterString" :submit-disabled="!editTarget.queryFilterString"
can-submit
@submit="editCookbook" @submit="editCookbook"
> >
<v-card-text> <v-card-text>
<CookbookEditor :cookbook="editTarget" :actions="actions" /> <CookbookEditor
v-model="editTarget"
/>
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<!-- Page --> <v-container
<v-container v-if="book" fluid> v-if="book"
<v-app-bar color="transparent" flat class="mt-n1"> class="my-0"
<v-icon large left> {{ $globals.icons.pages }} </v-icon> >
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title> <v-sheet
<v-spacer></v-spacer> color="transparent"
<BaseButton class="d-flex flex-column w-100 pa-0 ma-0"
v-if="canEdit" elevation="0"
class="mx-1" >
:edit="true" <div class="d-flex align-center w-100 mb-2">
@click="handleEditCookbook" <v-toolbar-title class="headline mb-0">
/> <v-icon size="large" class="mr-3">
</v-app-bar> {{ $globals.icons.pages }}
<v-card flat> </v-icon>
<v-card-text class="py-0"> {{ book.name }}
</v-toolbar-title>
<BaseButton
v-if="canEdit"
class="mx-1"
:edit="true"
@click="handleEditCookbook"
/>
</div>
<div v-if="book.description" class="subtitle-1 text-grey-lighten-1 mb-2">
{{ book.description }} {{ book.description }}
</v-card-text> </div>
</v-card> </v-sheet>
<v-container class="pa-0"> <v-container class="pa-0">
<RecipeCardSection <RecipeCardSection
class="mb-5 mx-1" class="mb-5 mx-1"
:recipes="recipes" :recipes="recipes"
:query="{ cookbook: slug }" :query="{ cookbook: slug }"
@sortRecipes="assignSorted" @sort-recipes="assignSorted"
@replaceRecipes="replaceRecipes" @replace-recipes="replaceRecipes"
@appendRecipes="appendRecipes" @append-recipes="appendRecipes"
@delete="removeRecipe" @delete="removeRecipe"
/> />
</v-container> </v-container>
@ -52,92 +64,67 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { computed, defineComponent, useRoute, ref, useContext, useMeta, reactive, useRouter } from "@nuxtjs/composition-api"; import { useLazyRecipes } from "~/composables/recipes";
import { useLazyRecipes } from "~/composables/recipes"; import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue"; import { useCookbookStore } from "~/composables/store/use-cookbook-store";
import { useCookbook, useCookbooks } from "~/composables/use-group-cookbooks"; import { useCookbook } from "~/composables/use-group-cookbooks";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { RecipeCookBook } from "~/lib/api/types/cookbook"; import type { RecipeCookBook } from "~/lib/api/types/cookbook";
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue"; import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
export default defineComponent({ const $auth = useMealieAuth();
components: { RecipeCardSection, CookbookEditor }, const { isOwnGroup } = useLoggedInState();
setup() {
const { $auth } = useContext();
const { isOwnGroup } = useLoggedInState();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value); const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const slug = route.value.params.slug; const slug = route.params.slug as string;
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value); const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
const { actions } = useCookbooks(); const { actions } = useCookbookStore();
const router = useRouter(); const router = useRouter();
const tab = ref(null); const book = getOne(slug);
const book = getOne(slug);
const isOwnHousehold = computed(() => { const isOwnHousehold = computed(() => {
if (!($auth.user && book.value?.householdId)) { if (!($auth.user.value && book.value?.householdId)) {
return false; return false;
} }
return $auth.user.householdId === book.value.householdId; return $auth.user.value.householdId === book.value.householdId;
}) });
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value); const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
const dialogStates = reactive({ const dialogStates = reactive({
edit: false, edit: false,
}); });
const editTarget = ref<RecipeCookBook | null>(null); const editTarget = ref<RecipeCookBook | null>(null);
function handleEditCookbook() { function handleEditCookbook() {
dialogStates.edit = true; dialogStates.edit = true;
editTarget.value = book.value; editTarget.value = book.value;
} }
async function editCookbook() { async function editCookbook() {
if (!editTarget.value) { if (!editTarget.value) {
return; return;
} }
const response = await actions.updateOne(editTarget.value); const response = await actions.updateOne(editTarget.value);
if (response?.slug && book.value?.slug !== response?.slug) { if (response?.slug && book.value?.slug !== response?.slug) {
// if name changed, redirect to new slug // if name changed, redirect to new slug
router.push(`/g/${route.value.params.groupSlug}/cookbooks/${response?.slug}`); router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
} else { }
// otherwise reload the page, since the recipe criteria changed else {
router.go(0); // otherwise reload the page, since the recipe criteria changed
} router.go(0);
dialogStates.edit = false; }
editTarget.value = null; dialogStates.edit = false;
} editTarget.value = null;
}
useMeta(() => { useSeoMeta({
return { title: book?.value?.name || "Cookbook",
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,55 +7,46 @@
class="elevation-0" class="elevation-0"
@click:row="downloadData" @click:row="downloadData"
> >
<template #item.expires="{ item }"> <template #[`item.expires`]="{ item }">
{{ getTimeToExpire(item.expires) }} {{ getTimeToExpire(item.expires) }}
</template> </template>
<template #item.actions="{ item }"> <template #[`item.actions`]="{ item }">
<BaseButton download small :download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"> <BaseButton
</BaseButton> download
size="small"
:download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"
/>
</template> </template>
</v-data-table> </v-data-table>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent, useContext } from "@nuxtjs/composition-api";
import { parseISO, formatDistanceToNow } from "date-fns"; import { parseISO, formatDistanceToNow } from "date-fns";
import { GroupDataExport } from "~/lib/api/types/group"; import type { GroupDataExport } from "~/lib/api/types/group";
export default defineComponent({
props: {
exports: {
type: Array as () => GroupDataExport[],
required: true,
},
},
setup() {
const { i18n } = useContext();
const headers = [ defineProps<{
{ text: i18n.t("export.export"), value: "name" }, exports: GroupDataExport[];
{ text: i18n.t("export.file-name"), value: "filename" }, }>();
{ text: i18n.t("export.size"), value: "size" },
{ text: i18n.t("export.link-expires"), value: "expires" },
{ text: "", value: "actions" },
];
function getTimeToExpire(timeString: string) { const i18n = useI18n();
const expiresAt = parseISO(timeString);
return formatDistanceToNow(expiresAt, { const headers = [
addSuffix: false, { 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 downloadData(_: any) { function getTimeToExpire(timeString: string) {
console.log("Downloading data..."); const expiresAt = parseISO(timeString);
}
return { return formatDistanceToNow(expiresAt, {
downloadData, addSuffix: false,
headers, });
getTimeToExpire, }
};
}, function downloadData(_: any) {
}); console.log("Downloading data...");
}
</script> </script>

View file

@ -1,36 +1,18 @@
<template> <template>
<div v-if="preferences"> <div v-if="preferences">
<BaseCardSectionTitle :title="$tc('group.general-preferences')"></BaseCardSectionTitle> <BaseCardSectionTitle :title="$t('group.general-preferences')" />
<v-checkbox v-model="preferences.privateGroup" class="mt-n4" :label="$t('group.private-group')"></v-checkbox> <v-checkbox
v-model="preferences.privateGroup"
class="mt-n4"
:label="$t('group.private-group')"
/>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent, computed } from "@nuxtjs/composition-api"; import type { ReadGroupPreferences } from "~/lib/api/types/user";
export default defineComponent({ const preferences = defineModel<ReadGroupPreferences>({ required: true });
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> </script>
<style lang="scss" scoped> <style lang="scss" scoped></style>
</style>

View file

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

View file

@ -1,164 +1,122 @@
<template> <template>
<div> <div>
<div class="d-md-flex" style="gap: 10px"> <div
<v-select v-model="inputDay" :items="MEAL_DAY_OPTIONS" :label="$t('meal-plan.rule-day')"></v-select> class="d-md-flex"
<v-select v-model="inputEntryType" :items="MEAL_TYPE_OPTIONS" :label="$t('meal-plan.meal-type')"></v-select> style="gap: 10px"
>
<v-select
v-model="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> </div>
<div class="mb-5"> <div class="mb-5">
<QueryFilterBuilder <QueryFilterBuilder
:field-defs="fieldDefs" :field-defs="fieldDefs"
:initial-query-filter="queryFilter" :initial-query-filter="props.queryFilter"
@input="handleQueryFilterInput" @input="handleQueryFilterInput"
/> />
</div> </div>
<!-- TODO: proper pluralization of inputDay --> <!-- TODO: proper pluralization of inputDay -->
{{ $t('meal-plan.this-rule-will-apply', { {{ $t('meal-plan.this-rule-will-apply', {
dayCriteria: inputDay === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [inputDay]), dayCriteria: day === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [day]),
mealTypeCriteria: inputEntryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [inputEntryType]) mealTypeCriteria: entryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [entryType]),
}) }} }) }}
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue"; import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import { FieldDefinition } from "~/composables/use-query-filter-builder"; import type { FieldDefinition } from "~/composables/use-query-filter-builder";
import { Organizer } from "~/lib/api/types/non-generated"; import { Organizer } from "~/lib/api/types/non-generated";
import { QueryFilterJSON } from "~/lib/api/types/response"; import type { QueryFilterJSON } from "~/lib/api/types/response";
export default defineComponent({ interface Props {
components: { queryFilter?: QueryFilterJSON | null;
QueryFilterBuilder, showHelp?: boolean;
}, }
props: { const props = withDefaults(defineProps<Props>(), {
day: { queryFilter: null,
type: String, showHelp: false,
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 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 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 inputDay = computed({
get: () => {
return props.day;
},
set: (val) => {
context.emit("update:day", val);
},
});
const inputEntryType = computed({
get: () => {
return props.entryType;
},
set: (val) => {
context.emit("update:entry-type", val);
},
});
const inputQueryFilterString = computed({
get: () => {
return props.queryFilterString;
},
set: (val) => {
context.emit("update:query-filter-string", val);
},
});
function handleQueryFilterInput(value: string | undefined) {
inputQueryFilterString.value = value || "";
};
const fieldDefs: FieldDefinition[] = [
{
name: "recipe_category.id",
label: i18n.tc("category.categories"),
type: Organizer.Category,
},
{
name: "tags.id",
label: i18n.tc("tag.tags"),
type: Organizer.Tag,
},
{
name: "recipe_ingredient.food.id",
label: i18n.tc("recipe.ingredients"),
type: Organizer.Food,
},
{
name: "tools.id",
label: i18n.tc("tool.tools"),
type: Organizer.Tool,
},
{
name: "household_id",
label: i18n.tc("household.households"),
type: Organizer.Household,
},
{
name: "last_made",
label: i18n.tc("general.last-made"),
type: "date",
},
{
name: "created_at",
label: i18n.tc("general.date-created"),
type: "date",
},
{
name: "updated_at",
label: i18n.tc("general.date-updated"),
type: "date",
},
];
return {
MEAL_TYPE_OPTIONS,
MEAL_DAY_OPTIONS,
inputDay,
inputEntryType,
inputQueryFilterString,
handleQueryFilterInput,
fieldDefs,
};
},
}); });
const day = defineModel<string>("day", { default: "unset" });
const entryType = defineModel<string>("entryType", { default: "unset" });
const queryFilterString = defineModel<string>("queryFilterString", { default: "" });
const i18n = useI18n();
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 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" },
];
function handleQueryFilterInput(value: string | undefined) {
console.warn("handleQueryFilterInput called with value:", value);
queryFilterString.value = value || "";
}
const fieldDefs: FieldDefinition[] = [
{
name: "recipe_category.id",
label: i18n.t("category.categories"),
type: Organizer.Category,
},
{
name: "tags.id",
label: i18n.t("tag.tags"),
type: Organizer.Tag,
},
{
name: "recipe_ingredient.food.id",
label: i18n.t("recipe.ingredients"),
type: Organizer.Food,
},
{
name: "tools.id",
label: i18n.t("tool.tools"),
type: Organizer.Tool,
},
{
name: "household_id",
label: i18n.t("household.households"),
type: Organizer.Household,
},
{
name: "last_made",
label: i18n.t("general.last-made"),
type: "date",
},
{
name: "created_at",
label: i18n.t("general.date-created"),
type: "date",
},
{
name: "updated_at",
label: i18n.t("general.date-updated"),
type: "date",
},
];
</script> </script>

View file

@ -1,27 +1,44 @@
<template> <template>
<div> <div>
<v-card-text> <v-card-text>
<v-switch v-model="webhookCopy.enabled" :label="$t('general.enabled')"></v-switch> <v-switch
<v-text-field v-model="webhookCopy.name" :label="$t('settings.webhooks.webhook-name')"></v-text-field> v-model="webhookCopy.enabled"
<v-text-field v-model="webhookCopy.url" :label="$t('settings.webhooks.webhook-url')"></v-text-field> color="primary"
<v-time-picker v-model="scheduledTime" class="elevation-2" ampm-in-title format="ampm"></v-time-picker> :label="$t('general.enabled')"
/>
<v-text-field
v-model="webhookCopy.name"
:label="$t('settings.webhooks.webhook-name')"
variant="underlined"
/>
<v-text-field
v-model="webhookCopy.url"
:label="$t('settings.webhooks.webhook-url')"
variant="underlined"
/>
<v-text-field
v-model="scheduledTime"
type="time"
clearable
variant="underlined"
/>
</v-card-text> </v-card-text>
<v-card-actions class="py-0 justify-end"> <v-card-actions class="py-0 justify-end">
<BaseButtonGroup <BaseButtonGroup
:buttons="[ :buttons="[
{ {
icon: $globals.icons.delete, icon: $globals.icons.delete,
text: $tc('general.delete'), text: $t('general.delete'),
event: 'delete', event: 'delete',
}, },
{ {
icon: $globals.icons.testTube, icon: $globals.icons.testTube,
text: $tc('general.test'), text: $t('general.test'),
event: 'test', event: 'test',
}, },
{ {
icon: $globals.icons.save, icon: $globals.icons.save,
text: $tc('general.save'), text: $t('general.save'),
event: 'save', event: 'save',
}, },
]" ]"
@ -33,52 +50,43 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent, computed, ref } from "@nuxtjs/composition-api"; import type { ReadWebhook } from "~/lib/api/types/household";
import { ReadWebhook } from "~/lib/api/types/household";
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks"; import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
export default defineComponent({ const props = defineProps<{
props: { webhook: ReadWebhook;
webhook: { }>();
type: Object as () => ReadWebhook,
required: true, 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({
get() {
return itemLocal.value;
}, },
emits: ["delete", "save", "test"], set(v: string) {
setup(props, { emit }) { itemUTC.value = timeLocalToUTC(v);
const itemUTC = ref<string>(props.webhook.scheduledTime); itemLocal.value = v;
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
const scheduledTime = computed({
get() {
return itemLocal.value;
},
set(v: string) {
itemUTC.value = timeLocalToUTC(v);
itemLocal.value = v;
},
});
const webhookCopy = ref({ ...props.webhook });
function handleSave() {
webhookCopy.value.scheduledTime = itemLocal.value;
emit("save", webhookCopy.value);
}
return {
webhookCopy,
scheduledTime,
handleSave,
itemUTC,
itemLocal,
};
},
head() {
return {
title: this.$t("settings.webhooks.webhooks") as string,
};
}, },
}); });
const webhookCopy = ref({ ...props.webhook });
function handleSave() {
webhookCopy.value.scheduledTime = itemLocal.value;
emit("save", webhookCopy.value);
}
// Set page title using useSeoMeta
useSeoMeta({
title: i18n.t("settings.webhooks.webhooks"),
});
</script> </script>

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
<v-img <v-img
v-if="!fallBackImage" v-if="!fallBackImage"
:height="height" :height="height"
cover
min-height="125" min-height="125"
max-height="fill-height" max-height="fill-height"
:src="getImage(recipeId)" :src="getImage(recipeId)"
@ -9,94 +10,78 @@
@load="fallBackImage = false" @load="fallBackImage = false"
@error="fallBackImage = true" @error="fallBackImage = true"
> >
<slot> </slot> <slot />
</v-img> </v-img>
<div v-else class="icon-slot" @click="$emit('click')"> <div
<v-icon color="primary" class="icon-position" :size="iconSize"> v-else
class="icon-slot"
@click="$emit('click')"
>
<v-icon
color="primary"
class="icon-position"
:size="iconSize"
>
{{ $globals.icons.primary }} {{ $globals.icons.primary }}
</v-icon> </v-icon>
<slot> </slot> <slot />
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { computed, defineComponent, ref, watch } from "@nuxtjs/composition-api"; import { useStaticRoutes } from "~/composables/api";
import { useStaticRoutes, useUserApi } from "~/composables/api";
export default defineComponent({ interface Props {
props: { tiny?: boolean | null;
tiny: { small?: boolean | null;
type: Boolean, large?: boolean | null;
default: null, iconSize?: number | string;
}, slug?: string | null;
small: { recipeId: string;
type: Boolean, imageVersion?: string | null;
default: null, height?: number | string;
}, }
large: { const props = withDefaults(defineProps<Props>(), {
type: Boolean, tiny: null,
default: null, small: null,
}, large: null,
iconSize: { iconSize: 100,
type: [Number, String], slug: null,
default: 100, imageVersion: null,
}, height: "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();
const { recipeImage, recipeSmallImage, recipeTinyImage } = useStaticRoutes();
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(
() => props.recipeId,
() => {
fallBackImage.value = false;
}
);
function getImage(recipeId: string) {
switch (imageSize.value) {
case "tiny":
return recipeTinyImage(recipeId, props.imageVersion);
case "small":
return recipeSmallImage(recipeId, props.imageVersion);
case "large":
return recipeImage(recipeId, props.imageVersion);
}
}
return {
api,
fallBackImage,
imageSize,
getImage,
};
},
}); });
defineEmits<{
click: [];
}>();
const { recipeImage, recipeSmallImage, recipeTinyImage } = useStaticRoutes();
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(
() => props.recipeId,
() => {
fallBackImage.value = false;
},
);
function getImage(recipeId: string) {
switch (imageSize.value) {
case "tiny":
return recipeTinyImage(recipeId, props.imageVersion);
case "small":
return recipeSmallImage(recipeId, props.imageVersion);
case "large":
return recipeImage(recipeId, props.imageVersion);
}
}
</script> </script>
<style scoped> <style scoped>

View file

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

View file

@ -1,67 +1,102 @@
<template> <template>
<div> <div>
<v-app-bar v-if="!disableToolbar" color="transparent" flat class="mt-n1 flex-sm-wrap rounded"> <v-app-bar
v-if="!disableToolbar"
color="transparent"
:absolute="false"
flat
class="mt-n1 flex-sm-wrap rounded position-relative w-100 left-0 top-0"
>
<slot name="title"> <slot name="title">
<v-icon v-if="title" large left> <v-icon
v-if="title"
size="large"
start
>
{{ displayTitleIcon }} {{ displayTitleIcon }}
</v-icon> </v-icon>
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title> <v-toolbar-title class="headline">
{{ title }}
</v-toolbar-title>
</slot> </slot>
<v-spacer></v-spacer> <v-spacer />
<v-btn :icon="$vuetify.breakpoint.xsOnly" text :disabled="recipes.length === 0" @click="navigateRandom"> <v-btn
<v-icon :left="!$vuetify.breakpoint.xsOnly"> :icon="$vuetify.display.xs"
variant="text"
:disabled="recipes.length === 0"
@click="navigateRandom"
>
<v-icon :start="!$vuetify.display.xs">
{{ $globals.icons.diceMultiple }} {{ $globals.icons.diceMultiple }}
</v-icon> </v-icon>
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.random") }} {{ $vuetify.display.xs ? null : $t("general.random") }}
</v-btn> </v-btn>
<v-menu
<v-menu v-if="$listeners.sortRecipes" offset-y left> v-if="!disableSort"
<template #activator="{ on, attrs }"> offset-y
<v-btn text :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" :loading="sortLoading" v-on="on"> start
<v-icon :left="!$vuetify.breakpoint.xsOnly"> >
<template #activator="{ props: activatorProps }">
<v-btn
variant="text"
:icon="$vuetify.display.xs"
v-bind="activatorProps"
:loading="sortLoading"
>
<v-icon :start="!$vuetify.display.xs">
{{ preferences.sortIcon }} {{ preferences.sortIcon }}
</v-icon> </v-icon>
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.sort") }} {{ $vuetify.display.xs ? null : $t("general.sort") }}
</v-btn> </v-btn>
</template> </template>
<v-list> <v-list>
<v-list-item @click="sortRecipes(EVENTS.az)"> <v-list-item @click="sortRecipes(EVENTS.az)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
{{ $globals.icons.orderAlphabeticalAscending }} <v-icon class="mr-2" inline>
</v-icon> {{ $globals.icons.orderAlphabeticalAscending }}
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title> </v-icon>
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
<v-list-item @click="sortRecipes(EVENTS.rating)"> <v-list-item @click="sortRecipes(EVENTS.rating)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
{{ $globals.icons.star }} <v-icon class="mr-2" inline>
</v-icon> {{ $globals.icons.star }}
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title> </v-icon>
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
<v-list-item @click="sortRecipes(EVENTS.created)"> <v-list-item @click="sortRecipes(EVENTS.created)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
{{ $globals.icons.newBox }} <v-icon class="mr-2" inline>
</v-icon> {{ $globals.icons.newBox }}
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title> </v-icon>
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
<v-list-item @click="sortRecipes(EVENTS.updated)"> <v-list-item @click="sortRecipes(EVENTS.updated)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
{{ $globals.icons.update }} <v-icon class="mr-2" inline>
</v-icon> {{ $globals.icons.update }}
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title> </v-icon>
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
<v-list-item @click="sortRecipes(EVENTS.lastMade)"> <v-list-item @click="sortRecipes(EVENTS.lastMade)">
<v-icon left> <div class="d-flex align-center flex-nowrap">
{{ $globals.icons.chefHat }} <v-icon class="mr-2" inline>
</v-icon> {{ $globals.icons.chefHat }}
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title> </v-icon>
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
</div>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
<ContextMenu <ContextMenu
v-if="!$vuetify.breakpoint.smAndDown" v-if="!$vuetify.display.smAndDown"
:items="[ :items="[
{ {
title: $tc('general.toggle-view'), title: $t('general.toggle-view'),
icon: $globals.icons.eye, icon: $globals.icons.eye,
event: 'toggle-dense-view', event: 'toggle-dense-view',
}, },
@ -72,348 +107,311 @@
<div v-if="recipes && ready"> <div v-if="recipes && ready">
<div class="mt-2"> <div class="mt-2">
<v-row v-if="!useMobileCards"> <v-row v-if="!useMobileCards">
<v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3">
<v-lazy>
<RecipeCard
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:tags="recipe.tags"
:recipe-id="recipe.id"
v-on="$listeners"
/>
</v-lazy>
</v-col>
</v-row>
<v-row v-else dense>
<v-col <v-col
v-for="recipe in recipes" v-for="recipe in recipes"
:key="recipe.name" :key="recipe.id!"
:sm="6"
:md="6"
:lg="4"
:xl="3"
>
<RecipeCard
:name="recipe.name!"
:description="recipe.description!"
:slug="recipe.slug!"
:rating="recipe.rating!"
:image="recipe.image!"
:tags="recipe.tags!"
:recipe-id="recipe.id!"
/>
</v-col>
</v-row>
<v-row
v-else
dense
>
<v-col
v-for="recipe in recipes"
:key="recipe.id!"
cols="12" cols="12"
:sm="singleColumn ? '12' : '12'" :sm="singleColumn ? '12' : '12'"
:md="singleColumn ? '12' : '6'" :md="singleColumn ? '12' : '6'"
:lg="singleColumn ? '12' : '4'" :lg="singleColumn ? '12' : '4'"
:xl="singleColumn ? '12' : '3'" :xl="singleColumn ? '12' : '3'"
> >
<v-lazy> <RecipeCardMobile
<RecipeCardMobile :name="recipe.name!"
:name="recipe.name" :description="recipe.description!"
:description="recipe.description" :slug="recipe.slug!"
:slug="recipe.slug" :rating="recipe.rating!"
:rating="recipe.rating" :image="recipe.image!"
:image="recipe.image" :tags="recipe.tags!"
:tags="recipe.tags" :recipe-id="recipe.id!"
:recipe-id="recipe.id" />
v-on="$listeners"
/>
</v-lazy>
</v-col> </v-col>
</v-row> </v-row>
</div> </div>
<v-card v-intersect="infiniteScroll"></v-card> <v-card v-intersect="infiniteScroll" />
<v-fade-transition> <v-fade-transition>
<AppLoader v-if="loading" :loading="loading" /> <AppLoader
v-if="loading"
:loading="loading"
/>
</v-fade-transition> </v-fade-transition>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import {
computed,
defineComponent,
onMounted,
reactive,
ref,
toRefs,
useAsync,
useContext,
useRoute,
useRouter,
watch,
} from "@nuxtjs/composition-api";
import { useThrottleFn } from "@vueuse/core"; import { useThrottleFn } from "@vueuse/core";
import RecipeCard from "./RecipeCard.vue"; import RecipeCard from "./RecipeCard.vue";
import RecipeCardMobile from "./RecipeCardMobile.vue"; import RecipeCardMobile from "./RecipeCardMobile.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useAsyncKey } from "~/composables/use-utils";
import { useLazyRecipes } from "~/composables/recipes"; import { useLazyRecipes } from "~/composables/recipes";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import { useUserSortPreferences } from "~/composables/use-users/preferences"; import { useUserSortPreferences } from "~/composables/use-users/preferences";
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe"; import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
const REPLACE_RECIPES_EVENT = "replaceRecipes"; const REPLACE_RECIPES_EVENT = "replaceRecipes";
const APPEND_RECIPES_EVENT = "appendRecipes"; const APPEND_RECIPES_EVENT = "appendRecipes";
export default defineComponent({ interface Props {
components: { disableToolbar?: boolean;
RecipeCard, disableSort?: boolean;
RecipeCardMobile, icon?: string | null;
}, title?: string | null;
props: { singleColumn?: boolean;
disableToolbar: { recipes?: Recipe[];
type: Boolean, query?: RecipeSearchQuery | null;
default: false, }
}, const props = withDefaults(defineProps<Props>(), {
icon: { disableToolbar: false,
type: String, disableSort: false,
default: null, icon: null,
}, title: null,
title: { singleColumn: false,
type: String, recipes: () => [],
default: null, query: 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 EVENTS = { const emit = defineEmits<{
az: "az", replaceRecipes: [recipes: Recipe[]];
rating: "rating", appendRecipes: [recipes: Recipe[]];
created: "created", }>();
updated: "updated",
lastMade: "lastMade",
shuffle: "shuffle",
};
const { $auth, $globals, $vuetify } = useContext(); const { $vuetify } = useNuxtApp();
const { isOwnGroup } = useLoggedInState(); const preferences = useUserSortPreferences();
const useMobileCards = computed(() => {
return $vuetify.breakpoint.smAndDown || preferences.value.useMobileCards;
});
const displayTitleIcon = computed(() => { const EVENTS = {
return props.icon || $globals.icons.tags; az: "az",
}); rating: "rating",
created: "created",
updated: "updated",
lastMade: "lastMade",
shuffle: "shuffle",
};
const state = reactive({ const $auth = useMealieAuth();
sortLoading: false, const { $globals } = useNuxtApp();
}); const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => {
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
});
const route = useRoute(); const displayTitleIcon = computed(() => {
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); return props.icon || $globals.icons.tags;
});
const page = ref(1); const sortLoading = ref(false);
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 route = useRoute();
const router = useRouter(); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const queryFilter = computed(() => { const page = ref(1);
return props.query.queryFilter || null; const perPage = 32;
const hasMore = ref(true);
const ready = ref(false);
const loading = ref(false);
// TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade) const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const router = useRouter();
// const orderBy = props.query?.orderBy || preferences.value.orderBy; const queryFilter = computed(() => {
// const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null; return props.query?.queryFilter || null;
// if (props.query.queryFilter && orderByFilter) { // TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade)
// return `(${props.query.queryFilter}) AND ${orderByFilter}`;
// } else if (props.query.queryFilter) {
// return props.query.queryFilter;
// } else {
// return orderByFilter;
// }
});
async function fetchRecipes(pageCount = 1) { // const orderBy = props.query?.orderBy || preferences.value.orderBy;
const orderDir = props.query?.orderDirection || preferences.value.orderDirection; // const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
return await fetchMore(
page.value,
perPage * pageCount,
props.query?.orderBy || preferences.value.orderBy,
orderDir,
orderByNullPosition,
props.query,
// 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 () => { // if (props.query.queryFilter && orderByFilter) {
// return `(${props.query.queryFilter}) AND ${orderByFilter}`;
// } else if (props.query.queryFilter) {
// return props.query.queryFilter;
// } else {
// return orderByFilter;
// }
});
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(
page.value,
perPage * pageCount,
props.query?.orderBy || preferences.value.orderBy,
orderDir,
orderByNullPosition,
props.query,
// 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 () => {
await initRecipes();
ready.value = true;
});
let lastQuery: string | undefined = JSON.stringify(props.query);
watch(
() => props.query,
async (newValue: RecipeSearchQuery | undefined | null) => {
const newValueString = JSON.stringify(newValue);
if (lastQuery !== newValueString) {
lastQuery = newValueString;
ready.value = false;
await initRecipes(); await initRecipes();
ready.value = true; ready.value = true;
});
let lastQuery: string | undefined = JSON.stringify(props.query);
watch(
() => props.query,
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() {
page.value = 1;
hasMore.value = true;
// we double-up the first call to avoid a bug with large screens that render
// the entire first page without scrolling, preventing additional loading
const newRecipes = await fetchRecipes(page.value + 1);
if (newRecipes.length < perPage) {
hasMore.value = false;
}
// since we doubled the first call, we also need to advance the page
page.value = page.value + 1;
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
} }
const infiniteScroll = useThrottleFn(() => {
useAsync(async () => {
if (!hasMore.value || loading.value) {
return;
}
loading.value = true;
page.value = page.value + 1;
const newRecipes = await fetchRecipes();
if (newRecipes.length < perPage) {
hasMore.value = false;
}
if (newRecipes.length) {
context.emit(APPEND_RECIPES_EVENT, newRecipes);
}
loading.value = false;
}, useAsyncKey());
}, 500);
function sortRecipes(sortType: string) {
if (state.sortLoading || loading.value) {
return;
}
function setter(
orderBy: string,
ascIcon: string,
descIcon: string,
defaultOrderDirection = "asc",
filterNull = false
) {
if (preferences.value.orderBy !== orderBy) {
preferences.value.orderBy = orderBy;
preferences.value.orderDirection = defaultOrderDirection;
preferences.value.filterNull = filterNull;
} else {
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
}
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
}
switch (sortType) {
case EVENTS.az:
setter(
"name",
$globals.icons.sortAlphabeticalAscending,
$globals.icons.sortAlphabeticalDescending,
"asc",
false
);
break;
case EVENTS.rating:
setter("rating", $globals.icons.sortAscending, $globals.icons.sortDescending, "desc", true);
break;
case EVENTS.created:
setter(
"created_at",
$globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending,
"desc",
false
);
break;
case EVENTS.updated:
setter("updated_at", $globals.icons.sortClockAscending, $globals.icons.sortClockDescending, "desc", false);
break;
case EVENTS.lastMade:
setter(
"last_made",
$globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending,
"desc",
true
);
break;
default:
console.log("Unknown Event", sortType);
return;
}
useAsync(async () => {
// reset pagination
page.value = 1;
hasMore.value = true;
state.sortLoading = true;
loading.value = true;
// fetch new recipes
const newRecipes = await fetchRecipes();
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
state.sortLoading = false;
loading.value = false;
}, useAsyncKey());
}
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() {
preferences.value.useMobileCards = !preferences.value.useMobileCards;
}
return {
...toRefs(state),
displayTitleIcon,
EVENTS,
infiniteScroll,
ready,
loading,
navigateRandom,
preferences,
sortRecipes,
toggleMobileCards,
useMobileCards,
};
}, },
}); );
async function initRecipes() {
page.value = 1;
hasMore.value = true;
// we double-up the first call to avoid a bug with large screens that render
// the entire first page without scrolling, preventing additional loading
const newRecipes = await fetchRecipes(page.value + 1);
if (newRecipes.length < perPage) {
hasMore.value = false;
}
// since we doubled the first call, we also need to advance the page
page.value = page.value + 1;
emit(REPLACE_RECIPES_EVENT, newRecipes);
}
const infiniteScroll = useThrottleFn(async () => {
if (!hasMore.value || loading.value) {
return;
}
loading.value = true;
page.value = page.value + 1;
const newRecipes = await fetchRecipes();
if (newRecipes.length < perPage) {
hasMore.value = false;
}
if (newRecipes.length) {
emit(APPEND_RECIPES_EVENT, newRecipes);
}
loading.value = false;
}, 500);
async function sortRecipes(sortType: string) {
if (sortLoading.value || loading.value) {
return;
}
function setter(
orderBy: string,
ascIcon: string,
descIcon: string,
defaultOrderDirection = "asc",
filterNull = false,
) {
if (preferences.value.orderBy !== orderBy) {
preferences.value.orderBy = orderBy;
preferences.value.orderDirection = defaultOrderDirection;
preferences.value.filterNull = filterNull;
}
else {
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
}
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
}
switch (sortType) {
case EVENTS.az:
setter(
"name",
$globals.icons.sortAlphabeticalAscending,
$globals.icons.sortAlphabeticalDescending,
"asc",
false,
);
break;
case EVENTS.rating:
setter("rating", $globals.icons.sortAscending, $globals.icons.sortDescending, "desc", true);
break;
case EVENTS.created:
setter(
"created_at",
$globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending,
"desc",
false,
);
break;
case EVENTS.updated:
setter("updated_at", $globals.icons.sortClockAscending, $globals.icons.sortClockDescending, "desc", false);
break;
case EVENTS.lastMade:
setter(
"last_made",
$globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending,
"desc",
true,
);
break;
default:
console.log("Unknown Event", sortType);
return;
}
// reset pagination
page.value = 1;
hasMore.value = true;
sortLoading.value = true;
loading.value = true;
// fetch new recipes
const newRecipes = await fetchRecipes();
emit(REPLACE_RECIPES_EVENT, newRecipes);
sortLoading.value = false;
loading.value = false;
}
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() {
preferences.value.useMobileCards = !preferences.value.useMobileCards;
}
</script> </script>
<style> <style>

View file

@ -1,13 +1,19 @@
<template> <template>
<div v-if="items.length > 0"> <div v-if="items.length > 0">
<h2 v-if="title" class="mt-4">{{ title }}</h2> <h2
v-if="title"
class="mt-4"
>
{{ title }}
</h2>
<v-chip <v-chip
v-for="category in items.slice(0, limit)" v-for="category in items.slice(0, limit)"
:key="category.name" :key="category.name"
label label
class="ma-1" class="mr-1 mt-1"
color="accent" color="accent"
:small="small" variant="flat"
:size="small ? 'small' : 'default'"
dark dark
@click.prevent="() => $emit('item-selected', category, urlPrefix)" @click.prevent="() => $emit('item-selected', category, urlPrefix)"
@ -17,66 +23,38 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api"; import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
export type UrlPrefixParam = "tags" | "categories" | "tools"; export type UrlPrefixParam = "tags" | "categories" | "tools";
export default defineComponent({ interface Props {
props: { truncate?: boolean;
truncate: { items?: RecipeCategory[] | RecipeTag[] | RecipeTool[];
type: Boolean, title?: boolean;
default: false, urlPrefix?: UrlPrefixParam;
}, limit?: number;
items: { small?: boolean;
type: Array as () => RecipeCategory[] | RecipeTag[] | RecipeTool[], maxWidth?: string | null;
default: () => [], }
}, const props = withDefaults(defineProps<Props>(), {
title: { truncate: false,
type: Boolean, items: () => [],
default: false, title: false,
}, urlPrefix: "categories",
urlPrefix: { limit: 999,
type: String as () => UrlPrefixParam, small: false,
default: "categories", maxWidth: null,
},
limit: {
type: Number,
default: 999,
},
small: {
type: Boolean,
default: false,
},
maxWidth: {
type: String,
default: null,
},
},
setup(props) {
const { $auth } = useContext();
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,
};
},
}); });
defineEmits(["item-selected"]);
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;
}
</script> </script>
<style></style> <style></style>

View file

@ -8,10 +8,16 @@
:title="$t('recipe.delete-recipe')" :title="$t('recipe.delete-recipe')"
color="error" color="error"
:icon="$globals.icons.alertCircle" :icon="$globals.icons.alertCircle"
can-confirm
@confirm="deleteRecipe()" @confirm="deleteRecipe()"
> >
<v-card-text> <v-card-text>
{{ $t("recipe.delete-confirmation") }} <template v-if="isAdminAndNotOwner">
{{ $t("recipe.admin-delete-confirmation") }}
</template>
<template v-else>
{{ $t("recipe.delete-confirmation") }}
</template>
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<BaseDialog <BaseDialog
@ -19,16 +25,17 @@
:title="$t('recipe.duplicate')" :title="$t('recipe.duplicate')"
color="primary" color="primary"
:icon="$globals.icons.duplicate" :icon="$globals.icons.duplicate"
can-confirm
@confirm="duplicateRecipe()" @confirm="duplicateRecipe()"
> >
<v-card-text> <v-card-text>
<v-text-field <v-text-field
v-model="recipeName" v-model="recipeName"
dense density="compact"
:label="$t('recipe.recipe-name')" :label="$t('recipe.recipe-name')"
autofocus autofocus
@keyup.enter="duplicateRecipe()" @keyup.enter="duplicateRecipe()"
></v-text-field> />
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<BaseDialog <BaseDialog
@ -36,6 +43,7 @@
:title="$t('recipe.add-recipe-to-mealplan')" :title="$t('recipe.add-recipe-to-mealplan')"
color="primary" color="primary"
:icon="$globals.icons.calendar" :icon="$globals.icons.calendar"
can-confirm
@confirm="addRecipeToPlan()" @confirm="addRecipeToPlan()"
> >
<v-card-text> <v-card-text>
@ -47,22 +55,21 @@
max-width="290px" max-width="290px"
min-width="auto" min-width="auto"
> >
<template #activator="{ on, attrs }"> <template #activator="{ props: activatorProps }">
<v-text-field <v-text-field
v-model="newMealdate" v-model="newMealdateString"
:label="$t('general.date')" :label="$t('general.date')"
:prepend-icon="$globals.icons.calendar" :prepend-icon="$globals.icons.calendar"
v-bind="attrs" v-bind="activatorProps"
readonly readonly
v-on="on" />
></v-text-field>
</template> </template>
<v-date-picker <v-date-picker
v-model="newMealdate" v-model="newMealdate"
no-title hide-header
:first-day-of-week="firstDayOfWeek" :first-day-of-week="firstDayOfWeek"
:local="$i18n.locale" :local="$i18n.locale"
@input="pickerMenu = false" @update:model-value="pickerMenu = false"
/> />
</v-menu> </v-menu>
<v-select <v-select
@ -70,7 +77,9 @@
:return-object="false" :return-object="false"
:items="planTypeOptions" :items="planTypeOptions"
:label="$t('recipe.entry-type')" :label="$t('recipe.entry-type')"
></v-select> item-title="text"
item-value="value"
/>
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<RecipeDialogAddToShoppingList <RecipeDialogAddToShoppingList
@ -81,55 +90,67 @@
/> />
<v-menu <v-menu
offset-y offset-y
left start
:bottom="!menuTop" :bottom="!menuTop"
:nudge-bottom="!menuTop ? '5' : '0'" :nudge-bottom="!menuTop ? '5' : '0'"
:top="menuTop" :top="menuTop"
:nudge-top="menuTop ? '5' : '0'" :nudge-top="menuTop ? '5' : '0'"
allow-overflow allow-overflow
close-delay="125" close-delay="125"
:open-on-hover="$vuetify.breakpoint.mdAndUp" :open-on-hover="$vuetify.display.mdAndUp"
content-class="d-print-none" content-class="d-print-none"
> >
<template #activator="{ on, attrs }"> <template #activator="{ props: activatorProps }">
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent> <v-btn
<v-icon>{{ icon }}</v-icon> icon
:variant="fab ? 'flat' : undefined"
:rounded="fab ? 'circle' : undefined"
:size="fab ? 'small' : undefined"
:color="fab ? 'info' : 'secondary'"
:fab="fab"
v-bind="activatorProps"
@click.prevent
>
<v-icon
:size="!fab ? undefined : 'x-large'"
:color="fab ? 'white' : 'secondary'"
>
{{ icon }}
</v-icon>
</v-btn> </v-btn>
</template> </template>
<v-list dense> <v-list density="compact">
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)"> <v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
<v-list-item-icon> <template #prepend>
<v-icon :color="item.color"> {{ item.icon }} </v-icon> <v-icon :color="item.color">
</v-list-item-icon> {{ item.icon }}
</v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title> <v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item> </v-list-item>
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length"> <div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
<v-divider /> <v-divider />
<v-list-group @click.stop> <v-list-item
<template #activator> v-for="(action, index) in recipeActions"
<v-list-item-title>{{ $tc("recipe.recipe-actions") }}</v-list-item-title> :key="index"
@click="executeRecipeAction(action)"
>
<template #prepend>
<v-icon color="undefined">
{{ $globals.icons.linkVariantPlus }}
</v-icon>
</template> </template>
<v-list dense class="ma-0 pa-0"> <v-list-item-title>
<v-list-item {{ action.title }}
v-for="(action, index) in recipeActions" </v-list-item-title>
:key="index" </v-list-item>
class="pl-6"
@click="executeRecipeAction(action)"
>
<v-list-item-title>
{{ action.title }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-list-group>
</div> </div>
</v-list> </v-list>
</v-menu> </v-menu>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { computed, defineComponent, reactive, toRefs, useContext, useRoute, useRouter, ref } from "@nuxtjs/composition-api";
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue"; import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue"; import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
import RecipeDialogShare from "./RecipeDialogShare.vue"; import RecipeDialogShare from "./RecipeDialogShare.vue";
@ -139,15 +160,16 @@ import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { usePlanTypeOptions } from "~/composables/use-group-mealplan"; import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
import { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household"; import type { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
import { PlanEntryType } from "~/lib/api/types/meal-plan"; import type { PlanEntryType } from "~/lib/api/types/meal-plan";
import { useAxiosDownloader } from "~/composables/api/use-axios-download"; import { useDownloader } from "~/composables/api/use-downloader";
export interface ContextMenuIncludes { export interface ContextMenuIncludes {
delete: boolean; delete: boolean;
edit: boolean; edit: boolean;
download: boolean; download: boolean;
duplicate: boolean;
mealplanner: boolean; mealplanner: boolean;
shoppingList: boolean; shoppingList: boolean;
print: boolean; print: boolean;
@ -164,327 +186,312 @@ export interface ContextMenuItem {
isPublic: boolean; isPublic: boolean;
} }
export default defineComponent({ interface Props {
components: { useItems?: ContextMenuIncludes;
RecipeDialogAddToShoppingList, appendItems?: ContextMenuItem[];
RecipeDialogPrintPreferences, leadingItems?: ContextMenuItem[];
RecipeDialogShare, menuTop?: boolean;
}, fab?: boolean;
props: { color?: string;
useItems: { slug: string;
type: Object as () => ContextMenuIncludes, menuIcon?: string | null;
default: () => ({ name: string;
delete: true, recipe?: Recipe;
edit: true, recipeId: string;
download: true, recipeScale?: number;
duplicate: false, }
mealplanner: true, const props = withDefaults(defineProps<Props>(), {
shoppingList: true, useItems: () => ({
print: true, delete: true,
printPreferences: true, edit: true,
share: true, download: true,
recipeActions: true, duplicate: false,
}), mealplanner: true,
}, shoppingList: true,
// Append items are added at the end of the useItems list print: true,
appendItems: { printPreferences: true,
type: Array as () => ContextMenuItem[], share: true,
default: () => [], recipeActions: true,
}, }),
// Append items are added at the beginning of the useItems list appendItems: () => [],
leadingItems: { leadingItems: () => [],
type: Array as () => ContextMenuItem[], menuTop: true,
default: () => [], fab: false,
}, color: "primary",
menuTop: { menuIcon: null,
type: Boolean, recipe: undefined,
default: true, recipeScale: 1,
},
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 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 { i18n, $auth, $globals } = useContext();
const { household } = useHouseholdSelf();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
// ===========================================================================
// Context Menu Setup
const defaultItems: { [key: string]: ContextMenuItem } = {
edit: {
title: i18n.tc("general.edit"),
icon: $globals.icons.edit,
color: undefined,
event: "edit",
isPublic: false,
},
delete: {
title: i18n.tc("general.delete"),
icon: $globals.icons.delete,
color: undefined,
event: "delete",
isPublic: false,
},
download: {
title: i18n.tc("general.download"),
icon: $globals.icons.download,
color: undefined,
event: "download",
isPublic: false,
},
duplicate: {
title: i18n.tc("general.duplicate"),
icon: $globals.icons.duplicate,
color: undefined,
event: "duplicate",
isPublic: false,
},
mealplanner: {
title: i18n.tc("recipe.add-to-plan"),
icon: $globals.icons.calendar,
color: undefined,
event: "mealplanner",
isPublic: false,
},
shoppingList: {
title: i18n.tc("recipe.add-to-list"),
icon: $globals.icons.cartCheck,
color: undefined,
event: "shoppingList",
isPublic: false,
},
print: {
title: i18n.tc("general.print"),
icon: $globals.icons.printer,
color: undefined,
event: "print",
isPublic: true,
},
printPreferences: {
title: i18n.tc("general.print-preferences"),
icon: $globals.icons.printerSettings,
color: undefined,
event: "printPreferences",
isPublic: true,
},
share: {
title: i18n.tc("general.share"),
icon: $globals.icons.shareVariant,
color: undefined,
event: "share",
isPublic: false,
},
};
// 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)) {
state.menuItems.push(item);
}
}
}
// Add leading and Appending Items
state.menuItems = [...state.menuItems, ...props.leadingItems, ...props.appendItems];
const icon = props.menuIcon || $globals.icons.dotsVertical;
// ===========================================================================
// Context Menu Event Handler
const 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() {
const { data } = await api.recipes.getOne(props.slug);
if (data) {
recipeRef.value = data;
}
}
const router = useRouter();
const groupRecipeActionsStore = useGroupRecipeActions();
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.tc("events.message-sent"));
} else {
alert.error(i18n.tc("events.something-went-wrong"));
}
}
}
async function deleteRecipe() {
const { data } = await api.recipes.deleteOne(props.slug);
if (data?.slug) {
router.push(`/g/${groupSlug.value}`);
}
context.emit("delete", props.slug);
}
const download = useAxiosDownloader();
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() {
const { response } = await api.mealplans.createOne({
date: state.newMealdate,
entryType: state.newMealType,
title: "",
text: "",
recipeId: props.recipeId,
});
if (response?.status === 201) {
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
} 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, 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
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
delete: () => {
state.recipeDeleteDialog = true;
},
edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"),
download: handleDownloadEvent,
duplicate: () => {
state.recipeDuplicateDialog = true;
},
mealplanner: () => {
state.mealplannerDialog = true;
},
printPreferences: async () => {
if (!recipeRef.value) {
await refreshRecipe();
}
state.printPreferencesDialog = true;
},
shoppingList: () => {
const promises: Promise<void>[] = [getShoppingLists()];
if (!recipeRef.value) {
promises.push(refreshRecipe());
}
Promise.allSettled(promises).then(() => { state.shoppingListDialog = true });
},
share: () => {
state.shareDialog = true;
},
};
function contextMenuEventHandler(eventKey: string) {
const handler = eventHandlers[eventKey];
if (handler && typeof handler === "function") {
handler();
state.loading = false;
return;
}
context.emit(eventKey);
state.loading = false;
}
const planTypeOptions = usePlanTypeOptions();
return {
...toRefs(state),
recipeRef,
recipeRefWithScale,
executeRecipeAction,
recipeActions: groupRecipeActionsStore.recipeActions,
shoppingLists,
duplicateRecipe,
contextMenuEventHandler,
deleteRecipe,
addRecipeToPlan,
icon,
planTypeOptions,
firstDayOfWeek,
};
},
}); });
const emit = defineEmits<{
[key: string]: any;
delete: [slug: string];
}>();
const api = useUserApi();
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 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(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
// ===========================================================================
// Context Menu Setup
const defaultItems: { [key: string]: ContextMenuItem } = {
edit: {
title: i18n.t("general.edit"),
icon: $globals.icons.edit,
color: undefined,
event: "edit",
isPublic: false,
},
delete: {
title: i18n.t("general.delete"),
icon: $globals.icons.delete,
color: undefined,
event: "delete",
isPublic: false,
},
download: {
title: i18n.t("general.download"),
icon: $globals.icons.download,
color: undefined,
event: "download",
isPublic: false,
},
duplicate: {
title: i18n.t("general.duplicate"),
icon: $globals.icons.duplicate,
color: undefined,
event: "duplicate",
isPublic: false,
},
mealplanner: {
title: i18n.t("recipe.add-to-plan"),
icon: $globals.icons.calendar,
color: undefined,
event: "mealplanner",
isPublic: false,
},
shoppingList: {
title: i18n.t("recipe.add-to-list"),
icon: $globals.icons.cartCheck,
color: undefined,
event: "shoppingList",
isPublic: false,
},
print: {
title: i18n.t("general.print"),
icon: $globals.icons.printer,
color: undefined,
event: "print",
isPublic: true,
},
printPreferences: {
title: i18n.t("general.print-preferences"),
icon: $globals.icons.printerSettings,
color: undefined,
event: "printPreferences",
isPublic: true,
},
share: {
title: i18n.t("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;
const item = defaultItems[key];
if (item && (item.isPublic || isOwnGroup.value)) {
menuItems.value.push(item);
}
}
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() {
const { data } = await api.recipes.getOne(props.slug);
if (data) {
recipeRef.value = data;
}
}
const router = useRouter();
const groupRecipeActionsStore = useGroupRecipeActions();
async function executeRecipeAction(action: GroupRecipeActionOut) {
if (!props.recipe) return;
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
if (action.actionType === "post") {
if (!response?.error) {
alert.success(i18n.t("events.message-sent"));
}
else {
alert.error(i18n.t("events.something-went-wrong"));
}
}
}
async function deleteRecipe() {
const { data } = await api.recipes.deleteOne(props.slug);
if (data?.slug) {
router.push(`/g/${groupSlug.value}`);
}
emit("delete", props.slug);
}
const download = useDownloader();
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() {
const { response } = await api.mealplans.createOne({
date: newMealdateString.value,
entryType: newMealType.value,
title: "",
text: "",
recipeId: props.recipeId,
});
if (response?.status === 201) {
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
}
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);
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> } = {
delete: () => {
recipeDeleteDialog.value = true;
},
edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"),
download: handleDownloadEvent,
duplicate: () => {
recipeDuplicateDialog.value = true;
},
mealplanner: () => {
mealplannerDialog.value = true;
},
printPreferences: async () => {
if (!recipeRef.value) {
await refreshRecipe();
}
printPreferencesDialog.value = true;
},
shoppingList: () => {
const promises: Promise<void>[] = [getShoppingLists()];
if (!recipeRef.value) {
promises.push(refreshRecipe());
}
Promise.allSettled(promises).then(() => {
shoppingListDialog.value = true;
});
},
share: () => {
shareDialog.value = true;
},
};
function contextMenuEventHandler(eventKey: string) {
const handler = eventHandlers[eventKey];
if (handler && typeof handler === "function") {
handler();
loading.value = false;
return;
}
emit(eventKey);
loading.value = false;
}
const planTypeOptions = usePlanTypeOptions();
const recipeActions = groupRecipeActionsStore.recipeActions;
</script> </script>

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