mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-25 11:06:08 -05:00
Compare commits
4 Commits
renovate/n
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee2c351a3 | ||
|
|
874dc94d81 | ||
|
|
9247204f59 | ||
|
|
bd23896e34 |
@@ -1,10 +1,9 @@
|
||||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.224.2/containers/python-3/.devcontainer/base.Dockerfile
|
||||
|
||||
# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster
|
||||
ARG VARIANT="3.12-bullseye"
|
||||
FROM mcr.microsoft.com/devcontainers/python:${VARIANT}
|
||||
|
||||
# Remove outdated yarn GPG key, if it exists
|
||||
RUN rm -f /etc/apt/sources.list.d/yarn.list /usr/share/keyrings/yarn-archive-keyring.gpg || true
|
||||
|
||||
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
|
||||
ARG NODE_VERSION="none"
|
||||
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||
|
||||
113
.github/workflows/auto-merge-l10n.yml
vendored
113
.github/workflows/auto-merge-l10n.yml
vendored
@@ -1,113 +0,0 @@
|
||||
name: Auto-merge l10n PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, labeled]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.labels.*.name, 'l10n')
|
||||
|
||||
steps:
|
||||
- name: Validate PR author
|
||||
env:
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
if [[
|
||||
"$AUTHOR" != "hay-kot" &&
|
||||
"$AUTHOR" != "github-actions[bot]" &&
|
||||
"$AUTHOR" != "mealie-actions[bot]"
|
||||
]]; then
|
||||
echo "::error::PR author must be hay-kot, github-actions[bot], or mealie-actions[bot] for auto-merge (got: $AUTHOR)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Author validated: $AUTHOR"
|
||||
|
||||
- name: Validate PR size
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
ADDITIONS=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json additions --jq '.additions')
|
||||
DELETIONS=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json deletions --jq '.deletions')
|
||||
TOTAL=$((ADDITIONS + DELETIONS))
|
||||
|
||||
echo "PR changes: +$ADDITIONS -$DELETIONS (total: $TOTAL lines)"
|
||||
|
||||
if [ "$TOTAL" -gt 400 ]; then
|
||||
echo "::error::PR exceeds 400 line change limit ($TOTAL lines)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate file paths
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
FILES=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json files --jq '.files[].path')
|
||||
|
||||
for file in $FILES; do
|
||||
# Check if file matches any allowed path
|
||||
if [[ "$file" == "frontend/composables/use-locales/available-locales.ts" ]] || \
|
||||
[[ "$file" =~ ^frontend/lang/ ]] || \
|
||||
[[ "$file" =~ ^mealie/repos/seed/resources/[^/]+/locales/ ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# File doesn't match allowed paths
|
||||
echo "::error::Invalid file path: $file"
|
||||
echo "Only the following paths are allowed:"
|
||||
echo " - frontend/composables/use-locales/available-locales.ts"
|
||||
echo " - frontend/lang/"
|
||||
echo " - mealie/repos/seed/resources/*/locales/"
|
||||
exit 1
|
||||
done
|
||||
|
||||
echo "All files are in allowed paths"
|
||||
|
||||
- name: Approve PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
APPROVED=$(gh pr view "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--json reviews \
|
||||
--jq '.reviews[] | select(.state == "APPROVED") | .id' \
|
||||
| wc -l)
|
||||
|
||||
if [ "$APPROVED" -gt 0 ]; then
|
||||
echo "PR already approved"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr review "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--approve \
|
||||
--body "Auto-approved: l10n PR from trusted author with valid file paths"
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Enable auto-merge
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr merge "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--auto \
|
||||
--squash
|
||||
11
.github/workflows/locale-sync.yml
vendored
11
.github/workflows/locale-sync.yml
vendored
@@ -15,17 +15,10 @@ jobs:
|
||||
sync-locales:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -112,7 +105,7 @@ jobs:
|
||||
- Updated frontend locale files
|
||||
- Generated from latest translation sources" \
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: No changes detected
|
||||
if: steps.changes.outputs.has_changes == 'false'
|
||||
|
||||
12
.github/workflows/publish.yml
vendored
12
.github/workflows/publish.yml
vendored
@@ -37,17 +37,6 @@ jobs:
|
||||
|
||||
- uses: depot/setup-action@v1
|
||||
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
hkotel/mealie
|
||||
ghcr.io/${{ github.repository }}
|
||||
# Overwrite the image.version label with our tag
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ inputs.tag }}
|
||||
|
||||
- name: Retrieve Python package
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -68,6 +57,5 @@ jobs:
|
||||
hkotel/mealie:${{ inputs.tag }}
|
||||
ghcr.io/${{ github.repository }}:${{ inputs.tag }}
|
||||
${{ inputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
COMMIT=${{ github.sha }}
|
||||
|
||||
13
.github/workflows/pull-requests.yml
vendored
13
.github/workflows/pull-requests.yml
vendored
@@ -4,19 +4,14 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- mealie-next
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
branches:
|
||||
- mealie-next
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.merge_group.head_ref }}
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pull-request-lint:
|
||||
name: "Lint PR"
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/pull-request-lint.yml
|
||||
|
||||
backend-tests:
|
||||
@@ -29,7 +24,6 @@ jobs:
|
||||
|
||||
container-scanning:
|
||||
name: "Trivy Container Scanning"
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/partial-trivy-container-scanning.yml
|
||||
|
||||
code-ql:
|
||||
@@ -53,10 +47,7 @@ jobs:
|
||||
|
||||
publish-image:
|
||||
name: "Publish PR Image"
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'build-image') &&
|
||||
github.repository == 'mealie-recipes/mealie'
|
||||
if: contains(github.event.pull_request.labels.*.name, 'build-image') && github.repository == 'mealie-recipes/mealie'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
47
.github/workflows/scheduled-checks.yml
vendored
47
.github/workflows/scheduled-checks.yml
vendored
@@ -40,18 +40,12 @@ jobs:
|
||||
shell: bash
|
||||
run: pre-commit autoupdate --color=always
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Create Pull Request
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
# This doesn't currently work for us because it creates the PR but the workflows don't run.
|
||||
# TODO: Provide a personal access token as a parameter here, that solves that problem.
|
||||
# https://github.com/peter-evans/create-pull-request
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
commit-message: "Update pre-commit hooks"
|
||||
branch: "fix/update-pre-commit-hooks"
|
||||
labels: |
|
||||
@@ -60,38 +54,3 @@ jobs:
|
||||
base: mealie-next
|
||||
title: "chore(auto): Update pre-commit hooks"
|
||||
body: "Auto-generated by `.github/workflows/scheduled-checks.yml`"
|
||||
|
||||
- name: Approve PR
|
||||
if: steps.create-pr.outputs.pull-request-number
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
APPROVED=$(gh pr view "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--json reviews \
|
||||
--jq '.reviews[] | select(.state == "APPROVED") | .id' \
|
||||
| wc -l)
|
||||
|
||||
if [ "$APPROVED" -gt 0 ]; then
|
||||
echo "PR already approved"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr review "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--approve \
|
||||
--body "Auto-approved: Pre-commit hook updates"
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.create-pr.outputs.pull-request-number
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr merge "$PR_NUMBER" \
|
||||
--repo "$REPO" \
|
||||
--auto \
|
||||
--squash
|
||||
|
||||
@@ -12,7 +12,7 @@ repos:
|
||||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.2
|
||||
rev: v0.14.14
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
104
Taskfile.yml
104
Taskfile.yml
@@ -25,9 +25,16 @@ dotenv:
|
||||
- .env
|
||||
- .dev.env
|
||||
tasks:
|
||||
docs:gen:
|
||||
desc: runs the API documentation generator
|
||||
cmds:
|
||||
- uv run python dev/code-generation/gen_docs_api.py
|
||||
|
||||
docs:
|
||||
desc: runs the documentation server
|
||||
dir: docs
|
||||
deps:
|
||||
- docs:gen
|
||||
cmds:
|
||||
- uv run python -m mkdocs serve
|
||||
|
||||
@@ -40,6 +47,8 @@ tasks:
|
||||
sources:
|
||||
- package.json
|
||||
- yarn.lock
|
||||
generates:
|
||||
- node_modules/**
|
||||
|
||||
setup:py:
|
||||
desc: setup python dependencies
|
||||
@@ -52,18 +61,6 @@ tasks:
|
||||
- pyproject.toml
|
||||
- .pre-commit-config.yaml
|
||||
|
||||
setup:e2e:
|
||||
desc: setup e2e test dependencies
|
||||
dir: tests/e2e
|
||||
run: once
|
||||
cmds:
|
||||
- yarn install
|
||||
- yarn playwright install --with-deps
|
||||
sources:
|
||||
- package.json
|
||||
- playwright.config.ts
|
||||
- yarn.lock
|
||||
|
||||
setup:
|
||||
desc: setup all dependencies
|
||||
deps:
|
||||
@@ -74,6 +71,7 @@ tasks:
|
||||
desc: run code generators
|
||||
cmds:
|
||||
- uv run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
||||
- task: docs:gen
|
||||
- task: py:format
|
||||
|
||||
dev:services:
|
||||
@@ -181,21 +179,12 @@ tasks:
|
||||
status:
|
||||
- '{{ .SKIP_PACKAGE_DEPS | default "false"}}'
|
||||
|
||||
py:package:build:
|
||||
internal: true
|
||||
py:package:
|
||||
desc: builds Python packages (sdist and wheel) in top-level dist directory
|
||||
deps:
|
||||
- py:package:deps
|
||||
cmds:
|
||||
- uv build --out-dir dist
|
||||
sources:
|
||||
- uv.lock
|
||||
- pyproject.toml
|
||||
- mealie/**
|
||||
|
||||
py:package:
|
||||
desc: builds Python packages (sdist and wheel) in top-level dist directory
|
||||
cmds:
|
||||
- task: py:package:build
|
||||
- task: py:package:generate-requirements
|
||||
|
||||
py:
|
||||
@@ -226,12 +215,6 @@ tasks:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn build
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:generate:
|
||||
desc: generates a static version of the frontend in frontend/dist
|
||||
@@ -240,36 +223,18 @@ tasks:
|
||||
- setup:ui
|
||||
cmds:
|
||||
- yarn generate
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:lint:
|
||||
desc: runs the frontend linter
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn lint --max-warnings=0
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:test:
|
||||
desc: runs the frontend tests
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn test
|
||||
sources:
|
||||
- "**"
|
||||
- exclude: .nuxt/**
|
||||
- exclude: .output/**
|
||||
- exclude: dist/**
|
||||
- exclude: node_modules/.cache/**
|
||||
|
||||
ui:check:
|
||||
desc: runs all frontend checks
|
||||
@@ -298,48 +263,3 @@ tasks:
|
||||
dir: docker
|
||||
cmds:
|
||||
- docker compose -f docker-compose.yml -p mealie up -d --build
|
||||
|
||||
e2e:build-image:
|
||||
desc: builds the e2e test docker image
|
||||
deps:
|
||||
- py:package
|
||||
cmds:
|
||||
- docker build --tag mealie:e2e --file docker/Dockerfile --build-context packages=dist .
|
||||
sources:
|
||||
- docker/Dockerfile
|
||||
- dist/**
|
||||
|
||||
e2e:start-server:
|
||||
desc: Builds the image and starts the containers for e2e testing
|
||||
dir: tests/e2e/docker
|
||||
deps:
|
||||
- e2e:build-image
|
||||
vars:
|
||||
WAIT_UNTIL_HEALTHY: '{{if .WAIT_UNTIL_HEALTHY}}--wait{{else}}{{end}}'
|
||||
cmds:
|
||||
- docker compose up -d {{.WAIT_UNTIL_HEALTHY}}
|
||||
|
||||
e2e:stop-server:
|
||||
desc: Shuts down the e2e testing containers
|
||||
dir: tests/e2e/docker
|
||||
cmds:
|
||||
- docker compose down --volumes
|
||||
|
||||
e2e:test:
|
||||
desc: runs the e2e tests
|
||||
dir: tests/e2e
|
||||
deps:
|
||||
- setup:e2e
|
||||
vars:
|
||||
PREVENT_REPORT_OPEN: '{{if .PREVENT_REPORT_OPEN}}PLAYWRIGHT_HTML_OPEN=never{{else}}{{end}}'
|
||||
cmds:
|
||||
- '{{.PREVENT_REPORT_OPEN}} yarn playwright test'
|
||||
|
||||
e2e:
|
||||
desc: runs the full e2e test suite
|
||||
cmds:
|
||||
- task: e2e:start-server
|
||||
vars: { WAIT_UNTIL_HEALTHY: true }
|
||||
- defer: { task: e2e:stop-server }
|
||||
- task: e2e:test
|
||||
vars: { PREVENT_REPORT_OPEN: true }
|
||||
|
||||
80
dev/code-generation/gen_docs_api.py
Normal file
80
dev/code-generation/gen_docs_api.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from mealie.app import app
|
||||
from mealie.core.config import determine_data_dir
|
||||
|
||||
DATA_DIR = determine_data_dir()
|
||||
|
||||
"""Script to export the ReDoc documentation page into a standalone HTML file."""
|
||||
|
||||
HTML_TEMPLATE = """<!-- Custom HTML site displayed as the Home chapter -->
|
||||
{% extends "main.html" %}
|
||||
{% block tabs %}
|
||||
{{ super() }}
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<div id="redoc-container"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"> </script>
|
||||
<script>
|
||||
var spec = MY_SPECIFIC_TEXT;
|
||||
Redoc.init(spec, {}, document.getElementById("redoc-container"));
|
||||
</script>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
{% block footer %}{% endblock %}
|
||||
"""
|
||||
|
||||
HTML_PATH = DATA_DIR.parent.parent.joinpath("docs/docs/overrides/api.html")
|
||||
CONSTANT_DT = datetime(2025, 10, 24, 15, 53, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def normalize_timestamps(s: dict[str, Any]) -> dict[str, Any]:
|
||||
field_format = s.get("format")
|
||||
is_timestamp = field_format in ["date-time", "date", "time"]
|
||||
has_default = s.get("default")
|
||||
|
||||
if not is_timestamp:
|
||||
for k, v in s.items():
|
||||
if isinstance(v, dict):
|
||||
s[k] = normalize_timestamps(v)
|
||||
elif isinstance(v, list):
|
||||
s[k] = [normalize_timestamps(i) if isinstance(i, dict) else i for i in v]
|
||||
|
||||
return s
|
||||
elif not has_default:
|
||||
return s
|
||||
|
||||
if field_format == "date-time":
|
||||
s["default"] = CONSTANT_DT.isoformat()
|
||||
elif field_format == "date":
|
||||
s["default"] = CONSTANT_DT.date().isoformat()
|
||||
elif field_format == "time":
|
||||
s["default"] = CONSTANT_DT.time().isoformat()
|
||||
|
||||
return s
|
||||
|
||||
|
||||
def generate_api_docs(my_app: FastAPI):
|
||||
openapi_schema = my_app.openapi()
|
||||
openapi_schema = normalize_timestamps(openapi_schema)
|
||||
|
||||
with open(HTML_PATH, "w") as fd:
|
||||
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(openapi_schema))
|
||||
fd.write(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_api_docs(app)
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import dotenv
|
||||
@@ -11,7 +10,6 @@ from pydantic import ConfigDict
|
||||
from requests import Response
|
||||
from utils import CodeDest, CodeKeys, inject_inline, log
|
||||
|
||||
from mealie.lang.locale_config import LOCALE_CONFIG, LocalePluralFoodHandling, LocaleTextDirection
|
||||
from mealie.schema._mealie import MealieModel
|
||||
|
||||
BASE = pathlib.Path(__file__).parent.parent.parent
|
||||
@@ -19,6 +17,57 @@ BASE = pathlib.Path(__file__).parent.parent.parent
|
||||
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") or os.environ.get("CROWDIN_API_KEY", "")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocaleData:
|
||||
name: str
|
||||
dir: str = "ltr"
|
||||
|
||||
|
||||
LOCALE_DATA: dict[str, LocaleData] = {
|
||||
"af-ZA": LocaleData(name="Afrikaans (Afrikaans)"),
|
||||
"ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"),
|
||||
"bg-BG": LocaleData(name="Български (Bulgarian)"),
|
||||
"ca-ES": LocaleData(name="Català (Catalan)"),
|
||||
"cs-CZ": LocaleData(name="Čeština (Czech)"),
|
||||
"da-DK": LocaleData(name="Dansk (Danish)"),
|
||||
"de-DE": LocaleData(name="Deutsch (German)"),
|
||||
"el-GR": LocaleData(name="Ελληνικά (Greek)"),
|
||||
"en-GB": LocaleData(name="British English"),
|
||||
"en-US": LocaleData(name="American English"),
|
||||
"es-ES": LocaleData(name="Español (Spanish)"),
|
||||
"et-EE": LocaleData(name="Eesti (Estonian)"),
|
||||
"fi-FI": LocaleData(name="Suomi (Finnish)"),
|
||||
"fr-BE": LocaleData(name="Belge (Belgian)"),
|
||||
"fr-CA": LocaleData(name="Français canadien (Canadian French)"),
|
||||
"fr-FR": LocaleData(name="Français (French)"),
|
||||
"gl-ES": LocaleData(name="Galego (Galician)"),
|
||||
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
|
||||
"hr-HR": LocaleData(name="Hrvatski (Croatian)"),
|
||||
"hu-HU": LocaleData(name="Magyar (Hungarian)"),
|
||||
"is-IS": LocaleData(name="Íslenska (Icelandic)"),
|
||||
"it-IT": LocaleData(name="Italiano (Italian)"),
|
||||
"ja-JP": LocaleData(name="日本語 (Japanese)"),
|
||||
"ko-KR": LocaleData(name="한국어 (Korean)"),
|
||||
"lt-LT": LocaleData(name="Lietuvių (Lithuanian)"),
|
||||
"lv-LV": LocaleData(name="Latviešu (Latvian)"),
|
||||
"nl-NL": LocaleData(name="Nederlands (Dutch)"),
|
||||
"no-NO": LocaleData(name="Norsk (Norwegian)"),
|
||||
"pl-PL": LocaleData(name="Polski (Polish)"),
|
||||
"pt-BR": LocaleData(name="Português do Brasil (Brazilian Portuguese)"),
|
||||
"pt-PT": LocaleData(name="Português (Portuguese)"),
|
||||
"ro-RO": LocaleData(name="Română (Romanian)"),
|
||||
"ru-RU": LocaleData(name="Pусский (Russian)"),
|
||||
"sk-SK": LocaleData(name="Slovenčina (Slovak)"),
|
||||
"sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
|
||||
"sr-SP": LocaleData(name="српски (Serbian)"),
|
||||
"sv-SE": LocaleData(name="Svenska (Swedish)"),
|
||||
"tr-TR": LocaleData(name="Türkçe (Turkish)"),
|
||||
"uk-UA": LocaleData(name="Українська (Ukrainian)"),
|
||||
"vi-VN": LocaleData(name="Tiếng Việt (Vietnamese)"),
|
||||
"zh-CN": LocaleData(name="简体中文 (Chinese simplified)"),
|
||||
"zh-TW": LocaleData(name="繁體中文 (Chinese traditional)"),
|
||||
}
|
||||
|
||||
LOCALE_TEMPLATE = """// This Code is auto generated by gen_ts_locales.py
|
||||
export const LOCALES = [{% for locale in locales %}
|
||||
{
|
||||
@@ -26,7 +75,6 @@ export const LOCALES = [{% for locale in locales %}
|
||||
value: "{{ locale.locale }}",
|
||||
progress: {{ locale.progress }},
|
||||
dir: "{{ locale.dir }}",
|
||||
pluralFoodHandling: "{{ locale.plural_food_handling }}",
|
||||
},{% endfor %}
|
||||
];
|
||||
|
||||
@@ -39,11 +87,10 @@ class TargetLanguage(MealieModel):
|
||||
id: str
|
||||
name: str
|
||||
locale: str
|
||||
dir: LocaleTextDirection = LocaleTextDirection.LTR
|
||||
plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
|
||||
dir: str = "ltr"
|
||||
threeLettersCode: str
|
||||
twoLettersCode: str
|
||||
progress: int = 0
|
||||
progress: float = 0.0
|
||||
|
||||
|
||||
class CrowdinApi:
|
||||
@@ -70,15 +117,43 @@ class CrowdinApi:
|
||||
def get_languages(self) -> list[TargetLanguage]:
|
||||
response = self.get_project()
|
||||
tls = response.json()["data"]["targetLanguages"]
|
||||
return [TargetLanguage(**t) for t in tls]
|
||||
|
||||
def get_progress(self) -> dict[str, int]:
|
||||
models = [TargetLanguage(**t) for t in tls]
|
||||
|
||||
models.insert(
|
||||
0,
|
||||
TargetLanguage(
|
||||
id="en-US",
|
||||
name="English",
|
||||
locale="en-US",
|
||||
dir="ltr",
|
||||
threeLettersCode="en",
|
||||
twoLettersCode="en",
|
||||
progress=100,
|
||||
),
|
||||
)
|
||||
|
||||
progress: list[dict] = self.get_progress()["data"]
|
||||
|
||||
for model in models:
|
||||
if model.locale in LOCALE_DATA:
|
||||
locale_data = LOCALE_DATA[model.locale]
|
||||
model.name = locale_data.name
|
||||
model.dir = locale_data.dir
|
||||
|
||||
for p in progress:
|
||||
if p["data"]["languageId"] == model.id:
|
||||
model.progress = p["data"]["translationProgress"]
|
||||
|
||||
models.sort(key=lambda x: x.locale, reverse=True)
|
||||
return models
|
||||
|
||||
def get_progress(self) -> dict:
|
||||
response = requests.get(
|
||||
f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500",
|
||||
headers=self.headers,
|
||||
)
|
||||
data = response.json()["data"]
|
||||
return {p["data"]["languageId"]: p["translationProgress"] for p in data}
|
||||
return response.json()
|
||||
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
@@ -120,8 +195,8 @@ def inject_nuxt_values():
|
||||
|
||||
all_langs = []
|
||||
for match in locales_dir.glob("*.json"):
|
||||
match_data = LOCALE_CONFIG.get(match.stem)
|
||||
match_dir = match_data.dir if match_data else LocaleTextDirection.LTR
|
||||
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)
|
||||
@@ -146,82 +221,9 @@ def inject_registration_validation_values():
|
||||
inject_inline(reg_valid, CodeKeys.nuxt_local_messages, all_langs)
|
||||
|
||||
|
||||
def _get_local_models() -> list[TargetLanguage]:
|
||||
return [
|
||||
TargetLanguage(
|
||||
id=locale,
|
||||
name=data.name,
|
||||
locale=locale,
|
||||
threeLettersCode=locale.split("-")[-1],
|
||||
twoLettersCode=locale.split("-")[-1],
|
||||
)
|
||||
for locale, data in LOCALE_CONFIG.items()
|
||||
if locale != "en-US" # Crowdin doesn't include this, so we manually inject it later
|
||||
]
|
||||
|
||||
|
||||
def _get_local_progress() -> dict[str, int]:
|
||||
with open(CodeDest.use_locales) as f:
|
||||
content = f.read()
|
||||
|
||||
# Extract the array content between [ and ]
|
||||
match = re.search(r"export const LOCALES = (\[.*?\]);", content, re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError("Could not find LOCALES array in file")
|
||||
|
||||
# Convert JS to JSON
|
||||
array_content = match.group(1)
|
||||
|
||||
# Replace unquoted keys with quoted keys for valid JSON
|
||||
# This converts: { name: "value" } to { "name": "value" }
|
||||
json_str = re.sub(r"([,\{\s])([a-zA-Z_][a-zA-Z0-9_]*)\s*:", r'\1"\2":', array_content)
|
||||
|
||||
# Remove trailing commas before } and ]
|
||||
json_str = re.sub(r",(\s*[}\]])", r"\1", json_str)
|
||||
|
||||
locales = json.loads(json_str)
|
||||
return {locale["value"]: locale["progress"] for locale in locales}
|
||||
|
||||
|
||||
def get_languages() -> list[TargetLanguage]:
|
||||
if API_KEY:
|
||||
api = CrowdinApi(None)
|
||||
models = api.get_languages()
|
||||
progress = api.get_progress()
|
||||
else:
|
||||
log.warning("CROWDIN_API_KEY is not set, using local lanugages instead")
|
||||
log.warning("DOUBLE CHECK the output!!! Do not overwrite with bad local locale data!")
|
||||
models = _get_local_models()
|
||||
progress = _get_local_progress()
|
||||
|
||||
models.insert(
|
||||
0,
|
||||
TargetLanguage(
|
||||
id="en-US",
|
||||
name="English",
|
||||
locale="en-US",
|
||||
dir=LocaleTextDirection.LTR,
|
||||
plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT,
|
||||
threeLettersCode="en",
|
||||
twoLettersCode="en",
|
||||
progress=100,
|
||||
),
|
||||
)
|
||||
|
||||
for model in models:
|
||||
if model.locale in LOCALE_CONFIG:
|
||||
locale_data = LOCALE_CONFIG[model.locale]
|
||||
model.name = locale_data.name
|
||||
model.dir = locale_data.dir
|
||||
model.plural_food_handling = locale_data.plural_food_handling
|
||||
model.progress = progress.get(model.id, model.progress)
|
||||
|
||||
models.sort(key=lambda x: x.locale, reverse=True)
|
||||
return models
|
||||
|
||||
|
||||
def generate_locales_ts_file():
|
||||
models = get_languages()
|
||||
api = CrowdinApi(None)
|
||||
models = api.get_languages()
|
||||
tmpl = Template(LOCALE_TEMPLATE)
|
||||
rendered = tmpl.render(locales=models)
|
||||
|
||||
@@ -231,6 +233,10 @@ def generate_locales_ts_file():
|
||||
|
||||
|
||||
def main():
|
||||
if API_KEY is None or API_KEY == "":
|
||||
log.error("CROWDIN_API_KEY is not set")
|
||||
return
|
||||
|
||||
generate_locales_ts_file()
|
||||
inject_nuxt_values()
|
||||
inject_registration_validation_values()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###############################################
|
||||
# Frontend Build
|
||||
###############################################
|
||||
FROM node:24@sha256:ed4a00946596b5453f4cd562aa2a6128d82cc9e2d11b70279a916c02aaedbc67 \
|
||||
FROM node:24@sha256:b2b2184ba9b78c022e1d6a7924ec6fba577adf28f15c9d9c457730cc4ad3807a \
|
||||
AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
@@ -111,6 +111,7 @@ RUN . $VENV_PATH/bin/activate \
|
||||
# Production Image
|
||||
###############################################
|
||||
FROM python-base AS production
|
||||
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
|
||||
ENV PRODUCTION=true
|
||||
ENV TESTING=false
|
||||
|
||||
|
||||
4
docs/docs/api/redoc.md
Normal file
4
docs/docs/api/redoc.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: API
|
||||
template: api.html
|
||||
---
|
||||
@@ -6,7 +6,7 @@ While this guide aims to simplify the migration process for developers, it's not
|
||||
|
||||
## V1 → V2
|
||||
|
||||
The biggest change between V1 and V2 is the introduction of Households. For more information on how households work in relation to groups/users, check out the [Groups and Households](../../documentation/getting-started/features.md#groups-and-households) section in the Features guide.
|
||||
The biggest change between V1 and V2 is the introduction of Households. For more information on how households work in relation to groups/users, check out the [Groups and Households](./features.md#groups-and-households) section in the Features guide.
|
||||
|
||||
### `updateAt` is now `updatedAt`
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Recipes extras are a key feature of the Mealie API. They allow you to create cus
|
||||
For example you could add `{"message": "Remember to thaw the chicken"}` to a recipe and use the webhooks built into mealie to send that message payload to a destination to be processed.
|
||||
|
||||
#### Shopping List and Food Extras
|
||||
Similarly to recipes, extras are supported on shopping lists, shopping list items, and foods. At this time they are only accessible through the API. Extras for these objects allow for rich integrations between the Mealie shopping list and your favorite list manager, such as Todoist, Trello, or any other list manager with an API.
|
||||
Similarly to recipes, extras are supported on shopping lists, shopping list items, and foods. At this time they are only accessible through the API. Extras for these objects allow for rich integrations between the Mealie shopping list and your favorite list manager, such as Alexa, ToDoist, Trello, or any other list manager with an API.
|
||||
|
||||
To keep shopping lists in sync, for instance, you can store your Trello list id on your Mealie shopping list: <br />
|
||||
`{"trello_list_id": "5abbe4b7ddc1b351ef961414"}`
|
||||
@@ -52,7 +52,6 @@ Many applications will keep track of the query and adjust the page parameter app
|
||||
Notice that the route does not contain the baseurl (e.g. `https://mymealieapplication.com/api`).
|
||||
|
||||
There are a few shorthands available to reduce the number of calls for certain common requests:
|
||||
|
||||
- if you want to return _all_ results, effectively disabling pagination, set `perPage = -1` (and fetch the first page)
|
||||
- if you want to fetch the _last_ page, set `page = -1`
|
||||
|
||||
@@ -79,8 +78,8 @@ This filter will find all foods that are not named "carrot": <br>
|
||||
##### Keyword Filters
|
||||
The API supports many SQL keywords, such as `IS NULL` and `IN`, as well as their negations (e.g. `IS NOT NULL` and `NOT IN`).
|
||||
|
||||
Here is an example of a filter that returns all shopping list items without a food: <br>
|
||||
`foodId IS NULL`
|
||||
Here is an example of a filter that returns all recipes where the "last made" value is not null: <br>
|
||||
`lastMade IS NOT NULL`
|
||||
|
||||
This filter will find all recipes that don't start with the word "Test": <br>
|
||||
`name NOT LIKE "Test%"`
|
||||
@@ -90,28 +89,6 @@ This filter will find all recipes that don't start with the word "Test": <br>
|
||||
This filter will find all recipes that have particular slugs: <br>
|
||||
`slug IN ["pasta-fagioli", "delicious-ramen"]`
|
||||
|
||||
##### Placeholder Keywords
|
||||
You can use placeholders to insert dynamic values as opposed to static values. Currently the only supported placeholder keyword is `$NOW`, to insert the current date/time.
|
||||
|
||||
`$NOW` can optionally be paired with basic offsets. Here is an example of a filter which gives you recipes not made within the past 30 days: <br>
|
||||
`lastMade <= "$NOW-30d"`
|
||||
|
||||
Supported offset operations include:
|
||||
|
||||
- `-` for subtracting a time (i.e. in the past)
|
||||
- `+` for adding a time (i.e. in the future)
|
||||
|
||||
Supported offset intervals include:
|
||||
|
||||
- `y` for years
|
||||
- `m` for months
|
||||
- `d` for days
|
||||
- `H` for hours
|
||||
- `M` for minutes
|
||||
- `S` for seconds
|
||||
|
||||
Note that intervals are _case sensitive_ (e.g. `s` is an invalid interval).
|
||||
|
||||
##### Nested Property filters
|
||||
When querying tables with relationships, you can filter properties on related tables. For instance, if you want to query all recipes owned by a particular user: <br>
|
||||
`user.username = "SousChef20220320"`
|
||||
@@ -119,7 +96,7 @@ When querying tables with relationships, you can filter properties on related ta
|
||||
This timeline event filter will return all timeline events for recipes that were created after a particular date: <br>
|
||||
`recipe.createdAt >= "2023-02-25"`
|
||||
|
||||
This recipe filter will return all recipes that contain a particular set of tags: <br>
|
||||
This recipe filter will return all recipes that contains a particular set of tags: <br>
|
||||
`tags.name CONTAINS ALL ["Easy", "Cajun"]`
|
||||
|
||||
##### Compound Filters
|
||||
|
||||
@@ -122,18 +122,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).
|
||||
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 |
|
||||
|---------------------------------------------------|:-------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| OPENAI_BASE_URL<super>[†][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>[†][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_CUSTOM_HEADERS <br/> :octicons-tag-24: v2.0.0 | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
||||
| OPENAI_CUSTOM_PARAMS <br/> :octicons-tag-24: v2.0.0 | 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 <br/> :octicons-tag-24: v1.12.0 | 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 | 300 | 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 |
|
||||
| OPENAI_CUSTOM_PROMPT_DIR <br/> :octicons-tag-24: v3.10.0 | None | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. |
|
||||
| Variables | Default | Description |
|
||||
| ------------------------------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| OPENAI_BASE_URL<super>[†][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>[†][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_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_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 | 300 | 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
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
||||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||
|
||||
1. Take a backup just in case!
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.11.0`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.9.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.
|
||||
4. Restart the container
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.11.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.9.2 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.11.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.9.2 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
24
docs/docs/overrides/api.html
Normal file
24
docs/docs/overrides/api.html
Normal file
File diff suppressed because one or more lines are too long
@@ -93,7 +93,7 @@ nav:
|
||||
- iOS Shortcut: "documentation/community-guide/ios-shortcut.md"
|
||||
- Reverse Proxy (SWAG): "documentation/community-guide/swag.md"
|
||||
|
||||
- API Reference: "https://demo.mealie.io/docs"
|
||||
- API Reference: "api/redoc.md"
|
||||
|
||||
- Contributors Guide:
|
||||
- Non-Code: "contributors/non-coders.md"
|
||||
|
||||
@@ -16,10 +16,6 @@
|
||||
max-width: 950px !important;
|
||||
}
|
||||
|
||||
.lg-container {
|
||||
max-width: 1100px !important;
|
||||
}
|
||||
|
||||
.theme--dark.v-application {
|
||||
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
export default defineNuxtComponent({
|
||||
setup() {
|
||||
const i18n = useI18n();
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = computed(() => auth.user.value?.groupSlug);
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => $auth.user.value?.groupSlug);
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const sections = ref([
|
||||
|
||||
@@ -73,11 +73,11 @@ import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.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 slug = route.params.slug as string;
|
||||
@@ -88,11 +88,11 @@ const router = useRouter();
|
||||
const book = getOne(slug);
|
||||
|
||||
const isOwnHousehold = computed(() => {
|
||||
if (!(auth.user.value && book.value?.householdId)) {
|
||||
if (!($auth.user.value && book.value?.householdId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return auth.user.value.householdId === book.value.householdId;
|
||||
return $auth.user.value.householdId === book.value.householdId;
|
||||
});
|
||||
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
||||
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
<template>
|
||||
<!-- Create Dialog -->
|
||||
<BaseDialog
|
||||
v-model="createDialog"
|
||||
:title="$t('general.create')"
|
||||
:icon="icon"
|
||||
color="primary"
|
||||
:submit-disabled="!createFormValid"
|
||||
can-confirm
|
||||
@confirm="emit('create-one', createForm.data)"
|
||||
>
|
||||
<div class="mx-2 mt-2">
|
||||
<slot name="create-dialog-top" />
|
||||
<AutoForm
|
||||
v-model="createForm.data"
|
||||
v-model:is-valid="createFormValid"
|
||||
:items="createForm.items"
|
||||
/>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<BaseDialog
|
||||
v-model="editDialog"
|
||||
:title="$t('general.edit')"
|
||||
:icon="icon"
|
||||
color="primary"
|
||||
:submit-disabled="!editFormValid"
|
||||
can-confirm
|
||||
@confirm="emit('edit-one', editForm.data)"
|
||||
>
|
||||
<div class="mx-2 mt-2">
|
||||
<AutoForm
|
||||
v-model="editForm.data"
|
||||
v-model:is-valid="editFormValid"
|
||||
:items="editForm.items"
|
||||
/>
|
||||
</div>
|
||||
<template #custom-card-action>
|
||||
<slot name="edit-dialog-custom-action" />
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Dialog -->
|
||||
<BaseDialog
|
||||
v-model="deleteDialog"
|
||||
:title="$t('general.confirm')"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
color="error"
|
||||
can-confirm
|
||||
@confirm="$emit('deleteOne', deleteTarget.id)"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("general.confirm-delete-generic") }}
|
||||
<p v-if="deleteTarget" class="mt-4 ml-4">
|
||||
{{ deleteTarget.name || deleteTarget.title || deleteTarget.id }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Bulk Delete Dialog -->
|
||||
<BaseDialog
|
||||
v-model="bulkDeleteDialog"
|
||||
width="650px"
|
||||
:title="$t('general.confirm')"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
color="error"
|
||||
can-confirm
|
||||
@confirm="$emit('bulk-action', 'delete-selected', bulkDeleteTarget)"
|
||||
>
|
||||
<v-card-text>
|
||||
<p class="h4">
|
||||
{{ $t('general.confirm-delete-generic-items') }}
|
||||
</p>
|
||||
<v-card variant="outlined">
|
||||
<v-virtual-scroll height="400" item-height="25" :items="bulkDeleteTarget">
|
||||
<template #default="{ item }">
|
||||
<v-list-item class="pb-2">
|
||||
<v-list-item-title>{{ item.name || item.title || item.id }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseCardSectionTitle
|
||||
:icon="icon"
|
||||
section
|
||||
:title="title"
|
||||
/>
|
||||
|
||||
<CrudTable
|
||||
:headers="tableHeaders"
|
||||
:table-config="tableConfig"
|
||||
:data="data || []"
|
||||
:bulk-actions="bulkActions"
|
||||
:initial-sort="initialSort"
|
||||
@edit-one="editEventHandler"
|
||||
@delete-one="deleteEventHandler"
|
||||
@bulk-action="handleBulkAction"
|
||||
>
|
||||
<template
|
||||
v-for="slotName in itemSlotNames"
|
||||
#[slotName]="slotProps"
|
||||
>
|
||||
<slot
|
||||
:name="slotName"
|
||||
v-bind="slotProps"
|
||||
/>
|
||||
</template>
|
||||
<template #button-row>
|
||||
<BaseButton
|
||||
create
|
||||
@click="createDialog = true"
|
||||
>
|
||||
{{ $t("general.create") }}
|
||||
</BaseButton>
|
||||
<slot name="table-button-row" />
|
||||
</template>
|
||||
<template #button-bottom>
|
||||
<slot name="table-button-bottom" />
|
||||
</template>
|
||||
</CrudTable>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TableHeaders, TableConfig, BulkAction } from "~/components/global/CrudTable.vue";
|
||||
import type { AutoFormItems } from "~/types/auto-forms";
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "deleteOne", id: string): void;
|
||||
(e: "deleteMany", ids: string[]): void;
|
||||
(e: "create-one" | "edit-one", data: any): void;
|
||||
(e: "bulk-action", event: string, items: any[]): void;
|
||||
}>();
|
||||
|
||||
const tableHeaders = defineModel<TableHeaders[]>("tableHeaders", { required: true });
|
||||
const createForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("createForm", { required: true });
|
||||
const createDialog = defineModel("createDialog", { type: Boolean, default: false });
|
||||
|
||||
const editForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("editForm", { required: true });
|
||||
const editDialog = defineModel("editDialog", { type: Boolean, default: false });
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
tableConfig: {
|
||||
type: Object as PropType<TableConfig>,
|
||||
default: () => ({
|
||||
hideColumns: false,
|
||||
canExport: true,
|
||||
}),
|
||||
},
|
||||
data: {
|
||||
type: Array as PropType<Array<any>>,
|
||||
required: true,
|
||||
},
|
||||
bulkActions: {
|
||||
type: Array as PropType<BulkAction[]>,
|
||||
required: true,
|
||||
},
|
||||
initialSort: {
|
||||
type: String,
|
||||
default: "name",
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Bulk Action Handler
|
||||
function handleBulkAction(event: string, items: any[]) {
|
||||
if (event === "delete-selected") {
|
||||
bulkDeleteEventHandler(items);
|
||||
return;
|
||||
}
|
||||
emit("bulk-action", event, items);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Create & Edit
|
||||
const createFormValid = ref(false);
|
||||
const editFormValid = ref(false);
|
||||
const itemSlotNames = computed(() => Object.keys(slots).filter(slotName => slotName.startsWith("item.")));
|
||||
const editEventHandler = (item: any) => {
|
||||
editForm.value.data = { ...item };
|
||||
editDialog.value = true;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Delete Logic
|
||||
const deleteTarget = ref<any>(null);
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
function deleteEventHandler(item: any) {
|
||||
deleteTarget.value = item;
|
||||
deleteDialog.value = true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Bulk Delete Logic
|
||||
const bulkDeleteTarget = ref<Array<any>>([]);
|
||||
const bulkDeleteDialog = ref(false);
|
||||
|
||||
function bulkDeleteEventHandler(items: Array<any>) {
|
||||
bulkDeleteTarget.value = items;
|
||||
bulkDeleteDialog.value = true;
|
||||
console.log("Bulk Delete Event Handler", items);
|
||||
}
|
||||
</script>
|
||||
@@ -15,6 +15,7 @@
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="mdAndUp"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
@@ -82,6 +83,8 @@ const emit = defineEmits<{
|
||||
[key: string]: [];
|
||||
}>();
|
||||
|
||||
const { mdAndUp } = useDisplay();
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const api = useUserApi();
|
||||
@@ -91,7 +94,7 @@ const state = reactive({
|
||||
shoppingListDialog: false,
|
||||
menuItems: [
|
||||
{
|
||||
title: i18n.t("meal-plan.add-day-to-list"),
|
||||
title: i18n.t("recipe.add-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
@@ -123,8 +126,8 @@ async function getShoppingLists() {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||
shoppingList: async () => {
|
||||
await getShoppingLists();
|
||||
shoppingList: () => {
|
||||
getShoppingLists();
|
||||
state.shoppingListDialog = true;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
|
||||
interface Props {
|
||||
queryFilter?: QueryFilterJSON | null;
|
||||
@@ -76,6 +76,7 @@ const MEAL_DAY_OPTIONS = [
|
||||
];
|
||||
|
||||
function handleQueryFilterInput(value: string | undefined) {
|
||||
console.warn("handleQueryFilterInput called with value:", value);
|
||||
queryFilterString.value = value || "";
|
||||
}
|
||||
|
||||
@@ -113,7 +114,7 @@ const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "last_made",
|
||||
label: i18n.t("general.last-made"),
|
||||
type: "relativeDate",
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
<v-select
|
||||
v-if="field.type !== 'boolean'"
|
||||
:model-value="field.relationalOperatorValue"
|
||||
:items="field.relationalOperatorChoices"
|
||||
:items="field.relationalOperatorOptions"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
variant="underlined"
|
||||
@@ -129,9 +129,9 @@
|
||||
:class="config.col.class"
|
||||
>
|
||||
<v-select
|
||||
v-if="field.fieldChoices"
|
||||
v-if="field.fieldOptions"
|
||||
:model-value="field.values"
|
||||
:items="field.fieldChoices"
|
||||
:items="field.fieldOptions"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
multiple
|
||||
@@ -169,39 +169,23 @@
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
:model-value="$d(safeNewDate(field.value + 'T00:00:00'))"
|
||||
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
variant="underlined"
|
||||
color="primary"
|
||||
class="date-input"
|
||||
v-bind="activatorProps"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
:model-value="safeNewDate(field.value + 'T00:00:00')"
|
||||
:model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
|
||||
hide-header
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
|
||||
/>
|
||||
</v-menu>
|
||||
<!--
|
||||
Relative dates are assumed to be negative intervals with a unit of days.
|
||||
The input is a *positive*, interpreted internally as a *negative* offset.
|
||||
-->
|
||||
<v-number-input
|
||||
v-else-if="field.type === 'relativeDate'"
|
||||
:model-value="parseRelativeDateOffset(field.value)"
|
||||
:suffix="$t('query-filter.dates.days-ago', parseRelativeDateOffset(field.value))"
|
||||
variant="underlined"
|
||||
control-variant="stacked"
|
||||
density="compact"
|
||||
inset
|
||||
:min="0"
|
||||
:precision="0"
|
||||
class="date-input"
|
||||
@update:model-value="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Category"
|
||||
v-model="field.organizers"
|
||||
@@ -335,13 +319,7 @@ import { useDebounceFn } from "@vueuse/core";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type {
|
||||
LogicalOperator,
|
||||
QueryFilterJSON,
|
||||
QueryFilterJSONPart,
|
||||
RelationalKeyword,
|
||||
RelationalOperator,
|
||||
} from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { useUserStore } from "~/composables/store/use-user-store";
|
||||
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||
@@ -363,14 +341,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { household } = useHouseholdSelf();
|
||||
const {
|
||||
logOps,
|
||||
placeholderKeywords,
|
||||
getRelOps,
|
||||
buildQueryFilterString,
|
||||
getFieldFromFieldDef,
|
||||
isOrganizerType,
|
||||
} = useQueryFilterBuilder();
|
||||
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
@@ -425,29 +396,16 @@ function setField(index: number, fieldLabel: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldChoices !== fields.value[index].fieldChoices);
|
||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
|
||||
const updatedField = { ...fields.value[index], ...fieldDef };
|
||||
|
||||
// we have to set this explicitly since it might be undefined
|
||||
updatedField.fieldChoices = fieldDef.fieldChoices;
|
||||
updatedField.fieldOptions = fieldDef.fieldOptions;
|
||||
|
||||
fields.value[index] = {
|
||||
...getFieldFromFieldDef(updatedField, resetValue),
|
||||
id: fields.value[index].id, // keep the id
|
||||
};
|
||||
|
||||
// Defaults
|
||||
switch (fields.value[index].type) {
|
||||
case "date":
|
||||
fields.value[index].value = safeNewDate("");
|
||||
break;
|
||||
case "relativeDate":
|
||||
fields.value[index].value = "$NOW-30d";
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||
@@ -467,21 +425,12 @@ function setLogicalOperatorValue(field: FieldWithId, index: number, value: Logic
|
||||
}
|
||||
|
||||
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
|
||||
const relOps = getRelOps(field.type);
|
||||
fields.value[index].relationalOperatorValue = relOps.value[value];
|
||||
}
|
||||
|
||||
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
|
||||
state.datePickers[index] = false;
|
||||
|
||||
if (field.type === "relativeDate") {
|
||||
// Value is set to an int representing the offset from $NOW
|
||||
// Values are assumed to be negative offsets ('-') with a unit of days ('d')
|
||||
fields.value[index].value = `$NOW-${Math.abs(value)}d`;
|
||||
}
|
||||
else {
|
||||
fields.value[index].value = value;
|
||||
}
|
||||
fields.value[index].value = value;
|
||||
}
|
||||
|
||||
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
|
||||
@@ -499,7 +448,12 @@ function removeField(index: number) {
|
||||
state.datePickers.splice(index, 1);
|
||||
}
|
||||
|
||||
const fieldsUpdater = useDebounceFn(() => {
|
||||
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
||||
/* newFields.forEach((field, index) => {
|
||||
const updatedField = getFieldFromFieldDef(field);
|
||||
fields.value[index] = updatedField; // recursive!!!
|
||||
}); */
|
||||
|
||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||
if (qf) {
|
||||
console.debug(`Set query filter: ${qf}`);
|
||||
@@ -565,9 +519,6 @@ async function initializeFields() {
|
||||
...getFieldFromFieldDef(fieldDef),
|
||||
id: useUid(),
|
||||
};
|
||||
|
||||
const relOps = getRelOps(field.type);
|
||||
|
||||
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
|
||||
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
|
||||
field.logicalOperator = part.logicalOperator
|
||||
@@ -576,15 +527,12 @@ async function initializeFields() {
|
||||
field.relationalOperatorValue = part.relationalOperator
|
||||
? relOps.value[part.relationalOperator]
|
||||
: field.relationalOperatorValue;
|
||||
field.relationalOperatorValue = part.relationalOperator
|
||||
? relOps.value[part.relationalOperator]
|
||||
: field.relationalOperatorValue;
|
||||
|
||||
if (field.leftParenthesis || field.rightParenthesis) {
|
||||
state.showAdvanced = true;
|
||||
}
|
||||
|
||||
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (typeof part.value === "string") {
|
||||
field.values = part.value ? [part.value] : [];
|
||||
}
|
||||
@@ -653,7 +601,7 @@ function buildQueryFilterJSON(): QueryFilterJSON {
|
||||
relationalOperator: field.relationalOperatorValue?.value,
|
||||
};
|
||||
|
||||
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
part.value = field.values.map(value => value.toString());
|
||||
}
|
||||
else if (field.type === "boolean") {
|
||||
@@ -671,50 +619,6 @@ function buildQueryFilterJSON(): QueryFilterJSON {
|
||||
return qfJSON;
|
||||
}
|
||||
|
||||
function safeNewDate(input: string): Date {
|
||||
const date = new Date(input);
|
||||
if (isNaN(date.getTime())) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return today;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a relative date string offset (e.g. $NOW-30d --> 30)
|
||||
*
|
||||
* Currently only values with a negative offset ('-') and a unit of days ('d') are supported
|
||||
*/
|
||||
function parseRelativeDateOffset(value: string): number {
|
||||
const defaultVal = 30;
|
||||
if (!value) {
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!value.startsWith(placeholderKeywords.value["$NOW"].value)) {
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
const remainder = value.slice(placeholderKeywords.value["$NOW"].value.length);
|
||||
if (!remainder.startsWith("-")) {
|
||||
throw new Error("Invalid operator (not '-')");
|
||||
}
|
||||
|
||||
if (remainder.slice(-1) !== "d") {
|
||||
throw new Error("Invalid unit (not 'd')");
|
||||
}
|
||||
|
||||
// Slice off sign and unit
|
||||
return parseInt(remainder.slice(1, -1));
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Unable to parse relative date offset from '${value}': ${error}`);
|
||||
return defaultVal;
|
||||
}
|
||||
}
|
||||
|
||||
const config = computed(() => {
|
||||
const multiple = fields.value.length > 1;
|
||||
const adv = state.showAdvanced;
|
||||
@@ -785,13 +689,4 @@ const config = computed(() => {
|
||||
.bg-light {
|
||||
background-color: rgba(255, 255, 255, var(--bg-opactity));
|
||||
}
|
||||
|
||||
:deep(.date-input input) {
|
||||
text-align: end;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
:deep(.date-input .v-field__field) {
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
@print="$emit('print')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="open" class="custom-btn-group gapped ma-1">
|
||||
<div v-if="open" class="custom-btn-group gapped">
|
||||
<v-btn
|
||||
v-for="(btn, index) in editorButtons"
|
||||
:key="index"
|
||||
|
||||
@@ -1,97 +1,60 @@
|
||||
<template>
|
||||
<div v-if="model.length > 0 || edit">
|
||||
<v-card class="mt-4">
|
||||
<v-list-item class="pr-2 pl-0">
|
||||
<v-card-title>
|
||||
{{ $t("asset.assets") }}
|
||||
</v-card-title>
|
||||
<template #append>
|
||||
<v-btn
|
||||
v-if="edit"
|
||||
variant="plain"
|
||||
:icon="$globals.icons.create"
|
||||
@click="state.newAssetDialog = true"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-card-title class="py-2">
|
||||
{{ $t("asset.assets") }}
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2" />
|
||||
<v-list
|
||||
v-if="model.length > 0"
|
||||
lines="two"
|
||||
:flat="!edit"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(item, i) in model"
|
||||
:key="i"
|
||||
:href="!edit ? assetURL(item.fileName ?? '') : ''"
|
||||
target="_blank"
|
||||
class="pr-2"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar size="48" rounded="lg" class="elevation-1">
|
||||
<v-img
|
||||
v-if="isImage(item.fileName)"
|
||||
:src="assetURL(item.fileName ?? '')"
|
||||
:alt="item.name"
|
||||
loading="lazy"
|
||||
cover
|
||||
/>
|
||||
<v-icon v-else size="large">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
<div class="ma-auto">
|
||||
<v-tooltip location="bottom">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-icon v-bind="tooltipProps">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ getIconDefinition(item.icon).title }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-list-item-title>
|
||||
<v-list-item-title class="pl-2">
|
||||
{{ item.name }}
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<v-menu v-if="edit" location="bottom end">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon
|
||||
variant="plain"
|
||||
>
|
||||
<v-icon :icon="$globals.icons.dotsVertical" />
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" min-width="220">
|
||||
<v-list-item
|
||||
:href="assetURL(item.fileName ?? '')"
|
||||
:prepend-icon="$globals.icons.eye"
|
||||
:title="$t('general.view')"
|
||||
target="_blank"
|
||||
/>
|
||||
<v-list-item
|
||||
:href="assetURL(item.fileName ?? '')"
|
||||
:prepend-icon="$globals.icons.download"
|
||||
:title="$t('general.download')"
|
||||
download
|
||||
/>
|
||||
<v-list-item
|
||||
v-if="edit"
|
||||
:prepend-icon="$globals.icons.contentCopy"
|
||||
:title="$t('general.copy')"
|
||||
@click="copyText(assetEmbed(item.fileName ?? ''))"
|
||||
/>
|
||||
<v-list-item
|
||||
v-if="edit"
|
||||
:prepend-icon="$globals.icons.delete"
|
||||
:title="$t('general.delete')"
|
||||
@click="model.splice(i, 1)"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
v-if="!edit"
|
||||
color="primary"
|
||||
icon
|
||||
variant="plain"
|
||||
size="small"
|
||||
:href="assetURL(item.fileName ?? '')"
|
||||
download
|
||||
target="_blank"
|
||||
top
|
||||
>
|
||||
<v-icon> {{ $globals.icons.download }} </v-icon>
|
||||
</v-btn>
|
||||
<div v-else>
|
||||
<v-btn
|
||||
color="error"
|
||||
icon
|
||||
size="small"
|
||||
top
|
||||
@click="model.splice(i, 1)"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
<AppButtonCopy
|
||||
color=""
|
||||
:copy-text="assetEmbed(item.fileName ?? '')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
@@ -105,9 +68,18 @@
|
||||
can-submit
|
||||
@submit="addAsset"
|
||||
>
|
||||
<template #activator>
|
||||
<BaseButton
|
||||
v-if="edit"
|
||||
size="small"
|
||||
create
|
||||
@click="state.newAssetDialog = true"
|
||||
/>
|
||||
</template>
|
||||
<v-card-text class="pt-4">
|
||||
<v-text-field
|
||||
v-model="state.newAsset.name"
|
||||
density="compact"
|
||||
:label="$t('general.name')"
|
||||
/>
|
||||
<div class="d-flex justify-space-between">
|
||||
@@ -120,14 +92,10 @@
|
||||
item-value="name"
|
||||
class="mr-2"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #prepend>
|
||||
<v-avatar>
|
||||
<v-icon>
|
||||
{{ item.raw.icon }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
<v-icon>{{ item.raw.icon }}</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
@@ -139,6 +107,7 @@
|
||||
@uploaded="setFileObject"
|
||||
/>
|
||||
</div>
|
||||
{{ state.fileObject.name }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
@@ -149,7 +118,6 @@
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import type { RecipeAsset } from "~/lib/api/types/recipe";
|
||||
import { useCopy } from "~/composables/use-copy";
|
||||
|
||||
const props = defineProps({
|
||||
slug: {
|
||||
@@ -181,7 +149,6 @@ const state = reactive({
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { copyText } = useCopy();
|
||||
|
||||
const iconOptions = [
|
||||
{
|
||||
@@ -217,31 +184,21 @@ function getIconDefinition(icon: string) {
|
||||
return iconOptions.find(item => item.name === icon) || iconOptions[0];
|
||||
}
|
||||
|
||||
function isImage(fileName?: string | null) {
|
||||
if (!fileName) return false;
|
||||
return /\.(png|jpe?g|gif|webp|bmp|avif)$/i.test(fileName);
|
||||
}
|
||||
|
||||
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%" />`;
|
||||
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%"> </img>`;
|
||||
}
|
||||
|
||||
function setFileObject(fileObject: File) {
|
||||
state.fileObject = fileObject;
|
||||
// If the user didn't provide a name, default to the file base name
|
||||
if (!state.newAsset.name?.trim()) {
|
||||
state.newAsset.name = fileObject.name.substring(0, fileObject.name.lastIndexOf("."));
|
||||
}
|
||||
}
|
||||
|
||||
function validFields() {
|
||||
// Only require a file; name will fall back to the file name if empty
|
||||
return Boolean(state.fileObject?.name);
|
||||
return state.newAsset.name.length > 0 && state.fileObject.name.length > 0;
|
||||
}
|
||||
|
||||
async function addAsset() {
|
||||
@@ -250,10 +207,8 @@ async function addAsset() {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameToUse = state.newAsset.name?.trim() || state.fileObject.name;
|
||||
|
||||
const { data } = await api.recipes.createAsset(props.slug, {
|
||||
name: nameToUse,
|
||||
name: state.newAsset.name,
|
||||
icon: state.newAsset.icon,
|
||||
file: state.fileObject,
|
||||
extension: state.fileObject.name.split(".").pop() || "",
|
||||
|
||||
@@ -130,11 +130,11 @@ defineEmits<{
|
||||
delete: [slug: string];
|
||||
}>();
|
||||
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||
const recipeRoute = computed<string>(() => {
|
||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||
|
||||
@@ -160,11 +160,11 @@ defineEmits<{
|
||||
delete: [slug: string];
|
||||
}>();
|
||||
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||
const recipeRoute = computed<string>(() => {
|
||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||
|
||||
@@ -219,7 +219,7 @@ const EVENTS = {
|
||||
shuffle: "shuffle",
|
||||
};
|
||||
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const useMobileCards = computed(() => {
|
||||
@@ -234,7 +234,7 @@ const sortLoading = ref(false);
|
||||
const randomSeed = ref(Date.now().toString());
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const page = ref(1);
|
||||
const perPage = 32;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="$vuetify.display.mdAndUp"
|
||||
content-class="d-print-none"
|
||||
@update:model-value="onMenuToggle"
|
||||
>
|
||||
@@ -23,6 +24,7 @@
|
||||
:fab="fab"
|
||||
v-bind="activatorProps"
|
||||
@click.prevent
|
||||
@mouseenter="onHover"
|
||||
>
|
||||
<v-icon
|
||||
:size="!fab ? undefined : 'x-large'"
|
||||
@@ -125,6 +127,12 @@ const contentProps = computed(() => {
|
||||
return rest;
|
||||
});
|
||||
|
||||
function onHover() {
|
||||
if (!isMenuContentLoaded.value) {
|
||||
isMenuContentLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuToggle(isOpen: boolean) {
|
||||
if (isOpen && !isMenuContentLoaded.value) {
|
||||
isMenuContentLoaded.value = true;
|
||||
|
||||
@@ -176,7 +176,6 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<{
|
||||
[key: string]: any;
|
||||
deleted: [slug: string];
|
||||
print: [];
|
||||
}>();
|
||||
|
||||
const api = useUserApi();
|
||||
@@ -202,13 +201,13 @@ const newMealdateString = computed(() => {
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const auth = useMealieAuth();
|
||||
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 groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
@@ -296,12 +295,12 @@ const recipeRefWithScale = computed(() =>
|
||||
);
|
||||
const isAdminAndNotOwner = computed(() => {
|
||||
return (
|
||||
auth.user.value?.admin
|
||||
&& auth.user.value?.id !== recipeRef.value?.userId
|
||||
$auth.user.value?.admin
|
||||
&& $auth.user.value?.id !== recipeRef.value?.userId
|
||||
);
|
||||
});
|
||||
const canDelete = computed(() => {
|
||||
const user = auth.user.value;
|
||||
const user = $auth.user.value;
|
||||
const recipe = recipeRef.value;
|
||||
return user && recipe && (user.admin || user.id === recipe.userId);
|
||||
});
|
||||
|
||||
@@ -110,8 +110,8 @@ defineEmits<{
|
||||
const selected = defineModel<Recipe[]>({ default: () => [] });
|
||||
|
||||
const i18n = useI18n();
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = auth.user.value?.groupSlug;
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = $auth.user.value?.groupSlug;
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize sort state with default sorting by dateAdded descending
|
||||
|
||||
@@ -217,7 +217,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
const i18n = useI18n();
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const api = useUserApi();
|
||||
const preferences = useShoppingListPreferences();
|
||||
const ready = ref(false);
|
||||
@@ -227,7 +227,7 @@ const currentHouseholdSlug = ref("");
|
||||
const filteredShoppingLists = ref<ShoppingListSummary[]>([]);
|
||||
|
||||
const state = reactive({
|
||||
shoppingListDialog: false,
|
||||
shoppingListDialog: true,
|
||||
shoppingListIngredientDialog: false,
|
||||
shoppingListShowAllToggled: false,
|
||||
});
|
||||
@@ -237,11 +237,11 @@ const { shoppingListDialog, shoppingListIngredientDialog, shoppingListShowAllTog
|
||||
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
||||
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
|
||||
|
||||
watch([dialog, () => preferences.value.viewAllLists], () => {
|
||||
if (dialog.value) {
|
||||
currentHouseholdSlug.value = auth.user.value?.householdSlug || "";
|
||||
watch(dialog, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
currentHouseholdSlug.value = $auth.user.value?.householdSlug || "";
|
||||
filteredShoppingLists.value = props.shoppingLists.filter(
|
||||
list => preferences.value.viewAllLists || list.userId === auth.user.value?.id,
|
||||
list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id,
|
||||
);
|
||||
|
||||
if (filteredShoppingLists.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
@@ -249,11 +249,10 @@ watch([dialog, () => preferences.value.viewAllLists], () => {
|
||||
openShoppingListIngredientDialog(selectedShoppingList.value);
|
||||
}
|
||||
else {
|
||||
state.shoppingListDialog = true;
|
||||
ready.value = true;
|
||||
}
|
||||
}
|
||||
else if (!dialog.value) {
|
||||
else if (!newVal) {
|
||||
initState();
|
||||
}
|
||||
});
|
||||
@@ -372,7 +371,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
}
|
||||
|
||||
function initState() {
|
||||
state.shoppingListDialog = false;
|
||||
state.shoppingListDialog = true;
|
||||
state.shoppingListIngredientDialog = false;
|
||||
state.shoppingListShowAllToggled = false;
|
||||
recipeIngredientSections.value = [];
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
dark
|
||||
color="primary-lighten-1 top-0 position-relative left-0"
|
||||
:rounded="!$vuetify.display.xs"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<v-text-field
|
||||
id="arrow-search"
|
||||
@@ -33,8 +32,9 @@
|
||||
|
||||
<v-btn
|
||||
v-if="$vuetify.display.xs"
|
||||
icon
|
||||
size="x-small"
|
||||
class="rounded-circle"
|
||||
light
|
||||
@click="dialog = false"
|
||||
>
|
||||
<v-icon>
|
||||
@@ -87,7 +87,7 @@ const emit = defineEmits<{
|
||||
selected: [recipe: RecipeSummary];
|
||||
}>();
|
||||
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const loading = ref(false);
|
||||
const selectedIndex = ref(-1);
|
||||
|
||||
@@ -153,7 +153,7 @@ watch(dialog, (val) => {
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
watch(route, close);
|
||||
|
||||
function open() {
|
||||
|
||||
@@ -119,10 +119,10 @@ whenever(
|
||||
);
|
||||
|
||||
const i18n = useI18n();
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const { household } = useHouseholdSelf();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
|
||||
@@ -34,11 +34,11 @@ import { useLazyRecipes } from "~/composables/recipes";
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeCardSection, RecipeExplorerPageSearch },
|
||||
setup() {
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const { recipes, appendRecipes, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
|
||||
|
||||
@@ -141,13 +141,13 @@ const emit = defineEmits<{
|
||||
ready: [];
|
||||
}>();
|
||||
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const { $globals } = useNuxtApp();
|
||||
const i18n = useI18n();
|
||||
const showRandomLoading = ref(false);
|
||||
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const {
|
||||
state,
|
||||
|
||||
@@ -81,11 +81,11 @@ import {
|
||||
usePublicToolStore,
|
||||
} from "~/composables/store";
|
||||
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const {
|
||||
state,
|
||||
|
||||
@@ -52,14 +52,14 @@ const isFavorite = computed(() => {
|
||||
|
||||
async function toggleFavorite() {
|
||||
const api = useUserApi();
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
|
||||
if (!auth.user.value) return;
|
||||
if (!$auth.user.value) return;
|
||||
if (!isFavorite.value) {
|
||||
await api.users.addFavorite(auth.user.value?.id, props.recipeId);
|
||||
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
else {
|
||||
await api.users.removeFavorite(auth.user.value?.id, props.recipeId);
|
||||
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
await refreshUserRatings();
|
||||
}
|
||||
|
||||
@@ -58,8 +58,8 @@
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="filteredUnits"
|
||||
:custom-filter="() => true"
|
||||
:items="units || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
item-title="name"
|
||||
class="mx-1"
|
||||
:placeholder="$t('recipe.choose-unit')"
|
||||
@@ -117,8 +117,8 @@
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="filteredFoods"
|
||||
:custom-filter="() => true"
|
||||
:items="foods || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
item-title="name"
|
||||
class="mx-1 py-0"
|
||||
:placeholder="$t('recipe.choose-food')"
|
||||
@@ -176,6 +176,7 @@
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="search.data.value || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
item-title="name"
|
||||
class="mx-1 py-0"
|
||||
:placeholder="$t('search.type-to-search')"
|
||||
@@ -226,11 +227,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, toRefs, watch } from "vue";
|
||||
import { ref, computed, reactive, toRefs } from "vue";
|
||||
import { useDisplay } from "vuetify";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||
import { useSearch } from "~/composables/use-search";
|
||||
import { normalizeFilter } from "~/composables/use-utils";
|
||||
import { useNuxtApp } from "#app";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
||||
@@ -342,8 +343,8 @@ const btns = computed(() => {
|
||||
// Foods
|
||||
const foodStore = useFoodStore();
|
||||
const foodData = useFoodData();
|
||||
const foodSearch = ref("");
|
||||
const foodAutocomplete = ref<HTMLInputElement>();
|
||||
const { search: foodSearch, filtered: filteredFoods } = useSearch(foodStore.store);
|
||||
|
||||
async function createAssignFood() {
|
||||
foodData.data.name = foodSearch.value;
|
||||
@@ -354,8 +355,8 @@ async function createAssignFood() {
|
||||
|
||||
// Recipes
|
||||
const route = useRoute();
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||
@@ -374,8 +375,8 @@ watch(loading, (val) => {
|
||||
// Units
|
||||
const unitStore = useUnitStore();
|
||||
const unitsData = useUnitData();
|
||||
const unitSearch = ref("");
|
||||
const unitAutocomplete = ref<HTMLInputElement>();
|
||||
const { search: unitSearch, filtered: filteredUnits } = useSearch(unitStore.store);
|
||||
|
||||
async function createAssignUnit() {
|
||||
unitsData.data.name = unitSearch.value;
|
||||
@@ -429,6 +430,9 @@ function quantityFilter(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
const { showTitle } = toRefs(state);
|
||||
|
||||
const foods = foodStore.store;
|
||||
const units = unitStore.store;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
|
||||
interface Props {
|
||||
ingredient?: RecipeIngredient;
|
||||
@@ -20,7 +20,6 @@ interface Props {
|
||||
}
|
||||
|
||||
const { ingredient, scale = 1 } = defineProps<Props>();
|
||||
const { useParsedIngredientText } = useIngredientTextParser();
|
||||
|
||||
const baseText = computed(() => {
|
||||
if (!ingredient) return "";
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RecipeIngredient } from "~/lib/api/types/household";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
|
||||
interface Props {
|
||||
ingredient: RecipeIngredient;
|
||||
@@ -44,9 +44,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
});
|
||||
const route = useRoute();
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
|
||||
const { useParsedIngredientText } = useIngredientTextParser();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
const parsedIng = computed(() => {
|
||||
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
|
||||
|
||||
@@ -17,13 +17,15 @@
|
||||
v-for="(ingredient, index) in value"
|
||||
:key="'ingredient' + index"
|
||||
>
|
||||
<h3
|
||||
v-if="showTitleEditor[index]"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ ingredient.title }}
|
||||
</h3>
|
||||
<v-divider v-if="showTitleEditor[index]" />
|
||||
<template v-if="!isCookMode">
|
||||
<h3
|
||||
v-if="showTitleEditor[index]"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ ingredient.title }}
|
||||
</h3>
|
||||
<v-divider v-if="showTitleEditor[index]" />
|
||||
</template>
|
||||
<v-list-item
|
||||
density="compact"
|
||||
class="pa-0"
|
||||
@@ -52,7 +54,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
|
||||
interface Props {
|
||||
@@ -66,8 +68,6 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isCookMode: false,
|
||||
});
|
||||
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
function validateTitle(title?: string | null) {
|
||||
return !(title === undefined || title === "" || title === null);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ const madeThisDialog = ref(false);
|
||||
const userApi = useUserApi();
|
||||
const { household } = useHouseholdSelf();
|
||||
const i18n = useI18n();
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const domMadeThisForm = ref<VForm>();
|
||||
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
||||
subject: "",
|
||||
@@ -179,7 +179,7 @@ const newTimelineEventTimestampString = computed(() => {
|
||||
const lastMade = ref(props.recipe.lastMade);
|
||||
const lastMadeReady = ref(false);
|
||||
onMounted(async () => {
|
||||
if (!auth.user?.value?.householdSlug) {
|
||||
if (!$auth.user?.value?.householdSlug) {
|
||||
lastMade.value = props.recipe.lastMade;
|
||||
}
|
||||
else {
|
||||
@@ -255,8 +255,8 @@ async function createTimelineEvent() {
|
||||
madeThisFormLoading.value = true;
|
||||
|
||||
newTimelineEvent.value.recipeId = props.recipe.id;
|
||||
// Note: auth.user is now a ref
|
||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: auth.user.value?.fullName });
|
||||
// Note: $auth.user is now a ref
|
||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
|
||||
|
||||
// the user only selects the date, so we set the time to end of day local time
|
||||
// we choose the end of day so it always comes after "new recipe" events
|
||||
|
||||
@@ -73,10 +73,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const { frac } = useFraction();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
const attrs = computed(() => {
|
||||
return props.small
|
||||
|
||||
@@ -162,9 +162,9 @@ const state = reactive({
|
||||
},
|
||||
});
|
||||
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user?.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
// =================================================================
|
||||
// Context Menu
|
||||
|
||||
@@ -13,6 +13,22 @@
|
||||
{{ $t("general.discard-changes-description") }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<BaseDialog
|
||||
v-model="saveErrorDialog"
|
||||
:title="$t('recipe.save-error')"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
@cancel="saveErrorDialog = false"
|
||||
>
|
||||
<v-card-text>
|
||||
<p class="mb-2">
|
||||
<strong>{{ saveErrorMessage }}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{{ $t('recipe.save-error-description') }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<RecipePageParseDialog
|
||||
:model-value="isParsing"
|
||||
:ingredients="recipe.recipeIngredient"
|
||||
@@ -220,11 +236,11 @@ import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
const display = useDisplay();
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || auth.user?.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
const router = useRouter();
|
||||
const api = useUserApi();
|
||||
@@ -246,6 +262,8 @@ const notLinkedIngredients = computed(() => {
|
||||
*/
|
||||
const originalRecipe = ref<Recipe | null>(null);
|
||||
const discardDialog = ref(false);
|
||||
const saveErrorDialog = ref(false);
|
||||
const saveErrorMessage = ref("");
|
||||
const pendingRoute = ref<RouteLocationNormalized | null>(null);
|
||||
|
||||
invoke(async () => {
|
||||
@@ -352,6 +370,11 @@ async function saveRecipe() {
|
||||
const { data, error } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||
if (!error) {
|
||||
setMode(PageMode.VIEW);
|
||||
} else {
|
||||
// Show prominent error dialog for save failures
|
||||
saveErrorMessage.value = error?.response?.data?.detail?.message || "An error occurred while saving the recipe.";
|
||||
saveErrorDialog.value = true;
|
||||
return;
|
||||
}
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
||||
|
||||
@@ -431,7 +431,6 @@ const props = defineProps({
|
||||
const emit = defineEmits(["click-instruction-field", "update:assets"]);
|
||||
|
||||
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
|
||||
const dialog = ref(false);
|
||||
const disabledSteps = ref<number[]>([]);
|
||||
@@ -582,7 +581,7 @@ function setUsedIngredients() {
|
||||
watch(activeRefs, () => setUsedIngredients());
|
||||
|
||||
function autoSetReferences() {
|
||||
extractIngredientReferences(
|
||||
useExtractIngredientReferences(
|
||||
props.recipe.recipeIngredient,
|
||||
activeRefs.value,
|
||||
activeText.value,
|
||||
|
||||
@@ -197,7 +197,7 @@ import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient
|
||||
import type { Parser } from "~/lib/api/user/recipes/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
|
||||
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
@@ -208,8 +208,6 @@ const props = defineProps<{
|
||||
ingredients: NoUndefinedField<RecipeIngredient[]>;
|
||||
}>();
|
||||
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
|
||||
|
||||
@@ -192,7 +192,7 @@ import { useStaticRoutes } from "~/composables/api";
|
||||
import type { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||
import { useIngredientTextParser, useNutritionLabels } from "~/composables/recipes";
|
||||
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
@@ -362,8 +362,6 @@ const hasNotes = computed(() => {
|
||||
return props.recipe.notes && props.recipe.notes.length > 0;
|
||||
});
|
||||
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
function parseText(ingredient: RecipeIngredient) {
|
||||
return parseIngredientText(ingredient, props.scale);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
:nudge-top="props.menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="!props.useMobileFormat"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props: btnProps }">
|
||||
@@ -98,6 +99,7 @@ const props = defineProps<{
|
||||
color?: string;
|
||||
event: RecipeTimelineEventOut;
|
||||
menuIcon?: string | null;
|
||||
useMobileFormat?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["delete", "update"]);
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
:menu-top="false"
|
||||
:event="event"
|
||||
:menu-icon="$globals.icons.dotsVertical"
|
||||
:use-mobile-format="useMobileFormat"
|
||||
color="transparent"
|
||||
:elevation="0"
|
||||
:card-menu="false"
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
<v-card width="400">
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="searchInput"
|
||||
v-memo="[searchInput]"
|
||||
v-model="state.search"
|
||||
v-memo="[state.search]"
|
||||
class="mb-2"
|
||||
hide-details
|
||||
density="comfortable"
|
||||
@@ -38,7 +38,7 @@
|
||||
clearable
|
||||
/>
|
||||
<div />
|
||||
<div class="d-flex flex-wrap py-4 px-1 align-center">
|
||||
<div class="d-flex py-4 px-1 align-center">
|
||||
<v-btn-toggle
|
||||
v-if="requireAll != undefined"
|
||||
v-model="combinator"
|
||||
@@ -46,7 +46,6 @@
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
class="my-1"
|
||||
>
|
||||
<v-btn value="hasAll">
|
||||
{{ $t('search.has-all') }}
|
||||
@@ -59,7 +58,6 @@
|
||||
<v-btn
|
||||
size="small"
|
||||
color="accent"
|
||||
class="my-1"
|
||||
@click="clearSelection"
|
||||
>
|
||||
{{ $t("search.clear-selection") }}
|
||||
@@ -146,13 +144,17 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ISearchableItem } from "~/composables/use-search";
|
||||
import { useSearch } from "~/composables/use-search";
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
|
||||
export interface SelectableItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as () => ISearchableItem[],
|
||||
type: Array as () => SelectableItem[],
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
@@ -171,11 +173,12 @@ export default defineNuxtComponent({
|
||||
emits: ["update:requireAll", "update:modelValue"],
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
search: "",
|
||||
menu: false,
|
||||
});
|
||||
|
||||
// Use the search composable
|
||||
const { search: searchInput, filtered } = useSearch(computed(() => props.items));
|
||||
// Use shallowRef for better performance with arrays
|
||||
const debouncedSearch = shallowRef("");
|
||||
|
||||
const combinator = computed({
|
||||
get: () => (props.requireAll ? "hasAll" : "hasAny"),
|
||||
@@ -186,7 +189,7 @@ export default defineNuxtComponent({
|
||||
|
||||
// Use shallowRef to prevent deep reactivity on large arrays
|
||||
const selected = computed({
|
||||
get: () => props.modelValue as ISearchableItem[],
|
||||
get: () => props.modelValue as SelectableItem[],
|
||||
set: (value) => {
|
||||
context.emit("update:modelValue", value);
|
||||
},
|
||||
@@ -199,12 +202,44 @@ export default defineNuxtComponent({
|
||||
},
|
||||
});
|
||||
|
||||
watchDebounced(
|
||||
() => state.search,
|
||||
(newSearch) => {
|
||||
debouncedSearch.value = newSearch;
|
||||
},
|
||||
{ debounce: 500, maxWait: 1500, immediate: false }, // Increased debounce time
|
||||
);
|
||||
|
||||
const filtered = computed(() => {
|
||||
const items = props.items;
|
||||
const search = debouncedSearch.value;
|
||||
|
||||
if (!search || search.length < 2) { // Only filter after 2 characters
|
||||
return items;
|
||||
}
|
||||
|
||||
const searchLower = search.toLowerCase();
|
||||
return items.filter(item => item.name.toLowerCase().includes(searchLower));
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => selected.value.length);
|
||||
const selectedIds = computed(() => {
|
||||
return new Set(selected.value.map(item => item.id));
|
||||
});
|
||||
|
||||
const handleRadioClick = (item: ISearchableItem) => {
|
||||
const handleCheckboxClick = (item: SelectableItem) => {
|
||||
const currentSelection = selected.value;
|
||||
const isSelected = selectedIds.value.has(item.id);
|
||||
|
||||
if (isSelected) {
|
||||
selected.value = currentSelection.filter(i => i.id !== item.id);
|
||||
}
|
||||
else {
|
||||
selected.value = [...currentSelection, item];
|
||||
}
|
||||
};
|
||||
|
||||
const handleRadioClick = (item: SelectableItem) => {
|
||||
if (selectedRadio.value === item) {
|
||||
selectedRadio.value = null;
|
||||
}
|
||||
@@ -213,18 +248,18 @@ export default defineNuxtComponent({
|
||||
function clearSelection() {
|
||||
selected.value = [];
|
||||
selectedRadio.value = null;
|
||||
searchInput.value = "";
|
||||
state.search = "";
|
||||
}
|
||||
|
||||
return {
|
||||
combinator,
|
||||
state,
|
||||
searchInput,
|
||||
selected,
|
||||
selectedRadio,
|
||||
selectedCount,
|
||||
selectedIds,
|
||||
filtered,
|
||||
handleCheckboxClick,
|
||||
handleRadioClick,
|
||||
clearSelection,
|
||||
};
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
:items="foods"
|
||||
:label="$t('shopping-list.food')"
|
||||
:icon="$globals.icons.foods"
|
||||
:autofocus="autoFocus === 'food'"
|
||||
create
|
||||
@create="createAssignFood"
|
||||
/>
|
||||
@@ -42,7 +41,7 @@
|
||||
:label="$t('shopping-list.note')"
|
||||
rows="1"
|
||||
auto-grow
|
||||
:autofocus="autoFocus === 'note'"
|
||||
autofocus
|
||||
@keypress="handleNoteKeyPress"
|
||||
/>
|
||||
</div>
|
||||
@@ -57,6 +56,25 @@
|
||||
width="250"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-menu
|
||||
v-if="listItem.recipeReferences && listItem.recipeReferences.length > 0"
|
||||
open-on-hover
|
||||
offset-y
|
||||
start
|
||||
top
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-icon class="mt-auto" :icon="$globals.icons.alert" v-bind="props" color="warning">
|
||||
{{ $globals.icons.alert }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-card max-width="350px" class="left-warning-border">
|
||||
<v-card-text>
|
||||
{{ $t("shopping-list.linked-item-warning") }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
<BaseButton
|
||||
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
|
||||
@@ -166,8 +184,6 @@ export default defineNuxtComponent({
|
||||
},
|
||||
);
|
||||
|
||||
const autoFocus = !listItem.value.food && listItem.value.note ? "note" : "food";
|
||||
|
||||
async function createAssignFood(val: string) {
|
||||
// keep UI reactive
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
@@ -207,7 +223,6 @@ export default defineNuxtComponent({
|
||||
|
||||
return {
|
||||
listItem,
|
||||
autoFocus,
|
||||
createAssignFood,
|
||||
createAssignUnit,
|
||||
assignLabelToFood,
|
||||
|
||||
@@ -62,15 +62,15 @@ export default defineNuxtComponent({
|
||||
error: false,
|
||||
});
|
||||
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const { store: users } = useUserStore();
|
||||
const user = computed(() => {
|
||||
return users.value.find(user => user.id === props.userId);
|
||||
});
|
||||
|
||||
const imageURL = computed(() => {
|
||||
// Note: auth.user is a ref now
|
||||
const authUser = auth.user.value;
|
||||
// Note: $auth.user is a ref now
|
||||
const authUser = $auth.user.value;
|
||||
const key = authUser?.cacheKey ?? "";
|
||||
return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`;
|
||||
});
|
||||
|
||||
@@ -102,9 +102,9 @@ export default defineNuxtComponent({
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const i18n = useI18n();
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
|
||||
const isAdmin = computed(() => auth.user.value?.admin);
|
||||
const isAdmin = computed(() => $auth.user.value?.admin);
|
||||
const token = ref("");
|
||||
const selectedGroup = ref<string | null>(null);
|
||||
const selectedHousehold = ref<string | null>(null);
|
||||
|
||||
@@ -106,11 +106,11 @@ export default defineNuxtComponent({
|
||||
const i18n = useI18n();
|
||||
const { $appInfo, $globals } = useNuxtApp();
|
||||
const display = useDisplay();
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const cookbookPreferences = useCookbookPreferences();
|
||||
const ownCookbookStore = useCookbookStore(i18n);
|
||||
@@ -152,7 +152,7 @@ export default defineNuxtComponent({
|
||||
};
|
||||
}
|
||||
|
||||
const currentUserHouseholdId = computed(() => auth.user.value?.householdId);
|
||||
const currentUserHouseholdId = computed(() => $auth.user.value?.householdId);
|
||||
const cookbookLinks = computed<SideBarLink[]>(() => {
|
||||
if (!cookbooks.value?.length) {
|
||||
return [];
|
||||
@@ -187,7 +187,7 @@ export default defineNuxtComponent({
|
||||
});
|
||||
|
||||
links.sort((a, b) => a.title.localeCompare(b.title));
|
||||
if (auth.user.value && cookbookPreferences.value.hideOtherHouseholds) {
|
||||
if ($auth.user.value && cookbookPreferences.value.hideOtherHouseholds) {
|
||||
return ownLinks;
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -97,10 +97,10 @@ export default defineNuxtComponent({
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const { loggedIn } = useLoggedInState();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const { xs, smAndUp } = useDisplay();
|
||||
|
||||
const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/");
|
||||
@@ -128,7 +128,7 @@ export default defineNuxtComponent({
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await auth.signOut("/login?direct=1");
|
||||
await $auth.signOut("/login?direct=1");
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
|
||||
@@ -168,13 +168,13 @@ export default defineNuxtComponent({
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const { loggedIn, isOwnGroup } = useLoggedInState();
|
||||
const isAdmin = computed(() => auth.user.value?.admin);
|
||||
const canManage = computed(() => auth.user.value?.canManage);
|
||||
const isAdmin = computed(() => $auth.user.value?.admin);
|
||||
const canManage = computed(() => $auth.user.value?.canManage);
|
||||
|
||||
const userFavoritesLink = computed(() => auth.user.value ? `/user/${auth.user.value.id}/favorites` : undefined);
|
||||
const userProfileLink = computed(() => auth.user.value ? "/user/profile" : undefined);
|
||||
const userFavoritesLink = computed(() => $auth.user.value ? `/user/${$auth.user.value.id}/favorites` : undefined);
|
||||
const userProfileLink = computed(() => $auth.user.value ? "/user/profile" : undefined);
|
||||
|
||||
const toggleDark = useToggleDarkMode();
|
||||
|
||||
@@ -217,7 +217,7 @@ export default defineNuxtComponent({
|
||||
isAdmin,
|
||||
canManage,
|
||||
isOwnGroup,
|
||||
sessionUser: auth.user,
|
||||
sessionUser: $auth.user,
|
||||
toggleDark,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
*/
|
||||
export default defineNuxtComponent({
|
||||
setup(_, ctx) {
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
|
||||
const r = auth.user.value?.advanced || false;
|
||||
const r = $auth.user.value?.advanced || false;
|
||||
|
||||
return () => {
|
||||
return r ? ctx.slots.default?.() : null;
|
||||
|
||||
@@ -1,136 +1,211 @@
|
||||
<template>
|
||||
<v-form v-model="isValid" validate-on="input">
|
||||
<v-card
|
||||
:color="color"
|
||||
:dark="dark"
|
||||
flat
|
||||
:width="width"
|
||||
class="my-2"
|
||||
>
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="(inputField, index) in items"
|
||||
:key="index"
|
||||
cols="12"
|
||||
sm="12"
|
||||
<v-card
|
||||
:color="color"
|
||||
:dark="dark"
|
||||
flat
|
||||
:width="width"
|
||||
class="my-2"
|
||||
>
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="(inputField, index) in items"
|
||||
:key="index"
|
||||
cols="12"
|
||||
sm="12"
|
||||
>
|
||||
<v-divider
|
||||
v-if="inputField.section"
|
||||
class="my-2"
|
||||
/>
|
||||
<v-card-title
|
||||
v-if="inputField.section"
|
||||
class="pl-0"
|
||||
>
|
||||
<v-divider
|
||||
v-if="inputField.section"
|
||||
class="my-2"
|
||||
/>
|
||||
<v-card-title
|
||||
v-if="inputField.section"
|
||||
class="pl-0"
|
||||
>
|
||||
{{ inputField.section }}
|
||||
</v-card-title>
|
||||
<v-card-text
|
||||
v-if="inputField.sectionDetails"
|
||||
class="pl-0 mt-0 pt-0"
|
||||
>
|
||||
{{ inputField.sectionDetails }}
|
||||
</v-card-text>
|
||||
{{ inputField.section }}
|
||||
</v-card-title>
|
||||
<v-card-text
|
||||
v-if="inputField.sectionDetails"
|
||||
class="pl-0 mt-0 pt-0"
|
||||
>
|
||||
{{ inputField.sectionDetails }}
|
||||
</v-card-text>
|
||||
|
||||
<!-- Check Box -->
|
||||
<v-checkbox
|
||||
v-if="inputField.type === fieldTypes.BOOLEAN"
|
||||
v-model="model[inputField.varName]"
|
||||
:name="inputField.varName"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
:hint="inputField.hint"
|
||||
:hide-details="!inputField.hint"
|
||||
:persistent-hint="!!inputField.hint"
|
||||
density="comfortable"
|
||||
validate-on="input"
|
||||
>
|
||||
<template #label>
|
||||
<span class="ml-4">
|
||||
<!-- Check Box -->
|
||||
<v-checkbox
|
||||
v-if="inputField.type === fieldTypes.BOOLEAN"
|
||||
v-model="model[inputField.varName]"
|
||||
:name="inputField.varName"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
:hint="inputField.hint"
|
||||
:hide-details="!inputField.hint"
|
||||
:persistent-hint="!!inputField.hint"
|
||||
density="comfortable"
|
||||
@change="emitBlur"
|
||||
>
|
||||
<template #label>
|
||||
<span class="ml-4">
|
||||
{{ inputField.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
|
||||
<!-- Text Field -->
|
||||
<v-text-field
|
||||
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:autofocus="index === 0"
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules as any), ...defaultRules] : []"
|
||||
lazy-validation
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
|
||||
<!-- Text Area -->
|
||||
<v-textarea
|
||||
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
rows="3"
|
||||
auto-grow
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
:rules="[...rulesByKey(inputField.rules as any), ...defaultRules]"
|
||||
lazy-validation
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
|
||||
<!-- Option Select -->
|
||||
<v-select
|
||||
v-else-if="inputField.type === fieldTypes.SELECT"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:items="inputField.options"
|
||||
item-title="text"
|
||||
item-value="text"
|
||||
:return-object="false"
|
||||
:hint="inputField.hint"
|
||||
density="comfortable"
|
||||
persistent-hint
|
||||
lazy-validation
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div
|
||||
v-else-if="inputField.type === fieldTypes.COLOR"
|
||||
class="d-flex"
|
||||
style="width: 100%"
|
||||
>
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ props: templateProps }">
|
||||
<v-btn
|
||||
class="my-2 ml-auto"
|
||||
style="min-width: 200px"
|
||||
:color="model[inputField.varName]"
|
||||
dark
|
||||
v-bind="templateProps"
|
||||
>
|
||||
{{ inputField.label }}
|
||||
</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
<v-color-picker
|
||||
v-model="model[inputField.varName]"
|
||||
value="#7417BE"
|
||||
hide-canvas
|
||||
hide-inputs
|
||||
show-swatches
|
||||
class="mx-auto"
|
||||
@input="emitBlur"
|
||||
/>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<!-- Text Field -->
|
||||
<v-text-field
|
||||
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
|
||||
<!-- Object Type -->
|
||||
<div v-else-if="inputField.type === fieldTypes.OBJECT">
|
||||
<auto-form
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
|
||||
validate-on="input"
|
||||
:color="color"
|
||||
:items="(inputField as any).items"
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Text Area -->
|
||||
<v-textarea
|
||||
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
rows="3"
|
||||
auto-grow
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
|
||||
validate-on="input"
|
||||
/>
|
||||
|
||||
<!-- Option Select -->
|
||||
<v-select
|
||||
v-else-if="inputField.type === fieldTypes.SELECT"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:items="inputField.options"
|
||||
item-title="text"
|
||||
:item-value="inputField.selectReturnValue || 'text'"
|
||||
:return-object="false"
|
||||
:hint="inputField.hint"
|
||||
density="comfortable"
|
||||
persistent-hint
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
|
||||
validate-on="input"
|
||||
/>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<!-- List Type -->
|
||||
<div v-else-if="inputField.type === fieldTypes.LIST">
|
||||
<div
|
||||
v-else-if="inputField.type === fieldTypes.COLOR"
|
||||
class="d-flex"
|
||||
style="width: 100%"
|
||||
v-for="(item, idx) in model[inputField.varName]"
|
||||
:key="idx"
|
||||
>
|
||||
<InputColor v-model="model[inputField.varName]" />
|
||||
<p>
|
||||
{{ inputField.label }} {{ idx + 1 }}
|
||||
<span>
|
||||
<BaseButton
|
||||
class="ml-5"
|
||||
x-small
|
||||
delete
|
||||
@click="removeByIndex(model[inputField.varName], idx)"
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
<v-divider class="mb-5 mx-2" />
|
||||
<auto-form
|
||||
v-model="model[inputField.varName][idx]"
|
||||
:color="color"
|
||||
:items="(inputField as any).items"
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-form>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<BaseButton
|
||||
small
|
||||
@click="model[inputField.varName].push(getTemplate((inputField as any).items))"
|
||||
>
|
||||
{{ $t("general.new") }}
|
||||
</BaseButton>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { validators } from "@/composables/use-validators";
|
||||
import { fieldTypes } from "@/composables/forms";
|
||||
import type { AutoFormItems } from "~/types/auto-forms";
|
||||
|
||||
const BLUR_EVENT = "blur";
|
||||
|
||||
type ValidatorKey = keyof typeof validators;
|
||||
|
||||
// Use defineModel for v-model
|
||||
const model = defineModel<Record<string, any> | any[]>({
|
||||
const modelValue = defineModel<Record<string, any> | any[]>({
|
||||
type: [Object, Array],
|
||||
required: true,
|
||||
});
|
||||
const isValid = defineModel("isValid", { type: Boolean, default: false });
|
||||
|
||||
// alias to avoid template TS complaining about possible undefined
|
||||
const model = modelValue as any;
|
||||
|
||||
const props = defineProps({
|
||||
updateMode: {
|
||||
@@ -145,6 +220,10 @@ const props = defineProps({
|
||||
type: [Number, String],
|
||||
default: "max",
|
||||
},
|
||||
globalRules: {
|
||||
default: null,
|
||||
type: Array as () => string[],
|
||||
},
|
||||
color: {
|
||||
default: null,
|
||||
type: String,
|
||||
@@ -163,6 +242,31 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["blur", "update:modelValue"]);
|
||||
|
||||
function rulesByKey(keys?: ValidatorKey[] | null) {
|
||||
if (keys === undefined || keys === null) {
|
||||
return [] as any[];
|
||||
}
|
||||
|
||||
const list: any[] = [];
|
||||
keys.forEach((key) => {
|
||||
const split = key.split(":");
|
||||
const validatorKey = split[0] as ValidatorKey;
|
||||
if (validatorKey in validators) {
|
||||
if (split.length === 1) {
|
||||
list.push((validators as any)[validatorKey]);
|
||||
}
|
||||
else {
|
||||
list.push((validators as any)[validatorKey](split[1] as any));
|
||||
}
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
const defaultRules = computed<any[]>(() => rulesByKey(props.globalRules as any));
|
||||
|
||||
// Combined state map for readonly and disabled fields
|
||||
const fieldState = computed<Record<string, { readonly: boolean; disabled: boolean }>>(() => {
|
||||
const map: Record<string, { readonly: boolean; disabled: boolean }> = {};
|
||||
@@ -175,6 +279,25 @@ const fieldState = computed<Record<string, { readonly: boolean; disabled: boolea
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
function removeByIndex(list: never[], index: number) {
|
||||
// Removes the item at the index
|
||||
list.splice(index, 1);
|
||||
}
|
||||
|
||||
function getTemplate(item: AutoFormItems) {
|
||||
const obj = {} as { [key: string]: string };
|
||||
|
||||
item.forEach((field) => {
|
||||
obj[field.varName] = "";
|
||||
});
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
function emitBlur() {
|
||||
emit(BLUR_EVENT, modelValue.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
open-on-hover
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
nudge-bottom="6"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
color="accent"
|
||||
variant="elevated"
|
||||
v-bind="activatorProps"
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.cog }}
|
||||
@@ -108,7 +108,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { downloadAsJson } from "~/composables/use-utils";
|
||||
|
||||
export interface TableConfig {
|
||||
@@ -120,7 +120,7 @@ export interface TableHeaders {
|
||||
text: string;
|
||||
value: string;
|
||||
show: boolean;
|
||||
align?: "start" | "center" | "end";
|
||||
align?: string;
|
||||
sortable?: boolean;
|
||||
sort?: (a: any, b: any) => number;
|
||||
}
|
||||
@@ -131,95 +131,106 @@ export interface BulkAction {
|
||||
event: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
tableConfig: {
|
||||
type: Object as () => TableConfig,
|
||||
default: () => ({
|
||||
hideColumns: false,
|
||||
canExport: false,
|
||||
}),
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
tableConfig: {
|
||||
type: Object as () => TableConfig,
|
||||
default: () => ({
|
||||
hideColumns: false,
|
||||
canExport: false,
|
||||
}),
|
||||
},
|
||||
headers: {
|
||||
type: Array as () => TableHeaders[],
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Array as () => any[],
|
||||
required: true,
|
||||
},
|
||||
bulkActions: {
|
||||
type: Array as () => BulkAction[],
|
||||
default: () => [],
|
||||
},
|
||||
initialSort: {
|
||||
type: String,
|
||||
default: "id",
|
||||
},
|
||||
initialSortDesc: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
type: Array as () => TableHeaders[],
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Array as () => any[],
|
||||
required: true,
|
||||
},
|
||||
bulkActions: {
|
||||
type: Array as () => BulkAction[],
|
||||
default: () => [],
|
||||
},
|
||||
initialSort: {
|
||||
type: String,
|
||||
default: "id",
|
||||
},
|
||||
initialSortDesc: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
emits: ["delete-one", "edit-one"],
|
||||
setup(props, context) {
|
||||
const i18n = useI18n();
|
||||
const sortBy = computed(() => [{
|
||||
key: props.initialSort,
|
||||
order: props.initialSortDesc ? "desc" : "asc",
|
||||
}]);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete-one" | "edit-one", item: any): void;
|
||||
(e: "bulk-action", event: string, items: any[]): void;
|
||||
}>();
|
||||
// ===========================================================
|
||||
// Reactive Headers
|
||||
// Create a local reactive copy of headers that we can modify
|
||||
const localHeaders = ref([...props.headers]);
|
||||
|
||||
const i18n = useI18n();
|
||||
const sortBy = computed<{ key: string; order: "asc" | "desc" }[]>(() => [{
|
||||
key: props.initialSort,
|
||||
order: props.initialSortDesc ? "desc" : "asc",
|
||||
}]);
|
||||
// Watch for changes in props.headers and update local copy
|
||||
watch(() => props.headers, (newHeaders) => {
|
||||
localHeaders.value = [...newHeaders];
|
||||
}, { deep: true });
|
||||
|
||||
// ===========================================================
|
||||
// Reactive Headers
|
||||
// Create a local reactive copy of headers that we can modify
|
||||
const localHeaders = ref([...props.headers]);
|
||||
const filteredHeaders = computed<string[]>(() => {
|
||||
return localHeaders.value.filter(header => header.show).map(header => header.value);
|
||||
});
|
||||
|
||||
// Watch for changes in props.headers and update local copy
|
||||
watch(() => props.headers, (newHeaders) => {
|
||||
localHeaders.value = [...newHeaders];
|
||||
}, { deep: true });
|
||||
const headersWithoutActions = computed(() =>
|
||||
localHeaders.value
|
||||
.filter(header => filteredHeaders.value.includes(header.value))
|
||||
.map(header => ({
|
||||
...header,
|
||||
title: i18n.t(header.text),
|
||||
})),
|
||||
);
|
||||
|
||||
const filteredHeaders = computed<string[]>(() => {
|
||||
return localHeaders.value.filter(header => header.show).map(header => header.value);
|
||||
});
|
||||
const activeHeaders = computed(() => [
|
||||
...headersWithoutActions.value,
|
||||
{ title: "", value: "actions", show: true, align: "end" },
|
||||
]);
|
||||
|
||||
const headersWithoutActions = computed(() =>
|
||||
localHeaders.value
|
||||
.filter(header => filteredHeaders.value.includes(header.value))
|
||||
.map(header => ({
|
||||
...header,
|
||||
title: i18n.t(header.text),
|
||||
})),
|
||||
);
|
||||
const selected = ref<any[]>([]);
|
||||
|
||||
const activeHeaders = computed(() => [
|
||||
...headersWithoutActions.value,
|
||||
{ title: "", value: "actions", show: true, align: "end" },
|
||||
]);
|
||||
// ===========================================================
|
||||
// Bulk Action Event Handler
|
||||
|
||||
const selected = ref<any[]>([]);
|
||||
const bulkActionListener = computed(() => {
|
||||
const handlers: { [key: string]: () => void } = {};
|
||||
|
||||
// ===========================================================
|
||||
// Bulk Action Event Handler
|
||||
props.bulkActions.forEach((action) => {
|
||||
handlers[action.event] = () => {
|
||||
context.emit(action.event, selected.value);
|
||||
// clear selection
|
||||
selected.value = [];
|
||||
};
|
||||
});
|
||||
|
||||
const bulkActionListener = computed(() => {
|
||||
const handlers: { [key: string]: () => void } = {};
|
||||
return handlers;
|
||||
});
|
||||
|
||||
props.bulkActions.forEach((action) => {
|
||||
handlers[action.event] = () => {
|
||||
emit("bulk-action", action.event, selected.value);
|
||||
// clear selection
|
||||
selected.value = [];
|
||||
const search = ref("");
|
||||
|
||||
return {
|
||||
sortBy,
|
||||
selected,
|
||||
localHeaders,
|
||||
filteredHeaders,
|
||||
headersWithoutActions,
|
||||
activeHeaders,
|
||||
bulkActionListener,
|
||||
search,
|
||||
downloadAsJson,
|
||||
};
|
||||
});
|
||||
|
||||
return handlers;
|
||||
},
|
||||
});
|
||||
|
||||
const search = ref("");
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
v-model:search="searchInput"
|
||||
item-title="name"
|
||||
return-object
|
||||
:items="filteredItems"
|
||||
:items="items"
|
||||
:custom-filter="normalizeFilter"
|
||||
:prepend-icon="icon || $globals.icons.tags"
|
||||
auto-select-first
|
||||
clearable
|
||||
color="primary"
|
||||
hide-details
|
||||
:custom-filter="() => true"
|
||||
@keyup.enter="emitCreate"
|
||||
>
|
||||
<template
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
import { useSearch } from "~/composables/use-search";
|
||||
import { normalizeFilter } from "~/composables/use-utils";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
@@ -85,10 +85,7 @@ export default defineNuxtComponent({
|
||||
emits: ["update:modelValue", "update:item-id", "create"],
|
||||
setup(props, context) {
|
||||
const autocompleteRef = ref<HTMLInputElement>();
|
||||
|
||||
// Use the search composable
|
||||
const { search: searchInput, filtered: filteredItems } = useSearch(computed(() => props.items));
|
||||
|
||||
const searchInput = ref("");
|
||||
const itemIdVal = computed({
|
||||
get: () => {
|
||||
return props.itemId || undefined;
|
||||
@@ -126,8 +123,8 @@ export default defineNuxtComponent({
|
||||
itemVal,
|
||||
itemIdVal,
|
||||
searchInput,
|
||||
filteredItems,
|
||||
emitCreate,
|
||||
normalizeFilter,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,10 +7,6 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
enum DOMPurifyHook {
|
||||
UponSanitizeAttribute = "uponSanitizeAttribute",
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
source: {
|
||||
@@ -19,26 +15,14 @@ export default defineNuxtComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const ALLOWED_STYLE_TAGS = [
|
||||
"background-color", "color", "font-style", "font-weight", "text-decoration", "text-align",
|
||||
];
|
||||
|
||||
function sanitizeMarkdown(rawHtml: string | null | undefined): string {
|
||||
if (!rawHtml) {
|
||||
return "";
|
||||
}
|
||||
|
||||
DOMPurify.addHook(DOMPurifyHook.UponSanitizeAttribute, (node, data) => {
|
||||
if (data.attrName === "style") {
|
||||
const styles = data.attrValue.split(";").filter((style) => {
|
||||
const [property] = style.split(":");
|
||||
return ALLOWED_STYLE_TAGS.includes(property.trim().toLowerCase());
|
||||
});
|
||||
data.attrValue = styles.join(";");
|
||||
}
|
||||
});
|
||||
|
||||
const sanitized = DOMPurify.sanitize(rawHtml, {
|
||||
// List based on
|
||||
// https://support.zendesk.com/hc/en-us/articles/4408824584602-Allowing-unsafe-HTML-in-help-center-articles
|
||||
ALLOWED_TAGS: [
|
||||
"strong", "em", "b", "i", "u", "p", "code", "pre", "samp", "kbd", "var", "sub", "sup", "dfn", "cite",
|
||||
"small", "address", "hr", "br", "id", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
@@ -47,14 +31,10 @@ export default defineNuxtComponent({
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
"href", "src", "alt", "height", "width", "class", "allow", "title", "allowfullscreen", "frameborder",
|
||||
"scrolling", "cite", "datetime", "name", "abbr", "target", "border", "start", "style",
|
||||
"scrolling", "cite", "datetime", "name", "abbr", "target", "border", "start",
|
||||
],
|
||||
});
|
||||
|
||||
Object.values(DOMPurifyHook).forEach((hook) => {
|
||||
DOMPurify.removeHook(hook);
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export const fieldTypes = {
|
||||
TEXT: "text",
|
||||
TEXT_AREA: "textarea",
|
||||
LIST: "list",
|
||||
SELECT: "select",
|
||||
OBJECT: "object",
|
||||
BOOLEAN: "boolean",
|
||||
PASSWORD: "password",
|
||||
COLOR: "color",
|
||||
PASSWORD: "password",
|
||||
} as const;
|
||||
|
||||
@@ -165,14 +165,14 @@ export function clearPageState(slug: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* usePageUser provides a wrapper around auth that provides a type-safe way to
|
||||
* usePageUser provides a wrapper around $auth that provides a type-safe way to
|
||||
* access the UserOut type from the context. If no user is logged in then an empty
|
||||
* object with all properties set to their zero value is returned.
|
||||
*/
|
||||
export function usePageUser(): { user: UserOut } {
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
|
||||
if (!auth.user.value) {
|
||||
if (!$auth.user.value) {
|
||||
return {
|
||||
user: {
|
||||
id: "",
|
||||
@@ -188,5 +188,5 @@ export function usePageUser(): { user: UserOut } {
|
||||
};
|
||||
}
|
||||
|
||||
return { user: auth.user.value };
|
||||
return { user: $auth.user.value };
|
||||
}
|
||||
|
||||
@@ -1,82 +1,60 @@
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { useExtractIngredientReferences } from "./use-extract-ingredient-references";
|
||||
import { useLocales } from "../use-locales";
|
||||
|
||||
vi.mock("../use-locales");
|
||||
|
||||
const punctuationMarks = ["*", "?", "/", "!", "**", "&", "."];
|
||||
|
||||
describe("test use extract ingredient references", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
|
||||
} as any);
|
||||
});
|
||||
|
||||
test("when text empty return empty", () => {
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "");
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "", true);
|
||||
expect(result).toStrictEqual(new Set());
|
||||
});
|
||||
|
||||
test("when and ingredient matches exactly and has a reference id, return the referenceId", () => {
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion");
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion", true);
|
||||
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test.each(punctuationMarks)("when ingredient is suffixed by punctuation, return the referenceId", (suffix) => {
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix);
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix, true);
|
||||
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test.each(punctuationMarks)("when ingredient is prefixed by punctuation, return the referenceId", (prefix) => {
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion");
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion", true);
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test("when ingredient is first on a multiline, return the referenceId", () => {
|
||||
const multilineSting = "lksjdlk\nOnion";
|
||||
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting);
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting, true);
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test("when the ingredient matches partially exactly and has a reference id, return the referenceId", () => {
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions");
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions", true);
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test("when the ingredient matches with different casing and has a reference id, return the referenceId", () => {
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions");
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions", true);
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test("when no ingredients, return empty", () => {
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([], [], "A sentence containing oNions");
|
||||
const result = useExtractIngredientReferences([], [], "A sentence containing oNions", true);
|
||||
expect(result).toEqual(new Set());
|
||||
});
|
||||
|
||||
test("when and ingredient matches but in the existing referenceIds, do not return the referenceId", () => {
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion");
|
||||
const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion", true);
|
||||
|
||||
expect(result).toEqual(new Set());
|
||||
});
|
||||
|
||||
test("when an word is 2 letter of shorter, it is ignored", () => {
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On");
|
||||
const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On", true);
|
||||
|
||||
expect(result).toEqual(new Set());
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
|
||||
function normalize(word: string): string {
|
||||
let normalizing = word;
|
||||
@@ -18,6 +18,11 @@ function removeStartingPunctuation(word: string): string {
|
||||
return word.replace(punctuationAtBeginning, "");
|
||||
}
|
||||
|
||||
function ingredientMatchesWord(ingredient: RecipeIngredient, word: string) {
|
||||
const searchText = parseIngredientText(ingredient);
|
||||
return searchText.toLowerCase().includes(word.toLowerCase());
|
||||
}
|
||||
|
||||
function isBlackListedWord(word: string) {
|
||||
// Ignore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
|
||||
// other common phrases trigger false positives and I'm not sure how else to approach this. In the future I maybe look at looking directly
|
||||
@@ -34,33 +39,20 @@ function isBlackListedWord(word: string) {
|
||||
return blackListedText.includes(word) || word.match(blackListedRegexMatch);
|
||||
}
|
||||
|
||||
export function useExtractIngredientReferences() {
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
export function useExtractIngredientReferences(recipeIngredients: RecipeIngredient[], activeRefs: string[], text: string): Set<string> {
|
||||
const availableIngredients = recipeIngredients
|
||||
.filter(ingredient => ingredient.referenceId !== undefined)
|
||||
.filter(ingredient => !activeRefs.includes(ingredient.referenceId as string));
|
||||
|
||||
function extractIngredientReferences(recipeIngredients: RecipeIngredient[], activeRefs: string[], text: string): Set<string> {
|
||||
function ingredientMatchesWord(ingredient: RecipeIngredient, word: string) {
|
||||
const searchText = parseIngredientText(ingredient);
|
||||
return searchText.toLowerCase().includes(word.toLowerCase());
|
||||
}
|
||||
const allMatchedIngredientIds: string[] = text
|
||||
.toLowerCase()
|
||||
.split(/\s/)
|
||||
.map(normalize)
|
||||
.filter(word => word.length > 2)
|
||||
.filter(word => !isBlackListedWord(word))
|
||||
.flatMap(word => availableIngredients.filter(ingredient => ingredientMatchesWord(ingredient, word)))
|
||||
.map(ingredient => ingredient.referenceId as string);
|
||||
// deduplicate
|
||||
|
||||
const availableIngredients = recipeIngredients
|
||||
.filter(ingredient => ingredient.referenceId !== undefined)
|
||||
.filter(ingredient => !activeRefs.includes(ingredient.referenceId as string));
|
||||
|
||||
const allMatchedIngredientIds: string[] = text
|
||||
.toLowerCase()
|
||||
.split(/\s/)
|
||||
.map(normalize)
|
||||
.filter(word => word.length > 2)
|
||||
.filter(word => !isBlackListedWord(word))
|
||||
.flatMap(word => availableIngredients.filter(ingredient => ingredientMatchesWord(ingredient, word)))
|
||||
.map(ingredient => ingredient.referenceId as string);
|
||||
// deduplicate
|
||||
|
||||
return new Set<string>(allMatchedIngredientIds);
|
||||
}
|
||||
|
||||
return {
|
||||
extractIngredientReferences,
|
||||
};
|
||||
return new Set<string>(allMatchedIngredientIds);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { useFraction } from "./use-fraction";
|
||||
export { useRecipe } from "./use-recipe";
|
||||
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes";
|
||||
export { useIngredientTextParser } from "./use-recipe-ingredients";
|
||||
export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients";
|
||||
export { useNutritionLabels } from "./use-recipe-nutrition";
|
||||
export { useTools } from "./use-recipe-tools";
|
||||
export { useRecipePermissions } from "./use-recipe-permissions";
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
import { useIngredientTextParser } from "./use-recipe-ingredients";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { parseIngredientText } from "./use-recipe-ingredients";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { useLocales } from "../use-locales";
|
||||
|
||||
vi.mock("../use-locales");
|
||||
|
||||
let parseIngredientText: (ingredient: RecipeIngredient, scale?: number, includeFormating?: boolean) => string;
|
||||
|
||||
describe("parseIngredientText", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "always" },
|
||||
} as any);
|
||||
({ parseIngredientText } = useIngredientTextParser());
|
||||
});
|
||||
|
||||
describe(parseIngredientText.name, () => {
|
||||
const createRecipeIngredient = (overrides: Partial<RecipeIngredient>): RecipeIngredient => ({
|
||||
quantity: 1,
|
||||
food: {
|
||||
@@ -141,98 +128,4 @@ describe("parseIngredientText", () => {
|
||||
|
||||
expect(parseIngredientText(ingredient, 2)).toEqual("2 tablespoons diced onions");
|
||||
});
|
||||
|
||||
test("plural handling: 'always' strategy uses plural food with unit", () => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "always" },
|
||||
} as any);
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onions");
|
||||
});
|
||||
|
||||
test("plural handling: 'never' strategy never uses plural food", () => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "never" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "never" },
|
||||
} as any);
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion");
|
||||
});
|
||||
|
||||
test("plural handling: 'without-unit' strategy uses plural food without unit", () => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
|
||||
} as any);
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
unit: undefined,
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 diced onions");
|
||||
});
|
||||
|
||||
test("plural handling: 'without-unit' strategy uses singular food with unit", () => {
|
||||
vi.mocked(useLocales).mockReturnValue({
|
||||
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
|
||||
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
|
||||
} as any);
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 2,
|
||||
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion");
|
||||
});
|
||||
|
||||
test("decimal below minimum precision shows < 0.001", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 0.0001,
|
||||
unit: { id: "1", name: "cup", useAbbreviation: false },
|
||||
food: { id: "1", name: "salt" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("< 0.001 cup salt");
|
||||
});
|
||||
|
||||
test("fraction below minimum denominator shows < 1/10", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 0.05,
|
||||
unit: { id: "1", name: "cup", fraction: true, useAbbreviation: false },
|
||||
food: { id: "1", name: "salt" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("< <sup>1</sup><span>⁄</span><sub>10</sub> cup salt");
|
||||
});
|
||||
|
||||
test("fraction below minimum denominator without formatting shows < 1/10", () => {
|
||||
const ingredient = createRecipeIngredient({
|
||||
quantity: 0.05,
|
||||
unit: { id: "1", name: "cup", fraction: true, useAbbreviation: false },
|
||||
food: { id: "1", name: "salt" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient, 1, false)).toEqual("< 1/10 cup salt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { useFraction } from "./use-fraction";
|
||||
import { useLocales } from "../use-locales";
|
||||
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
|
||||
const { frac } = useFraction();
|
||||
|
||||
const FRAC_MIN_DENOM = 10;
|
||||
const DECIMAL_PRECISION = 3;
|
||||
|
||||
export function sanitizeIngredientHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
@@ -60,90 +56,47 @@ type ParsedIngredientText = {
|
||||
recipeLink?: string;
|
||||
};
|
||||
|
||||
function shouldUsePluralFood(quantity: number, hasUnit: boolean, pluralFoodHandling: string): boolean {
|
||||
if (quantity && quantity <= 1) {
|
||||
return false;
|
||||
}
|
||||
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
|
||||
const { quantity, food, unit, note, referencedRecipe } = ingredient;
|
||||
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
|
||||
const usePluralFood = (!quantity) || quantity * scale > 1;
|
||||
|
||||
switch (pluralFoodHandling) {
|
||||
case "always":
|
||||
return true;
|
||||
case "without-unit":
|
||||
return !(quantity && hasUnit);
|
||||
case "never":
|
||||
return false;
|
||||
let returnQty = "";
|
||||
|
||||
default:
|
||||
// same as without-unit
|
||||
return !(quantity && hasUnit);
|
||||
}
|
||||
}
|
||||
|
||||
export function useIngredientTextParser() {
|
||||
const { locales, locale } = useLocales();
|
||||
|
||||
function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
|
||||
const filteredLocales = locales.filter(lc => lc.value === locale.value);
|
||||
const pluralFoodHandling = filteredLocales.length ? filteredLocales[0].pluralFoodHandling : "without-unit";
|
||||
|
||||
const { quantity, food, unit, note, referencedRecipe } = ingredient;
|
||||
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
|
||||
const usePluralFood = shouldUsePluralFood((quantity || 0) * scale, !!unit, pluralFoodHandling);
|
||||
|
||||
let returnQty = "";
|
||||
|
||||
// casting to number is required as sometimes quantity is a string
|
||||
if (quantity && Number(quantity) !== 0) {
|
||||
const scaledQuantity = Number((quantity * scale));
|
||||
|
||||
if (unit && !unit.fraction) {
|
||||
const minVal = 10 ** -DECIMAL_PRECISION;
|
||||
returnQty = scaledQuantity >= minVal
|
||||
? Number(scaledQuantity.toPrecision(DECIMAL_PRECISION)).toString()
|
||||
: `< ${minVal}`;
|
||||
// casting to number is required as sometimes quantity is a string
|
||||
if (quantity && Number(quantity) !== 0) {
|
||||
if (unit && !unit.fraction) {
|
||||
returnQty = Number((quantity * scale).toPrecision(3)).toString();
|
||||
}
|
||||
else {
|
||||
const fraction = frac(quantity * scale, 10, true);
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
returnQty += fraction[0];
|
||||
}
|
||||
else {
|
||||
const minVal = 1 / FRAC_MIN_DENOM;
|
||||
const isUnderMinVal = !(scaledQuantity >= minVal);
|
||||
|
||||
const fraction = !isUnderMinVal ? frac(scaledQuantity, FRAC_MIN_DENOM, true) : [0, 1, FRAC_MIN_DENOM];
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
returnQty += fraction[0];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
returnQty += includeFormating
|
||||
? `<sup>${fraction[1]}</sup><span>⁄</span><sub>${fraction[2]}</sub>`
|
||||
: ` ${fraction[1]}/${fraction[2]}`;
|
||||
}
|
||||
|
||||
if (isUnderMinVal) {
|
||||
returnQty = `< ${returnQty}`;
|
||||
}
|
||||
if (fraction[1] > 0) {
|
||||
returnQty += includeFormating
|
||||
? `<sup>${fraction[1]}</sup><span>⁄</span><sub>${fraction[2]}</sub>`
|
||||
: ` ${fraction[1]}/${fraction[2]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unitName = useUnitName(unit || undefined, usePluralUnit);
|
||||
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
|
||||
|
||||
return {
|
||||
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
|
||||
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
|
||||
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
|
||||
note: note ? sanitizeIngredientHTML(note) : undefined,
|
||||
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
|
||||
};
|
||||
};
|
||||
|
||||
function parseIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true): string {
|
||||
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, scale, includeFormating);
|
||||
|
||||
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
|
||||
return sanitizeIngredientHTML(text);
|
||||
};
|
||||
const unitName = useUnitName(unit || undefined, usePluralUnit);
|
||||
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
|
||||
|
||||
return {
|
||||
useParsedIngredientText,
|
||||
parseIngredientText,
|
||||
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
|
||||
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
|
||||
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
|
||||
note: note ? sanitizeIngredientHTML(note) : undefined,
|
||||
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true): string {
|
||||
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, scale, includeFormating);
|
||||
|
||||
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
|
||||
return sanitizeIngredientHTML(text);
|
||||
}
|
||||
|
||||
@@ -5,293 +5,251 @@ export const LOCALES = [
|
||||
value: "zh-TW",
|
||||
progress: 9,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "简体中文 (Chinese simplified)",
|
||||
value: "zh-CN",
|
||||
progress: 38,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "Tiếng Việt (Vietnamese)",
|
||||
value: "vi-VN",
|
||||
progress: 2,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "Українська (Ukrainian)",
|
||||
value: "uk-UA",
|
||||
progress: 83,
|
||||
progress: 100,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Türkçe (Turkish)",
|
||||
value: "tr-TR",
|
||||
progress: 40,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "Svenska (Swedish)",
|
||||
value: "sv-SE",
|
||||
progress: 61,
|
||||
progress: 68,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "српски (Serbian)",
|
||||
value: "sr-SP",
|
||||
progress: 16,
|
||||
progress: 9,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Slovenščina (Slovenian)",
|
||||
value: "sl-SI",
|
||||
progress: 40,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Slovenčina (Slovak)",
|
||||
value: "sk-SK",
|
||||
progress: 47,
|
||||
progress: 46,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Pусский (Russian)",
|
||||
value: "ru-RU",
|
||||
progress: 44,
|
||||
progress: 46,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Română (Romanian)",
|
||||
value: "ro-RO",
|
||||
progress: 44,
|
||||
progress: 45,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Português (Portuguese)",
|
||||
value: "pt-PT",
|
||||
progress: 39,
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Português do Brasil (Brazilian Portuguese)",
|
||||
value: "pt-BR",
|
||||
progress: 46,
|
||||
progress: 49,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Polski (Polish)",
|
||||
value: "pl-PL",
|
||||
progress: 49,
|
||||
progress: 53,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Norsk (Norwegian)",
|
||||
value: "no-NO",
|
||||
progress: 42,
|
||||
progress: 43,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Nederlands (Dutch)",
|
||||
value: "nl-NL",
|
||||
progress: 60,
|
||||
progress: 59,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Latviešu (Latvian)",
|
||||
value: "lv-LV",
|
||||
progress: 35,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Lietuvių (Lithuanian)",
|
||||
value: "lt-LT",
|
||||
progress: 30,
|
||||
progress: 31,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "한국어 (Korean)",
|
||||
value: "ko-KR",
|
||||
progress: 38,
|
||||
progress: 39,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "日本語 (Japanese)",
|
||||
value: "ja-JP",
|
||||
progress: 36,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "Italiano (Italian)",
|
||||
value: "it-IT",
|
||||
progress: 52,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Íslenska (Icelandic)",
|
||||
value: "is-IS",
|
||||
progress: 43,
|
||||
progress: 46,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Magyar (Hungarian)",
|
||||
value: "hu-HU",
|
||||
progress: 46,
|
||||
progress: 48,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Hrvatski (Croatian)",
|
||||
value: "hr-HR",
|
||||
progress: 30,
|
||||
progress: 29,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "עברית (Hebrew)",
|
||||
value: "he-IL",
|
||||
progress: 64,
|
||||
progress: 72,
|
||||
dir: "rtl",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Galego (Galician)",
|
||||
value: "gl-ES",
|
||||
progress: 38,
|
||||
progress: 39,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Français (French)",
|
||||
value: "fr-FR",
|
||||
progress: 67,
|
||||
progress: 74,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Français canadien (Canadian French)",
|
||||
value: "fr-CA",
|
||||
progress: 83,
|
||||
progress: 99,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Belge (Belgian)",
|
||||
value: "fr-BE",
|
||||
progress: 39,
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Suomi (Finnish)",
|
||||
value: "fi-FI",
|
||||
progress: 40,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Eesti (Estonian)",
|
||||
value: "et-EE",
|
||||
progress: 45,
|
||||
progress: 47,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Español (Spanish)",
|
||||
value: "es-ES",
|
||||
progress: 46,
|
||||
progress: 47,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "American English",
|
||||
value: "en-US",
|
||||
progress: 100,
|
||||
progress: 100.0,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "without-unit",
|
||||
},
|
||||
{
|
||||
name: "British English",
|
||||
value: "en-GB",
|
||||
progress: 42,
|
||||
progress: 45,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "without-unit",
|
||||
},
|
||||
{
|
||||
name: "Ελληνικά (Greek)",
|
||||
value: "el-GR",
|
||||
progress: 41,
|
||||
progress: 42,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Deutsch (German)",
|
||||
value: "de-DE",
|
||||
progress: 85,
|
||||
progress: 98,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Dansk (Danish)",
|
||||
value: "da-DK",
|
||||
progress: 65,
|
||||
progress: 66,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Čeština (Czech)",
|
||||
value: "cs-CZ",
|
||||
progress: 43,
|
||||
progress: 45,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Català (Catalan)",
|
||||
value: "ca-ES",
|
||||
progress: 40,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Български (Bulgarian)",
|
||||
value: "bg-BG",
|
||||
progress: 49,
|
||||
progress: 51,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "العربية (Arabic)",
|
||||
value: "ar-SA",
|
||||
progress: 25,
|
||||
dir: "rtl",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Afrikaans (Afrikaans)",
|
||||
value: "af-ZA",
|
||||
progress: 26,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { LocaleObject } from "@nuxtjs/i18n";
|
||||
import { LOCALES } from "./available-locales";
|
||||
import { useGlobalI18n } from "../use-global-i18n";
|
||||
|
||||
export const useLocales = () => {
|
||||
const i18n = useGlobalI18n();
|
||||
const i18n = useI18n();
|
||||
const { current: vuetifyLocale } = useLocale();
|
||||
|
||||
const locale = computed<LocaleObject["code"]>({
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export const useLoggedInState = function () {
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
|
||||
const loggedIn = computed(() => auth.loggedIn.value);
|
||||
const loggedIn = computed(() => $auth.loggedIn.value);
|
||||
const isOwnGroup = computed(() => {
|
||||
if (!route.params.groupSlug) {
|
||||
return loggedIn.value;
|
||||
}
|
||||
else {
|
||||
return loggedIn.value && auth.user.value?.groupSlug === route.params.groupSlug;
|
||||
return loggedIn.value && $auth.user.value?.groupSlug === route.params.groupSlug;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useRecipeCreatePreferences } from "~/composables/use-users/preferences"
|
||||
|
||||
export interface UseNewRecipeOptionsProps {
|
||||
enableImportKeywords?: boolean;
|
||||
enableImportCategories?: boolean;
|
||||
enableStayInEditMode?: boolean;
|
||||
enableParseRecipe?: boolean;
|
||||
}
|
||||
@@ -10,7 +9,6 @@ export interface UseNewRecipeOptionsProps {
|
||||
export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
|
||||
const {
|
||||
enableImportKeywords = true,
|
||||
enableImportCategories = true,
|
||||
enableStayInEditMode = true,
|
||||
enableParseRecipe = true,
|
||||
} = props;
|
||||
@@ -29,17 +27,6 @@ export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
|
||||
},
|
||||
});
|
||||
|
||||
const importCategories = computed({
|
||||
get() {
|
||||
if (!enableImportCategories) return false;
|
||||
return recipeCreatePreferences.value.importCategories;
|
||||
},
|
||||
set(v: boolean) {
|
||||
if (!enableImportCategories) return;
|
||||
recipeCreatePreferences.value.importCategories = v;
|
||||
},
|
||||
});
|
||||
|
||||
const stayInEditMode = computed({
|
||||
get() {
|
||||
if (!enableStayInEditMode) return false;
|
||||
@@ -84,7 +71,6 @@ export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
|
||||
return {
|
||||
// Computed properties for the checkboxes
|
||||
importKeywordsAsTags,
|
||||
importCategories,
|
||||
stayInEditMode,
|
||||
parseRecipe,
|
||||
|
||||
@@ -93,7 +79,6 @@ export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
|
||||
|
||||
// Props for conditional rendering
|
||||
enableImportKeywords,
|
||||
enableImportCategories,
|
||||
enableStayInEditMode,
|
||||
enableParseRecipe,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, PlaceholderKeyword, RecipeOrganizer, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated";
|
||||
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||
|
||||
export interface FieldLogicalOperator {
|
||||
label: string;
|
||||
@@ -11,11 +11,6 @@ export interface FieldRelationalOperator {
|
||||
value: RelationalKeyword | RelationalOperator;
|
||||
}
|
||||
|
||||
export interface FieldPlaceholderKeyword {
|
||||
label: string;
|
||||
value: PlaceholderKeyword;
|
||||
}
|
||||
|
||||
export interface OrganizerBase {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -27,7 +22,6 @@ export type FieldType
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "relativeDate"
|
||||
| RecipeOrganizer;
|
||||
|
||||
export type FieldValue
|
||||
@@ -47,8 +41,8 @@ export interface FieldDefinition {
|
||||
label: string;
|
||||
type: FieldType;
|
||||
|
||||
// Select/Organizer
|
||||
fieldChoices?: SelectableItem[];
|
||||
// only for select/organizer fields
|
||||
fieldOptions?: SelectableItem[];
|
||||
}
|
||||
|
||||
export interface Field extends FieldDefinition {
|
||||
@@ -56,10 +50,10 @@ export interface Field extends FieldDefinition {
|
||||
logicalOperator?: FieldLogicalOperator;
|
||||
value: FieldValue;
|
||||
relationalOperatorValue: FieldRelationalOperator;
|
||||
relationalOperatorChoices: FieldRelationalOperator[];
|
||||
relationalOperatorOptions: FieldRelationalOperator[];
|
||||
rightParenthesis?: string;
|
||||
|
||||
// Select/Organizer
|
||||
// only for select/organizer fields
|
||||
values: FieldValue[];
|
||||
organizers: OrganizerBase[];
|
||||
}
|
||||
@@ -167,36 +161,6 @@ export function useQueryFilterBuilder() {
|
||||
};
|
||||
});
|
||||
|
||||
const placeholderKeywords = computed<Record<PlaceholderKeyword, FieldPlaceholderKeyword>>(() => {
|
||||
const NOW = {
|
||||
label: "Now",
|
||||
value: "$NOW",
|
||||
} as FieldPlaceholderKeyword;
|
||||
|
||||
return {
|
||||
$NOW: NOW,
|
||||
};
|
||||
});
|
||||
|
||||
const relativeDateRelOps = computed<Record<RelationalKeyword | RelationalOperator, FieldRelationalOperator>>(() => {
|
||||
const ops = { ...relOps.value };
|
||||
|
||||
ops[">="] = { ...relOps.value[">="], label: i18n.t("query-filter.relational-operators.is-newer-than") };
|
||||
ops["<="] = { ...relOps.value["<="], label: i18n.t("query-filter.relational-operators.is-older-than") };
|
||||
|
||||
return ops;
|
||||
});
|
||||
|
||||
function getRelOps(fieldType: FieldType): typeof relOps | typeof relativeDateRelOps {
|
||||
switch (fieldType) {
|
||||
case "relativeDate":
|
||||
return relativeDateRelOps;
|
||||
|
||||
default:
|
||||
return relOps;
|
||||
}
|
||||
}
|
||||
|
||||
function isOrganizerType(type: FieldType): type is Organizer {
|
||||
return (
|
||||
type === Organizer.Category
|
||||
@@ -209,14 +173,10 @@ export function useQueryFilterBuilder() {
|
||||
};
|
||||
|
||||
function getFieldFromFieldDef(field: Field | FieldDefinition, resetValue = false): Field {
|
||||
const updatedField = {
|
||||
logicalOperator: logOps.value.AND,
|
||||
...field,
|
||||
} as Field;
|
||||
|
||||
let operatorChoices: FieldRelationalOperator[];
|
||||
if (updatedField.fieldChoices?.length || isOrganizerType(updatedField.type)) {
|
||||
operatorChoices = [
|
||||
const updatedField = { logicalOperator: logOps.value.AND, ...field } as Field;
|
||||
let operatorOptions: FieldRelationalOperator[];
|
||||
if (updatedField.fieldOptions?.length || isOrganizerType(updatedField.type)) {
|
||||
operatorOptions = [
|
||||
relOps.value["IN"],
|
||||
relOps.value["NOT IN"],
|
||||
relOps.value["CONTAINS ALL"],
|
||||
@@ -225,7 +185,7 @@ export function useQueryFilterBuilder() {
|
||||
else {
|
||||
switch (updatedField.type) {
|
||||
case "string":
|
||||
operatorChoices = [
|
||||
operatorOptions = [
|
||||
relOps.value["="],
|
||||
relOps.value["<>"],
|
||||
relOps.value["LIKE"],
|
||||
@@ -233,7 +193,7 @@ export function useQueryFilterBuilder() {
|
||||
];
|
||||
break;
|
||||
case "number":
|
||||
operatorChoices = [
|
||||
operatorOptions = [
|
||||
relOps.value["="],
|
||||
relOps.value["<>"],
|
||||
relOps.value[">"],
|
||||
@@ -243,10 +203,10 @@ export function useQueryFilterBuilder() {
|
||||
];
|
||||
break;
|
||||
case "boolean":
|
||||
operatorChoices = [relOps.value["="]];
|
||||
operatorOptions = [relOps.value["="]];
|
||||
break;
|
||||
case "date":
|
||||
operatorChoices = [
|
||||
operatorOptions = [
|
||||
relOps.value["="],
|
||||
relOps.value["<>"],
|
||||
relOps.value[">"],
|
||||
@@ -255,20 +215,13 @@ export function useQueryFilterBuilder() {
|
||||
relOps.value["<="],
|
||||
];
|
||||
break;
|
||||
case "relativeDate":
|
||||
operatorChoices = [
|
||||
// "<=" is first since "older than" is the most common operator
|
||||
relativeDateRelOps.value["<="],
|
||||
relativeDateRelOps.value[">="],
|
||||
];
|
||||
break;
|
||||
default:
|
||||
operatorChoices = [relOps.value["="], relOps.value["<>"]];
|
||||
operatorOptions = [relOps.value["="], relOps.value["<>"]];
|
||||
}
|
||||
}
|
||||
updatedField.relationalOperatorChoices = operatorChoices;
|
||||
if (!operatorChoices.includes(updatedField.relationalOperatorValue)) {
|
||||
updatedField.relationalOperatorValue = operatorChoices[0];
|
||||
updatedField.relationalOperatorOptions = operatorOptions;
|
||||
if (!operatorOptions.includes(updatedField.relationalOperatorValue)) {
|
||||
updatedField.relationalOperatorValue = operatorOptions[0];
|
||||
}
|
||||
|
||||
if (resetValue) {
|
||||
@@ -318,7 +271,7 @@ export function useQueryFilterBuilder() {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (field.values?.length) {
|
||||
let val: string;
|
||||
if (field.type === "string" || field.type === "date" || isOrganizerType(field.type)) {
|
||||
@@ -363,8 +316,7 @@ export function useQueryFilterBuilder() {
|
||||
|
||||
return {
|
||||
logOps,
|
||||
placeholderKeywords,
|
||||
getRelOps,
|
||||
relOps,
|
||||
buildQueryFilterString,
|
||||
getFieldFromFieldDef,
|
||||
isOrganizerType,
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
import type { IFuseOptions } from "fuse.js";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
export interface IAlias {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ISearchableItem {
|
||||
id: string;
|
||||
name: string;
|
||||
aliases?: IAlias[] | undefined;
|
||||
}
|
||||
|
||||
interface ISearchItemInternal extends ISearchableItem {
|
||||
aliasesText?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ISearchOptions {
|
||||
debounceMs?: number;
|
||||
maxWaitMs?: number;
|
||||
minSearchLength?: number;
|
||||
fuseOptions?: Partial<IFuseOptions<ISearchItemInternal>>;
|
||||
}
|
||||
|
||||
export function useSearch<T extends ISearchableItem>(
|
||||
items: ComputedRef<T[]> | Ref<T[]> | T[],
|
||||
options: ISearchOptions = {},
|
||||
) {
|
||||
const {
|
||||
debounceMs = 0,
|
||||
maxWaitMs = 1500,
|
||||
minSearchLength = 1,
|
||||
fuseOptions: customFuseOptions = {},
|
||||
} = options;
|
||||
|
||||
// State
|
||||
const search = ref("");
|
||||
const debouncedSearch = shallowRef("");
|
||||
|
||||
// Flatten item aliases to include as searchable text
|
||||
const searchItems = computed(() => {
|
||||
const itemsArray = Array.isArray(items) ? items : items.value;
|
||||
return itemsArray.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
aliasesText: item.aliases ? item.aliases.map(a => a.name).join(" ") : "",
|
||||
} as ISearchItemInternal;
|
||||
});
|
||||
});
|
||||
|
||||
// Default Fuse options
|
||||
const defaultFuseOptions: IFuseOptions<ISearchItemInternal> = {
|
||||
keys: [
|
||||
{ name: "name", weight: 3 },
|
||||
{ name: "pluralName", weight: 3 },
|
||||
{ name: "abbreviation", weight: 2 },
|
||||
{ name: "pluralAbbreviation", weight: 2 },
|
||||
{ name: "aliasesText", weight: 1 },
|
||||
],
|
||||
ignoreLocation: true,
|
||||
shouldSort: true,
|
||||
threshold: 0.3,
|
||||
minMatchCharLength: 1,
|
||||
findAllMatches: false,
|
||||
};
|
||||
|
||||
// Merge custom options with defaults
|
||||
const fuseOptions = computed(() => ({
|
||||
...defaultFuseOptions,
|
||||
...customFuseOptions,
|
||||
}));
|
||||
|
||||
// Debounce search input
|
||||
watchDebounced(
|
||||
() => search.value,
|
||||
(newSearch) => {
|
||||
debouncedSearch.value = newSearch;
|
||||
},
|
||||
{ debounce: debounceMs, maxWait: maxWaitMs, immediate: false },
|
||||
);
|
||||
|
||||
// Initialize Fuse instance
|
||||
const fuse = computed(() => {
|
||||
return new Fuse(searchItems.value || [], fuseOptions.value);
|
||||
});
|
||||
|
||||
// Compute filtered results
|
||||
const filtered = computed(() => {
|
||||
const itemsArray = Array.isArray(items) ? items : items.value;
|
||||
const searchTerm = debouncedSearch.value.trim();
|
||||
|
||||
// If no search query or less than minSearchLength characters, return all items
|
||||
if (!searchTerm || searchTerm.length < minSearchLength) {
|
||||
return itemsArray;
|
||||
}
|
||||
|
||||
if (!itemsArray || itemsArray.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = fuse.value.search(searchTerm);
|
||||
return results.map(result => result.item as T);
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
search.value = "";
|
||||
debouncedSearch.value = "";
|
||||
};
|
||||
|
||||
return {
|
||||
search,
|
||||
debouncedSearch,
|
||||
filtered,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { fieldTypes } from "../forms";
|
||||
import { validators } from "../use-validators";
|
||||
import type { AutoFormItems } from "~/types/auto-forms";
|
||||
|
||||
export const useCommonSettingsForm = () => {
|
||||
@@ -12,7 +11,7 @@ export const useCommonSettingsForm = () => {
|
||||
hint: i18n.t("group.enable-public-access-description"),
|
||||
varName: "makeGroupRecipesPublic",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: [validators.required],
|
||||
rules: ["required"],
|
||||
},
|
||||
{
|
||||
section: i18n.t("data-pages.data-management"),
|
||||
@@ -20,7 +19,7 @@ export const useCommonSettingsForm = () => {
|
||||
hint: i18n.t("user-registration.use-seed-data-description"),
|
||||
varName: "useSeedData",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: [validators.required],
|
||||
rules: ["required"],
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
|
||||
import { ActivityKey } from "~/lib/api/types/activity";
|
||||
import type { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
|
||||
export interface UserPrintPreferences {
|
||||
imagePosition: string;
|
||||
@@ -63,7 +63,6 @@ export interface UserRecipeFinderPreferences {
|
||||
|
||||
export interface UserRecipeCreatePreferences {
|
||||
importKeywordsAsTags: boolean;
|
||||
importCategories: boolean;
|
||||
stayInEditMode: boolean;
|
||||
parseRecipe: boolean;
|
||||
}
|
||||
@@ -234,7 +233,6 @@ export function useRecipeCreatePreferences(): Ref<UserRecipeCreatePreferences> {
|
||||
"recipe-create-preferences",
|
||||
{
|
||||
importKeywordsAsTags: false,
|
||||
importCategories: false,
|
||||
stayInEditMode: false,
|
||||
parseRecipe: true,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { fieldTypes } from "../forms";
|
||||
import { validators } from "../use-validators";
|
||||
import type { AutoFormItems } from "~/types/auto-forms";
|
||||
|
||||
export const useUserForm = () => {
|
||||
@@ -11,26 +10,26 @@ export const useUserForm = () => {
|
||||
label: i18n.t("user.user-name"),
|
||||
varName: "username",
|
||||
type: fieldTypes.TEXT,
|
||||
rules: [validators.required],
|
||||
rules: ["required"],
|
||||
},
|
||||
{
|
||||
label: i18n.t("user.full-name"),
|
||||
varName: "fullName",
|
||||
type: fieldTypes.TEXT,
|
||||
rules: [validators.required],
|
||||
rules: ["required"],
|
||||
},
|
||||
{
|
||||
label: i18n.t("user.email"),
|
||||
varName: "email",
|
||||
type: fieldTypes.TEXT,
|
||||
rules: [validators.required],
|
||||
rules: ["required"],
|
||||
},
|
||||
{
|
||||
label: i18n.t("user.password"),
|
||||
varName: "password",
|
||||
disableUpdate: true,
|
||||
type: fieldTypes.PASSWORD,
|
||||
rules: [validators.required, validators.minLength(8)],
|
||||
rules: ["required", "minLength:8"],
|
||||
},
|
||||
{
|
||||
label: i18n.t("user.authentication-method"),
|
||||
@@ -45,37 +44,37 @@ export const useUserForm = () => {
|
||||
label: i18n.t("user.administrator"),
|
||||
varName: "admin",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: [validators.required],
|
||||
rules: ["required"],
|
||||
},
|
||||
{
|
||||
label: i18n.t("user.user-can-invite-other-to-group"),
|
||||
varName: "canInvite",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: [validators.required],
|
||||
rules: ["required"],
|
||||
},
|
||||
{
|
||||
label: i18n.t("user.user-can-manage-group"),
|
||||
varName: "canManage",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: [validators.required],
|
||||
rules: ["required"],
|
||||
},
|
||||
{
|
||||
label: i18n.t("user.user-can-organize-group-data"),
|
||||
varName: "canOrganize",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: [validators.required],
|
||||
rules: ["required"],
|
||||
},
|
||||
{
|
||||
label: i18n.t("user.user-can-manage-household"),
|
||||
varName: "canManageHousehold",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: [validators.required],
|
||||
rules: ["required"],
|
||||
},
|
||||
{
|
||||
label: i18n.t("user.enable-advanced-features"),
|
||||
varName: "advanced",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: [validators.required],
|
||||
rules: ["required"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ const loading = ref(false);
|
||||
const ready = ref(false);
|
||||
|
||||
export const useUserSelfRatings = function () {
|
||||
const auth = useMealieAuth();
|
||||
const $auth = useMealieAuth();
|
||||
|
||||
async function refreshUserRatings() {
|
||||
if (!auth.user.value || loading.value) {
|
||||
if (!$auth.user.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const useUserSelfRatings = function () {
|
||||
loading.value = true;
|
||||
const api = useUserApi();
|
||||
|
||||
const userId = auth.user.value?.id || "";
|
||||
const userId = $auth.user.value?.id || "";
|
||||
await api.users.setRating(userId, slug, rating, isFavorite);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
@@ -34,9 +34,6 @@ const normalizeLigatures = replaceAllBuilder(new Map([
|
||||
["st", "st"],
|
||||
]));
|
||||
|
||||
/**
|
||||
* @deprecated prefer fuse.js/use-search.ts
|
||||
*/
|
||||
export const normalize = (str: string) => {
|
||||
if (!str) {
|
||||
return "";
|
||||
@@ -48,9 +45,6 @@ export const normalize = (str: string) => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated prefer fuse.js/use-search.ts
|
||||
*/
|
||||
export const normalizeFilter: FilterFunction = (value: string, query: string) => {
|
||||
const normalizedValue = normalize(value);
|
||||
const normalizeQuery = normalize(query);
|
||||
|
||||
@@ -13,10 +13,10 @@ export const validators = {
|
||||
};
|
||||
|
||||
/**
|
||||
* useAsyncValidator us a factory function that returns an async function that
|
||||
* when called will validate the input against the backend database and set the
|
||||
* error messages when applicable to the ref.
|
||||
*/
|
||||
* useAsyncValidator us a factory function that returns an async function that
|
||||
* when called will validate the input against the backend database and set the
|
||||
* error messages when applicable to the ref.
|
||||
*/
|
||||
export const useAsyncValidator = (
|
||||
value: Ref<string>,
|
||||
validatorFunc: (v: string) => Promise<RequestResponse<ValidationResponse>>,
|
||||
|
||||
@@ -212,8 +212,6 @@
|
||||
"upload-file": "Laai dokument op",
|
||||
"created-on-date": "Geskep op: {0}",
|
||||
"unsaved-changes": "You have unsaved changes. Do you want to save before leaving? Okay to save, Cancel to discard changes.",
|
||||
"discard-changes": "Discard Changes",
|
||||
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"clipboard-copy-failure": "Kon nie kopieer na die knipbord toe nie.",
|
||||
"confirm-delete-generic-items": "Is jy seker jy wil die volgende items verwyder?",
|
||||
"organizers": "Organiseerders",
|
||||
@@ -369,9 +367,7 @@
|
||||
"recipe-rules": "Resepreëls",
|
||||
"applies-to-all-days": "Van toepassing op alle dae",
|
||||
"applies-on-days": "Van toepassing op {0}s",
|
||||
"meal-plan-settings": "Maaltydplan verstellings",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"meal-plan-settings": "Maaltydplan verstellings"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Migrasiedata is uitgevee",
|
||||
@@ -644,7 +640,6 @@
|
||||
"scrape-recipe-website-being-blocked": "Website being blocked?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
|
||||
"import-original-keywords-as-tags": "Voer oorspronklike sleutelwoorde as merkers in",
|
||||
"import-original-categories": "Import original categories",
|
||||
"stay-in-edit-mode": "Bly in redigeer modus",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Voer vanaf zip in",
|
||||
@@ -1422,9 +1417,7 @@
|
||||
"is-greater-than": "is greater than",
|
||||
"is-greater-than-or-equal-to": "is greater than or equal to",
|
||||
"is-less-than": "is less than",
|
||||
"is-less-than-or-equal-to": "is less than or equal to",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
"is-less-than-or-equal-to": "is less than or equal to"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "is",
|
||||
@@ -1434,9 +1427,6 @@
|
||||
"contains-all-of": "contains all of",
|
||||
"is-like": "is like",
|
||||
"is-not-like": "is not like"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1445,6 +1435,6 @@
|
||||
"invalid-url": "Must Be A Valid URL",
|
||||
"no-whitespace": "No Whitespace Allowed",
|
||||
"min-length": "Must Be At Least {min} Characters",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
"max-length": "Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,6 @@
|
||||
"upload-file": "تحميل الملف",
|
||||
"created-on-date": "تم الإنشاء في {0}",
|
||||
"unsaved-changes": "لديك تغييرات غير محفوظة. هل تريد الحفظ قبل المغادرة؟ حسنًا للحفظ، قم بإلغاء تجاهل التغييرات.",
|
||||
"discard-changes": "إلغاء التغييرات",
|
||||
"discard-changes-description": "لديك تغييرات غير محفوظة. هل أنت متأكد من أنك تريد تجاهلها؟",
|
||||
"clipboard-copy-failure": "فشل في النسخ إلى الحافظة.",
|
||||
"confirm-delete-generic-items": "هل أنت متأكد أنك تريد حذف المجموعات التالية؟",
|
||||
"organizers": "المنظمون",
|
||||
@@ -369,9 +367,7 @@
|
||||
"recipe-rules": "قواعد الوصفات",
|
||||
"applies-to-all-days": "ينطبق على جميع الأيام",
|
||||
"applies-on-days": "يطبق على أيام {0}",
|
||||
"meal-plan-settings": "إعدادات خِطَّة الوجبات الغذائية",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"meal-plan-settings": "إعدادات خِطَّة الوجبات الغذائية"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "حذف بيانات الهجرة",
|
||||
@@ -644,7 +640,6 @@
|
||||
"scrape-recipe-website-being-blocked": "Website being blocked?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
|
||||
"import-original-keywords-as-tags": "استيراد الكلمات المفتاحية الأصلية كوسوم",
|
||||
"import-original-categories": "Import original categories",
|
||||
"stay-in-edit-mode": "البقاء في وضع التعديل",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "استيراد من ملف Zip",
|
||||
@@ -1422,9 +1417,7 @@
|
||||
"is-greater-than": "أكبر من",
|
||||
"is-greater-than-or-equal-to": "أكبر من أو يساوي",
|
||||
"is-less-than": "أقل من",
|
||||
"is-less-than-or-equal-to": "أقل من أو يساوي",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
"is-less-than-or-equal-to": "أقل من أو يساوي"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "هو",
|
||||
@@ -1434,9 +1427,6 @@
|
||||
"contains-all-of": "يحتوي على كل من",
|
||||
"is-like": "هو مثل",
|
||||
"is-not-like": "ليس مثل"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1445,6 +1435,6 @@
|
||||
"invalid-url": "يجب أن يكون عنوان URL صالحًا",
|
||||
"no-whitespace": "لا يسمح باستخدام المسافات",
|
||||
"min-length": "يجب أن يكون على الأقل {min} أحرف",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
"max-length": "يجب أن يكون على الأكثر {max} أحرف"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,6 @@
|
||||
"upload-file": "Качване на файл",
|
||||
"created-on-date": "Добавена на {0}",
|
||||
"unsaved-changes": "Имате незапазени промени. Желаете ли да ги запазите преди да излезете? Натиснете Ок за запазване и Отказ за отхвърляне на промените.",
|
||||
"discard-changes": "Отхвърляне на промените",
|
||||
"discard-changes-description": "Имате незаписани промени, сигурни ли сте, че искате да ги отмените?",
|
||||
"clipboard-copy-failure": "Линкът към рецептата е копиран в клипборда.",
|
||||
"confirm-delete-generic-items": "Сигурни ли сте, че желаете да изтриете следните елементи?",
|
||||
"organizers": "Органайзер",
|
||||
@@ -369,9 +367,7 @@
|
||||
"recipe-rules": "Правила на рецептата",
|
||||
"applies-to-all-days": "Прилага се за всички дни",
|
||||
"applies-on-days": "Всеки/всяка {0}",
|
||||
"meal-plan-settings": "Настройки на плана за хранене",
|
||||
"add-all-to-list": "Добавяне на всички към списъка за пазаруване",
|
||||
"add-day-to-list": "Добавяне на ден към списъка за пазаруване"
|
||||
"meal-plan-settings": "Настройки на плана за хранене"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Данните за мигриране са премахнати",
|
||||
@@ -644,7 +640,6 @@
|
||||
"scrape-recipe-website-being-blocked": "Блокиран ли е уебсайтът?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Опитайте вместо това да импортирате суровия HTML код.",
|
||||
"import-original-keywords-as-tags": "Добави оригиналните ключови думи като етикети",
|
||||
"import-original-categories": "Импортиране на оригиналните категории",
|
||||
"stay-in-edit-mode": "Остани в режим на редакция",
|
||||
"parse-recipe-ingredients-after-import": "Анализиране на съставките на рецептата след импортиране",
|
||||
"import-from-zip": "Импортирай от Zip",
|
||||
@@ -1422,9 +1417,7 @@
|
||||
"is-greater-than": "е по-голямо от",
|
||||
"is-greater-than-or-equal-to": "е по-голямо от или равно на",
|
||||
"is-less-than": "е по-малко от",
|
||||
"is-less-than-or-equal-to": "e по-малко или равно на",
|
||||
"is-older-than": "е по-стар от",
|
||||
"is-newer-than": "е по-нов от"
|
||||
"is-less-than-or-equal-to": "e по-малко или равно на"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "е",
|
||||
@@ -1434,9 +1427,6 @@
|
||||
"contains-all-of": "съдържа всички от",
|
||||
"is-like": "е като",
|
||||
"is-not-like": "не е като"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "преди дни|преди ден|преди дни"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1445,6 +1435,6 @@
|
||||
"invalid-url": "Линкът трябва да е валиден",
|
||||
"no-whitespace": "Не са позволени интервали",
|
||||
"min-length": "Трябва да съдържа поне {min} знака",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
"max-length": "Трябва да съдържа най-много {max} знака"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,6 @@
|
||||
"upload-file": "Puja un fitxer",
|
||||
"created-on-date": "Creat el: {0}",
|
||||
"unsaved-changes": "Tens canvis que no estan guardats. Vols guardar-los abans de sortir? Clica d'acord per guardar-los o cancel·lar per descartar els canvis.",
|
||||
"discard-changes": "Discard Changes",
|
||||
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"clipboard-copy-failure": "No s'ha pogut copiar al porta-retalls.",
|
||||
"confirm-delete-generic-items": "Are you sure you want to delete the following items?",
|
||||
"organizers": "Organitzadors",
|
||||
@@ -369,9 +367,7 @@
|
||||
"recipe-rules": "Normes per la recepta",
|
||||
"applies-to-all-days": "Aplica a tots els dies",
|
||||
"applies-on-days": "S'aplicarà en {0}s",
|
||||
"meal-plan-settings": "Opcions de planificació de menús",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"meal-plan-settings": "Opcions de planificació de menús"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "S'han suprimit les dades migrades",
|
||||
@@ -644,7 +640,6 @@
|
||||
"scrape-recipe-website-being-blocked": "S'està bloquejant el lloc web?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Prova important l'HTML directament.",
|
||||
"import-original-keywords-as-tags": "Importa les paraules clau originals com a tags",
|
||||
"import-original-categories": "Import original categories",
|
||||
"stay-in-edit-mode": "Segueix en el mode d'edició",
|
||||
"parse-recipe-ingredients-after-import": "Analitza els ingredients de la recepta després d'importar",
|
||||
"import-from-zip": "Importa des d'un ZIP",
|
||||
@@ -1422,9 +1417,7 @@
|
||||
"is-greater-than": "és més gran que",
|
||||
"is-greater-than-or-equal-to": "és més gran o igual a",
|
||||
"is-less-than": "és menys que",
|
||||
"is-less-than-or-equal-to": "és menor o igual a",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
"is-less-than-or-equal-to": "és menor o igual a"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "és",
|
||||
@@ -1434,9 +1427,6 @@
|
||||
"contains-all-of": "conté tots de",
|
||||
"is-like": "és com",
|
||||
"is-not-like": "no és com"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1445,6 +1435,6 @@
|
||||
"invalid-url": "La URL ha de ser vàlida",
|
||||
"no-whitespace": "No es permeten espais en blanc",
|
||||
"min-length": "Ha de tenir almenys {min} caràcters",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
"max-length": "Ha de tenir com a màxim {max} caràcters"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,6 @@
|
||||
"upload-file": "Nahrát soubor",
|
||||
"created-on-date": "Vytvořeno dne: {0}",
|
||||
"unsaved-changes": "Máte neuložené změny. Chcete je uložit před odchodem? Klikněte Okay pro uložení, Cancel pro smazání změn.",
|
||||
"discard-changes": "Zahodit změny",
|
||||
"discard-changes-description": "Máte neuložené změny. Určitě je chcete zahodit?",
|
||||
"clipboard-copy-failure": "Zkopírování do schránky se nezdařilo.",
|
||||
"confirm-delete-generic-items": "Opravdu chcete smazat následující položky?",
|
||||
"organizers": "Organizace",
|
||||
@@ -369,9 +367,7 @@
|
||||
"recipe-rules": "Pravidla receptu",
|
||||
"applies-to-all-days": "Použije se na všechny dny",
|
||||
"applies-on-days": "Platí pro {0}",
|
||||
"meal-plan-settings": "Nastavení jídelníčku",
|
||||
"add-all-to-list": "Přidat vše do seznamu",
|
||||
"add-day-to-list": "Přidat den do seznamu"
|
||||
"meal-plan-settings": "Nastavení jídelníčku"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Data z migrace byla smazána",
|
||||
@@ -644,7 +640,6 @@
|
||||
"scrape-recipe-website-being-blocked": "Webové stránky jsou blokovány?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Zkuste namísto toho importovat raw HTML.",
|
||||
"import-original-keywords-as-tags": "Importovat původní klíčová slova jako štítky",
|
||||
"import-original-categories": "Importovat původní kategorie",
|
||||
"stay-in-edit-mode": "Zůstat v režimu úprav",
|
||||
"parse-recipe-ingredients-after-import": "Po importu analyzovat ingredience receptu",
|
||||
"import-from-zip": "Importovat ze zipu",
|
||||
@@ -1422,9 +1417,7 @@
|
||||
"is-greater-than": "je větší než",
|
||||
"is-greater-than-or-equal-to": "je větší než nebo rovno",
|
||||
"is-less-than": "je menší než",
|
||||
"is-less-than-or-equal-to": "je menší než nebo rovno",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
"is-less-than-or-equal-to": "je menší než nebo rovno"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "je",
|
||||
@@ -1434,9 +1427,6 @@
|
||||
"contains-all-of": "obsahuje všechny z",
|
||||
"is-like": "je jako",
|
||||
"is-not-like": "není jako"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1445,6 +1435,6 @@
|
||||
"invalid-url": "Musí být platná URL adresa",
|
||||
"no-whitespace": "Mezery nejsou povoleny",
|
||||
"min-length": "Musí být alespoň {min} znaků",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
"max-length": "Musí být nejvíce {max} znaků"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,6 @@
|
||||
"upload-file": "Upload fil",
|
||||
"created-on-date": "Oprettet den: {0}",
|
||||
"unsaved-changes": "Du har ændringer som ikke er gemt. Vil du gemme før du forlader? Vælg \"Okay\" for at gemme, eller \"Annullér\" for at kassere ændringer.",
|
||||
"discard-changes": "Kassér ændringer",
|
||||
"discard-changes-description": "Du har ændringer, der ikke er gemt. Er du sikker på, at du vil kassere dem?",
|
||||
"clipboard-copy-failure": "Kopiering til udklipsholderen mislykkedes.",
|
||||
"confirm-delete-generic-items": "Er du sikker på at du ønsker at slette de valgte emner?",
|
||||
"organizers": "Organisatorer",
|
||||
@@ -369,9 +367,7 @@
|
||||
"recipe-rules": "Opskriftsregler",
|
||||
"applies-to-all-days": "Gælder for alle dage",
|
||||
"applies-on-days": "Gælder for {0}e",
|
||||
"meal-plan-settings": "Indstillinger for madplanlægning",
|
||||
"add-all-to-list": "Tilføj alle til liste",
|
||||
"add-day-to-list": "Tilføj dag til liste"
|
||||
"meal-plan-settings": "Indstillinger for madplanlægning"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Migreringsdata fjernet",
|
||||
@@ -644,7 +640,6 @@
|
||||
"scrape-recipe-website-being-blocked": "Bliver hjemmesiden blokeret?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Forsøg at importere den rå HTML i stedet.",
|
||||
"import-original-keywords-as-tags": "Importér originale nøgleord som mærker",
|
||||
"import-original-categories": "Importér originale kategorier",
|
||||
"stay-in-edit-mode": "Bliv i redigeringstilstand",
|
||||
"parse-recipe-ingredients-after-import": "Fortolk opskrift ingredienser efter import",
|
||||
"import-from-zip": "Importer fra zip-fil",
|
||||
@@ -1422,9 +1417,7 @@
|
||||
"is-greater-than": "er større end",
|
||||
"is-greater-than-or-equal-to": "er større end eller lig med (Automatic Translation)",
|
||||
"is-less-than": "er mindre end (Automatic Translation)",
|
||||
"is-less-than-or-equal-to": "er mindre end eller lig med (Automatic Translation)",
|
||||
"is-older-than": "er ældre end",
|
||||
"is-newer-than": "er nyere end"
|
||||
"is-less-than-or-equal-to": "er mindre end eller lig med (Automatic Translation)"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "er",
|
||||
@@ -1434,9 +1427,6 @@
|
||||
"contains-all-of": "indeholder alle af",
|
||||
"is-like": "er ligesom",
|
||||
"is-not-like": "er ikke som"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "dage siden|dag siden|dage siden"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1445,6 +1435,6 @@
|
||||
"invalid-url": "URL'en skal være gyldig",
|
||||
"no-whitespace": "Mellemrum er ikke tilladt",
|
||||
"min-length": "Der skal mindst være {min} tegn",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
"max-length": "Der må højst være {max} tegn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,6 @@
|
||||
"upload-file": "Datei hochladen",
|
||||
"created-on-date": "Erstellt am: {0}",
|
||||
"unsaved-changes": "Du hast ungespeicherte Änderungen. Möchtest du vor dem Verlassen speichern? OK um zu speichern, Cancel um Änderungen zu verwerfen.",
|
||||
"discard-changes": "Änderungen verwerfen",
|
||||
"discard-changes-description": "Du hast ungespeicherte Änderungen. Bist du sicher, dass du sie verwerfen möchtest?",
|
||||
"clipboard-copy-failure": "Fehler beim Kopieren in die Zwischenablage.",
|
||||
"confirm-delete-generic-items": "Bist du dir sicher, dass du die folgenden Einträge löschen möchtest?",
|
||||
"organizers": "Organisieren",
|
||||
@@ -369,9 +367,7 @@
|
||||
"recipe-rules": "Rezeptregeln",
|
||||
"applies-to-all-days": "Gilt an allen Tagen",
|
||||
"applies-on-days": "Gilt {0}s",
|
||||
"meal-plan-settings": "Essensplan Einstellungen",
|
||||
"add-all-to-list": "Alle zur Einkaufsliste hinzufügen",
|
||||
"add-day-to-list": "Tag zur Einkaufsliste hinzufügen"
|
||||
"meal-plan-settings": "Essensplan Einstellungen"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Migrationsdaten entfernt",
|
||||
@@ -644,7 +640,6 @@
|
||||
"scrape-recipe-website-being-blocked": "Die Website wird blockiert?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Versuche stattdessen das reine HTML zu importieren.",
|
||||
"import-original-keywords-as-tags": "Importiere ursprüngliche Stichwörter als Schlagwörter",
|
||||
"import-original-categories": "Importiere ursprüngliche Kategorien",
|
||||
"stay-in-edit-mode": "Im Bearbeitungsmodus bleiben",
|
||||
"parse-recipe-ingredients-after-import": "Zutaten nach dem Import parsen",
|
||||
"import-from-zip": "Von Zip importieren",
|
||||
@@ -1422,9 +1417,7 @@
|
||||
"is-greater-than": "ist größer als",
|
||||
"is-greater-than-or-equal-to": "ist größer gleich",
|
||||
"is-less-than": "ist weniger als",
|
||||
"is-less-than-or-equal-to": "ist kleiner gleich",
|
||||
"is-older-than": "Ist älter als",
|
||||
"is-newer-than": "Ist neuer als"
|
||||
"is-less-than-or-equal-to": "ist kleiner gleich"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "ist",
|
||||
@@ -1434,9 +1427,6 @@
|
||||
"contains-all-of": "enthält alle",
|
||||
"is-like": "ist wie",
|
||||
"is-not-like": "ist nicht wie"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "vor Tagen|vor Tag|vor Tagen"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1445,6 +1435,6 @@
|
||||
"invalid-url": "Muss eine gültige URL sein",
|
||||
"no-whitespace": "Kein Leerzeichen erlaubt",
|
||||
"min-length": "Muss mindestens {min} Zeichen haben",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
"max-length": "Darf mindestens {max} Zeichen haben"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,6 @@
|
||||
"upload-file": "Μεταφόρτωση αρχείου",
|
||||
"created-on-date": "Δημιουργήθηκε στις: {0}",
|
||||
"unsaved-changes": "Εχετε μη αποθηκευμένες αλλαγές. Θέλετε να κάνετε αποθήκευση πριν από την αποχώρηση; Εντάξει για αποθήκευση, Ακυρο για απόρριψη των αλλαγών.",
|
||||
"discard-changes": "Απόρριψη αλλαγών",
|
||||
"discard-changes-description": "Εχετε μη αποθηκευμένες αλλαγές, θέλετε σίγουρα να τις απορρίψετε;",
|
||||
"clipboard-copy-failure": "Η αντιγραφή στο πρόχειρο απέτυχε.",
|
||||
"confirm-delete-generic-items": "Θέλετε σίγουρα να διαγράψετε τα ακόλουθα αντικείμενα;",
|
||||
"organizers": "Οργανωτές",
|
||||
@@ -369,9 +367,7 @@
|
||||
"recipe-rules": "Κανόνες Συνταγής",
|
||||
"applies-to-all-days": "Εφαρμόζεται για όλες τις ημέρες",
|
||||
"applies-on-days": "Εφαρμόζεται κάθε {0}",
|
||||
"meal-plan-settings": "Ρυθμίσεις προγράμματος γευμάτων",
|
||||
"add-all-to-list": "Προσθήκη όλων στη λίστα",
|
||||
"add-day-to-list": "Προσθήκη ημέρας στη λίστα"
|
||||
"meal-plan-settings": "Ρυθμίσεις προγράμματος γευμάτων"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Τα δεδομένα μετεγκατάστασης καταργήθηκαν",
|
||||
@@ -644,7 +640,6 @@
|
||||
"scrape-recipe-website-being-blocked": "Η ιστοσελίδα μπλοκάρεται;",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Δοκιμάστε να εισάγετε τον ακατέργαστο κώδικα HTML.",
|
||||
"import-original-keywords-as-tags": "Εισαγωγή αρχικών λέξεων-κλειδιών ως ετικέτες",
|
||||
"import-original-categories": "Εισαγωγή αρχικών κατηγοριών",
|
||||
"stay-in-edit-mode": "Παραμονή σε λειτουργία επεξεργασίας",
|
||||
"parse-recipe-ingredients-after-import": "Ανάλυση συστατικών συνταγής μετά την εισαγωγή",
|
||||
"import-from-zip": "Εισαγωγή μέσω zip",
|
||||
@@ -1422,9 +1417,7 @@
|
||||
"is-greater-than": "είναι μεγαλύτερο από",
|
||||
"is-greater-than-or-equal-to": "είναι μεγαλύτερο από ή ίσο με",
|
||||
"is-less-than": "είναι μικρότερο από",
|
||||
"is-less-than-or-equal-to": "είναι μικρότερο από ή ίσο με",
|
||||
"is-older-than": "είναι παλαιότερο από",
|
||||
"is-newer-than": "είναι νεότερο από"
|
||||
"is-less-than-or-equal-to": "είναι μικρότερο από ή ίσο με"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "είναι",
|
||||
@@ -1434,9 +1427,6 @@
|
||||
"contains-all-of": "περιέχει όλα τα",
|
||||
"is-like": "είναι όμοιο με",
|
||||
"is-not-like": "δεν είναι όμοιο με"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "ημέρες πριν|ημέρα πριν|ημέρες πριν"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1445,6 +1435,6 @@
|
||||
"invalid-url": "Πρέπει να είναι μια έγκυρη διεύθυνση URL",
|
||||
"no-whitespace": "Δεν επιτρέπονται κενοί χαρακτήρες",
|
||||
"min-length": "Πρέπει να αποτελείται από τουλάχιστον {min} χαρακτήρες",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
"max-length": "Πρέπει να αποτελείται το πολύ από {max} χαρακτήρες"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,6 @@
|
||||
"upload-file": "Upload File",
|
||||
"created-on-date": "Created on: {0}",
|
||||
"unsaved-changes": "You have unsaved changes. Do you want to save before leaving? Okay to save, Cancel to discard changes.",
|
||||
"discard-changes": "Discard Changes",
|
||||
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"clipboard-copy-failure": "Failed to copy to the clipboard.",
|
||||
"confirm-delete-generic-items": "Are you sure you want to delete the following items?",
|
||||
"organizers": "Organisers",
|
||||
@@ -369,9 +367,7 @@
|
||||
"recipe-rules": "Recipe Rules",
|
||||
"applies-to-all-days": "Applies to all days",
|
||||
"applies-on-days": "Applies on {0}s",
|
||||
"meal-plan-settings": "Meal Plan Settings",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"meal-plan-settings": "Meal Plan Settings"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Migration data removed",
|
||||
@@ -644,7 +640,6 @@
|
||||
"scrape-recipe-website-being-blocked": "Website being blocked?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
|
||||
"import-original-keywords-as-tags": "Import original keywords as tags",
|
||||
"import-original-categories": "Import original categories",
|
||||
"stay-in-edit-mode": "Stay in Edit mode",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Import from Zip",
|
||||
@@ -1422,9 +1417,7 @@
|
||||
"is-greater-than": "is greater than",
|
||||
"is-greater-than-or-equal-to": "is greater than or equal to",
|
||||
"is-less-than": "is less than",
|
||||
"is-less-than-or-equal-to": "is less than or equal to",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
"is-less-than-or-equal-to": "is less than or equal to"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "is",
|
||||
@@ -1434,9 +1427,6 @@
|
||||
"contains-all-of": "contains all of",
|
||||
"is-like": "is like",
|
||||
"is-not-like": "is not like"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1445,6 +1435,6 @@
|
||||
"invalid-url": "Must Be A Valid URL",
|
||||
"no-whitespace": "No Whitespace Allowed",
|
||||
"min-length": "Must Be At Least {min} Characters",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
"max-length": "Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,9 +369,7 @@
|
||||
"recipe-rules": "Recipe Rules",
|
||||
"applies-to-all-days": "Applies to all days",
|
||||
"applies-on-days": "Applies on {0}s",
|
||||
"meal-plan-settings": "Meal Plan Settings",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"meal-plan-settings": "Meal Plan Settings"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Migration data removed",
|
||||
@@ -532,6 +530,8 @@
|
||||
"recipe-settings": "Recipe Settings",
|
||||
"recipe-update-failed": "Recipe update failed",
|
||||
"recipe-updated": "Recipe updated",
|
||||
"save-error": "Unable to Save Recipe",
|
||||
"save-error-description": "Your changes have been preserved in the editor. Please fix the issue and try saving again.",
|
||||
"remove-from-favorites": "Remove from Favorites",
|
||||
"remove-section": "Remove Section",
|
||||
"saturated-fat-content": "Saturated fat",
|
||||
@@ -644,7 +644,6 @@
|
||||
"scrape-recipe-website-being-blocked": "Website being blocked?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
|
||||
"import-original-keywords-as-tags": "Import original keywords as tags",
|
||||
"import-original-categories": "Import original categories",
|
||||
"stay-in-edit-mode": "Stay in Edit mode",
|
||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||
"import-from-zip": "Import from Zip",
|
||||
@@ -1422,9 +1421,7 @@
|
||||
"is-greater-than": "is greater than",
|
||||
"is-greater-than-or-equal-to": "is greater than or equal to",
|
||||
"is-less-than": "is less than",
|
||||
"is-less-than-or-equal-to": "is less than or equal to",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
"is-less-than-or-equal-to": "is less than or equal to"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "is",
|
||||
@@ -1434,9 +1431,6 @@
|
||||
"contains-all-of": "contains all of",
|
||||
"is-like": "is like",
|
||||
"is-not-like": "is not like"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1445,6 +1439,6 @@
|
||||
"invalid-url": "Must Be A Valid URL",
|
||||
"no-whitespace": "No Whitespace Allowed",
|
||||
"min-length": "Must Be At Least {min} Characters",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
"max-length": "Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,6 @@
|
||||
"upload-file": "Subir Archivo",
|
||||
"created-on-date": "Creado el {0}",
|
||||
"unsaved-changes": "Tienes cambios sin guardar. ¿Quieres guardar antes de salir? Aceptar para guardar, Cancelar para descartar cambios.",
|
||||
"discard-changes": "Descartar Cambios",
|
||||
"discard-changes-description": "Tiene cambios sin guardar. ¿Está seguro que desea descartarlos?",
|
||||
"clipboard-copy-failure": "No se pudo copiar al portapapeles.",
|
||||
"confirm-delete-generic-items": "¿Estás seguro que quieres eliminar los siguientes elementos?",
|
||||
"organizers": "Organizadores",
|
||||
@@ -369,9 +367,7 @@
|
||||
"recipe-rules": "Reglas de Recetas",
|
||||
"applies-to-all-days": "Aplica para todos los días",
|
||||
"applies-on-days": "Se aplica en {0}s",
|
||||
"meal-plan-settings": "Configuración del Plan de Comidas",
|
||||
"add-all-to-list": "Añadir todos a la lista",
|
||||
"add-day-to-list": "Añadir Día a la Lista"
|
||||
"meal-plan-settings": "Configuración del Plan de Comidas"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Datos de migración eliminados",
|
||||
@@ -644,7 +640,6 @@
|
||||
"scrape-recipe-website-being-blocked": "¿Sitio web bloqueado?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Intenta importar el HTML en bruto.",
|
||||
"import-original-keywords-as-tags": "Importar palabras clave originales como etiquetas",
|
||||
"import-original-categories": "Importar categorías originales",
|
||||
"stay-in-edit-mode": "Permanecer en modo edición",
|
||||
"parse-recipe-ingredients-after-import": "Analizar los ingredientes de la receta después de importarla",
|
||||
"import-from-zip": "Importar desde zip",
|
||||
@@ -1422,9 +1417,7 @@
|
||||
"is-greater-than": "es mayor que",
|
||||
"is-greater-than-or-equal-to": "es mayor que o igual a",
|
||||
"is-less-than": "es menor que",
|
||||
"is-less-than-or-equal-to": "es menor que o igual a",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
"is-less-than-or-equal-to": "es menor que o igual a"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "es",
|
||||
@@ -1434,9 +1427,6 @@
|
||||
"contains-all-of": "contiene todo de",
|
||||
"is-like": "es como",
|
||||
"is-not-like": "no es como"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1445,6 +1435,6 @@
|
||||
"invalid-url": "Debe ser una URL válida",
|
||||
"no-whitespace": "No se permiten espacios en blanco",
|
||||
"min-length": "Debe ser como mínimo {min} caracteres",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
"max-length": "Debe ser como máximo {max} caracteres"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,6 @@
|
||||
"upload-file": "Lae fail üles",
|
||||
"created-on-date": "Loodud: {0}",
|
||||
"unsaved-changes": "Sul on salvestamata muudatusi. Kas sa tahad salvestada enne lehelt lahkumist? Vajuta OK salvestamiseks või Tühista, et muudatused tühistada.",
|
||||
"discard-changes": "Loobu muudatustest",
|
||||
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"clipboard-copy-failure": "Lõikepuhvrisse kopeerimine ebaõnnestus.",
|
||||
"confirm-delete-generic-items": "Kas oled kindel, et tahad kustutada järgnevad asjad?",
|
||||
"organizers": "Korraldajad",
|
||||
@@ -344,9 +342,9 @@
|
||||
"breakfast": "Hommikusöök",
|
||||
"lunch": "Lõuna",
|
||||
"dinner": "Õhtusöök",
|
||||
"snack": "Snäkk",
|
||||
"drink": "Jook",
|
||||
"dessert": "Magustoit",
|
||||
"snack": "Snack",
|
||||
"drink": "Drink",
|
||||
"dessert": "Dessert",
|
||||
"type-any": "Kõik",
|
||||
"day-any": "Kõik",
|
||||
"editor": "Editor",
|
||||
@@ -369,9 +367,7 @@
|
||||
"recipe-rules": "Retsepti reeglid",
|
||||
"applies-to-all-days": "Kehtib kõikide päevade kohta",
|
||||
"applies-on-days": "Kehtib {0}l",
|
||||
"meal-plan-settings": "Toitumisplaani sätted",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"meal-plan-settings": "Toitumisplaani sätted"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Ületoomiste andmed eemaldatud",
|
||||
@@ -644,7 +640,6 @@
|
||||
"scrape-recipe-website-being-blocked": "Website being blocked?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
|
||||
"import-original-keywords-as-tags": "Impordi originaal võtmesõnad siltidena",
|
||||
"import-original-categories": "Import original categories",
|
||||
"stay-in-edit-mode": "Püsige redigeerimisrežiimis",
|
||||
"parse-recipe-ingredients-after-import": "Tuvasta retsepti koostisosad pärast importimist",
|
||||
"import-from-zip": "Impordi .zip-st",
|
||||
@@ -1422,9 +1417,7 @@
|
||||
"is-greater-than": "on suurem kui",
|
||||
"is-greater-than-or-equal-to": "on suurem või võrdne kui",
|
||||
"is-less-than": "on vähem kui",
|
||||
"is-less-than-or-equal-to": "on väiksem või võrdne kui",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
"is-less-than-or-equal-to": "on väiksem või võrdne kui"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "on",
|
||||
@@ -1434,9 +1427,6 @@
|
||||
"contains-all-of": "sisaldab kõiki",
|
||||
"is-like": "on nagu",
|
||||
"is-not-like": "ei ole nagu"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1445,6 +1435,6 @@
|
||||
"invalid-url": "Must Be A Valid URL",
|
||||
"no-whitespace": "No Whitespace Allowed",
|
||||
"min-length": "Must Be At Least {min} Characters",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
"max-length": "Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,18 @@
|
||||
"api-port": "API-portti",
|
||||
"application-mode": "Sovellustila",
|
||||
"database-type": "Tietokannan tyyppi",
|
||||
"database-url": "Tietokannan URL-osoite",
|
||||
"database-url": "Tietokannan URL",
|
||||
"default-group": "Oletusryhmä",
|
||||
"default-household": "Oletuskotitalous",
|
||||
"demo": "Esittelytila",
|
||||
"demo-status": "Esittelytila",
|
||||
"demo": "Demo",
|
||||
"demo-status": "Demon tila",
|
||||
"development": "Kehitys",
|
||||
"docs": "Dokumentit",
|
||||
"download-log": "Latausloki",
|
||||
"download-recipe-json": "Viimeisin haettu JSON",
|
||||
"github": "GitHub",
|
||||
"log-lines": "Lokirivit",
|
||||
"not-demo": "Ei käytössä",
|
||||
"not-demo": "Ei demotilassa",
|
||||
"portfolio": "Portfolio",
|
||||
"production": "Tuotanto",
|
||||
"support": "Tuki",
|
||||
@@ -138,7 +138,7 @@
|
||||
"print": "Tulosta",
|
||||
"print-preferences": "Tulostusasetukset",
|
||||
"random": "Satunnainen",
|
||||
"rating": "Arvosana",
|
||||
"rating": "Arvio",
|
||||
"recent": "Viimeisimmät",
|
||||
"recipe": "Resepti",
|
||||
"recipes": "Reseptit",
|
||||
@@ -153,7 +153,7 @@
|
||||
"sort": "Järjestä",
|
||||
"sort-ascending": "Järjestä nousevasti",
|
||||
"sort-descending": "Järjestä laskevasti",
|
||||
"sort-alphabetically": "Aakkosjärjestys",
|
||||
"sort-alphabetically": "Aakkosjärjestyksessä",
|
||||
"status": "Tila",
|
||||
"subject": "Aihe",
|
||||
"submit": "Lähetä",
|
||||
@@ -205,15 +205,13 @@
|
||||
"copied-to-clipboard": "Kopioitu leikepöydälle",
|
||||
"your-browser-does-not-support-clipboard": "Selaimesi ei tue leikepöytää",
|
||||
"copied-items-to-clipboard": "Mitään ei kopioitu leikepöydälle|Kohde kopioitu leikepöydälle|{count} kohdetta kopioitu leikepöydälle",
|
||||
"actions": "Toiminnot",
|
||||
"actions": "Toimet",
|
||||
"selected-count": "Valittu {count}",
|
||||
"export-all": "Vie kaikki",
|
||||
"refresh": "Päivitä",
|
||||
"upload-file": "Tuo tiedosto",
|
||||
"created-on-date": "Luotu {0}",
|
||||
"unsaved-changes": "Tallenna muutokset? ”Ok” tallentaa, ”Peruuta” hylkää muutokset.",
|
||||
"discard-changes": "Hylkää muutokset",
|
||||
"discard-changes-description": "Muutoksia ei ole tallennettu. Hylätäänkö muutokset?",
|
||||
"unsaved-changes": "Et ole tallentanut tekemiäsi muutoksia. Tallennetaanko ne? Paina \"ok\" tallentaaksesi ja \"peruuta\", jos et halua tallentaa.",
|
||||
"clipboard-copy-failure": "Kopioiminen leikepöydälle epäonnistui.",
|
||||
"confirm-delete-generic-items": "Haluatko varmasti poistaa seuraavat kohteet?",
|
||||
"organizers": "Järjestäjät",
|
||||
@@ -369,9 +367,7 @@
|
||||
"recipe-rules": "Reseptimääritykset",
|
||||
"applies-to-all-days": "Sovelletaan kaikkiin päiviin",
|
||||
"applies-on-days": "Käytetään {0}",
|
||||
"meal-plan-settings": "Ateriasuunnitelman asetukset",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"meal-plan-settings": "Ateriasuunnitelman asetukset"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Tuodut tiedot poistettu",
|
||||
@@ -644,7 +640,6 @@
|
||||
"scrape-recipe-website-being-blocked": "Onko sivusto estetty?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
|
||||
"import-original-keywords-as-tags": "Tuo alkuperäiset avainsanat tunnisteiksi",
|
||||
"import-original-categories": "Import original categories",
|
||||
"stay-in-edit-mode": "Pysy muokkaustilassa",
|
||||
"parse-recipe-ingredients-after-import": "Jäsennä reseptin ainesosat tuonnin jälkeen",
|
||||
"import-from-zip": "Tuo zip-arkistosta",
|
||||
@@ -712,8 +707,8 @@
|
||||
"toggle-recipe": "Vaihda osio"
|
||||
},
|
||||
"recipe-finder": {
|
||||
"recipe-finder": "Reseptihaku",
|
||||
"recipe-finder-description": "Etsi sopivia reseptejä saatavilla olevien ainesosien perusteella. Voit myös suodattaa tulokset saatavilla olevien keittiövälineiden perusteella, ja asettaa enimmäismäärän puuttuvia ainesosia tai välineitä.",
|
||||
"recipe-finder": "Reseptin etsijä",
|
||||
"recipe-finder-description": "Etsi sopivia reseptejä saatavilla olevien ainesosien perusteella. Voit myös suodattaa tulokset saatavilla olevien ruoanvalmistusvälineiden perusteella, ja asettaa enimmäismäärän puuttuvia ainesosia tai välineitä.",
|
||||
"selected-ingredients": "Valitut ainesosat",
|
||||
"no-ingredients-selected": "Ei valittuja ainesosia",
|
||||
"missing": "Puuttuu",
|
||||
@@ -723,7 +718,7 @@
|
||||
"include-tools-on-hand": "Sisällytä saatavilla olevat välineet",
|
||||
"max-missing-ingredients": "Puuttuvien ainesten enimmäismäärä",
|
||||
"max-missing-tools": "Puuttuvien välineiden enimmäismäärä",
|
||||
"selected-tools": "Valitut keittiövälineet",
|
||||
"selected-tools": "Valitut välineet",
|
||||
"other-filters": "Muut suodattimet",
|
||||
"ready-to-make": "Valmis tekemään",
|
||||
"almost-ready-to-make": "Melkein valmis tekemään"
|
||||
@@ -980,14 +975,14 @@
|
||||
"tag": "Tunniste"
|
||||
},
|
||||
"tool": {
|
||||
"tools": "Keittiövälineet",
|
||||
"on-hand": "Omistan välineen",
|
||||
"create-a-tool": "Lisää keittiöväline",
|
||||
"tool-name": "Keittiöväline",
|
||||
"create-new-tool": "Lisää keittiöväline",
|
||||
"on-hand-checkbox-label": "Näytä keittiövälineeni (valittu)",
|
||||
"required-tools": "Tarvittavat keittiövälineet",
|
||||
"tool": "Keittiöväline"
|
||||
"tools": "Työkalut",
|
||||
"on-hand": "Minulla on tämä työkalu",
|
||||
"create-a-tool": "Luo työkalu",
|
||||
"tool-name": "Työkalun Nimi",
|
||||
"create-new-tool": "Luo Uusi Työkalu",
|
||||
"on-hand-checkbox-label": "Näytä työkalut, jotka omistan jo (valittu)",
|
||||
"required-tools": "Tarvittavat Työkalut",
|
||||
"tool": "Työkalu"
|
||||
},
|
||||
"user": {
|
||||
"admin": "Ylläpitäjä",
|
||||
@@ -1191,9 +1186,9 @@
|
||||
"tag-data": "Tunnisteen tiedot"
|
||||
},
|
||||
"tools": {
|
||||
"new-tool": "Lisää keittiöväline",
|
||||
"edit-tool": "Muokkaa keittiövälinettä",
|
||||
"tool-data": "Keittiövälineen tiedot"
|
||||
"new-tool": "Uusi työkalu",
|
||||
"edit-tool": "Muokkaa työkalua",
|
||||
"tool-data": "Työkalun tiedot"
|
||||
}
|
||||
},
|
||||
"user-registration": {
|
||||
@@ -1239,7 +1234,7 @@
|
||||
"preview-markdown-button-label": "Esikatsele Markdownia"
|
||||
},
|
||||
"demo": {
|
||||
"info_message_with_version": "Mealie on esittelytilassa. Mealien versio: {version}",
|
||||
"info_message_with_version": "Tämä on demo: {version}",
|
||||
"demo_username": "Käyttäjätunnus: {username}",
|
||||
"demo_password": "Salasana: {password}"
|
||||
},
|
||||
@@ -1404,7 +1399,7 @@
|
||||
"filter-options-description": "Kun vaaditaan kaikki on valittu, keittokirja sisältää vain reseptejä, joissa on kaikki valitut tuotteet. Tämä koskee jokaista valitsimien osajoukkoa, ei valittujen kohteiden poikkileikkausta.",
|
||||
"require-all-categories": "Vaadi Kaikki Kategoriat",
|
||||
"require-all-tags": "Vaadi Kaikki Tunnisteet",
|
||||
"require-all-tools": "Kaikki keittiövälineet tulee löytyä",
|
||||
"require-all-tools": "Vaadi Kaikki Työkalut",
|
||||
"cookbook-name": "Keittokirjan Nimi",
|
||||
"cookbook-with-name": "Keittokirja {0}",
|
||||
"household-cookbook-name": "{0} Keittokirja {1}",
|
||||
@@ -1422,9 +1417,7 @@
|
||||
"is-greater-than": "on suurempi kuin",
|
||||
"is-greater-than-or-equal-to": "on suurempi tai yhtäsuuri kuin",
|
||||
"is-less-than": "on vähemmän kuin",
|
||||
"is-less-than-or-equal-to": "on vähemmän tai yhtäsuuri kuin",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
"is-less-than-or-equal-to": "on vähemmän tai yhtäsuuri kuin"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "on",
|
||||
@@ -1434,17 +1427,14 @@
|
||||
"contains-all-of": "sisältää kaikki nämä",
|
||||
"is-like": "on kuin",
|
||||
"is-not-like": "ei ole kuin"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
"required": "Tämä kenttä on pakollinen",
|
||||
"invalid-email": "Sähköpostiosoite ei ole kelvollinen",
|
||||
"invalid-url": "URL ei ole kelvollinen",
|
||||
"no-whitespace": "Tekstissä ei saa olla välilyöntejä",
|
||||
"no-whitespace": "No Whitespace Allowed",
|
||||
"min-length": "Vähimmäispituus on {min} merkkiä",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
"max-length": "Enimmäispituus on {max} merkkiä"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user