mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-26 01:33:12 -05:00
Compare commits
235 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a1798cd88 | ||
|
|
64f47c1589 | ||
|
|
326bb1eb8e | ||
|
|
80dc2ecfb7 | ||
|
|
b72082663f | ||
|
|
f46ae423d3 | ||
|
|
05cdff8ae7 | ||
|
|
0facdf73be | ||
|
|
cbad569134 | ||
|
|
1063433aa9 | ||
|
|
0ba22c81e7 | ||
|
|
0667177a2e | ||
|
|
6fcf22869b | ||
|
|
20b45e57e0 | ||
|
|
7a38a52158 | ||
|
|
e27eca5571 | ||
|
|
a90b2ccafd | ||
|
|
e0d8104643 | ||
|
|
53ee64828b | ||
|
|
6f7fba5ac1 | ||
|
|
89aed15905 | ||
|
|
aac48287a4 | ||
|
|
34daaa0476 | ||
|
|
af56a3e69d | ||
|
|
0908812b47 | ||
|
|
d910fbafe8 | ||
|
|
c7692426d5 | ||
|
|
b7a615add9 | ||
|
|
3167e23b6b | ||
|
|
8b582f8682 | ||
|
|
05f648d7fb | ||
|
|
1f19133870 | ||
|
|
98273da16e | ||
|
|
f857ca18da | ||
|
|
22a0e6d608 | ||
|
|
ed806b9fec | ||
|
|
ae8b489f97 | ||
|
|
71732d4766 | ||
|
|
6695314588 | ||
|
|
c115e6d83f | ||
|
|
e3e970213c | ||
|
|
7fe358e5e7 | ||
|
|
c7f3334479 | ||
|
|
d4467f65fb | ||
|
|
27e61ec6b1 | ||
|
|
6c6dc8103d | ||
|
|
35963dad2e | ||
|
|
acd0c2cb3e | ||
|
|
28d00f7dd5 | ||
|
|
fdd3d4b37a | ||
|
|
b09a85dfab | ||
|
|
b6ceece901 | ||
|
|
54b8760d15 | ||
|
|
187e0300a0 | ||
|
|
c398316b55 | ||
|
|
eb093a755b | ||
|
|
2e982fad82 | ||
|
|
f5570bf9b2 | ||
|
|
ddd7ee0696 | ||
|
|
f1b5b999b9 | ||
|
|
47892f84be | ||
|
|
18002351b6 | ||
|
|
9605c448e7 | ||
|
|
9499c2942c | ||
|
|
f04bd7b777 | ||
|
|
710708ea68 | ||
|
|
bb196da83b | ||
|
|
d500fbf0b4 | ||
|
|
ca94ca973c | ||
|
|
454d1eff1c | ||
|
|
280be88fc5 | ||
|
|
e24c37957b | ||
|
|
46b069ba71 | ||
|
|
2caed5e192 | ||
|
|
406f44e6a7 | ||
|
|
f6787f18ba | ||
|
|
1d64f428db | ||
|
|
77906da9f1 | ||
|
|
35d470f5ea | ||
|
|
d7cdcfa734 | ||
|
|
bfbdf76c2d | ||
|
|
7cc0fafbaa | ||
|
|
5b65ceda93 | ||
|
|
07ecd88685 | ||
|
|
8f1ce1a1c3 | ||
|
|
3146e99b03 | ||
|
|
fe53cc28ba | ||
|
|
d85635997b | ||
|
|
1ca29df52e | ||
|
|
ee5de10ffb | ||
|
|
201ab4b8ac | ||
|
|
45af609161 | ||
|
|
c4a3068492 | ||
|
|
6d4f573526 | ||
|
|
3c14df453e | ||
|
|
9826f3483e | ||
|
|
caf0f5f441 | ||
|
|
b599de9c22 | ||
|
|
fd7aa44c13 | ||
|
|
82b7bacdb7 | ||
|
|
84f86c2682 | ||
|
|
527edb1a92 | ||
|
|
6e11b92e74 | ||
|
|
3f5b25a30e | ||
|
|
662d06b5a8 | ||
|
|
9003d0f1d1 | ||
|
|
1cf7e37ada | ||
|
|
930c92365d | ||
|
|
6f1fee5511 | ||
|
|
f5de126d86 | ||
|
|
725dae41b1 | ||
|
|
39e919526a | ||
|
|
1978ad2c96 | ||
|
|
23e8dc1941 | ||
|
|
96b408a661 | ||
|
|
20a9a94770 | ||
|
|
b280e2d1a0 | ||
|
|
735162d042 | ||
|
|
60d9294861 | ||
|
|
ff42964537 | ||
|
|
bb67d993a0 | ||
|
|
7bb0f0801a | ||
|
|
3a4875a54f | ||
|
|
0371874670 | ||
|
|
3d177566ed | ||
|
|
14e87918fb | ||
|
|
ac75b0254d | ||
|
|
7f2927600b | ||
|
|
5e8c4a6cee | ||
|
|
a460c32674 | ||
|
|
973cd5ab02 | ||
|
|
ac355c1071 | ||
|
|
3a617cd3c3 | ||
|
|
3c874c2f85 | ||
|
|
fb3be73163 | ||
|
|
14b783852e | ||
|
|
75616d66b8 | ||
|
|
01713b0416 | ||
|
|
123a8b99f8 | ||
|
|
6732fcd696 | ||
|
|
5fcbfbf361 | ||
|
|
1318998bc9 | ||
|
|
0947212271 | ||
|
|
92ac5c6253 | ||
|
|
5f96f4b47f | ||
|
|
dbcd430425 | ||
|
|
4c9164594b | ||
|
|
e5a13f8b43 | ||
|
|
726ad10c7e | ||
|
|
df53310f2e | ||
|
|
82bf5c1bae | ||
|
|
c70a63f0ff | ||
|
|
14bfa6bcae | ||
|
|
adbafef157 | ||
|
|
62d52f53e4 | ||
|
|
4370319fec | ||
|
|
15908d190d | ||
|
|
fcb909e072 | ||
|
|
8e532af4d9 | ||
|
|
831cb6dd17 | ||
|
|
089bb24c0f | ||
|
|
107dfc34de | ||
|
|
144d4caea6 | ||
|
|
b3db81b9a4 | ||
|
|
dc2bbdc494 | ||
|
|
8f17a08923 | ||
|
|
f6209bff54 | ||
|
|
33865285d1 | ||
|
|
e226b9b1d5 | ||
|
|
201c63d1e4 | ||
|
|
a242f567ad | ||
|
|
67ead2e8a1 | ||
|
|
7b273b77e2 | ||
|
|
b4cd095360 | ||
|
|
a9bb27c782 | ||
|
|
9df1523911 | ||
|
|
0c8a1ae608 | ||
|
|
7d54404bf0 | ||
|
|
8bbe70d245 | ||
|
|
6c87f7fe33 | ||
|
|
7e168eb75b | ||
|
|
64d481b4fc | ||
|
|
a9926557bc | ||
|
|
2a908c0dd2 | ||
|
|
c64a0dc769 | ||
|
|
7ce9c35ef5 | ||
|
|
0acca2021d | ||
|
|
5de0b48aa9 | ||
|
|
ffe199c083 | ||
|
|
215a18be42 | ||
|
|
a1b065e5d1 | ||
|
|
d660d89a1b | ||
|
|
ade1f797a9 | ||
|
|
192872b9ec | ||
|
|
25ebcb1a05 | ||
|
|
89d95ca5e1 | ||
|
|
b705652af3 | ||
|
|
b0c78de2da | ||
|
|
c4b1f9fd01 | ||
|
|
2b0d8227f4 | ||
|
|
42517e9f8a | ||
|
|
37c97c8aba | ||
|
|
64d36a2608 | ||
|
|
563defe074 | ||
|
|
f5ffb760d3 | ||
|
|
3118a0c0cf | ||
|
|
444beb68f9 | ||
|
|
49a97ebc0e | ||
|
|
6f682b742e | ||
|
|
32d4d22bb8 | ||
|
|
71d86489f4 | ||
|
|
a95eaf3d2e | ||
|
|
414af989e7 | ||
|
|
b7b191a5ee | ||
|
|
5620370ade | ||
|
|
d333d47e34 | ||
|
|
b34b1c9be3 | ||
|
|
8c5010148d | ||
|
|
a17b0e329e | ||
|
|
8ab69a7d7a | ||
|
|
f4ecf74b91 | ||
|
|
ba9d816f64 | ||
|
|
6895b49543 | ||
|
|
fffe7b05e0 | ||
|
|
1271e0e49b | ||
|
|
478054b724 | ||
|
|
57d259a7a3 | ||
|
|
a4a6d4dfb1 | ||
|
|
f7b4f79312 | ||
|
|
434d312f7c | ||
|
|
bda460b49e | ||
|
|
d3e1c48655 | ||
|
|
b2a3430f2c | ||
|
|
3d792d9333 | ||
|
|
2e028d7e12 |
@@ -8,28 +8,13 @@ FROM mcr.microsoft.com/devcontainers/python:${VARIANT}
|
|||||||
ARG NODE_VERSION="none"
|
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
|
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||||
|
|
||||||
# install poetry - respects $POETRY_VERSION & $POETRY_HOME
|
|
||||||
|
|
||||||
RUN echo "export PROMPT_COMMAND='history -a'" >> /home/vscode/.bashrc \
|
RUN echo "export PROMPT_COMMAND='history -a'" >> /home/vscode/.bashrc \
|
||||||
&& echo "export HISTFILE=~/commandhistory/.bash_history" >> /home/vscode/.bashrc \
|
&& echo "export HISTFILE=~/commandhistory/.bash_history" >> /home/vscode/.bashrc \
|
||||||
&& chown vscode:vscode -R /home/vscode/
|
&& chown vscode:vscode -R /home/vscode/
|
||||||
|
|
||||||
RUN npm install -g @go-task/cli
|
RUN npm install -g @go-task/cli
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
# Install additional OS packages
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PIP_NO_CACHE_DIR=off \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=on \
|
|
||||||
PIP_DEFAULT_TIMEOUT=100 \
|
|
||||||
POETRY_HOME="/opt/poetry" \
|
|
||||||
POETRY_VIRTUALENVS_IN_PROJECT=true
|
|
||||||
|
|
||||||
# prepend poetry and venv to path
|
|
||||||
ENV PATH="$POETRY_HOME/bin:$PATH"
|
|
||||||
|
|
||||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
|
||||||
# RUN poetry config virtualenvs.create false
|
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install --no-install-recommends -y \
|
&& apt-get install --no-install-recommends -y \
|
||||||
curl \
|
curl \
|
||||||
@@ -39,5 +24,9 @@ RUN apt-get update \
|
|||||||
libsasl2-dev libldap2-dev libssl-dev \
|
libsasl2-dev libldap2-dev libssl-dev \
|
||||||
gnupg gnupg2 gnupg1
|
gnupg gnupg2 gnupg1
|
||||||
|
|
||||||
# create directory used for Docker Secrets
|
# Install uv
|
||||||
|
RUN pip install uv
|
||||||
|
ENV UV_LINK_MODE=copy
|
||||||
|
|
||||||
|
# Create directory for Docker Secrets
|
||||||
RUN mkdir -p /run/secrets
|
RUN mkdir -p /run/secrets
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
"charliermarsh.ruff",
|
"charliermarsh.ruff",
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"matangover.mypy",
|
"matangover.mypy",
|
||||||
"ms-python.black-formatter",
|
|
||||||
"ms-python.pylint",
|
"ms-python.pylint",
|
||||||
"ms-python.python",
|
"ms-python.python",
|
||||||
"ms-python.vscode-pylance",
|
"ms-python.vscode-pylance",
|
||||||
@@ -48,7 +47,7 @@
|
|||||||
],
|
],
|
||||||
// Use 'onCreateCommand' to run commands at the end of container creation.
|
// Use 'onCreateCommand' to run commands at the end of container creation.
|
||||||
// Use 'postCreateCommand' to run commands after the container is created.
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
"onCreateCommand": "sudo chown -R vscode:vscode /workspaces/mealie/frontend/node_modules /home/vscode/commandhistory && task setup",
|
"onCreateCommand": "sudo chown -R vscode:vscode /workspaces/mealie/frontend/node_modules /home/vscode/commandhistory && task setup --force",
|
||||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||||
"remoteUser": "vscode",
|
"remoteUser": "vscode",
|
||||||
"features": {
|
"features": {
|
||||||
@@ -56,5 +55,8 @@
|
|||||||
"dockerDashComposeVersion": "v2"
|
"dockerDashComposeVersion": "v2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"appPort": 3000
|
"appPort": [
|
||||||
|
"3000:3000",
|
||||||
|
"9000:9000"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
240
.github/copilot-instructions.md
vendored
Normal file
240
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# Mealie Development Guide for AI Agents
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Mealie is a self-hosted recipe manager, meal planner, and shopping list application with a FastAPI backend (Python 3.12) and Nuxt 3 frontend (Vue 3 + TypeScript). It uses SQLAlchemy ORM with support for SQLite and PostgreSQL databases.
|
||||||
|
|
||||||
|
**Development vs Production:**
|
||||||
|
- **Development:** Frontend (port 3000) and backend (port 9000) run as separate processes
|
||||||
|
- **Production:** Frontend is statically generated and served via FastAPI's SPA module (`mealie/routes/spa/`) in a single container
|
||||||
|
|
||||||
|
## Architecture & Key Patterns
|
||||||
|
|
||||||
|
### Backend Architecture (mealie/)
|
||||||
|
|
||||||
|
**Repository-Service-Controller Pattern:**
|
||||||
|
- **Controllers** (`mealie/routes/**/controller_*.py`): Inherit from `BaseUserController` or `BaseAdminController`, handle HTTP concerns, delegate to services
|
||||||
|
- **Services** (`mealie/services/`): Business logic layer, inherit from `BaseService`, coordinate repos and external dependencies
|
||||||
|
- **Repositories** (`mealie/repos/`): Data access layer using SQLAlchemy, accessed via `AllRepositories` factory
|
||||||
|
- Get repos via dependency injection: `repos: AllRepositories = Depends(get_repositories)`
|
||||||
|
- All repos scoped to group/household context automatically
|
||||||
|
|
||||||
|
**Route Organization:**
|
||||||
|
- Routes in `mealie/routes/` organized by domain (auth, recipe, groups, households, admin)
|
||||||
|
- Use `APIRouter` with FastAPI dependency injection
|
||||||
|
- Apply `@router.get/post/put/delete` decorators with Pydantic response models
|
||||||
|
- Route controllers use `HttpRepo` mixin for common CRUD operations (see `mealie/routes/_base/mixins.py`)
|
||||||
|
|
||||||
|
**Schemas & Type Generation:**
|
||||||
|
- Pydantic schemas in `mealie/schema/` with strict separation: `*In`, `*Out`, `*Create`, `*Update` suffixes
|
||||||
|
- Auto-exported from submodules via `__init__.py` files (generated by `task dev:generate`)
|
||||||
|
- TypeScript types auto-generated from Pydantic schemas - **never manually edit** `frontend/lib/api/types/`
|
||||||
|
|
||||||
|
**Database & Sessions:**
|
||||||
|
- Session management via `Depends(generate_session)` in FastAPI routes
|
||||||
|
- Use `session_context()` context manager in services/scripts
|
||||||
|
- SQLAlchemy models in `mealie/db/models/`, migrations in `mealie/alembic/`
|
||||||
|
- Create migrations: `task py:migrate -- "description"`
|
||||||
|
|
||||||
|
### Frontend Architecture (frontend/)
|
||||||
|
|
||||||
|
**Component Organization (strict naming conventions):**
|
||||||
|
- **Domain Components** (`components/Domain/`): Feature-specific, prefix with domain (e.g., `AdminDashboard`)
|
||||||
|
- **Global Components** (`components/global/`): Reusable primitives, prefix with `Base` (e.g., `BaseButton`)
|
||||||
|
- **Layout Components** (`components/Layout/`): Layout-only, prefix with `App` if props or `The` if singleton
|
||||||
|
- **Page Components** (`components/` with page prefix): Last resort for breaking up complex pages
|
||||||
|
|
||||||
|
**API Client Pattern:**
|
||||||
|
- API clients in `frontend/lib/api/` extend `BaseAPI`, `BaseCRUDAPI`, or `BaseCRUDAPIReadOnly`
|
||||||
|
- Types imported from auto-generated `frontend/lib/api/types/` (DO NOT EDIT MANUALLY)
|
||||||
|
- Composables in `frontend/composables/` for shared state and API logic (e.g., `use-mealie-auth.ts`)
|
||||||
|
- Use `useAuthBackend()` for authentication state, `useMealieAuth()` for user management
|
||||||
|
|
||||||
|
**State Management:**
|
||||||
|
- Nuxt 3 composables for state (no Vuex)
|
||||||
|
- Auth state via `use-mealie-auth.ts` composable
|
||||||
|
- Prefer composables over global state stores
|
||||||
|
|
||||||
|
## Essential Commands (via Task/Taskfile.yml)
|
||||||
|
|
||||||
|
**Development workflow:**
|
||||||
|
```bash
|
||||||
|
task setup # Install all dependencies (Python + Node)
|
||||||
|
task dev:services # Start Postgres & Mailpit containers
|
||||||
|
task py # Start FastAPI backend (port 9000)
|
||||||
|
task ui # Start Nuxt frontend (port 3000)
|
||||||
|
task docs # Start MkDocs documentation server
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code generation (REQUIRED after schema changes):**
|
||||||
|
```bash
|
||||||
|
task dev:generate # Generate TypeScript types, schema exports, test helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
**Testing & Quality:**
|
||||||
|
```bash
|
||||||
|
task py:test # Run pytest (supports args: task py:test -- -k test_name)
|
||||||
|
task py:check # Format + lint + type-check + test (full validation)
|
||||||
|
task py:format # Ruff format
|
||||||
|
task py:lint # Ruff check
|
||||||
|
task py:mypy # Type checking
|
||||||
|
task ui:test # Vitest frontend tests
|
||||||
|
task ui:check # Frontend lint + test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database:**
|
||||||
|
```bash
|
||||||
|
task py:migrate -- "description" # Generate Alembic migration
|
||||||
|
task py:postgres # Run backend with PostgreSQL config
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
```bash
|
||||||
|
task docker:prod # Build and run production Docker compose
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical Development Practices
|
||||||
|
|
||||||
|
### Python Backend
|
||||||
|
|
||||||
|
1. **Always use `uv` for Python commands** (not `python` or `pip`):
|
||||||
|
```bash
|
||||||
|
uv run python mealie/app.py
|
||||||
|
uv run pytest tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Type hints are mandatory:** Use mypy-compatible annotations, handle Optional types explicitly
|
||||||
|
|
||||||
|
3. **Dependency injection pattern:**
|
||||||
|
```python
|
||||||
|
from fastapi import Depends
|
||||||
|
from mealie.repos.all_repositories import get_repositories, AllRepositories
|
||||||
|
|
||||||
|
def my_route(
|
||||||
|
repos: AllRepositories = Depends(get_repositories),
|
||||||
|
user: PrivateUser = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
recipe = repos.recipes.get_one(recipe_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Settings & Configuration:**
|
||||||
|
- Get settings: `settings = get_app_settings()` (cached singleton)
|
||||||
|
- Get directories: `dirs = get_app_dirs()`
|
||||||
|
- Never instantiate `AppSettings()` directly
|
||||||
|
|
||||||
|
5. **Testing:**
|
||||||
|
- Fixtures in `tests/fixtures/`
|
||||||
|
- Use `api_client` fixture for integration tests
|
||||||
|
- Follow existing patterns in `tests/integration_tests/` and `tests/unit_tests/`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. **Run code generation after backend schema changes:** `task dev:generate`
|
||||||
|
|
||||||
|
2. **TypeScript strict mode:** All code must pass type checking
|
||||||
|
|
||||||
|
3. **Component naming:** Follow strict conventions (see Architecture section above)
|
||||||
|
|
||||||
|
4. **API calls pattern:**
|
||||||
|
```typescript
|
||||||
|
const api = useUserApi();
|
||||||
|
const recipe = await api.recipes.getOne(recipeId);
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Composables for shared logic:** Prefer composables in `composables/` over inline code duplication
|
||||||
|
|
||||||
|
6. **Translations:** Only modify `en-US` locale files when adding new translation strings - other locales are managed via Crowdin and **must never be modified** (PRs modifying non-English locales will be rejected)
|
||||||
|
|
||||||
|
### Cross-Cutting Concerns
|
||||||
|
|
||||||
|
1. **Code generation is source of truth:** After Pydantic schema changes, run `task dev:generate` to update:
|
||||||
|
- TypeScript types (`frontend/lib/api/types/`)
|
||||||
|
- Schema exports (`mealie/schema/*/__init__.py`)
|
||||||
|
- Test data paths and routes
|
||||||
|
|
||||||
|
2. **Multi-tenancy:** All data scoped to **groups** and **households**:
|
||||||
|
- Groups contain multiple households
|
||||||
|
- Households contain recipes, meal plans, shopping lists
|
||||||
|
- Repositories automatically filter by group/household context
|
||||||
|
|
||||||
|
3. **Pre-commit hooks:** Install via `task setup:py`, enforces Ruff formatting/linting
|
||||||
|
|
||||||
|
4. **Testing before PRs:** Run `task py:check` and `task ui:check` before submitting PRs
|
||||||
|
|
||||||
|
## Pull Request Best Practices
|
||||||
|
|
||||||
|
### Before Submitting a PR
|
||||||
|
|
||||||
|
1. **Draft PRs are optional:** Create a draft PR early if you want feedback while working, or open directly as ready when complete
|
||||||
|
2. **Verify code generation:** If you modified Pydantic schemas, ensure `task dev:generate` was run
|
||||||
|
3. **Follow Conventional Commits:** Title your PR according to the conventional commits format (see PR template)
|
||||||
|
4. **Add release notes:** Include user-facing changes in the PR description
|
||||||
|
|
||||||
|
### What to Review
|
||||||
|
|
||||||
|
**Architecture & Patterns:**
|
||||||
|
- Does the code follow the repository-service-controller pattern?
|
||||||
|
- Are controllers delegating business logic to services?
|
||||||
|
- Are services coordinating repositories, not accessing the database directly?
|
||||||
|
- Is dependency injection used properly (`Depends(get_repositories)`, `Depends(get_current_user)`)?
|
||||||
|
|
||||||
|
**Data Scoping:**
|
||||||
|
- Are repositories correctly scoped to group/household context?
|
||||||
|
- Do route handlers properly validate group/household ownership before operations?
|
||||||
|
- Are multi-tenant boundaries enforced (users can't access other groups' data)?
|
||||||
|
|
||||||
|
**Type Safety:**
|
||||||
|
- Are type hints present on all functions and methods?
|
||||||
|
- Are Pydantic schemas using correct suffixes (`*In`, `*Out`, `*Create`, `*Update`)?
|
||||||
|
- For frontend, does TypeScript code pass strict type checking?
|
||||||
|
|
||||||
|
**Generated Files:**
|
||||||
|
- Verify `frontend/lib/api/types/` files weren't manually edited (they're auto-generated)
|
||||||
|
- Check that `mealie/schema/*/__init__.py` exports match actual schema files (auto-generated)
|
||||||
|
- If schemas changed, confirm generated files were updated via `task dev:generate`
|
||||||
|
|
||||||
|
**Code Quality:**
|
||||||
|
- Is the code readable and well-organized?
|
||||||
|
- Are complex operations documented with clear comments?
|
||||||
|
- Do component names follow the strict naming conventions (Domain/Global/Layout/Page prefixes)?
|
||||||
|
- Are composables used for shared frontend logic instead of duplication?
|
||||||
|
|
||||||
|
**Translations:**
|
||||||
|
- Were only `en-US` locale files modified for new translation strings?
|
||||||
|
- Verify no other locale files (managed by Crowdin) were touched
|
||||||
|
|
||||||
|
**Database Changes:**
|
||||||
|
- Are Alembic migrations included for schema changes?
|
||||||
|
- Are migrations tested against both SQLite and PostgreSQL?
|
||||||
|
|
||||||
|
### Review Etiquette
|
||||||
|
|
||||||
|
- Be constructive and specific in feedback
|
||||||
|
- Suggest code examples when proposing changes
|
||||||
|
- Focus on architecture and logic - formatting/linting is handled by CI
|
||||||
|
- Use "Approve" when ready to merge, "Request Changes" for blocking issues, "Comment" for non-blocking suggestions
|
||||||
|
|
||||||
|
## Common Gotchas
|
||||||
|
|
||||||
|
- **Don't manually edit generated files:** `frontend/lib/api/types/`, schema `__init__.py` files
|
||||||
|
- **Repository context:** Repos are group/household-scoped - passing wrong IDs causes 404s
|
||||||
|
- **Session handling:** Don't create sessions manually, use dependency injection or `session_context()`
|
||||||
|
- **Schema changes require codegen:** After changing Pydantic models, run `task dev:generate`
|
||||||
|
- **Translation files:** Only modify `en-US` locale files - all other locales are managed by Crowdin
|
||||||
|
- **Dev containers:** This project uses VS Code dev containers - leverage the pre-configured environment
|
||||||
|
- **Task commands:** Use `task` commands instead of direct tool invocation for consistency
|
||||||
|
|
||||||
|
## Key Files to Reference
|
||||||
|
|
||||||
|
- `Taskfile.yml` - All development commands and workflows
|
||||||
|
- `mealie/routes/_base/base_controllers.py` - Controller base classes and patterns
|
||||||
|
- `mealie/repos/repository_factory.py` - Repository factory and available repos
|
||||||
|
- `frontend/lib/api/base/base-clients.ts` - API client base classes
|
||||||
|
- `tests/conftest.py` - Test fixtures and setup
|
||||||
|
- `dev/code-generation/main.py` - Code generation entry point
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Documentation](https://docs.mealie.io/)
|
||||||
|
- [Contributors Guide](https://nightly.mealie.io/contributors/developers-guide/code-contributions/)
|
||||||
|
- [Discord](https://discord.gg/QuStdQGSGK)
|
||||||
9
.github/workflows/build-package.yml
vendored
9
.github/workflows/build-package.yml
vendored
@@ -70,13 +70,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install Poetry
|
- name: Install uv
|
||||||
uses: snok/install-poetry@v1
|
run: pip install uv
|
||||||
with:
|
|
||||||
virtualenvs-create: true
|
|
||||||
virtualenvs-in-project: true
|
|
||||||
plugins: |
|
|
||||||
poetry-plugin-export
|
|
||||||
|
|
||||||
- name: Retrieve built frontend
|
- name: Retrieve built frontend
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
|||||||
21
.github/workflows/locale-sync.yml
vendored
21
.github/workflows/locale-sync.yml
vendored
@@ -25,24 +25,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install Poetry
|
- name: Install uv
|
||||||
uses: snok/install-poetry@v1
|
run: pip install uv
|
||||||
with:
|
|
||||||
virtualenvs-create: true
|
|
||||||
virtualenvs-in-project: true
|
|
||||||
|
|
||||||
- name: Load cached venv
|
- name: Load cached venv
|
||||||
id: cached-poetry-dependencies
|
id: cached-python-dependencies
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: .venv
|
path: .venv
|
||||||
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
|
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
|
||||||
|
|
||||||
- name: Check venv cache
|
- name: Check venv cache
|
||||||
id: cache-validate
|
id: cache-validate
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
|
if: steps.cached-python-dependencies.outputs.cache-hit == 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
echo "import fastapi;print('venv good?')" > test.py && uv run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
||||||
rm test.py
|
rm test.py
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
@@ -50,13 +47,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
||||||
poetry install
|
uv sync --group dev
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
if: steps.cached-python-dependencies.outputs.cache-hit != 'true'
|
||||||
|
|
||||||
- name: Run locale generation
|
- name: Run locale generation
|
||||||
run: |
|
run: |
|
||||||
cd dev/code-generation
|
cd dev/code-generation
|
||||||
poetry run python main.py locales
|
uv run python main.py locales
|
||||||
env:
|
env:
|
||||||
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
|
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
|
||||||
|
|
||||||
|
|||||||
132
.github/workflows/release.yml
vendored
132
.github/workflows/release.yml
vendored
@@ -5,17 +5,73 @@ on:
|
|||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
commit-version-bump:
|
||||||
|
name: Commit version bump to repository
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
outputs:
|
||||||
|
commit-sha: ${{ steps.commit.outputs.commit-sha }}
|
||||||
|
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 🛎
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Extract Version From Tag Name
|
||||||
|
run: echo "VERSION_NUM=$(echo ${{ github.event.release.tag_name }} | sed 's/^v//')" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config user.name "mealie-commit-bot[bot]"
|
||||||
|
git config user.email "mealie-commit-bot[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Update all version strings
|
||||||
|
run: |
|
||||||
|
sed -i 's/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' pyproject.toml
|
||||||
|
sed -i '/^name = "mealie"$/,/^version = / s/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' uv.lock
|
||||||
|
sed -i 's/\("version": "\)[^"]*"/\1${{ env.VERSION_NUM }}"/' frontend/package.json
|
||||||
|
sed -i 's/:v[0-9]*\.[0-9]*\.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/installation-checklist.md
|
||||||
|
sed -i 's/:v[0-9]*\.[0-9]*\.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/sqlite.md
|
||||||
|
sed -i 's/:v[0-9]*\.[0-9]*\.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/postgres.md
|
||||||
|
|
||||||
|
- name: Commit and push changes
|
||||||
|
id: commit
|
||||||
|
run: |
|
||||||
|
git add pyproject.toml frontend/package.json uv.lock docs/
|
||||||
|
git commit -m "chore: bump version to ${{ github.event.release.tag_name }}"
|
||||||
|
git push origin HEAD:${{ github.event.repository.default_branch }}
|
||||||
|
echo "commit-sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Move release tag to new commit
|
||||||
|
run: |
|
||||||
|
git tag -f ${{ github.event.release.tag_name }}
|
||||||
|
git push -f origin ${{ github.event.release.tag_name }}
|
||||||
|
|
||||||
backend-tests:
|
backend-tests:
|
||||||
name: "Backend Server Tests"
|
name: "Backend Server Tests"
|
||||||
uses: ./.github/workflows/test-backend.yml
|
uses: ./.github/workflows/test-backend.yml
|
||||||
|
needs:
|
||||||
|
- commit-version-bump
|
||||||
|
|
||||||
frontend-tests:
|
frontend-tests:
|
||||||
name: "Frontend Tests"
|
name: "Frontend Tests"
|
||||||
uses: ./.github/workflows/test-frontend.yml
|
uses: ./.github/workflows/test-frontend.yml
|
||||||
|
needs:
|
||||||
|
- commit-version-bump
|
||||||
|
|
||||||
build-package:
|
build-package:
|
||||||
name: Build Package
|
name: Build Package
|
||||||
uses: ./.github/workflows/build-package.yml
|
uses: ./.github/workflows/build-package.yml
|
||||||
|
needs:
|
||||||
|
- commit-version-bump
|
||||||
with:
|
with:
|
||||||
tag: ${{ github.event.release.tag_name }}
|
tag: ${{ github.event.release.tag_name }}
|
||||||
|
|
||||||
@@ -43,10 +99,48 @@ jobs:
|
|||||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
rollback-on-failure:
|
||||||
|
name: Rollback version commit if deployment fails
|
||||||
|
needs:
|
||||||
|
- commit-version-bump
|
||||||
|
- publish
|
||||||
|
if: always() && needs.publish.result == 'failure'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
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 🛎
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config user.name "mealie-commit-bot[bot]"
|
||||||
|
git config user.email "mealie-commit-bot[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Delete release tag
|
||||||
|
run: |
|
||||||
|
git push --delete origin ${{ github.event.release.tag_name }}
|
||||||
|
|
||||||
|
- name: Revert version bump commit
|
||||||
|
run: |
|
||||||
|
git revert --no-edit ${{ needs.commit-version-bump.outputs.commit-sha }}
|
||||||
|
git push origin HEAD:${{ github.event.repository.default_branch }}
|
||||||
|
|
||||||
notify-discord:
|
notify-discord:
|
||||||
name: Notify Discord
|
name: Notify Discord
|
||||||
needs:
|
needs:
|
||||||
- publish
|
- publish
|
||||||
|
if: success()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
@@ -55,41 +149,3 @@ jobs:
|
|||||||
uses: Ilshidur/action-discord@0.3.2
|
uses: Ilshidur/action-discord@0.3.2
|
||||||
with:
|
with:
|
||||||
args: "🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of Mealie has been released. See the release notes https://github.com/mealie-recipes/mealie/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}"
|
args: "🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of Mealie has been released. See the release notes https://github.com/mealie-recipes/mealie/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}"
|
||||||
|
|
||||||
update-image-tags:
|
|
||||||
name: Update image tag in sample docker-compose files
|
|
||||||
needs:
|
|
||||||
- publish
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout 🛎
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Extract Version From Tag Name
|
|
||||||
run: echo "VERSION_NUM=$(echo ${{ github.event.release.tag_name }} | sed 's/^v//')" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Modify version strings
|
|
||||||
run: |
|
|
||||||
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/installation-checklist.md
|
|
||||||
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/sqlite.md
|
|
||||||
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/postgres.md
|
|
||||||
sed -i 's/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' pyproject.toml
|
|
||||||
sed -i 's/^\s*"version": "[^"]*"/"version": "${{ env.VERSION_NUM }}"/' frontend/package.json
|
|
||||||
|
|
||||||
- name: Create Pull Request
|
|
||||||
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:
|
|
||||||
commit-message: "Update image tag, for release ${{ github.event.release.tag_name }}"
|
|
||||||
branch: "docs/newrelease-update-version-${{ github.event.release.tag_name }}"
|
|
||||||
labels: |
|
|
||||||
documentation
|
|
||||||
delete-branch: true
|
|
||||||
base: mealie-next
|
|
||||||
title: "docs(auto): Update image tag, for release ${{ github.event.release.tag_name }}"
|
|
||||||
body: "Auto-generated by `.github/workflows/release.yml`, on publish of release ${{ github.event.release.tag_name }}"
|
|
||||||
|
|||||||
22
.github/workflows/test-backend.yml
vendored
22
.github/workflows/test-backend.yml
vendored
@@ -49,24 +49,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install Poetry
|
- name: Install uv
|
||||||
uses: snok/install-poetry@v1
|
run: pip install uv
|
||||||
with:
|
|
||||||
virtualenvs-create: true
|
|
||||||
virtualenvs-in-project: true
|
|
||||||
|
|
||||||
- name: Load cached venv
|
- name: Load cached venv
|
||||||
id: cached-poetry-dependencies
|
id: cached-python-dependencies
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: .venv
|
path: .venv
|
||||||
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
|
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
|
||||||
|
|
||||||
- name: Check venv cache
|
- name: Check venv cache
|
||||||
id: cache-validate
|
id: cache-validate
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
|
if: steps.cached-python-dependencies.outputs.cache-hit == 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
echo "import fastapi;print('venv good?')" > test.py && uv run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
||||||
rm test.py
|
rm test.py
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
@@ -74,13 +71,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
||||||
poetry install
|
uv sync --group dev --extra pgsql
|
||||||
poetry add "psycopg2-binary==2.9.9"
|
if: steps.cached-python-dependencies.outputs.cache-hit != 'true' || steps.cache-validate.outputs.cache-hit-success != 'true'
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' || steps.cache-validate.outputs.cache-hit-success != 'true'
|
|
||||||
|
|
||||||
- name: Formatting (Ruff)
|
- name: Formatting (Ruff)
|
||||||
run: |
|
run: |
|
||||||
poetry run ruff format . --check
|
uv run ruff format . --check
|
||||||
|
|
||||||
- name: Lint (Ruff)
|
- name: Lint (Ruff)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
2
.github/workflows/test-frontend.yml
vendored
2
.github/workflows/test-frontend.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
|||||||
working-directory: "frontend"
|
working-directory: "frontend"
|
||||||
|
|
||||||
- name: Run linter 👀
|
- name: Run linter 👀
|
||||||
run: yarn lint
|
run: yarn lint --max-warnings=0
|
||||||
working-directory: "frontend"
|
working-directory: "frontend"
|
||||||
|
|
||||||
- name: Run tests 🧪
|
- name: Run tests 🧪
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ repos:
|
|||||||
exclude: ^tests/data/
|
exclude: ^tests/data/
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.13.2
|
rev: v0.14.8
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -55,7 +55,7 @@
|
|||||||
"explorer.fileNesting.enabled": true,
|
"explorer.fileNesting.enabled": true,
|
||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig",
|
"package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig",
|
||||||
"pyproject.toml": "poetry.lock, alembic.ini, .pylintrc",
|
"pyproject.toml": "uv.lock, alembic.ini, .pylintrc",
|
||||||
"netlify.toml": "runtime.txt",
|
"netlify.toml": "runtime.txt",
|
||||||
"README.md": "LICENSE, SECURITY.md"
|
"README.md": "LICENSE, SECURITY.md"
|
||||||
},
|
},
|
||||||
|
|||||||
48
Taskfile.yml
48
Taskfile.yml
@@ -28,7 +28,7 @@ tasks:
|
|||||||
docs:gen:
|
docs:gen:
|
||||||
desc: runs the API documentation generator
|
desc: runs the API documentation generator
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python dev/code-generation/gen_docs_api.py
|
- uv run python dev/code-generation/gen_docs_api.py
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
desc: runs the documentation server
|
desc: runs the documentation server
|
||||||
@@ -36,7 +36,7 @@ tasks:
|
|||||||
deps:
|
deps:
|
||||||
- docs:gen
|
- docs:gen
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python -m mkdocs serve
|
- uv run python -m mkdocs serve
|
||||||
|
|
||||||
setup:ui:
|
setup:ui:
|
||||||
desc: setup frontend dependencies
|
desc: setup frontend dependencies
|
||||||
@@ -54,10 +54,10 @@ tasks:
|
|||||||
desc: setup python dependencies
|
desc: setup python dependencies
|
||||||
run: once
|
run: once
|
||||||
cmds:
|
cmds:
|
||||||
- poetry install --with main,dev,postgres
|
- uv sync --extra pgsql --group dev
|
||||||
- poetry run pre-commit install
|
- uv run pre-commit install
|
||||||
sources:
|
sources:
|
||||||
- poetry.lock
|
- uv.lock
|
||||||
- pyproject.toml
|
- pyproject.toml
|
||||||
- .pre-commit-config.yaml
|
- .pre-commit-config.yaml
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ tasks:
|
|||||||
dev:generate:
|
dev:generate:
|
||||||
desc: run code generators
|
desc: run code generators
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
- uv run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
||||||
- task: docs:gen
|
- task: docs:gen
|
||||||
- task: py:format
|
- task: py:format
|
||||||
|
|
||||||
@@ -96,22 +96,22 @@ tasks:
|
|||||||
py:mypy:
|
py:mypy:
|
||||||
desc: runs python type checking
|
desc: runs python type checking
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run mypy mealie
|
- uv run mypy mealie
|
||||||
|
|
||||||
py:test:
|
py:test:
|
||||||
desc: runs python tests (support args after '--')
|
desc: runs python tests (support args after '--')
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run pytest {{ .CLI_ARGS }}
|
- uv run pytest {{ .CLI_ARGS }}
|
||||||
|
|
||||||
py:format:
|
py:format:
|
||||||
desc: runs python code formatter
|
desc: runs python code formatter
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run ruff format .
|
- uv run ruff format .
|
||||||
|
|
||||||
py:lint:
|
py:lint:
|
||||||
desc: runs python linter
|
desc: runs python linter
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run ruff check mealie
|
- uv run ruff check mealie
|
||||||
|
|
||||||
py:check:
|
py:check:
|
||||||
desc: runs all linters, type checkers, and formatters
|
desc: runs all linters, type checkers, and formatters
|
||||||
@@ -124,10 +124,10 @@ tasks:
|
|||||||
py:coverage:
|
py:coverage:
|
||||||
desc: runs python coverage and generates html report
|
desc: runs python coverage and generates html report
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run pytest
|
- uv run pytest
|
||||||
- poetry run coverage report -m
|
- uv run coverage report -m
|
||||||
- poetry run coveragepy-lcov
|
- uv run coveragepy-lcov
|
||||||
- poetry run coverage html
|
- uv run coverage html
|
||||||
- open htmlcov/index.html
|
- open htmlcov/index.html
|
||||||
|
|
||||||
py:package:copy-frontend:
|
py:package:copy-frontend:
|
||||||
@@ -147,17 +147,17 @@ tasks:
|
|||||||
desc: Generate requirements file to pin all packages, effectively a "pip freeze" before installation begins
|
desc: Generate requirements file to pin all packages, effectively a "pip freeze" before installation begins
|
||||||
internal: true
|
internal: true
|
||||||
cmds:
|
cmds:
|
||||||
- poetry export -n --only=main --extras=pgsql --output=dist/requirements.txt
|
- uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file dist/requirements.txt
|
||||||
# Include mealie in the requirements, hashing the package that was just built to ensure it's the one installed
|
# Include mealie in the requirements, hashing the package that was just built to ensure it's the one installed
|
||||||
- echo "mealie[pgsql]=={{.MEALIE_VERSION}} \\" >> dist/requirements.txt
|
- echo "mealie[pgsql]=={{.MEALIE_VERSION}} \\" >> dist/requirements.txt
|
||||||
- poetry run pip hash dist/mealie-{{.MEALIE_VERSION}}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
|
- pip hash dist/mealie-{{.MEALIE_VERSION}}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
|
||||||
- echo " \\" >> dist/requirements.txt
|
- echo " \\" >> dist/requirements.txt
|
||||||
- poetry run pip hash dist/mealie-{{.MEALIE_VERSION}}.tar.gz | tail -n1 >> dist/requirements.txt
|
- pip hash dist/mealie-{{.MEALIE_VERSION}}.tar.gz | tail -n1 >> dist/requirements.txt
|
||||||
vars:
|
vars:
|
||||||
MEALIE_VERSION:
|
MEALIE_VERSION:
|
||||||
sh: poetry version --short
|
sh: python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])"
|
||||||
sources:
|
sources:
|
||||||
- poetry.lock
|
- uv.lock
|
||||||
- pyproject.toml
|
- pyproject.toml
|
||||||
- dist/mealie-*.whl
|
- dist/mealie-*.whl
|
||||||
- dist/mealie-*.tar.gz
|
- dist/mealie-*.tar.gz
|
||||||
@@ -184,13 +184,13 @@ tasks:
|
|||||||
deps:
|
deps:
|
||||||
- py:package:deps
|
- py:package:deps
|
||||||
cmds:
|
cmds:
|
||||||
- poetry build -n --output=dist
|
- uv build --out-dir dist
|
||||||
- task: py:package:generate-requirements
|
- task: py:package:generate-requirements
|
||||||
|
|
||||||
py:
|
py:
|
||||||
desc: runs the backend server
|
desc: runs the backend server
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python mealie/app.py
|
- uv run python mealie/app.py
|
||||||
|
|
||||||
py:postgres:
|
py:postgres:
|
||||||
desc: runs the backend server configured for containerized postgres
|
desc: runs the backend server configured for containerized postgres
|
||||||
@@ -202,12 +202,12 @@ tasks:
|
|||||||
POSTGRES_PORT: 5432
|
POSTGRES_PORT: 5432
|
||||||
POSTGRES_DB: mealie
|
POSTGRES_DB: mealie
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python mealie/app.py
|
- uv run python mealie/app.py
|
||||||
|
|
||||||
py:migrate:
|
py:migrate:
|
||||||
desc: generates a new database migration file e.g. task py:migrate -- "add new column"
|
desc: generates a new database migration file e.g. task py:migrate -- "add new column"
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run alembic --config mealie/alembic/alembic.ini revision --autogenerate -m "{{ .CLI_ARGS }}"
|
- uv run alembic --config mealie/alembic/alembic.ini revision --autogenerate -m "{{ .CLI_ARGS }}"
|
||||||
- task: py:format
|
- task: py:format
|
||||||
|
|
||||||
ui:build:
|
ui:build:
|
||||||
@@ -228,7 +228,7 @@ tasks:
|
|||||||
desc: runs the frontend linter
|
desc: runs the frontend linter
|
||||||
dir: frontend
|
dir: frontend
|
||||||
cmds:
|
cmds:
|
||||||
- yarn lint
|
- yarn lint --max-warnings=0
|
||||||
|
|
||||||
ui:test:
|
ui:test:
|
||||||
desc: runs the frontend tests
|
desc: runs the frontend tests
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from freezegun import freeze_time
|
|
||||||
|
|
||||||
from mealie.app import app
|
from mealie.app import app
|
||||||
from mealie.core.config import determine_data_dir
|
from mealie.core.config import determine_data_dir
|
||||||
@@ -37,14 +38,43 @@ HTML_TEMPLATE = """<!-- Custom HTML site displayed as the Home chapter -->
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
HTML_PATH = DATA_DIR.parent.parent.joinpath("docs/docs/overrides/api.html")
|
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):
|
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:
|
with open(HTML_PATH, "w") as fd:
|
||||||
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(my_app.openapi()))
|
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(openapi_schema))
|
||||||
fd.write(text)
|
fd.write(text)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
with freeze_time("2024-01-20T17:00:55Z"):
|
generate_api_docs(app)
|
||||||
generate_api_docs(app)
|
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ def main():
|
|||||||
{"children": all_children},
|
{"children": all_children},
|
||||||
)
|
)
|
||||||
|
|
||||||
subprocess.run(["poetry", "run", "ruff", "check", str(out_path), "--fix"])
|
subprocess.run(["uv", "run", "ruff", "check", str(out_path), "--fix"])
|
||||||
subprocess.run(["poetry", "run", "ruff", "format", str(out_path)])
|
subprocess.run(["uv", "run", "ruff", "format", str(out_path)])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ def main() -> None:
|
|||||||
render_python_template(template, template_path, {"module": module})
|
render_python_template(template, template_path, {"module": module})
|
||||||
|
|
||||||
path_args = (str(p) for p in template_paths)
|
path_args = (str(p) for p in template_paths)
|
||||||
subprocess.run(["poetry", "run", "ruff", "check", *path_args, "--fix"])
|
subprocess.run(["uv", "run", "ruff", "check", *path_args, "--fix"])
|
||||||
subprocess.run(["poetry", "run", "ruff", "format", *path_args])
|
subprocess.run(["uv", "run", "ruff", "format", *path_args])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
###############################################
|
###############################################
|
||||||
# Frontend Build
|
# Frontend Build
|
||||||
###############################################
|
###############################################
|
||||||
FROM node:22@sha256:d367fd3fce932a9d3bc3816c23f85313d9b44e58e1fed49ef90a10c4b99409e8 \
|
FROM node:24@sha256:20988bcdc6dc76690023eb2505dd273bdeefddcd0bde4bfd1efe4ebf8707f747 \
|
||||||
AS frontend-builder
|
AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
@@ -50,40 +50,29 @@ RUN apt-get update \
|
|||||||
curl \
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV POETRY_HOME="/opt/poetry" \
|
RUN pip install uv
|
||||||
POETRY_NO_INTERACTION=1
|
|
||||||
|
|
||||||
# prepend poetry to path
|
|
||||||
ENV PATH="$POETRY_HOME/bin:$PATH"
|
|
||||||
|
|
||||||
# install poetry - respects $POETRY_VERSION & $POETRY_HOME
|
|
||||||
ENV POETRY_VERSION=2.0.1
|
|
||||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
|
||||||
|
|
||||||
# install poetry plugins needed to build the package
|
|
||||||
RUN poetry self add "poetry-plugin-export>=1.9"
|
|
||||||
|
|
||||||
WORKDIR /mealie
|
WORKDIR /mealie
|
||||||
|
|
||||||
# copy project files here to ensure they will be cached.
|
# copy project files here to ensure they will be cached.
|
||||||
COPY poetry.lock pyproject.toml ./
|
COPY uv.lock pyproject.toml ./
|
||||||
COPY mealie ./mealie
|
COPY mealie ./mealie
|
||||||
|
|
||||||
# Copy frontend to package it into the wheel
|
# Copy frontend to package it into the wheel
|
||||||
COPY --from=frontend-builder /frontend/dist ./mealie/frontend
|
COPY --from=frontend-builder /frontend/dist ./mealie/frontend
|
||||||
|
|
||||||
# Build the source and binary package
|
# Build the source and binary package
|
||||||
RUN poetry build --output=dist
|
RUN uv build --out-dir dist
|
||||||
|
|
||||||
# Create the requirements file, which is used to install the built package and
|
# Create the requirements file, which is used to install the built package and
|
||||||
# its pinned dependencies later. mealie is included to ensure the built one is
|
# its pinned dependencies later. mealie is included to ensure the built one is
|
||||||
# what's installed.
|
# what's installed.
|
||||||
RUN export MEALIE_VERSION=$(poetry version --short) \
|
RUN uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file dist/requirements.txt \
|
||||||
&& poetry export --only=main --extras=pgsql --output=dist/requirements.txt \
|
&& MEALIE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") \
|
||||||
&& echo "mealie[pgsql]==$MEALIE_VERSION \\" >> dist/requirements.txt \
|
&& echo "mealie[pgsql]==${MEALIE_VERSION} \\" >> dist/requirements.txt \
|
||||||
&& poetry run pip hash dist/mealie-$MEALIE_VERSION-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt \
|
&& pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt \
|
||||||
&& echo " \\" >> dist/requirements.txt \
|
&& echo " \\" >> dist/requirements.txt \
|
||||||
&& poetry run pip hash dist/mealie-$MEALIE_VERSION.tar.gz | tail -n1 >> dist/requirements.txt
|
&& pip hash dist/mealie-${MEALIE_VERSION}.tar.gz | tail -n1 >> dist/requirements.txt
|
||||||
|
|
||||||
###############################################
|
###############################################
|
||||||
# Package Container
|
# Package Container
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ yarnpkg generate
|
|||||||
popd
|
popd
|
||||||
rm -r mealie/frontend
|
rm -r mealie/frontend
|
||||||
cp -a frontend/dist mealie/frontend
|
cp -a frontend/dist mealie/frontend
|
||||||
poetry build
|
uv build --out-dir dist
|
||||||
poetry export -n --only=main --extras=pgsql --output=dist/requirements.txt
|
uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file dist/requirements.txt
|
||||||
MEALIE_VERSION=$(poetry version --short)
|
MEALIE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
||||||
echo "mealie[pgsql]==${MEALIE_VERSION} \\" >> dist/requirements.txt
|
echo "mealie[pgsql]==${MEALIE_VERSION} \\" >> dist/requirements.txt
|
||||||
poetry run pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
|
pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
|
||||||
echo " \\" >> dist/requirements.txt
|
echo " \\" >> dist/requirements.txt
|
||||||
poetry run pip hash dist/mealie-${MEALIE_VERSION}.tar.gz | tail -n1 >> dist/requirements.txt
|
pip hash dist/mealie-${MEALIE_VERSION}.tar.gz | tail -n1 >> dist/requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
The Python package can be installed with all of its dependencies pinned to the versions tested by the developers with:
|
The Python package can be installed with all of its dependencies pinned to the versions tested by the developers with:
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Make sure the VSCode Dev Containers extension is installed, then select "Dev Con
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Python 3.12](https://www.python.org/downloads/)
|
- [Python 3.12](https://www.python.org/downloads/)
|
||||||
- [Poetry](https://python-poetry.org/docs/#installation)
|
- [uv](https://docs.astral.sh/uv/)
|
||||||
- [Node](https://nodejs.org/en/)
|
- [Node](https://nodejs.org/en/)
|
||||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)
|
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)
|
||||||
- [task](https://taskfile.dev/#/installation)
|
- [task](https://taskfile.dev/#/installation)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Mealie supports importing recipes from a few other sources besides websites. Cur
|
|||||||
- Recipe Keeper
|
- Recipe Keeper
|
||||||
- Copy Me That
|
- Copy Me That
|
||||||
- My Recipe Box
|
- My Recipe Box
|
||||||
|
- DVO Cook'n X3
|
||||||
|
|
||||||
You can access these options on your installation at the `/group/migrations` page on your installation. If you'd like to see another source added, feel free to request so on Github.
|
You can access these options on your installation at the `/group/migrations` page on your installation. If you'd like to see another source added, feel free to request so on Github.
|
||||||
|
|
||||||
|
|||||||
@@ -4,22 +4,22 @@
|
|||||||
|
|
||||||
### General
|
### General
|
||||||
|
|
||||||
| Variables | Default | Description |
|
| Variables | Default | Description |
|
||||||
| ----------------------------- | :-------------------: | -------------------------------------------------------------------------------------------------- |
|
| ----------------------------- | :-------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| PUID | 911 | UserID permissions between host OS and container |
|
| PUID | 911 | UserID permissions between host OS and container |
|
||||||
| PGID | 911 | GroupID permissions between host OS and container |
|
| PGID | 911 | GroupID permissions between host OS and container |
|
||||||
| DEFAULT_GROUP | Home | The default group for users |
|
| DEFAULT_GROUP | Home | The default group for users |
|
||||||
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
|
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
|
||||||
| BASE_URL | http://localhost:8080 | Used for Notifications |
|
| BASE_URL | http://localhost:8080 | Used for Notifications |
|
||||||
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid. Must be <= 87600 (10 years, in hours). |
|
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid. Must be <= 9600 (400 days, in hours). |
|
||||||
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
|
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
|
||||||
| API_DOCS | True | Turns on/off access to the API documentation locally |
|
| API_DOCS | True | Turns on/off access to the API documentation locally |
|
||||||
| TZ | UTC | Must be set to get correct date/time on the server |
|
| TZ | UTC | Must be set to get correct date/time on the server |
|
||||||
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
|
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
|
||||||
| ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication |
|
| ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication |
|
||||||
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
|
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
|
||||||
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
|
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
|
||||||
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
|
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
|
||||||
|
|
||||||
<super>\*</super> Starting in v1.4.0 this was changed to default to `false` as part of a security review of the application.
|
<super>\*</super> Starting in v1.4.0 this was changed to default to `false` as part of a security review of the application.
|
||||||
|
|
||||||
@@ -145,22 +145,95 @@ Setting the following environmental variables will change the theme of the front
|
|||||||
|
|
||||||
If using YAML sequence syntax, don't include any quotes:<br>`THEME_LIGHT_PRIMARY=#E58325` or `THEME_LIGHT_PRIMARY=E58325`
|
If using YAML sequence syntax, don't include any quotes:<br>`THEME_LIGHT_PRIMARY=#E58325` or `THEME_LIGHT_PRIMARY=E58325`
|
||||||
|
|
||||||
| Variables | Default | Description |
|
| Variables | Default | Description |
|
||||||
| --------------------- | :-----: | --------------------------- |
|
| --------------------- | :-----: | ---------------------------------- |
|
||||||
| THEME_LIGHT_PRIMARY | #E58325 | Light Theme Config Variable |
|
| THEME_LIGHT_PRIMARY | #E58325 | Main brand color and headers |
|
||||||
| THEME_LIGHT_ACCENT | #007A99 | Light Theme Config Variable |
|
| THEME_LIGHT_ACCENT | #007A99 | Buttons and interactive elements |
|
||||||
| THEME_LIGHT_SECONDARY | #973542 | Light Theme Config Variable |
|
| THEME_LIGHT_SECONDARY | #973542 | Navigation and sidebar backgrounds |
|
||||||
| THEME_LIGHT_SUCCESS | #43A047 | Light Theme Config Variable |
|
| THEME_LIGHT_SUCCESS | #43A047 | Success messages and confirmations |
|
||||||
| THEME_LIGHT_INFO | #1976D2 | Light Theme Config Variable |
|
| THEME_LIGHT_INFO | #1976D2 | Information alerts and tooltips |
|
||||||
| THEME_LIGHT_WARNING | #FF6D00 | Light Theme Config Variable |
|
| THEME_LIGHT_WARNING | #FF6D00 | Warning notifications |
|
||||||
| THEME_LIGHT_ERROR | #EF5350 | Light Theme Config Variable |
|
| THEME_LIGHT_ERROR | #EF5350 | Error messages and alerts |
|
||||||
| THEME_DARK_PRIMARY | #E58325 | Dark Theme Config Variable |
|
| THEME_DARK_PRIMARY | #E58325 | Main brand color and headers |
|
||||||
| THEME_DARK_ACCENT | #007A99 | Dark Theme Config Variable |
|
| THEME_DARK_ACCENT | #007A99 | Buttons and interactive elements |
|
||||||
| THEME_DARK_SECONDARY | #973542 | Dark Theme Config Variable |
|
| THEME_DARK_SECONDARY | #973542 | Navigation and sidebar backgrounds |
|
||||||
| THEME_DARK_SUCCESS | #43A047 | Dark Theme Config Variable |
|
| THEME_DARK_SUCCESS | #43A047 | Success messages and confirmations |
|
||||||
| THEME_DARK_INFO | #1976D2 | Dark Theme Config Variable |
|
| THEME_DARK_INFO | #1976D2 | Information alerts and tooltips |
|
||||||
| THEME_DARK_WARNING | #FF6D00 | Dark Theme Config Variable |
|
| THEME_DARK_WARNING | #FF6D00 | Warning notifications |
|
||||||
| THEME_DARK_ERROR | #EF5350 | Dark Theme Config Variable |
|
| THEME_DARK_ERROR | #EF5350 | Error messages and alerts |
|
||||||
|
|
||||||
|
#### Theming Examples
|
||||||
|
|
||||||
|
The examples below provide copy-ready Docker Compose environment configurations for three different color palettes. Copy and paste the desired theme into your `docker-compose.yml` file's environment section.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
These themes are functional and ready to use, but they are provided primarily as examples. The color palettes can be adjusted or refined to better suit your preferences.
|
||||||
|
|
||||||
|
=== "Blue Theme"
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
# Light mode colors
|
||||||
|
THEME_LIGHT_PRIMARY: '#5E9BD1'
|
||||||
|
THEME_LIGHT_ACCENT: '#A3C9E8'
|
||||||
|
THEME_LIGHT_SECONDARY: '#4F89BA'
|
||||||
|
THEME_LIGHT_SUCCESS: '#4CAF50'
|
||||||
|
THEME_LIGHT_INFO: '#4A9ED8'
|
||||||
|
THEME_LIGHT_WARNING: '#EAC46B'
|
||||||
|
THEME_LIGHT_ERROR: '#E57373'
|
||||||
|
# Dark mode colors
|
||||||
|
THEME_DARK_PRIMARY: '#5A8FBF'
|
||||||
|
THEME_DARK_ACCENT: '#90B8D9'
|
||||||
|
THEME_DARK_SECONDARY: '#406D96'
|
||||||
|
THEME_DARK_SUCCESS: '#81C784'
|
||||||
|
THEME_DARK_INFO: '#78B2C0'
|
||||||
|
THEME_DARK_WARNING: '#EBC86E'
|
||||||
|
THEME_DARK_ERROR: '#E57373'
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Green Theme"
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
# Light mode colors
|
||||||
|
THEME_LIGHT_PRIMARY: '#75A86C'
|
||||||
|
THEME_LIGHT_ACCENT: '#A8D0A6'
|
||||||
|
THEME_LIGHT_SECONDARY: '#638E5E'
|
||||||
|
THEME_LIGHT_SUCCESS: '#4CAF50'
|
||||||
|
THEME_LIGHT_INFO: '#4A9ED8'
|
||||||
|
THEME_LIGHT_WARNING: '#EAC46B'
|
||||||
|
THEME_LIGHT_ERROR: '#E57373'
|
||||||
|
# Dark mode colors
|
||||||
|
THEME_DARK_PRIMARY: '#739B7A'
|
||||||
|
THEME_DARK_ACCENT: '#9FBE9D'
|
||||||
|
THEME_DARK_SECONDARY: '#56775E'
|
||||||
|
THEME_DARK_SUCCESS: '#81C784'
|
||||||
|
THEME_DARK_INFO: '#78B2C0'
|
||||||
|
THEME_DARK_WARNING: '#EBC86E'
|
||||||
|
THEME_DARK_ERROR: '#E57373'
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Pink Theme"
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
# Light mode colors
|
||||||
|
THEME_LIGHT_PRIMARY: '#D97C96'
|
||||||
|
THEME_LIGHT_ACCENT: '#E891A7'
|
||||||
|
THEME_LIGHT_SECONDARY: '#C86C88'
|
||||||
|
THEME_LIGHT_SUCCESS: '#4CAF50'
|
||||||
|
THEME_LIGHT_INFO: '#2196F3'
|
||||||
|
THEME_LIGHT_WARNING: '#FFC107'
|
||||||
|
THEME_LIGHT_ERROR: '#E57373'
|
||||||
|
# Dark mode colors
|
||||||
|
THEME_DARK_PRIMARY: '#C2185B'
|
||||||
|
THEME_DARK_ACCENT: '#FF80AB'
|
||||||
|
THEME_DARK_SECONDARY: '#AD1457'
|
||||||
|
THEME_DARK_SUCCESS: '#81C784'
|
||||||
|
THEME_DARK_INFO: '#64B5F6'
|
||||||
|
THEME_DARK_WARNING: '#FFD54F'
|
||||||
|
THEME_DARK_ERROR: '#E57373'
|
||||||
|
```
|
||||||
|
|
||||||
### Docker Secrets
|
### Docker Secrets
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
|||||||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||||
|
|
||||||
1. Take a backup just in case!
|
1. Take a backup just in case!
|
||||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.3.0`
|
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.7.0`
|
||||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||||
4. Restart the container
|
4. Restart the container
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mealie:
|
mealie:
|
||||||
image: ghcr.io/mealie-recipes/mealie:v3.3.0 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v3.7.0 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mealie:
|
mealie:
|
||||||
image: ghcr.io/mealie-recipes/mealie:v3.3.0 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v3.7.0 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Mealie is a self hosted recipe manager and meal planner with a RestAPI backend a
|
|||||||
- Copy Me That
|
- Copy Me That
|
||||||
- Paprika
|
- Paprika
|
||||||
- Tandoor Recipes
|
- Tandoor Recipes
|
||||||
|
- DVO Cook'n X3
|
||||||
- Random Meal Plan generation
|
- Random Meal Plan generation
|
||||||
- Advanced rule configuration to fine tune random recipes
|
- Advanced rule configuration to fine tune random recipes
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,23 @@
|
|||||||
- Create a Backup and Download from the UI
|
- Create a Backup and Download from the UI
|
||||||
- Upgrade
|
- Upgrade
|
||||||
|
|
||||||
|
!!! info "Improved Image Processing"
|
||||||
|
Starting with :octicons-tag-24: v3.7.0, we updated our image processing algorithm to improve image quality and compression. New image processing can be up to 40%-50% smaller on disk while providing higher resolution thumbnails. To take advantage of these improvements on older recipes, you can run our image-processing script:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker exec -it mealie bash
|
||||||
|
python /opt/mealie/lib64/python3.12/site-packages/mealie/scripts/reprocess_images.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
- `--workers N`: Number of worker threads (default: 2, safe for low-powered devices)
|
||||||
|
- `--force-all`: Reprocess all recipes regardless of current image state
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```shell
|
||||||
|
python /opt/mealie/lib64/python3.12/site-packages/mealie/scripts/reprocess_images.py --workers 8
|
||||||
|
```
|
||||||
|
|
||||||
## Upgrading to Mealie v1 or later
|
## Upgrading to Mealie v1 or later
|
||||||
If you are upgrading from pre-v1.0.0 to v1.0.0 or later (v2.0.0, etc.), make sure you read [Migrating to Mealie v1](./migrating-to-mealie-v1.md)!
|
If you are upgrading from pre-v1.0.0 to v1.0.0 or later (v2.0.0, etc.), make sure you read [Migrating to Mealie v1](./migrating-to-mealie-v1.md)!
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
121
frontend/components/Domain/Admin/Setup/EndPageContent.vue
Normal file
121
frontend/components/Domain/Admin/Setup/EndPageContent.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<v-container max-width="880" class="end-page-content">
|
||||||
|
<div class="d-flex flex-column ga-6">
|
||||||
|
<div>
|
||||||
|
<v-card-title class="text-h4 justify-center">
|
||||||
|
{{ $t('admin.setup.setup-complete') }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-subtitle class="justify-center">
|
||||||
|
{{ $t('admin.setup.here-are-a-few-things-to-help-you-get-started') }}
|
||||||
|
</v-card-subtitle>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="section, idx in sections"
|
||||||
|
:key="idx"
|
||||||
|
class="d-flex flex-column ga-3"
|
||||||
|
>
|
||||||
|
<v-card-title class="text-h6 pl-0">
|
||||||
|
{{ section.title }}
|
||||||
|
</v-card-title>
|
||||||
|
<div class="sections d-flex flex-column ga-2">
|
||||||
|
<v-card
|
||||||
|
v-for="link, linkIdx in section.links"
|
||||||
|
:key="linkIdx"
|
||||||
|
clas="link-card"
|
||||||
|
:href="link.to"
|
||||||
|
:title="link.text"
|
||||||
|
:subtitle="link.description"
|
||||||
|
:append-icon="$globals.icons.chevronRight"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-avatar :icon="link.icon || undefined" variant="tonal" :color="section.color" />
|
||||||
|
</template>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default defineNuxtComponent({
|
||||||
|
setup() {
|
||||||
|
const i18n = useI18n();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const groupSlug = computed(() => $auth.user.value?.groupSlug);
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
|
||||||
|
const sections = ref([
|
||||||
|
{
|
||||||
|
title: i18n.t("profile.data-migrations"),
|
||||||
|
color: "info",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
icon: $globals.icons.backupRestore,
|
||||||
|
to: "/admin/backups",
|
||||||
|
text: i18n.t("settings.backup.backup-restore"),
|
||||||
|
description: i18n.t("admin.setup.restore-from-v1-backup"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.import,
|
||||||
|
to: "/group/migrations",
|
||||||
|
text: i18n.t("migration.recipe-migration"),
|
||||||
|
description: i18n.t("migration.coming-from-another-application-or-an-even-older-version-of-mealie"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: i18n.t("recipe.create-recipes"),
|
||||||
|
color: "success",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
icon: $globals.icons.createAlt,
|
||||||
|
to: computed(() => `/g/${groupSlug.value || ""}/r/create/new`),
|
||||||
|
text: i18n.t("recipe.create-recipe"),
|
||||||
|
description: i18n.t("recipe.create-recipe-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.link,
|
||||||
|
to: computed(() => `/g/${groupSlug.value || ""}/r/create/url`),
|
||||||
|
text: i18n.t("recipe.import-with-url"),
|
||||||
|
description: i18n.t("recipe.scrape-recipe-description"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: i18n.t("user.manage-users"),
|
||||||
|
color: "primary",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
icon: $globals.icons.group,
|
||||||
|
to: "/admin/manage/users",
|
||||||
|
text: i18n.t("user.manage-users"),
|
||||||
|
description: i18n.t("user.manage-users-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.user,
|
||||||
|
to: "/user/profile",
|
||||||
|
text: i18n.t("profile.manage-user-profile"),
|
||||||
|
description: i18n.t("admin.setup.manage-profile-or-get-invite-link"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return { sections };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.v-container {
|
||||||
|
.v-card-title,
|
||||||
|
.v-card-subtitle {
|
||||||
|
padding: 0;
|
||||||
|
white-space: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card-item {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -83,6 +83,11 @@ const fieldDefs: FieldDefinition[] = [
|
|||||||
label: i18n.t("household.households"),
|
label: i18n.t("household.households"),
|
||||||
type: Organizer.Household,
|
type: Organizer.Household,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "user_id",
|
||||||
|
label: i18n.t("user.users"),
|
||||||
|
type: Organizer.User,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "created_at",
|
name: "created_at",
|
||||||
label: i18n.t("general.date-created"),
|
label: i18n.t("general.date-created"),
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ const MEAL_TYPE_OPTIONS = [
|
|||||||
{ title: i18n.t("meal-plan.lunch"), value: "lunch" },
|
{ title: i18n.t("meal-plan.lunch"), value: "lunch" },
|
||||||
{ title: i18n.t("meal-plan.dinner"), value: "dinner" },
|
{ title: i18n.t("meal-plan.dinner"), value: "dinner" },
|
||||||
{ title: i18n.t("meal-plan.side"), value: "side" },
|
{ title: i18n.t("meal-plan.side"), value: "side" },
|
||||||
|
{ title: i18n.t("meal-plan.snack"), value: "snack" },
|
||||||
|
{ title: i18n.t("meal-plan.drink"), value: "drink" },
|
||||||
|
{ title: i18n.t("meal-plan.dessert"), value: "dessert" },
|
||||||
{ title: i18n.t("meal-plan.type-any"), value: "unset" },
|
{ title: i18n.t("meal-plan.type-any"), value: "unset" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -103,6 +106,11 @@ const fieldDefs: FieldDefinition[] = [
|
|||||||
label: i18n.t("household.households"),
|
label: i18n.t("household.households"),
|
||||||
type: Organizer.Household,
|
type: Organizer.Household,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "user_id",
|
||||||
|
label: i18n.t("user.users"),
|
||||||
|
type: Organizer.User,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "last_made",
|
name: "last_made",
|
||||||
label: i18n.t("general.last-made"),
|
label: i18n.t("general.last-made"),
|
||||||
|
|||||||
@@ -1,283 +1,297 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card class="ma-0" style="overflow-x: auto;">
|
<v-card class="ma-0" flat fluid>
|
||||||
<v-card-text class="ma-0 pa-0">
|
<v-card-text class="ma-0 pa-0">
|
||||||
<v-container fluid class="ma-0 pa-0">
|
<VueDraggable
|
||||||
<VueDraggable
|
v-model="fields"
|
||||||
v-model="fields"
|
handle=".handle"
|
||||||
handle=".handle"
|
:delay="250"
|
||||||
:delay="250"
|
:delay-on-touch-only="true"
|
||||||
:delay-on-touch-only="true"
|
v-bind="{
|
||||||
v-bind="{
|
animation: 200,
|
||||||
animation: 200,
|
group: 'recipe-instructions',
|
||||||
group: 'recipe-instructions',
|
ghostClass: 'ghost',
|
||||||
ghostClass: 'ghost',
|
}"
|
||||||
}"
|
@start="drag = true"
|
||||||
@start="drag = true"
|
@end="onDragEnd"
|
||||||
@end="onDragEnd"
|
>
|
||||||
|
<v-row
|
||||||
|
v-for="(field, index) in fields"
|
||||||
|
:key="field.id"
|
||||||
|
class="d-flex flex-row flex-wrap mx-auto pb-2"
|
||||||
|
:class="$vuetify.display.xs ? (Math.floor(index / 1) % 2 === 0 ? 'bg-dark' : 'bg-light') : ''"
|
||||||
|
style="max-width: 100%;"
|
||||||
>
|
>
|
||||||
<v-row
|
<!-- drag handle -->
|
||||||
v-for="(field, index) in fields"
|
<v-col
|
||||||
:key="field.id"
|
:cols="config.items.icon.cols(index)"
|
||||||
class="d-flex flex-nowrap"
|
:sm="config.items.icon.sm(index)"
|
||||||
style="max-width: 100%;"
|
:class="$vuetify.display.smAndDown ? 'd-flex pa-0' : 'd-flex justify-end pr-6'"
|
||||||
>
|
>
|
||||||
<!-- drag handle -->
|
<v-icon class="handle my-auto" :size="28" style="cursor: move;">
|
||||||
<v-col
|
{{ $globals.icons.arrowUpDown }}
|
||||||
:cols="config.items.icon.cols"
|
</v-icon>
|
||||||
:class="config.col.class"
|
</v-col>
|
||||||
:style="config.items.icon.style"
|
|
||||||
|
<!-- and / or -->
|
||||||
|
<v-col
|
||||||
|
v-if="index != 0 || $vuetify.display.smAndUp"
|
||||||
|
:cols="config.items.logicalOperator.cols(index)"
|
||||||
|
:sm="config.items.logicalOperator.sm(index)"
|
||||||
|
:class="config.col.class"
|
||||||
|
>
|
||||||
|
<v-select
|
||||||
|
v-if="index"
|
||||||
|
:model-value="field.logicalOperator"
|
||||||
|
:items="[logOps.AND, logOps.OR]"
|
||||||
|
item-title="label"
|
||||||
|
item-value="value"
|
||||||
|
variant="underlined"
|
||||||
|
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
|
||||||
>
|
>
|
||||||
<v-icon
|
<template #chip="{ item }">
|
||||||
class="handle"
|
<span :class="config.select.textClass" style="width: 100%;">
|
||||||
:size="24"
|
{{ item.raw.label }}
|
||||||
style="cursor: move;margin: auto;"
|
</span>
|
||||||
>
|
</template>
|
||||||
{{ $globals.icons.arrowUpDown }}
|
</v-select>
|
||||||
</v-icon>
|
</v-col>
|
||||||
</v-col>
|
|
||||||
<!-- and / or -->
|
<!-- left parenthesis -->
|
||||||
<v-col
|
<v-col
|
||||||
:cols="config.items.logicalOperator.cols"
|
v-if="showAdvanced"
|
||||||
:class="config.col.class"
|
:cols="config.items.leftParens.cols(index)"
|
||||||
:style="config.items.logicalOperator.style"
|
:sm="config.items.leftParens.sm(index)"
|
||||||
|
:class="config.col.class"
|
||||||
|
>
|
||||||
|
<v-select
|
||||||
|
:model-value="field.leftParenthesis"
|
||||||
|
:items="['', '(', '((', '(((']"
|
||||||
|
variant="underlined"
|
||||||
|
@update:model-value="setLeftParenthesisValue(field, index, $event)"
|
||||||
>
|
>
|
||||||
<v-select
|
<template #chip="{ item }">
|
||||||
v-if="index"
|
<span :class="config.select.textClass" style="width: 100%;">
|
||||||
:model-value="field.logicalOperator"
|
{{ item.raw }}
|
||||||
:items="[logOps.AND, logOps.OR]"
|
</span>
|
||||||
item-title="label"
|
</template>
|
||||||
item-value="value"
|
</v-select>
|
||||||
variant="underlined"
|
</v-col>
|
||||||
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
|
|
||||||
>
|
<!-- field name -->
|
||||||
<template #chip="{ item }">
|
<v-col
|
||||||
<span :class="config.select.textClass" style="width: 100%;">
|
:cols="config.items.fieldName.cols(index)"
|
||||||
{{ item.raw.label }}
|
:sm="config.items.fieldName.sm(index)"
|
||||||
</span>
|
:class="config.col.class"
|
||||||
</template>
|
>
|
||||||
</v-select>
|
<v-select
|
||||||
</v-col>
|
chips
|
||||||
<!-- left parenthesis -->
|
:model-value="field.label"
|
||||||
<v-col
|
:items="fieldDefs"
|
||||||
v-if="showAdvanced"
|
variant="underlined"
|
||||||
:cols="config.items.leftParens.cols"
|
item-title="label"
|
||||||
:class="config.col.class"
|
@update:model-value="setField(index, $event)"
|
||||||
:style="config.items.leftParens.style"
|
|
||||||
>
|
>
|
||||||
<v-select
|
<template #chip="{ item }">
|
||||||
:model-value="field.leftParenthesis"
|
<span :class="config.select.textClass" style="width: 100%;">
|
||||||
:items="['', '(', '((', '(((']"
|
{{ item.raw.label }}
|
||||||
variant="underlined"
|
</span>
|
||||||
@update:model-value="setLeftParenthesisValue(field, index, $event)"
|
</template>
|
||||||
>
|
</v-select>
|
||||||
<template #chip="{ item }">
|
</v-col>
|
||||||
<span :class="config.select.textClass" style="width: 100%;">
|
|
||||||
{{ item.raw }}
|
<!-- relational operator -->
|
||||||
</span>
|
<v-col
|
||||||
</template>
|
:cols="config.items.relationalOperator.cols(index)"
|
||||||
</v-select>
|
:sm="config.items.relationalOperator.sm(index)"
|
||||||
</v-col>
|
:class="config.col.class"
|
||||||
<!-- field name -->
|
>
|
||||||
<v-col
|
<v-select
|
||||||
:cols="config.items.fieldName.cols"
|
v-if="field.type !== 'boolean'"
|
||||||
:class="config.col.class"
|
:model-value="field.relationalOperatorValue"
|
||||||
:style="config.items.fieldName.style"
|
:items="field.relationalOperatorOptions"
|
||||||
|
item-title="label"
|
||||||
|
item-value="value"
|
||||||
|
variant="underlined"
|
||||||
|
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
|
||||||
>
|
>
|
||||||
<v-select
|
<template #chip="{ item }">
|
||||||
chips
|
<span :class="config.select.textClass" style="width: 100%;">
|
||||||
:model-value="field.label"
|
{{ item.raw.label }}
|
||||||
:items="fieldDefs"
|
</span>
|
||||||
variant="underlined"
|
</template>
|
||||||
item-title="label"
|
</v-select>
|
||||||
@update:model-value="setField(index, $event)"
|
</v-col>
|
||||||
>
|
|
||||||
<template #chip="{ item }">
|
<!-- field value -->
|
||||||
<span :class="config.select.textClass" style="width: 100%;">
|
<v-col
|
||||||
{{ item.raw.label }}
|
:cols="config.items.fieldValue.cols(index)"
|
||||||
</span>
|
:sm="config.items.fieldValue.sm(index)"
|
||||||
</template>
|
:class="config.col.class"
|
||||||
</v-select>
|
>
|
||||||
</v-col>
|
<v-select
|
||||||
<!-- relational operator -->
|
v-if="field.fieldOptions"
|
||||||
<v-col
|
:model-value="field.values"
|
||||||
:cols="config.items.relationalOperator.cols"
|
:items="field.fieldOptions"
|
||||||
:class="config.col.class"
|
item-title="label"
|
||||||
:style="config.items.relationalOperator.style"
|
item-value="value"
|
||||||
|
multiple
|
||||||
|
variant="underlined"
|
||||||
|
@update:model-value="setFieldValues(field, index, $event)"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-else-if="field.type === 'string'"
|
||||||
|
:model-value="field.value"
|
||||||
|
variant="underlined"
|
||||||
|
@update:model-value="setFieldValue(field, index, $event)"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-else-if="field.type === 'number'"
|
||||||
|
:model-value="field.value"
|
||||||
|
type="number"
|
||||||
|
variant="underlined"
|
||||||
|
@update:model-value="setFieldValue(field, index, $event)"
|
||||||
|
/>
|
||||||
|
<v-checkbox
|
||||||
|
v-else-if="field.type === 'boolean'"
|
||||||
|
:model-value="field.value"
|
||||||
|
@update:model-value="setFieldValue(field, index, $event!)"
|
||||||
|
/>
|
||||||
|
<v-menu
|
||||||
|
v-else-if="field.type === 'date'"
|
||||||
|
v-model="datePickers[index]"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
transition="scale-transition"
|
||||||
|
offset-y
|
||||||
|
max-width="290px"
|
||||||
|
min-width="auto"
|
||||||
>
|
>
|
||||||
<v-select
|
<template #activator="{ props: activatorProps }">
|
||||||
v-if="field.type !== 'boolean'"
|
<v-text-field
|
||||||
:model-value="field.relationalOperatorValue"
|
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null"
|
||||||
:items="field.relationalOperatorOptions"
|
persistent-hint
|
||||||
item-title="label"
|
:prepend-icon="$globals.icons.calendar"
|
||||||
item-value="value"
|
variant="underlined"
|
||||||
variant="underlined"
|
color="primary"
|
||||||
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
|
v-bind="activatorProps"
|
||||||
>
|
readonly
|
||||||
<template #chip="{ item }">
|
|
||||||
<span :class="config.select.textClass" style="width: 100%;">
|
|
||||||
{{ item.raw.label }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</v-select>
|
|
||||||
</v-col>
|
|
||||||
<!-- field value -->
|
|
||||||
<v-col
|
|
||||||
:cols="config.items.fieldValue.cols"
|
|
||||||
:class="config.col.class"
|
|
||||||
:style="config.items.fieldValue.style"
|
|
||||||
>
|
|
||||||
<v-select
|
|
||||||
v-if="field.fieldOptions"
|
|
||||||
:model-value="field.values"
|
|
||||||
:items="field.fieldOptions"
|
|
||||||
item-title="label"
|
|
||||||
item-value="value"
|
|
||||||
multiple
|
|
||||||
variant="underlined"
|
|
||||||
@update:model-value="setFieldValues(field, index, $event)"
|
|
||||||
/>
|
|
||||||
<v-text-field
|
|
||||||
v-else-if="field.type === 'string'"
|
|
||||||
:model-value="field.value"
|
|
||||||
variant="underlined"
|
|
||||||
@update:model-value="setFieldValue(field, index, $event)"
|
|
||||||
/>
|
|
||||||
<v-text-field
|
|
||||||
v-else-if="field.type === 'number'"
|
|
||||||
:model-value="field.value"
|
|
||||||
type="number"
|
|
||||||
variant="underlined"
|
|
||||||
@update:model-value="setFieldValue(field, index, $event)"
|
|
||||||
/>
|
|
||||||
<v-checkbox
|
|
||||||
v-else-if="field.type === 'boolean'"
|
|
||||||
:model-value="field.value"
|
|
||||||
@update:model-value="setFieldValue(field, index, $event!)"
|
|
||||||
/>
|
|
||||||
<v-menu
|
|
||||||
v-else-if="field.type === 'date'"
|
|
||||||
v-model="datePickers[index]"
|
|
||||||
:close-on-content-click="false"
|
|
||||||
transition="scale-transition"
|
|
||||||
offset-y
|
|
||||||
max-width="290px"
|
|
||||||
min-width="auto"
|
|
||||||
>
|
|
||||||
<template #activator="{ props: activatorProps }">
|
|
||||||
<v-text-field
|
|
||||||
v-model="field.value"
|
|
||||||
persistent-hint
|
|
||||||
:prepend-icon="$globals.icons.calendar"
|
|
||||||
variant="underlined"
|
|
||||||
color="primary"
|
|
||||||
v-bind="activatorProps"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<v-date-picker
|
|
||||||
: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>
|
</template>
|
||||||
<RecipeOrganizerSelector
|
<v-date-picker
|
||||||
v-else-if="field.type === Organizer.Category"
|
:model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
|
||||||
v-model="field.organizers"
|
hide-header
|
||||||
:selector-type="Organizer.Category"
|
:first-day-of-week="firstDayOfWeek"
|
||||||
:show-add="false"
|
:local="$i18n.locale"
|
||||||
:show-label="false"
|
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
|
||||||
:show-icon="false"
|
|
||||||
variant="underlined"
|
|
||||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
|
||||||
/>
|
/>
|
||||||
<RecipeOrganizerSelector
|
</v-menu>
|
||||||
v-else-if="field.type === Organizer.Tag"
|
<RecipeOrganizerSelector
|
||||||
v-model="field.organizers"
|
v-else-if="field.type === Organizer.Category"
|
||||||
:selector-type="Organizer.Tag"
|
v-model="field.organizers"
|
||||||
:show-add="false"
|
:selector-type="Organizer.Category"
|
||||||
:show-label="false"
|
:show-add="false"
|
||||||
:show-icon="false"
|
:show-label="false"
|
||||||
variant="underlined"
|
:show-icon="false"
|
||||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
variant="underlined"
|
||||||
/>
|
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
|
||||||
<RecipeOrganizerSelector
|
/>
|
||||||
v-else-if="field.type === Organizer.Tool"
|
<RecipeOrganizerSelector
|
||||||
v-model="field.organizers"
|
v-else-if="field.type === Organizer.Tag"
|
||||||
:selector-type="Organizer.Tool"
|
v-model="field.organizers"
|
||||||
:show-add="false"
|
:selector-type="Organizer.Tag"
|
||||||
:show-label="false"
|
:show-add="false"
|
||||||
:show-icon="false"
|
:show-label="false"
|
||||||
variant="underlined"
|
:show-icon="false"
|
||||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
variant="underlined"
|
||||||
/>
|
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
|
||||||
<RecipeOrganizerSelector
|
/>
|
||||||
v-else-if="field.type === Organizer.Food"
|
<RecipeOrganizerSelector
|
||||||
v-model="field.organizers"
|
v-else-if="field.type === Organizer.Tool"
|
||||||
:selector-type="Organizer.Food"
|
v-model="field.organizers"
|
||||||
:show-add="false"
|
:selector-type="Organizer.Tool"
|
||||||
:show-label="false"
|
:show-add="false"
|
||||||
:show-icon="false"
|
:show-label="false"
|
||||||
variant="underlined"
|
:show-icon="false"
|
||||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
variant="underlined"
|
||||||
/>
|
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
|
||||||
<RecipeOrganizerSelector
|
/>
|
||||||
v-else-if="field.type === Organizer.Household"
|
<RecipeOrganizerSelector
|
||||||
v-model="field.organizers"
|
v-else-if="field.type === Organizer.Food"
|
||||||
:selector-type="Organizer.Household"
|
v-model="field.organizers"
|
||||||
:show-add="false"
|
:selector-type="Organizer.Food"
|
||||||
:show-label="false"
|
:show-add="false"
|
||||||
:show-icon="false"
|
:show-label="false"
|
||||||
variant="underlined"
|
:show-icon="false"
|
||||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
variant="underlined"
|
||||||
/>
|
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
|
||||||
</v-col>
|
/>
|
||||||
<!-- right parenthesis -->
|
<RecipeOrganizerSelector
|
||||||
<v-col
|
v-else-if="field.type === Organizer.Household"
|
||||||
v-if="showAdvanced"
|
v-model="field.organizers"
|
||||||
:cols="config.items.rightParens.cols"
|
:selector-type="Organizer.Household"
|
||||||
:class="config.col.class"
|
:show-add="false"
|
||||||
:style="config.items.rightParens.style"
|
:show-label="false"
|
||||||
|
:show-icon="false"
|
||||||
|
variant="underlined"
|
||||||
|
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
|
||||||
|
/>
|
||||||
|
<RecipeOrganizerSelector
|
||||||
|
v-else-if="field.type === Organizer.User"
|
||||||
|
v-model="field.organizers"
|
||||||
|
:selector-type="Organizer.User"
|
||||||
|
:show-add="false"
|
||||||
|
:show-label="false"
|
||||||
|
:show-icon="false"
|
||||||
|
variant="underlined"
|
||||||
|
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<!-- right parenthesis -->
|
||||||
|
<v-col
|
||||||
|
v-if="showAdvanced"
|
||||||
|
:cols="config.items.rightParens.cols(index)"
|
||||||
|
:sm="config.items.rightParens.sm(index)"
|
||||||
|
:class="config.col.class"
|
||||||
|
>
|
||||||
|
<v-select
|
||||||
|
:model-value="field.rightParenthesis"
|
||||||
|
:items="['', ')', '))', ')))']"
|
||||||
|
variant="underlined"
|
||||||
|
@update:model-value="setRightParenthesisValue(field, index, $event)"
|
||||||
>
|
>
|
||||||
<v-select
|
<template #chip="{ item }">
|
||||||
:model-value="field.rightParenthesis"
|
<span :class="config.select.textClass" style="width: 100%;">
|
||||||
:items="['', ')', '))', ')))']"
|
{{ item.raw }}
|
||||||
variant="underlined"
|
</span>
|
||||||
@update:model-value="setRightParenthesisValue(field, index, $event)"
|
</template>
|
||||||
>
|
</v-select>
|
||||||
<template #chip="{ item }">
|
</v-col>
|
||||||
<span :class="config.select.textClass" style="width: 100%;">
|
|
||||||
{{ item.raw }}
|
<!-- field actions -->
|
||||||
</span>
|
<v-col
|
||||||
</template>
|
v-if="!$vuetify.display.smAndDown || index === fields.length - 1"
|
||||||
</v-select>
|
:cols="config.items.fieldActions.cols(index)"
|
||||||
</v-col>
|
:sm="config.items.fieldActions.sm(index)"
|
||||||
<!-- field actions -->
|
:class="config.col.class"
|
||||||
<v-col
|
>
|
||||||
:cols="config.items.fieldActions.cols"
|
<BaseButtonGroup
|
||||||
:class="config.col.class"
|
:buttons="[
|
||||||
:style="config.items.fieldActions.style"
|
{
|
||||||
>
|
icon: $globals.icons.delete,
|
||||||
<BaseButtonGroup
|
text: $t('general.delete'),
|
||||||
:buttons="[
|
event: 'delete',
|
||||||
{
|
disabled: fields.length === 1,
|
||||||
icon: $globals.icons.delete,
|
},
|
||||||
text: $t('general.delete'),
|
]"
|
||||||
event: 'delete',
|
class="my-auto"
|
||||||
disabled: fields.length === 1,
|
@delete="removeField(index)"
|
||||||
},
|
/>
|
||||||
]"
|
</v-col>
|
||||||
class="my-auto"
|
</v-row>
|
||||||
@delete="removeField(index)"
|
</VueDraggable>
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</VueDraggable>
|
|
||||||
</v-container>
|
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-row fluid class="d-flex justify-end pa-0 mx-2">
|
<v-row fluid class="d-flex justify-end ma-2">
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-model="showAdvanced"
|
v-model="showAdvanced"
|
||||||
@@ -305,6 +319,7 @@ import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerS
|
|||||||
import { Organizer } from "~/lib/api/types/non-generated";
|
import { Organizer } from "~/lib/api/types/non-generated";
|
||||||
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
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";
|
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -344,6 +359,7 @@ const storeMap = {
|
|||||||
[Organizer.Tool]: useToolStore(),
|
[Organizer.Tool]: useToolStore(),
|
||||||
[Organizer.Food]: useFoodStore(),
|
[Organizer.Food]: useFoodStore(),
|
||||||
[Organizer.Household]: useHouseholdStore(),
|
[Organizer.Household]: useHouseholdStore(),
|
||||||
|
[Organizer.User]: useUserStore(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function onDragEnd(event: any) {
|
function onDragEnd(event: any) {
|
||||||
@@ -602,46 +618,56 @@ function buildQueryFilterJSON(): QueryFilterJSON {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = computed(() => {
|
const config = computed(() => {
|
||||||
const baseColMaxWidth = 55;
|
const multiple = fields.value.length > 1;
|
||||||
|
const adv = state.showAdvanced;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
col: {
|
col: {
|
||||||
class: "d-flex justify-center align-end field-col pa-1",
|
class: "d-flex justify-center align-end py-0",
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
textClass: "d-flex justify-center text-center",
|
textClass: "d-flex justify-center text-center",
|
||||||
},
|
},
|
||||||
items: {
|
items: {
|
||||||
icon: {
|
icon: {
|
||||||
cols: 1,
|
cols: (_index: number) => 2,
|
||||||
|
sm: (_index: number) => 1,
|
||||||
style: "width: fit-content;",
|
style: "width: fit-content;",
|
||||||
},
|
},
|
||||||
leftParens: {
|
leftParens: {
|
||||||
cols: state.showAdvanced ? 1 : 0,
|
cols: (index: number) => (adv ? (index === 0 ? 2 : 0) : 0),
|
||||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
sm: (_index: number) => (adv ? 1 : 0),
|
||||||
},
|
},
|
||||||
logicalOperator: {
|
logicalOperator: {
|
||||||
cols: 1,
|
cols: (_index: number) => 0,
|
||||||
style: `min-width: ${baseColMaxWidth}px;`,
|
sm: (_index: number) => (multiple ? 1 : 0),
|
||||||
},
|
},
|
||||||
fieldName: {
|
fieldName: {
|
||||||
cols: state.showAdvanced ? 2 : 3,
|
cols: (index: number) => {
|
||||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
if (adv) return index === 0 ? 8 : 12;
|
||||||
|
return index === 0 ? 10 : 12;
|
||||||
|
},
|
||||||
|
sm: (_index: number) => (adv ? 2 : 3),
|
||||||
},
|
},
|
||||||
relationalOperator: {
|
relationalOperator: {
|
||||||
cols: 2,
|
cols: (_index: number) => 12,
|
||||||
style: `min-width: ${baseColMaxWidth * 2}px;`,
|
sm: (_index: number) => 2,
|
||||||
},
|
},
|
||||||
fieldValue: {
|
fieldValue: {
|
||||||
cols: state.showAdvanced ? 3 : 4,
|
cols: (index: number) => {
|
||||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
const last = index === fields.value.length - 1;
|
||||||
|
if (adv) return last ? 8 : 10;
|
||||||
|
return last ? 10 : 12;
|
||||||
|
},
|
||||||
|
sm: (_index: number) => (adv ? 3 : 4),
|
||||||
},
|
},
|
||||||
rightParens: {
|
rightParens: {
|
||||||
cols: state.showAdvanced ? 1 : 0,
|
cols: (index: number) => (adv ? (index === fields.value.length - 1 ? 2 : 0) : 0),
|
||||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
sm: (_index: number) => (adv ? 1 : 0),
|
||||||
},
|
},
|
||||||
fieldActions: {
|
fieldActions: {
|
||||||
cols: 1,
|
cols: (index: number) => (index === fields.value.length - 1 ? 2 : 0),
|
||||||
style: `min-width: ${baseColMaxWidth}px;`,
|
sm: (_index: number) => 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -651,5 +677,14 @@ const config = computed(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
* {
|
* {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
|
--bg-opactity: calc(var(--v-hover-opacity) * var(--v-theme-overlay-multiplier));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-dark {
|
||||||
|
background-color: rgba(0, 0, 0, var(--bg-opactity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-light {
|
||||||
|
background-color: rgba(255, 255, 255, var(--bg-opactity));
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -28,11 +28,12 @@
|
|||||||
<v-list-item-title class="pl-2">
|
<v-list-item-title class="pl-2">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<v-list-item-action>
|
<template #append>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="!edit"
|
v-if="!edit"
|
||||||
color="primary"
|
color="primary"
|
||||||
icon
|
icon
|
||||||
|
size="small"
|
||||||
:href="assetURL(item.fileName ?? '')"
|
:href="assetURL(item.fileName ?? '')"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
top
|
top
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
<v-btn
|
<v-btn
|
||||||
color="error"
|
color="error"
|
||||||
icon
|
icon
|
||||||
|
size="small"
|
||||||
top
|
top
|
||||||
@click="model.splice(i, 1)"
|
@click="model.splice(i, 1)"
|
||||||
>
|
>
|
||||||
@@ -53,7 +55,7 @@
|
|||||||
:copy-text="assetEmbed(item.fileName ?? '')"
|
:copy-text="assetEmbed(item.fileName ?? '')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</v-list-item-action>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -90,13 +92,12 @@
|
|||||||
item-value="name"
|
item-value="name"
|
||||||
class="mr-2"
|
class="mr-2"
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #item="{ item, props: itemProps }">
|
||||||
<v-avatar>
|
<v-list-item v-bind="itemProps">
|
||||||
<v-icon class="mr-auto">
|
<template #prepend>
|
||||||
{{ item.raw.icon }}
|
<v-icon>{{ item.raw.icon }}</v-icon>
|
||||||
</v-icon>
|
</template>
|
||||||
</v-avatar>
|
</v-list-item>
|
||||||
{{ item.title }}
|
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
<AppButtonUpload
|
<AppButtonUpload
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
@click.self="$emit('click')"
|
@click.self="$emit('click')"
|
||||||
>
|
>
|
||||||
<RecipeCardImage
|
<RecipeCardImage
|
||||||
|
small
|
||||||
:icon-size="imageHeight"
|
:icon-size="imageHeight"
|
||||||
:height="imageHeight"
|
:height="imageHeight"
|
||||||
:slug="slug"
|
:slug="slug"
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
size="small"
|
|
||||||
:image-version="image"
|
:image-version="image"
|
||||||
>
|
>
|
||||||
<v-expand-transition v-if="description">
|
<v-expand-transition v-if="description">
|
||||||
@@ -49,7 +49,6 @@
|
|||||||
>
|
>
|
||||||
<RecipeFavoriteBadge
|
<RecipeFavoriteBadge
|
||||||
v-if="isOwnGroup"
|
v-if="isOwnGroup"
|
||||||
class="absolute"
|
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
show-always
|
show-always
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,10 +19,10 @@
|
|||||||
cover
|
cover
|
||||||
>
|
>
|
||||||
<RecipeCardImage
|
<RecipeCardImage
|
||||||
|
tiny
|
||||||
:icon-size="100"
|
:icon-size="100"
|
||||||
:slug="slug"
|
:slug="slug"
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
size="small"
|
|
||||||
:image-version="image"
|
:image-version="image"
|
||||||
:height="height"
|
:height="height"
|
||||||
/>
|
/>
|
||||||
@@ -41,11 +41,11 @@
|
|||||||
name="avatar"
|
name="avatar"
|
||||||
>
|
>
|
||||||
<RecipeCardImage
|
<RecipeCardImage
|
||||||
|
tiny
|
||||||
:icon-size="100"
|
:icon-size="100"
|
||||||
:slug="slug"
|
:slug="slug"
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
:image-version="image"
|
:image-version="image"
|
||||||
size="small"
|
|
||||||
width="125"
|
width="125"
|
||||||
:height="height"
|
:height="height"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -90,6 +90,14 @@
|
|||||||
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
|
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
|
||||||
</div>
|
</div>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
<v-list-item @click="sortRecipes(EVENTS.shuffle)">
|
||||||
|
<div class="d-flex align-center flex-nowrap">
|
||||||
|
<v-icon class="mr-2" inline>
|
||||||
|
{{ $globals.icons.diceMultiple }}
|
||||||
|
</v-icon>
|
||||||
|
<v-list-item-title>{{ $t("general.random") }}</v-list-item-title>
|
||||||
|
</div>
|
||||||
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
@@ -223,6 +231,7 @@ const displayTitleIcon = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sortLoading = ref(false);
|
const sortLoading = ref(false);
|
||||||
|
const randomSeed = ref(Date.now().toString());
|
||||||
|
|
||||||
const route = useRoute();
|
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 || "");
|
||||||
@@ -256,13 +265,18 @@ const queryFilter = computed(() => {
|
|||||||
async function fetchRecipes(pageCount = 1) {
|
async function fetchRecipes(pageCount = 1) {
|
||||||
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
|
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
|
||||||
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
|
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
|
||||||
|
const orderBy = props.query?.orderBy || preferences.value.orderBy;
|
||||||
|
const localQuery = { ...props.query };
|
||||||
|
if (orderBy === "random") {
|
||||||
|
localQuery._searchSeed = randomSeed.value;
|
||||||
|
}
|
||||||
return await fetchMore(
|
return await fetchMore(
|
||||||
page.value,
|
page.value,
|
||||||
perPage * pageCount,
|
perPage * pageCount,
|
||||||
props.query?.orderBy || preferences.value.orderBy,
|
orderBy,
|
||||||
orderDir,
|
orderDir,
|
||||||
orderByNullPosition,
|
orderByNullPosition,
|
||||||
props.query,
|
localQuery,
|
||||||
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
|
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
|
||||||
queryFilter.value,
|
queryFilter.value,
|
||||||
);
|
);
|
||||||
@@ -288,6 +302,9 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function initRecipes() {
|
async function initRecipes() {
|
||||||
|
if (preferences.value.orderBy === "random") {
|
||||||
|
randomSeed.value = Date.now().toString();
|
||||||
|
}
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
hasMore.value = true;
|
hasMore.value = true;
|
||||||
|
|
||||||
@@ -380,6 +397,15 @@ async function sortRecipes(sortType: string) {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case EVENTS.shuffle:
|
||||||
|
setter(
|
||||||
|
"random",
|
||||||
|
$globals.icons.diceMultiple,
|
||||||
|
$globals.icons.diceMultiple, // icon in asc and desc is the same for random
|
||||||
|
);
|
||||||
|
// We update the seed value to have a different order
|
||||||
|
randomSeed.value = Date.now().toString();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.log("Unknown Event", sortType);
|
console.log("Unknown Event", sortType);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -45,31 +45,15 @@
|
|||||||
@confirm="addRecipeToPlan()"
|
@confirm="addRecipeToPlan()"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-menu
|
<v-date-picker
|
||||||
v-model="pickerMenu"
|
v-model="newMealdate"
|
||||||
:close-on-content-click="false"
|
class="mx-auto mb-3"
|
||||||
transition="scale-transition"
|
hide-header
|
||||||
offset-y
|
show-adjacent-months
|
||||||
max-width="290px"
|
color="primary"
|
||||||
min-width="auto"
|
:first-day-of-week="firstDayOfWeek"
|
||||||
>
|
:local="$i18n.locale"
|
||||||
<template #activator="{ props: activatorProps }">
|
/>
|
||||||
<v-text-field
|
|
||||||
v-model="newMealdateString"
|
|
||||||
:label="$t('general.date')"
|
|
||||||
:prepend-icon="$globals.icons.calendar"
|
|
||||||
v-bind="activatorProps"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<v-date-picker
|
|
||||||
v-model="newMealdate"
|
|
||||||
hide-header
|
|
||||||
:first-day-of-week="firstDayOfWeek"
|
|
||||||
:local="$i18n.locale"
|
|
||||||
@update:model-value="pickerMenu = false"
|
|
||||||
/>
|
|
||||||
</v-menu>
|
|
||||||
<v-select
|
<v-select
|
||||||
v-model="newMealType"
|
v-model="newMealType"
|
||||||
:return-object="false"
|
:return-object="false"
|
||||||
@@ -207,7 +191,6 @@ const loading = ref(false);
|
|||||||
const menuItems = ref<ContextMenuItem[]>([]);
|
const menuItems = ref<ContextMenuItem[]>([]);
|
||||||
const newMealdate = ref(new Date());
|
const newMealdate = ref(new Date());
|
||||||
const newMealType = ref<PlanEntryType>("dinner");
|
const newMealType = ref<PlanEntryType>("dinner");
|
||||||
const pickerMenu = ref(false);
|
|
||||||
|
|
||||||
const newMealdateString = computed(() => {
|
const newMealdateString = computed(() => {
|
||||||
// Format the date to YYYY-MM-DD in the same timezone as newMealdate
|
// Format the date to YYYY-MM-DD in the same timezone as newMealdate
|
||||||
@@ -377,11 +360,14 @@ async function deleteRecipe() {
|
|||||||
const download = useDownloader();
|
const download = useDownloader();
|
||||||
|
|
||||||
async function handleDownloadEvent() {
|
async function handleDownloadEvent() {
|
||||||
const { data } = await api.recipes.getZipToken(props.slug);
|
const { data: shareToken } = await api.recipes.share.createOne({ recipeId: props.recipeId });
|
||||||
|
if (!shareToken) {
|
||||||
if (data) {
|
console.error("No share token received");
|
||||||
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
download(api.recipes.share.getZipRedirectUrl(shareToken.id), `${props.slug}.zip`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRecipeToPlan() {
|
async function addRecipeToPlan() {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #[`item.dateAdded`]="{ item }">
|
<template #[`item.dateAdded`]="{ item }">
|
||||||
{{ formatDate(item.dateAdded!) }}
|
{{ item.dateAdded ? $d(new Date(item.dateAdded)) : '' }}
|
||||||
</template>
|
</template>
|
||||||
</v-data-table>
|
</v-data-table>
|
||||||
</template>
|
</template>
|
||||||
@@ -153,15 +153,6 @@ const headers = computed(() => {
|
|||||||
return hdrs;
|
return hdrs;
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDate(date: string) {
|
|
||||||
try {
|
|
||||||
return i18n.d(Date.parse(date), "medium");
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Group Members
|
// Group Members
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
density="compact"
|
density="compact"
|
||||||
/>
|
/>
|
||||||
<div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
|
<div :key="`${ingredientData.ingredient?.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
|
||||||
<RecipeIngredientListItem
|
<RecipeIngredientListItem
|
||||||
:ingredient="ingredientData.ingredient"
|
:ingredient="ingredientData.ingredient"
|
||||||
:scale="recipeSection.recipeScale"
|
:scale="recipeSection.recipeScale"
|
||||||
@@ -287,12 +287,35 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
|
const shoppingListIngredients: ShoppingListIngredient[] = [];
|
||||||
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
|
function flattenRecipeIngredients(ing: RecipeIngredient, parentTitle = ""): ShoppingListIngredient[] {
|
||||||
return {
|
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
|
||||||
checked: !householdsWithFood.includes(userHousehold.value),
|
if (ing.referencedRecipe) {
|
||||||
ingredient: ing,
|
// Recursively flatten all ingredients in the referenced recipe
|
||||||
};
|
return (ing.referencedRecipe.recipeIngredient ?? []).flatMap((subIng) => {
|
||||||
|
const calculatedQty = (ing.quantity || 1) * (subIng.quantity || 1);
|
||||||
|
// Pass the referenced recipe name as the section title
|
||||||
|
return flattenRecipeIngredients(
|
||||||
|
{ ...subIng, quantity: calculatedQty },
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Regular ingredient
|
||||||
|
return [{
|
||||||
|
checked: !householdsWithFood.includes(userHousehold.value),
|
||||||
|
ingredient: {
|
||||||
|
...ing,
|
||||||
|
title: ing.title || parentTitle,
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recipe.recipeIngredient.forEach((ing) => {
|
||||||
|
const flattened = flattenRecipeIngredients(ing, "");
|
||||||
|
shoppingListIngredients.push(...flattened);
|
||||||
});
|
});
|
||||||
|
|
||||||
let currentTitle = "";
|
let currentTitle = "";
|
||||||
@@ -301,6 +324,9 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
|||||||
if (ing.ingredient.title) {
|
if (ing.ingredient.title) {
|
||||||
currentTitle = ing.ingredient.title;
|
currentTitle = ing.ingredient.title;
|
||||||
}
|
}
|
||||||
|
else if (ing.ingredient.referencedRecipe?.name) {
|
||||||
|
currentTitle = ing.ingredient.referencedRecipe.name;
|
||||||
|
}
|
||||||
|
|
||||||
// If this is the first item in the section, create a new section
|
// If this is the first item in the section, create a new section
|
||||||
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
|
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
|
||||||
@@ -316,7 +342,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store the on-hand ingredients for later
|
// Store the on-hand ingredients for later
|
||||||
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
|
const householdsWithFood = (ing.ingredient?.food?.householdsWithIngredientFood || []);
|
||||||
if (householdsWithFood.includes(userHousehold.value)) {
|
if (householdsWithFood.includes(userHousehold.value)) {
|
||||||
onHandIngs.push(ing);
|
onHandIngs.push(ing);
|
||||||
return sections;
|
return sections;
|
||||||
|
|||||||
@@ -141,6 +141,13 @@ function save() {
|
|||||||
dialog.value = false;
|
dialog.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
dialog.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const utilities = [
|
const utilities = [
|
||||||
@@ -160,4 +167,10 @@ const utilities = [
|
|||||||
action: splitByNumberedLine,
|
action: splitByNumberedLine,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Expose functions to parent components
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -69,7 +69,14 @@
|
|||||||
:label="$t('recipe.nutrition')"
|
:label="$t('recipe.nutrition')"
|
||||||
/>
|
/>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row no-gutters />
|
<v-row no-gutters>
|
||||||
|
<v-switch
|
||||||
|
v-model="preferences.expandChildRecipes"
|
||||||
|
hide-details
|
||||||
|
color="primary"
|
||||||
|
:label="$t('recipe.include-linked-recipe-ingredients')"
|
||||||
|
/>
|
||||||
|
</v-row>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-container>
|
</v-container>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
>
|
>
|
||||||
<template #activator="{ props: activatorProps }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="expirationDateString"
|
:model-value="$d(expirationDate)"
|
||||||
:label="$t('recipe-share.expiration-date')"
|
:label="$t('recipe-share.expiration-date')"
|
||||||
:hint="$t('recipe-share.default-30-days')"
|
:hint="$t('recipe-share.default-30-days')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
@@ -59,11 +59,8 @@
|
|||||||
|
|
||||||
<div class="pl-3 flex-grow-1">
|
<div class="pl-3 flex-grow-1">
|
||||||
<v-list-item-title>
|
<v-list-item-title>
|
||||||
{{ $t("recipe-share.expires-at") }}
|
{{ $t("recipe-share.expires-at") + ' ' + $d(new Date(token.expiresAt!), "short") }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<v-list-item-subtitle>
|
|
||||||
{{ $d(new Date(token.expiresAt!), "long") }}
|
|
||||||
</v-list-item-subtitle>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -111,10 +108,6 @@ const datePickerMenu = ref(false);
|
|||||||
const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
|
const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
|
||||||
const tokens = ref<RecipeShareToken[]>([]);
|
const tokens = ref<RecipeShareToken[]>([]);
|
||||||
|
|
||||||
const expirationDateString = computed(() => {
|
|
||||||
return expirationDate.value.toISOString().substring(0, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
whenever(
|
whenever(
|
||||||
() => dialog.value,
|
() => dialog.value,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
v-bind="props"
|
v-bind="props"
|
||||||
>
|
>
|
||||||
<v-icon :start="!$vuetify.display.xs">
|
<v-icon :start="!$vuetify.display.xs">
|
||||||
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
|
{{ state.orderDirection === "asc" ? $globals.icons.sortDescending : $globals.icons.sortAscending }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $vuetify.display.xs ? null : sortText }}
|
{{ $vuetify.display.xs ? null : sortText }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<v-list-item
|
<v-list-item
|
||||||
slim
|
slim
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
|
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortAscending : $globals.icons.sortDescending"
|
||||||
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
||||||
@click="toggleOrderDirection"
|
@click="toggleOrderDirection"
|
||||||
/>
|
/>
|
||||||
@@ -53,10 +53,23 @@
|
|||||||
:active="state.orderBy === v.value"
|
:active="state.orderBy === v.value"
|
||||||
slim
|
slim
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
:prepend-icon="v.icon"
|
@click="v.value === 'random' ? setRandomOrderByWrapper() : setOrderBy(v.value)"
|
||||||
:title="v.name"
|
>
|
||||||
@click="setOrderBy(v.value)"
|
<template #prepend>
|
||||||
/>
|
<v-icon>{{ v.icon }}</v-icon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #title>
|
||||||
|
<span>{{ v.name }}</span>
|
||||||
|
<v-icon
|
||||||
|
v-if="v.value === 'random' && showRandomLoading"
|
||||||
|
size="small"
|
||||||
|
class="ml-3"
|
||||||
|
>
|
||||||
|
{{ $globals.icons.refreshCircle }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
@@ -131,6 +144,7 @@ const $auth = useMealieAuth();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { $globals } = useNuxtApp();
|
const { $globals } = useNuxtApp();
|
||||||
const i18n = useI18n();
|
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 || "");
|
||||||
|
|
||||||
@@ -141,6 +155,7 @@ const {
|
|||||||
reset,
|
reset,
|
||||||
toggleOrderDirection,
|
toggleOrderDirection,
|
||||||
setOrderBy,
|
setOrderBy,
|
||||||
|
setRandomOrderBy,
|
||||||
filterItems,
|
filterItems,
|
||||||
initialize,
|
initialize,
|
||||||
} = useRecipeExplorerSearch(groupSlug);
|
} = useRecipeExplorerSearch(groupSlug);
|
||||||
@@ -205,6 +220,14 @@ const input: Ref<any> = ref(null);
|
|||||||
function hideKeyboard() {
|
function hideKeyboard() {
|
||||||
input.value?.blur();
|
input.value?.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// function to show refresh icon
|
||||||
|
async function setRandomOrderByWrapper() {
|
||||||
|
if (!showRandomLoading.value) {
|
||||||
|
showRandomLoading.value = true;
|
||||||
|
}
|
||||||
|
await setRandomOrderBy();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -20,18 +20,36 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<v-card width="400">
|
<v-card width="400">
|
||||||
<v-card-title class="headline flex mb-0">
|
<v-card-title class="headline flex-wrap mb-0">
|
||||||
<div>
|
<div>
|
||||||
{{ $t("recipe.recipe-image") }}
|
{{ $t("recipe.recipe-image") }}
|
||||||
</div>
|
</div>
|
||||||
<AppButtonUpload
|
<div class="d-flex gap-2">
|
||||||
class="ml-auto"
|
<AppButtonUpload
|
||||||
url="none"
|
url="none"
|
||||||
file-name="image"
|
file-name="image"
|
||||||
:text-btn="false"
|
:text-btn="false"
|
||||||
:post="false"
|
:post="false"
|
||||||
@uploaded="uploadImage"
|
@uploaded="uploadImage"
|
||||||
/>
|
/>
|
||||||
|
<BaseButton
|
||||||
|
class="ml-2"
|
||||||
|
delete
|
||||||
|
@click="dialogDeleteImage = true"
|
||||||
|
/>
|
||||||
|
<BaseDialog
|
||||||
|
v-model="dialogDeleteImage"
|
||||||
|
:title="$t('recipe.delete-image')"
|
||||||
|
:icon="$globals.icons.alertCircle"
|
||||||
|
color="error"
|
||||||
|
can-delete
|
||||||
|
@delete="deleteImage"
|
||||||
|
>
|
||||||
|
<v-card-text>
|
||||||
|
{{ $t("recipe.delete-image-confirmation") }}
|
||||||
|
</v-card-text>
|
||||||
|
</BaseDialog>
|
||||||
|
</div>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text class="mt-n5">
|
<v-card-text class="mt-n5">
|
||||||
<div>
|
<div>
|
||||||
@@ -62,38 +80,58 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { alert } from "~/composables/use-toast";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
|
||||||
const REFRESH_EVENT = "refresh";
|
|
||||||
const UPLOAD_EVENT = "upload";
|
const UPLOAD_EVENT = "upload";
|
||||||
|
const DELETE_EVENT = "delete";
|
||||||
|
|
||||||
const props = defineProps<{ slug: string }>();
|
const props = defineProps<{ slug: string }>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
refresh: [];
|
refresh: [];
|
||||||
upload: [fileObject: File];
|
upload: [fileObject: File];
|
||||||
|
delete: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const api = useUserApi();
|
||||||
|
|
||||||
const url = ref("");
|
const url = ref("");
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const menu = ref(false);
|
const menu = ref(false);
|
||||||
|
const dialogDeleteImage = ref(false);
|
||||||
|
|
||||||
function uploadImage(fileObject: File) {
|
function uploadImage(fileObject: File) {
|
||||||
emit(UPLOAD_EVENT, fileObject);
|
emit(UPLOAD_EVENT, fileObject);
|
||||||
menu.value = false;
|
menu.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = useUserApi();
|
async function deleteImage() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await api.recipes.deleteImage(props.slug);
|
||||||
|
emit(DELETE_EVENT);
|
||||||
|
menu.value = false;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
console.error("Failed to delete image", e);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getImageFromURL() {
|
async function getImageFromURL() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
|
if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
|
||||||
emit(REFRESH_EVENT);
|
emit(DELETE_EVENT);
|
||||||
}
|
}
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
menu.value = false;
|
menu.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const i18n = useI18n();
|
|
||||||
const messages = computed(() =>
|
const messages = computed(() =>
|
||||||
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
|
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
</v-text-field>
|
</v-text-field>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col
|
<v-col
|
||||||
|
v-if="!state.isRecipe"
|
||||||
sm="12"
|
sm="12"
|
||||||
md="3"
|
md="3"
|
||||||
cols="12"
|
cols="12"
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
variant="solo"
|
variant="solo"
|
||||||
return-object
|
return-object
|
||||||
:items="units || []"
|
:items="units || []"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
class="mx-1"
|
class="mx-1"
|
||||||
:placeholder="$t('recipe.choose-unit')"
|
:placeholder="$t('recipe.choose-unit')"
|
||||||
@@ -97,6 +99,7 @@
|
|||||||
|
|
||||||
<!-- Foods Input -->
|
<!-- Foods Input -->
|
||||||
<v-col
|
<v-col
|
||||||
|
v-if="!state.isRecipe"
|
||||||
m="12"
|
m="12"
|
||||||
md="3"
|
md="3"
|
||||||
cols="12"
|
cols="12"
|
||||||
@@ -112,6 +115,7 @@
|
|||||||
variant="solo"
|
variant="solo"
|
||||||
return-object
|
return-object
|
||||||
:items="foods || []"
|
:items="foods || []"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
class="mx-1 py-0"
|
class="mx-1 py-0"
|
||||||
:placeholder="$t('recipe.choose-food')"
|
:placeholder="$t('recipe.choose-food')"
|
||||||
@@ -151,6 +155,36 @@
|
|||||||
</template>
|
</template>
|
||||||
</v-autocomplete>
|
</v-autocomplete>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<!-- Recipe Input -->
|
||||||
|
<v-col
|
||||||
|
v-if="state.isRecipe"
|
||||||
|
m="12"
|
||||||
|
md="6"
|
||||||
|
cols="12"
|
||||||
|
class=""
|
||||||
|
>
|
||||||
|
<v-autocomplete
|
||||||
|
ref="search.query"
|
||||||
|
v-model="model.referencedRecipe"
|
||||||
|
v-model:search="search.query.value"
|
||||||
|
auto-select-first
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
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')"
|
||||||
|
clearable
|
||||||
|
:label="!model.referencedRecipe ? $t('recipe.choose-recipe') : ''"
|
||||||
|
@click="search.trigger()"
|
||||||
|
@focus="search.trigger()"
|
||||||
|
>
|
||||||
|
<template #prepend />
|
||||||
|
</v-autocomplete>
|
||||||
|
</v-col>
|
||||||
<v-col
|
<v-col
|
||||||
sm="12"
|
sm="12"
|
||||||
md=""
|
md=""
|
||||||
@@ -173,6 +207,7 @@
|
|||||||
class="my-auto d-flex"
|
class="my-auto d-flex"
|
||||||
:buttons="btns"
|
:buttons="btns"
|
||||||
@toggle-section="toggleTitle"
|
@toggle-section="toggleTitle"
|
||||||
|
@toggle-subrecipe="toggleIsRecipe"
|
||||||
@insert-above="$emit('insert-above')"
|
@insert-above="$emit('insert-above')"
|
||||||
@insert-below="$emit('insert-below')"
|
@insert-below="$emit('insert-below')"
|
||||||
@delete="$emit('delete')"
|
@delete="$emit('delete')"
|
||||||
@@ -193,8 +228,11 @@ import { ref, computed, reactive, toRefs } from "vue";
|
|||||||
import { useDisplay } from "vuetify";
|
import { useDisplay } from "vuetify";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||||
|
import { normalizeFilter } from "~/composables/use-utils";
|
||||||
import { useNuxtApp } from "#app";
|
import { useNuxtApp } from "#app";
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
||||||
|
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
|
||||||
|
|
||||||
// defineModel replaces modelValue prop
|
// defineModel replaces modelValue prop
|
||||||
const model = defineModel<RecipeIngredient>({ required: true });
|
const model = defineModel<RecipeIngredient>({ required: true });
|
||||||
@@ -204,6 +242,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: "body",
|
default: "body",
|
||||||
},
|
},
|
||||||
|
isRecipe: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
unitError: {
|
unitError: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -247,6 +289,7 @@ const { $globals } = useNuxtApp();
|
|||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
showTitle: false,
|
showTitle: false,
|
||||||
|
isRecipe: props.isRecipe,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contextMenuOptions = computed(() => {
|
const contextMenuOptions = computed(() => {
|
||||||
@@ -255,6 +298,10 @@ const contextMenuOptions = computed(() => {
|
|||||||
text: i18n.t("recipe.toggle-section"),
|
text: i18n.t("recipe.toggle-section"),
|
||||||
event: "toggle-section",
|
event: "toggle-section",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: i18n.t("recipe.toggle-recipe"),
|
||||||
|
event: "toggle-subrecipe",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: i18n.t("recipe.insert-above"),
|
text: i18n.t("recipe.insert-above"),
|
||||||
event: "insert-above",
|
event: "insert-above",
|
||||||
@@ -303,6 +350,25 @@ async function createAssignFood() {
|
|||||||
foodAutocomplete.value?.blur();
|
foodAutocomplete.value?.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recipes
|
||||||
|
const route = useRoute();
|
||||||
|
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;
|
||||||
|
const search = useRecipeSearch(api);
|
||||||
|
const loading = ref(false);
|
||||||
|
const selectedIndex = ref(-1);
|
||||||
|
// Reset or Grab Recipes on Change
|
||||||
|
watch(loading, (val) => {
|
||||||
|
if (!val) {
|
||||||
|
search.query.value = "";
|
||||||
|
selectedIndex.value = -1;
|
||||||
|
search.data.value = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Units
|
// Units
|
||||||
const unitStore = useUnitStore();
|
const unitStore = useUnitStore();
|
||||||
const unitsData = useUnitData();
|
const unitsData = useUnitData();
|
||||||
@@ -323,6 +389,17 @@ function toggleTitle() {
|
|||||||
state.showTitle = !state.showTitle;
|
state.showTitle = !state.showTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleIsRecipe() {
|
||||||
|
if (state.isRecipe) {
|
||||||
|
model.value.referencedRecipe = undefined;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
model.value.unit = undefined;
|
||||||
|
model.value.food = undefined;
|
||||||
|
}
|
||||||
|
state.isRecipe = !state.isRecipe;
|
||||||
|
}
|
||||||
|
|
||||||
function handleUnitEnter() {
|
function handleUnitEnter() {
|
||||||
if (
|
if (
|
||||||
model.value.unit === undefined
|
model.value.unit === undefined
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
class="text-bold d-inline"
|
class="text-bold d-inline"
|
||||||
:source="parsedIng.note"
|
:source="parsedIng.note"
|
||||||
/>
|
/>
|
||||||
|
<template v-else-if="parsedIng.recipeLink">
|
||||||
|
<SafeMarkdown v-if="parsedIng.recipeLink" class="text-bold d-inline" :source="parsedIng.recipeLink" />
|
||||||
|
<SafeMarkdown v-if="parsedIng.note" class="note" :source="parsedIng.note" />
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SafeMarkdown
|
<SafeMarkdown
|
||||||
v-if="parsedIng.name"
|
v-if="parsedIng.name"
|
||||||
@@ -39,9 +43,12 @@ interface Props {
|
|||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
scale: 1,
|
scale: 1,
|
||||||
});
|
});
|
||||||
|
const route = useRoute();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||||
|
|
||||||
const parsedIng = computed(() => {
|
const parsedIng = computed(() => {
|
||||||
return useParsedIngredientText(props.ingredient, props.scale);
|
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
:title="$t('recipe.made-this')"
|
:title="$t('recipe.made-this')"
|
||||||
:submit-text="$t('recipe.add-to-timeline')"
|
:submit-text="$t('recipe.add-to-timeline')"
|
||||||
can-submit
|
can-submit
|
||||||
|
disable-submit-on-enter
|
||||||
@submit="createTimelineEvent"
|
@submit="createTimelineEvent"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
@@ -20,6 +21,29 @@
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
rows="4"
|
rows="4"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="childRecipes?.length">
|
||||||
|
<v-card-text class="pt-6 pb-0">
|
||||||
|
{{ $t('recipe.include-linked-recipes') }}
|
||||||
|
</v-card-text>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item
|
||||||
|
v-for="(childRecipe, i) in childRecipes"
|
||||||
|
:key="childRecipe.recipeId + i"
|
||||||
|
density="compact"
|
||||||
|
class="my-0 py-0"
|
||||||
|
@click="childRecipe.checked = !childRecipe.checked"
|
||||||
|
>
|
||||||
|
<v-checkbox
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
:input-value="childRecipe.checked"
|
||||||
|
:label="childRecipe.name"
|
||||||
|
class="my-0 py-0"
|
||||||
|
color="secondary"
|
||||||
|
/>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</div>
|
||||||
<v-container>
|
<v-container>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="6">
|
<v-col cols="6">
|
||||||
@@ -32,7 +56,7 @@
|
|||||||
>
|
>
|
||||||
<template #activator="{ props: activatorProps }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="newTimelineEventTimestampString"
|
:model-value="$d(newTimelineEventTimestamp)"
|
||||||
:prepend-icon="$globals.icons.calendar"
|
:prepend-icon="$globals.icons.calendar"
|
||||||
v-bind="activatorProps"
|
v-bind="activatorProps"
|
||||||
readonly
|
readonly
|
||||||
@@ -102,7 +126,7 @@
|
|||||||
<span class="text-body-1 opacity-80">
|
<span class="text-body-1 opacity-80">
|
||||||
<b>{{ $t("general.last-made") }}</b>
|
<b>{{ $t("general.last-made") }}</b>
|
||||||
<br>
|
<br>
|
||||||
{{ lastMade ? new Date(lastMade).toLocaleDateString($i18n.locale) : $t("general.never") }}
|
{{ lastMade ? $d(new Date(lastMade)) : $t("general.never") }}
|
||||||
</span>
|
</span>
|
||||||
<v-icon end size="large" color="primary">
|
<v-icon end size="large" color="primary">
|
||||||
{{ $globals.icons.createAlt }}
|
{{ $globals.icons.createAlt }}
|
||||||
@@ -166,6 +190,21 @@ onMounted(async () => {
|
|||||||
lastMadeReady.value = true;
|
lastMadeReady.value = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const childRecipes = computed(() => {
|
||||||
|
return props.recipe.recipeIngredient?.map((ingredient) => {
|
||||||
|
if (ingredient.referencedRecipe) {
|
||||||
|
return {
|
||||||
|
checked: false, // Default value for checked
|
||||||
|
recipeId: ingredient.referencedRecipe.id || "", // Non-nullable recipeId
|
||||||
|
...ingredient.referencedRecipe, // Spread the rest of the referencedRecipe properties
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}).filter(recipe => recipe !== undefined); // Filter out undefined values
|
||||||
|
});
|
||||||
|
|
||||||
whenever(
|
whenever(
|
||||||
() => madeThisDialog.value,
|
() => madeThisDialog.value,
|
||||||
() => {
|
() => {
|
||||||
@@ -250,6 +289,37 @@ async function createTimelineEvent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const childRecipe of childRecipes.value || []) {
|
||||||
|
if (!childRecipe.checked) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childTimelineEvent = {
|
||||||
|
...newTimelineEvent.value,
|
||||||
|
recipeId: childRecipe.recipeId,
|
||||||
|
eventMessage: i18n.t("recipe.made-for-recipe", { recipe: childRecipe.name }),
|
||||||
|
image: undefined,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await userApi.recipes.createTimelineEvent(childTimelineEvent);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(`Failed to create timeline event for child recipe ${childRecipe.slug}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
newTimelineEvent.value.timestamp
|
||||||
|
&& (!childRecipe.lastMade || newTimelineEvent.value.timestamp > childRecipe.lastMade)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await userApi.recipes.updateLastMade(childRecipe.slug || "", newTimelineEvent.value.timestamp);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(`Failed to update last made date for child recipe ${childRecipe.slug}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// update the image, if provided
|
// update the image, if provided
|
||||||
let imageError = false;
|
let imageError = false;
|
||||||
if (newTimelineEventImage.value) {
|
if (newTimelineEventImage.value) {
|
||||||
@@ -268,7 +338,6 @@ async function createTimelineEvent() {
|
|||||||
console.error("Failed to upload image for timeline event:", error);
|
console.error("Failed to upload image for timeline event:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imageError) {
|
if (imageError) {
|
||||||
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
|
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,10 +105,9 @@
|
|||||||
<v-icon>
|
<v-icon>
|
||||||
{{ icon }}
|
{{ icon }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-card-title class="py-1">
|
<v-card-title class="py-1 text-truncate flex-shrink-1 flex-grow-1">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-spacer />
|
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
:items="[presets.delete, presets.edit]"
|
:items="[presets.delete, presets.edit]"
|
||||||
@delete="confirmDelete(item)"
|
@delete="confirmDelete(item)"
|
||||||
|
|||||||
@@ -4,17 +4,19 @@
|
|||||||
v-bind="inputAttrs"
|
v-bind="inputAttrs"
|
||||||
v-model:search="searchInput"
|
v-model:search="searchInput"
|
||||||
:items="items"
|
:items="items"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
:label="label"
|
:label="label"
|
||||||
chips
|
chips
|
||||||
closable-chips
|
closable-chips
|
||||||
item-title="name"
|
:item-title="itemTitle"
|
||||||
|
item-value="name"
|
||||||
multiple
|
multiple
|
||||||
:variant="variant"
|
:variant="variant"
|
||||||
:prepend-inner-icon="icon"
|
:prepend-inner-icon="icon"
|
||||||
:append-icon="showAdd ? $globals.icons.create : undefined"
|
:append-icon="showAdd ? $globals.icons.create : undefined"
|
||||||
return-object
|
return-object
|
||||||
auto-select-first
|
auto-select-first
|
||||||
class="pa-0"
|
class="pa-0 ma-0"
|
||||||
@update:model-value="resetSearchInput"
|
@update:model-value="resetSearchInput"
|
||||||
@click:append="dialog = true"
|
@click:append="dialog = true"
|
||||||
>
|
>
|
||||||
@@ -32,7 +34,6 @@
|
|||||||
{{ item.value }}
|
{{ item.value }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template
|
<template
|
||||||
v-if="showAdd"
|
v-if="showAdd"
|
||||||
#append
|
#append
|
||||||
@@ -52,11 +53,13 @@ import type { RecipeTool } from "~/lib/api/types/admin";
|
|||||||
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||||
|
import { useUserStore } from "~/composables/store/use-user-store";
|
||||||
|
import { normalizeFilter } from "~/composables/use-utils";
|
||||||
|
import type { UserSummary } from "~/lib/api/types/user";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectorType: RecipeOrganizer;
|
selectorType: RecipeOrganizer;
|
||||||
inputAttrs?: Record<string, any>;
|
inputAttrs?: Record<string, any>;
|
||||||
returnObject?: boolean;
|
|
||||||
showAdd?: boolean;
|
showAdd?: boolean;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
showIcon?: boolean;
|
showIcon?: boolean;
|
||||||
@@ -65,7 +68,6 @@ interface Props {
|
|||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
inputAttrs: () => ({}),
|
inputAttrs: () => ({}),
|
||||||
returnObject: true,
|
|
||||||
showAdd: true,
|
showAdd: true,
|
||||||
showLabel: true,
|
showLabel: true,
|
||||||
showIcon: true,
|
showIcon: true,
|
||||||
@@ -78,7 +80,7 @@ const selected = defineModel<(
|
|||||||
| RecipeCategory
|
| RecipeCategory
|
||||||
| RecipeTool
|
| RecipeTool
|
||||||
| IngredientFood
|
| IngredientFood
|
||||||
| string
|
| UserSummary
|
||||||
)[] | undefined>({ required: true });
|
)[] | undefined>({ required: true });
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -106,6 +108,8 @@ const label = computed(() => {
|
|||||||
return i18n.t("general.foods");
|
return i18n.t("general.foods");
|
||||||
case Organizer.Household:
|
case Organizer.Household:
|
||||||
return i18n.t("household.households");
|
return i18n.t("household.households");
|
||||||
|
case Organizer.User:
|
||||||
|
return i18n.t("user.users");
|
||||||
default:
|
default:
|
||||||
return i18n.t("general.organizer");
|
return i18n.t("general.organizer");
|
||||||
}
|
}
|
||||||
@@ -127,11 +131,19 @@ const icon = computed(() => {
|
|||||||
return $globals.icons.foods;
|
return $globals.icons.foods;
|
||||||
case Organizer.Household:
|
case Organizer.Household:
|
||||||
return $globals.icons.household;
|
return $globals.icons.household;
|
||||||
|
case Organizer.User:
|
||||||
|
return $globals.icons.user;
|
||||||
default:
|
default:
|
||||||
return $globals.icons.tags;
|
return $globals.icons.tags;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const itemTitle = computed(() =>
|
||||||
|
props.selectorType === Organizer.User
|
||||||
|
? (i: any) => i?.fullName ?? i?.name ?? ""
|
||||||
|
: "name",
|
||||||
|
);
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Store & Items Setup
|
// Store & Items Setup
|
||||||
|
|
||||||
@@ -141,28 +153,19 @@ const storeMap = {
|
|||||||
[Organizer.Tool]: useToolStore(),
|
[Organizer.Tool]: useToolStore(),
|
||||||
[Organizer.Food]: useFoodStore(),
|
[Organizer.Food]: useFoodStore(),
|
||||||
[Organizer.Household]: useHouseholdStore(),
|
[Organizer.Household]: useHouseholdStore(),
|
||||||
|
[Organizer.User]: useUserStore(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const store = computed(() => {
|
const activeStore = computed(() => {
|
||||||
const { store } = storeMap[props.selectorType];
|
const { store } = storeMap[props.selectorType];
|
||||||
return store.value;
|
return store.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = computed(() => {
|
const items = computed<any[]>(() => {
|
||||||
if (!props.returnObject) {
|
const list = (activeStore.value as unknown as any[]) ?? [];
|
||||||
return store.value.map(item => item.name);
|
return list;
|
||||||
}
|
|
||||||
return store.value;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function removeByIndex(index: number) {
|
|
||||||
if (selected.value === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newSelected = selected.value.filter((_, i) => i !== index);
|
|
||||||
selected.value = [...newSelected];
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendCreated(item: any) {
|
function appendCreated(item: any) {
|
||||||
if (selected.value === undefined) {
|
if (selected.value === undefined) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -95,9 +95,12 @@
|
|||||||
<RecipePrintContainer :recipe="recipe" :scale="scale" />
|
<RecipePrintContainer :recipe="recipe" :scale="scale" />
|
||||||
</v-container>
|
</v-container>
|
||||||
<!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same time -->
|
<!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same time -->
|
||||||
|
<!-- The calc is to account for the navabar height (48px) -->
|
||||||
<v-sheet
|
<v-sheet
|
||||||
v-show="isCookMode && !hasLinkedIngredients"
|
v-show="isCookMode && !hasLinkedIngredients"
|
||||||
key="cookmode"
|
key="cookmode"
|
||||||
|
:height="$vuetify.display.smAndUp ? 'calc(100vh - 48px)' : 'auto'"
|
||||||
|
class-name="overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
|
<!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
|
||||||
<v-row style="height: 100%" no-gutters class="overflow-hidden">
|
<v-row style="height: 100%" no-gutters class="overflow-hidden">
|
||||||
@@ -290,10 +293,13 @@ watch(isParsing, () => {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
async function saveRecipe() {
|
async function saveRecipe() {
|
||||||
const { data } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
const { data, error } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||||
setMode(PageMode.VIEW);
|
if (!error) {
|
||||||
|
setMode(PageMode.VIEW);
|
||||||
|
}
|
||||||
if (data?.slug) {
|
if (data?.slug) {
|
||||||
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
||||||
|
recipe.value = data as NoUndefinedField<Recipe>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
:slug="recipe.slug"
|
:slug="recipe.slug"
|
||||||
@upload="uploadImage"
|
@upload="uploadImage"
|
||||||
@refresh="imageKey++"
|
@refresh="imageKey++"
|
||||||
|
@delete="deleteImage"
|
||||||
/>
|
/>
|
||||||
<RecipeSettingsMenu
|
<RecipeSettingsMenu
|
||||||
v-model="recipe.settings"
|
v-model="recipe.settings"
|
||||||
@@ -78,4 +79,10 @@ async function uploadImage(fileObject: File) {
|
|||||||
}
|
}
|
||||||
imageKey.value++;
|
imageKey.value++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteImage() {
|
||||||
|
// The image is already deleted on the backend, just need to update the UI
|
||||||
|
recipe.value.image = "";
|
||||||
|
imageKey.value++;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const display = useDisplay();
|
const display = useDisplay();
|
||||||
const { recipeImage } = useStaticRoutes();
|
const { recipeImage, recipeSmallImage } = useStaticRoutes();
|
||||||
const { imageKey } = usePageState(props.recipe.slug);
|
const { imageKey } = usePageState(props.recipe.slug);
|
||||||
const { user } = usePageUser();
|
const { user } = usePageUser();
|
||||||
|
|
||||||
@@ -46,7 +46,9 @@ const imageHeight = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const recipeImageUrl = computed(() => {
|
const recipeImageUrl = computed(() => {
|
||||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
return display.smAndDown.value
|
||||||
|
? recipeSmallImage(props.recipe.id, props.recipe.image, imageKey.value)
|
||||||
|
: recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
v-for="(ingredient, index) in recipe.recipeIngredient"
|
v-for="(ingredient, index) in recipe.recipeIngredient"
|
||||||
:key="ingredient.referenceId"
|
:key="ingredient.referenceId"
|
||||||
v-model="recipe.recipeIngredient[index]"
|
v-model="recipe.recipeIngredient[index]"
|
||||||
|
:is-recipe="ingredientIsRecipe(ingredient)"
|
||||||
enable-drag-handle
|
enable-drag-handle
|
||||||
enable-context-menu
|
enable-context-menu
|
||||||
class="list-group-item"
|
class="list-group-item"
|
||||||
@@ -69,15 +70,59 @@
|
|||||||
<span>{{ parserToolTip }}</span>
|
<span>{{ parserToolTip }}</span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<RecipeDialogBulkAdd
|
<RecipeDialogBulkAdd
|
||||||
|
ref="domBulkAddDialog"
|
||||||
class="mx-1 mb-1"
|
class="mx-1 mb-1"
|
||||||
|
style="display: none"
|
||||||
@bulk-data="addIngredient"
|
@bulk-data="addIngredient"
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<div class="d-inline-flex">
|
||||||
class="mb-1"
|
<!-- Main button: Add Food -->
|
||||||
@click="addIngredient"
|
<v-btn
|
||||||
>
|
color="success"
|
||||||
{{ $t("general.add") }}
|
class="split-main ml-2"
|
||||||
</BaseButton>
|
@click="addIngredient"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
|
{{ $globals.icons.createAlt }}
|
||||||
|
</v-icon>
|
||||||
|
{{ $t('general.add') || 'Add Food' }}
|
||||||
|
</v-btn>
|
||||||
|
<!-- Dropdown button -->
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
color="success"
|
||||||
|
class="split-dropdown"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon>{{ $globals.icons.chevronDown }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item
|
||||||
|
slim
|
||||||
|
density="comfortable"
|
||||||
|
:prepend-icon="$globals.icons.foods"
|
||||||
|
:title="$t('new-recipe.add-food')"
|
||||||
|
@click="addIngredient"
|
||||||
|
/>
|
||||||
|
<v-list-item
|
||||||
|
slim
|
||||||
|
density="comfortable"
|
||||||
|
:prepend-icon="$globals.icons.silverwareForkKnife"
|
||||||
|
:title="$t('new-recipe.add-recipe')"
|
||||||
|
@click="addRecipe"
|
||||||
|
/>
|
||||||
|
<v-list-item
|
||||||
|
slim
|
||||||
|
density="comfortable"
|
||||||
|
:prepend-icon="$globals.icons.create"
|
||||||
|
:title="$t('new-recipe.bulk-add')"
|
||||||
|
@click="showBulkAdd"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -85,16 +130,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { VueDraggable } from "vue-draggable-plus";
|
import { VueDraggable } from "vue-draggable-plus";
|
||||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import type { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
|
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
|
||||||
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
||||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||||
import { uuid4 } from "~/composables/use-utils";
|
import { uuid4 } from "~/composables/use-utils";
|
||||||
|
|
||||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||||
|
const ingredientsWithRecipe = new Map<string, boolean>();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const drag = ref(false);
|
const drag = ref(false);
|
||||||
|
const domBulkAddDialog = ref<InstanceType<typeof RecipeDialogBulkAdd> | null>(null);
|
||||||
const { toggleIsParsing } = usePageState(recipe.value.slug);
|
const { toggleIsParsing } = usePageState(recipe.value.slug);
|
||||||
|
|
||||||
const hasFoodOrUnit = computed(() => {
|
const hasFoodOrUnit = computed(() => {
|
||||||
@@ -118,6 +165,22 @@ const parserToolTip = computed(() => {
|
|||||||
return i18n.t("recipe.parse-ingredients");
|
return i18n.t("recipe.parse-ingredients");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function showBulkAdd() {
|
||||||
|
domBulkAddDialog.value?.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingredientIsRecipe(ingredient: RecipeIngredient): boolean {
|
||||||
|
if (ingredient.referencedRecipe) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ingredient.referenceId) {
|
||||||
|
return !!ingredientsWithRecipe.get(ingredient.referenceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function addIngredient(ingredients: Array<string> | null = null) {
|
function addIngredient(ingredients: Array<string> | null = null) {
|
||||||
if (ingredients?.length) {
|
if (ingredients?.length) {
|
||||||
const newIngredients = ingredients.map((x) => {
|
const newIngredients = ingredients.map((x) => {
|
||||||
@@ -150,6 +213,41 @@ function addIngredient(ingredients: Array<string> | null = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addRecipe(recipes: Array<string> | null = null) {
|
||||||
|
const refId = uuid4();
|
||||||
|
ingredientsWithRecipe.set(refId, true);
|
||||||
|
|
||||||
|
if (recipes?.length) {
|
||||||
|
const newRecipes = recipes.map((x) => {
|
||||||
|
return {
|
||||||
|
referenceId: refId,
|
||||||
|
title: "",
|
||||||
|
note: x,
|
||||||
|
unit: undefined,
|
||||||
|
referencedRecipe: undefined,
|
||||||
|
quantity: 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newRecipes) {
|
||||||
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
|
recipe.value.recipeIngredient.push(...newRecipes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
recipe.value.recipeIngredient.push({
|
||||||
|
referenceId: refId,
|
||||||
|
title: "",
|
||||||
|
note: "",
|
||||||
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
|
unit: undefined,
|
||||||
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
|
referencedRecipe: undefined,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function insertNewIngredient(dest: number) {
|
function insertNewIngredient(dest: number) {
|
||||||
recipe.value.recipeIngredient.splice(dest, 0, {
|
recipe.value.recipeIngredient.splice(dest, 0, {
|
||||||
referenceId: uuid4(),
|
referenceId: uuid4(),
|
||||||
@@ -163,3 +261,17 @@ function insertNewIngredient(dest: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.split-main {
|
||||||
|
border-top-right-radius: 0 !important;
|
||||||
|
border-bottom-right-radius: 0 !important;
|
||||||
|
}
|
||||||
|
.split-dropdown {
|
||||||
|
border-top-left-radius: 0 !important;
|
||||||
|
border-bottom-left-radius: 0 !important;
|
||||||
|
min-width: 30px;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ import { VueDraggable } from "vue-draggable-plus";
|
|||||||
import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
import type { Parser } from "~/lib/api/user/recipes/recipe";
|
import type { Parser } from "~/lib/api/user/recipes/recipe";
|
||||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { useAppInfo, useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { parseIngredientText } from "~/composables/recipes";
|
import { parseIngredientText } from "~/composables/recipes";
|
||||||
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
|
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
|
||||||
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
@@ -213,9 +213,9 @@ const emit = defineEmits<{
|
|||||||
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
|
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { $appInfo } = useNuxtApp();
|
||||||
const i18n = useGlobalI18n();
|
const i18n = useGlobalI18n();
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const appInfo = useAppInfo();
|
|
||||||
const drag = ref(false);
|
const drag = ref(false);
|
||||||
|
|
||||||
const unitStore = useUnitStore();
|
const unitStore = useUnitStore();
|
||||||
@@ -238,7 +238,7 @@ const availableParsers = computed(() => {
|
|||||||
{
|
{
|
||||||
text: i18n.t("recipe.parser.openai-parser"),
|
text: i18n.t("recipe.parser.openai-parser"),
|
||||||
value: "openai",
|
value: "openai",
|
||||||
hide: !appInfo.value?.enableOpenai,
|
hide: !$appInfo.enableOpenai,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -268,6 +268,11 @@ const state = reactive({
|
|||||||
function shouldReview(ing: ParsedIngredient): boolean {
|
function shouldReview(ing: ParsedIngredient): boolean {
|
||||||
console.debug(`Checking if ingredient needs review (input="${ing.input})":`, ing);
|
console.debug(`Checking if ingredient needs review (input="${ing.input})":`, ing);
|
||||||
|
|
||||||
|
if (ing.ingredient.referencedRecipe) {
|
||||||
|
console.debug("No review needed for sub-recipe ingredient");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if ((ing.confidence?.average || 0) < confidenceThreshold) {
|
if ((ing.confidence?.average || 0) < confidenceThreshold) {
|
||||||
console.debug("Needs review due to low confidence:", ing.confidence?.average);
|
console.debug("Needs review due to low confidence:", ing.confidence?.average);
|
||||||
return true;
|
return true;
|
||||||
@@ -364,12 +369,21 @@ async function parseIngredients() {
|
|||||||
}
|
}
|
||||||
state.loading.parser = true;
|
state.loading.parser = true;
|
||||||
try {
|
try {
|
||||||
const ingsAsString = props.ingredients.map(ing => parseIngredientText(ing, 1, false) ?? "");
|
const ingsAsString = props.ingredients
|
||||||
|
.filter(ing => !ing.referencedRecipe)
|
||||||
|
.map(ing => parseIngredientText(ing, 1, false) ?? "");
|
||||||
const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString);
|
const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString);
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
throw new Error("Failed to parse ingredients");
|
throw new Error("Failed to parse ingredients");
|
||||||
}
|
}
|
||||||
parsedIngs.value = data;
|
parsedIngs.value = data;
|
||||||
|
const parsed = data ?? [];
|
||||||
|
const recipeRefs = props.ingredients.filter(ing => ing.referencedRecipe).map(ing => ({
|
||||||
|
input: ing.note || "",
|
||||||
|
confidence: {},
|
||||||
|
ingredient: ing,
|
||||||
|
}));
|
||||||
|
parsedIngs.value = [...parsed, ...recipeRefs];
|
||||||
state.currentParsedIndex = -1;
|
state.currentParsedIndex = -1;
|
||||||
state.allReviewed = false;
|
state.allReviewed = false;
|
||||||
createdUnits.clear();
|
createdUnits.clear();
|
||||||
|
|||||||
@@ -262,32 +262,55 @@ const ingredientSections = computed<IngredientSection[]>(() => {
|
|||||||
if (!props.recipe.recipeIngredient) {
|
if (!props.recipe.recipeIngredient) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const addIngredientsToSections = (ingredients: RecipeIngredient[], sections: IngredientSection[], title: string | null) => {
|
||||||
return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
|
// If title is set, ensure the section exists before adding ingredients
|
||||||
// if title append new section to the end of the array
|
let section: IngredientSection | undefined;
|
||||||
if (ingredient.title) {
|
if (title) {
|
||||||
sections.push({
|
section = sections.find(sec => sec.sectionName === title);
|
||||||
sectionName: ingredient.title,
|
if (!section) {
|
||||||
ingredients: [ingredient],
|
section = { sectionName: title, ingredients: [] };
|
||||||
});
|
sections.push(section);
|
||||||
|
}
|
||||||
return sections;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// append new section if first
|
ingredients.forEach((ingredient) => {
|
||||||
if (sections.length === 0) {
|
if (preferences.value.expandChildRecipes && ingredient.referencedRecipe?.recipeIngredient?.length) {
|
||||||
sections.push({
|
// Recursively add to the section for this referenced recipe
|
||||||
sectionName: "",
|
addIngredientsToSections(
|
||||||
ingredients: [ingredient],
|
ingredient.referencedRecipe.recipeIngredient,
|
||||||
});
|
sections,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const sectionName = title || ingredient.title || "";
|
||||||
|
if (sectionName) {
|
||||||
|
let sec = sections.find(sec => sec.sectionName === sectionName);
|
||||||
|
if (!sec) {
|
||||||
|
sec = { sectionName, ingredients: [] };
|
||||||
|
sections.push(sec);
|
||||||
|
}
|
||||||
|
ingredient.title = sectionName;
|
||||||
|
sec.ingredients.push(ingredient);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (sections.length === 0) {
|
||||||
|
sections.push({
|
||||||
|
sectionName: "",
|
||||||
|
ingredients: [ingredient],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
sections[sections.length - 1].ingredients.push(ingredient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return sections;
|
const sections: IngredientSection[] = [];
|
||||||
}
|
addIngredientsToSections(props.recipe.recipeIngredient, sections, null);
|
||||||
|
return sections;
|
||||||
// otherwise add ingredient to last section in the array
|
|
||||||
sections[sections.length - 1].ingredients.push(ingredient);
|
|
||||||
return sections;
|
|
||||||
}, [] as IngredientSection[]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group instructions by section so we can style them independently
|
// Group instructions by section so we can style them independently
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
left
|
left
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
{{ $globals.icons.knfife }}
|
{{ $globals.icons.knife }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<p class="my-0">
|
<p class="my-0">
|
||||||
<span class="font-weight-bold opacity-80">{{ validatePrepTime.name }}</span><br>{{ validatePrepTime.value }}
|
<span class="font-weight-bold opacity-80">{{ validatePrepTime.name }}</span><br>{{ validatePrepTime.value }}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<v-icon class="mr-1">
|
<v-icon class="mr-1">
|
||||||
{{ $globals.icons.calendar }}
|
{{ $globals.icons.calendar }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
{{ new Date(event.timestamp).toLocaleDateString($i18n.locale) }}
|
{{ $d(new Date(event.timestamp)) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
<v-card
|
<v-card
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<v-col v-if="useMobileFormat" align-self="center" class="pr-0">
|
<v-col v-if="useMobileFormat" align-self="center" class="pr-0">
|
||||||
<v-chip label>
|
<v-chip label>
|
||||||
<v-icon> {{ $globals.icons.calendar }} </v-icon>
|
<v-icon> {{ $globals.icons.calendar }} </v-icon>
|
||||||
{{ new Date(event.timestamp || "").toLocaleDateString($i18n.locale) }}
|
{{ $d(new Date(event.timestamp || "")) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col v-else cols="9" style="margin: auto; text-align: center">
|
<v-col v-else cols="9" style="margin: auto; text-align: center">
|
||||||
@@ -119,7 +119,7 @@ defineEmits<{
|
|||||||
|
|
||||||
const { $globals } = useNuxtApp();
|
const { $globals } = useNuxtApp();
|
||||||
const display = useDisplay();
|
const display = useDisplay();
|
||||||
const { recipeTimelineEventImage } = useStaticRoutes();
|
const { recipeTimelineEventSmallImage } = useStaticRoutes();
|
||||||
const { eventTypeOptions } = useTimelineEventTypes();
|
const { eventTypeOptions } = useTimelineEventTypes();
|
||||||
|
|
||||||
const { user: currentUser } = useMealieAuth();
|
const { user: currentUser } = useMealieAuth();
|
||||||
@@ -173,7 +173,7 @@ const eventImageUrl = computed<string>(() => {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
|
return recipeTimelineEventSmallImage(props.event.recipeId, props.event.id);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -130,9 +130,8 @@
|
|||||||
<v-col cols="auto">
|
<v-col cols="auto">
|
||||||
<div class="text-caption font-weight-light font-italic">
|
<div class="text-caption font-weight-light font-italic">
|
||||||
{{ $t("shopping-list.completed-on", {
|
{{ $t("shopping-list.completed-on", {
|
||||||
date: new Date(listItem.updatedAt
|
date: listItem.updatedAt ? $d(new Date(listItem.updatedAt)) : '',
|
||||||
|| "").toLocaleDateString($i18n.locale) })
|
}) }}
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
v-model="listItem.unit"
|
v-model="listItem.unit"
|
||||||
v-model:item-id="listItem.unitId!"
|
v-model:item-id="listItem.unitId!"
|
||||||
:items="units"
|
:items="units"
|
||||||
:label="$t('general.units')"
|
:label="$t('recipe.unit')"
|
||||||
:icon="$globals.icons.units"
|
:icon="$globals.icons.units"
|
||||||
create
|
create
|
||||||
@create="createAssignUnit"
|
@create="createAssignUnit"
|
||||||
|
|||||||
@@ -97,7 +97,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import type { SideBarLink } from "~/types/application-types";
|
import type { SideBarLink } from "~/types/application-types";
|
||||||
import { useAppInfo } from "~/composables/api";
|
|
||||||
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
||||||
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
|
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
|
||||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||||
@@ -105,7 +104,7 @@ import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
|||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
setup() {
|
setup() {
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const { $globals } = useNuxtApp();
|
const { $appInfo, $globals } = useNuxtApp();
|
||||||
const display = useDisplay();
|
const display = useDisplay();
|
||||||
const $auth = useMealieAuth();
|
const $auth = useMealieAuth();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
@@ -135,9 +134,7 @@ export default defineNuxtComponent({
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const appInfo = useAppInfo();
|
const showImageImport = computed(() => $appInfo.enableOpenaiImageServices);
|
||||||
const showImageImport = computed(() => appInfo.value?.enableOpenaiImageServices);
|
|
||||||
|
|
||||||
const languageDialog = ref<boolean>(false);
|
const languageDialog = ref<boolean>(false);
|
||||||
|
|
||||||
const sidebar = ref<boolean>(false);
|
const sidebar = ref<boolean>(false);
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export default defineNuxtComponent({
|
|||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await $auth.signOut({ callbackUrl: "/login?direct=1" });
|
await $auth.signOut("/login?direct=1");
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|||||||
@@ -59,7 +59,6 @@
|
|||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="canDelete"
|
v-if="canDelete"
|
||||||
delete
|
delete
|
||||||
secondary
|
|
||||||
@click="deleteEvent"
|
@click="deleteEvent"
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
|||||||
17
frontend/components/global/BaseExpansionPanels.vue
Normal file
17
frontend/components/global/BaseExpansionPanels.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<v-expansion-panels v-model="open">
|
||||||
|
<slot />
|
||||||
|
</v-expansion-panels>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
startOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
startOpen: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = ref(props.startOpen ? [0] : []);
|
||||||
|
</script>
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
item-title="name"
|
item-title="name"
|
||||||
return-object
|
return-object
|
||||||
:items="items"
|
:items="items"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
:prepend-icon="icon || $globals.icons.tags"
|
:prepend-icon="icon || $globals.icons.tags"
|
||||||
auto-select-first
|
auto-select-first
|
||||||
clearable
|
clearable
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
|
|
||||||
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||||
|
import { normalizeFilter } from "~/composables/use-utils";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
@@ -122,6 +124,7 @@ export default defineNuxtComponent({
|
|||||||
itemIdVal,
|
itemIdVal,
|
||||||
searchInput,
|
searchInput,
|
||||||
emitCreate,
|
emitCreate,
|
||||||
|
normalizeFilter,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="selectedLocale"
|
v-model="selectedLocale"
|
||||||
:items="locales"
|
:items="locales"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
class="my-3"
|
class="my-3"
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useLocales } from "~/composables/use-locales";
|
import { useLocales } from "~/composables/use-locales";
|
||||||
|
import { normalizeFilter } from "~/composables/use-utils";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
@@ -83,6 +85,7 @@ export default defineNuxtComponent({
|
|||||||
locale,
|
locale,
|
||||||
selectedLocale,
|
selectedLocale,
|
||||||
onLocaleSelect,
|
onLocaleSelect,
|
||||||
|
normalizeFilter,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export { useAppInfo } from "./use-app-info";
|
|
||||||
export { useStaticRoutes } from "./static-routes";
|
export { useStaticRoutes } from "./static-routes";
|
||||||
export { useAdminApi, usePublicApi, usePublicExploreApi, useUserApi } from "./api-client";
|
export { useAdminApi, usePublicApi, usePublicExploreApi, useUserApi } from "./api-client";
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { AppInfo } from "~/lib/api/types/admin";
|
|
||||||
|
|
||||||
export function useAppInfo(): Ref<AppInfo | null> {
|
|
||||||
const i18n = useI18n();
|
|
||||||
const { $axios } = useNuxtApp();
|
|
||||||
$axios.defaults.headers.common["Accept-Language"] = i18n.locale.value;
|
|
||||||
|
|
||||||
const { data: appInfo } = useAsyncData("app-info", async () => {
|
|
||||||
const data = await $axios.get<AppInfo>("/api/app/about");
|
|
||||||
return data.data;
|
|
||||||
});
|
|
||||||
|
|
||||||
return appInfo;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import { alert } from "~/composables/use-toast";
|
||||||
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
|
|
||||||
export function useDownloader() {
|
export function useDownloader() {
|
||||||
function download(url: string, filename: string) {
|
function download(url: string, filename: string) {
|
||||||
useFetch(url, {
|
useFetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
onResponse({ response }) {
|
onResponse({ response }) {
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Download failed", response);
|
||||||
|
const i18n = useGlobalI18n();
|
||||||
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response._data]));
|
const url = window.URL.createObjectURL(new Blob([response._data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const useStore = function <T extends BoundT>(
|
|||||||
return await storeActions.refresh(1, -1, params);
|
return await storeActions.refresh(1, -1, params);
|
||||||
},
|
},
|
||||||
flushStore() {
|
flushStore() {
|
||||||
store = ref([]);
|
store.value = [];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import DOMPurify from "isomorphic-dompurify";
|
import DOMPurify from "isomorphic-dompurify";
|
||||||
import { useFraction } from "./use-fraction";
|
import { useFraction } from "./use-fraction";
|
||||||
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
const { frac } = useFraction();
|
const { frac } = useFraction();
|
||||||
|
|
||||||
@@ -36,8 +36,28 @@ function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, us
|
|||||||
return returnVal;
|
return returnVal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true) {
|
function useRecipeLink(recipe: Recipe | undefined, groupSlug: string | undefined): string | undefined {
|
||||||
const { quantity, food, unit, note, title } = ingredient;
|
if (!(recipe && recipe.slug && recipe.name && groupSlug)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<a href="/g/${groupSlug}/r/${recipe.slug}" target="_blank">${recipe.name}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedIngredientText = {
|
||||||
|
quantity?: string;
|
||||||
|
unit?: string;
|
||||||
|
name?: string;
|
||||||
|
note?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the ingredient is a linked recipe, an HTML link to the referenced recipe, otherwise undefined.
|
||||||
|
*/
|
||||||
|
recipeLink?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
|
||||||
const usePluralFood = (!quantity) || quantity * scale > 1;
|
const usePluralFood = (!quantity) || quantity * scale > 1;
|
||||||
|
|
||||||
@@ -63,14 +83,14 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unitName = useUnitName(unit || undefined, usePluralUnit);
|
const unitName = useUnitName(unit || undefined, usePluralUnit);
|
||||||
const foodName = useFoodName(food || undefined, usePluralFood);
|
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: title ? sanitizeIngredientHTML(title) : undefined,
|
|
||||||
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
|
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
|
||||||
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
|
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
|
||||||
name: foodName ? sanitizeIngredientHTML(foodName) : undefined,
|
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
|
||||||
note: note ? sanitizeIngredientHTML(note) : undefined,
|
note: note ? sanitizeIngredientHTML(note) : undefined,
|
||||||
|
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,13 +97,8 @@ export function useShoppingListCrud(
|
|||||||
.sort(sortCheckedItems);
|
.sort(sortCheckedItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the item if it's checked, otherwise updateUncheckedListItems will handle it
|
shoppingListItemActions.updateItem(item);
|
||||||
if (item.checked) {
|
|
||||||
shoppingListItemActions.updateItem(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateListItemOrder();
|
updateListItemOrder();
|
||||||
updateUncheckedListItems();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteListItem(item: ShoppingListItemOut) {
|
function deleteListItem(item: ShoppingListItemOut) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useToggle } from "@vueuse/core";
|
import { useToggle } from "@vueuse/core";
|
||||||
import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household";
|
import type { ShoppingListOut } from "~/lib/api/types/household";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for managing shopping list label state and operations
|
* Composable for managing shopping list label state and operations
|
||||||
@@ -36,14 +36,24 @@ export function useShoppingListLabels(shoppingList: Ref<ShoppingListOut | null>)
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const labelColorByName = computed(() => {
|
||||||
|
const map: Record<string, string | undefined> = {};
|
||||||
|
shoppingList.value?.listItems?.forEach((item) => {
|
||||||
|
if (!item.label) return;
|
||||||
|
const labelName = item.label?.name || t("shopping-list.no-label");
|
||||||
|
map[labelName] = item.label.color;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
watch(labelNames, initializeLabelOpenStates, { immediate: true });
|
watch(labelNames, initializeLabelOpenStates, { immediate: true });
|
||||||
|
|
||||||
function toggleShowLabel(key: string) {
|
function toggleShowLabel(key: string) {
|
||||||
labelOpenState.value[key] = !labelOpenState.value[key];
|
labelOpenState.value[key] = !labelOpenState.value[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLabelColor(item: ShoppingListItemOut | null) {
|
function getLabelColor(label: string) {
|
||||||
return item?.label?.color;
|
return labelColorByName.value[label];
|
||||||
}
|
}
|
||||||
|
|
||||||
const presentLabels = computed(() => {
|
const presentLabels = computed(() => {
|
||||||
|
|||||||
@@ -1,7 +1,31 @@
|
|||||||
export { useCategoryStore, usePublicCategoryStore, useCategoryData } from "./use-category-store";
|
import { resetCategoryStore } from "./use-category-store";
|
||||||
export { useFoodStore, usePublicFoodStore, useFoodData } from "./use-food-store";
|
import { resetFoodStore } from "./use-food-store";
|
||||||
export { useHouseholdStore, usePublicHouseholdStore } from "./use-household-store";
|
import { resetHouseholdStore } from "./use-household-store";
|
||||||
export { useLabelStore, useLabelData } from "./use-label-store";
|
import { resetLabelStore } from "./use-label-store";
|
||||||
export { useTagStore, usePublicTagStore, useTagData } from "./use-tag-store";
|
import { resetTagStore } from "./use-tag-store";
|
||||||
export { useToolStore, usePublicToolStore, useToolData } from "./use-tool-store";
|
import { resetToolStore } from "./use-tool-store";
|
||||||
export { useUnitStore, useUnitData } from "./use-unit-store";
|
import { resetUnitStore } from "./use-unit-store";
|
||||||
|
import { resetCookbookStore } from "./use-cookbook-store";
|
||||||
|
import { resetUserStore } from "./use-user-store";
|
||||||
|
|
||||||
|
export { useCategoryStore, usePublicCategoryStore, useCategoryData, resetCategoryStore } from "./use-category-store";
|
||||||
|
export { useFoodStore, usePublicFoodStore, useFoodData, resetFoodStore } from "./use-food-store";
|
||||||
|
export { useHouseholdStore, usePublicHouseholdStore, resetHouseholdStore } from "./use-household-store";
|
||||||
|
export { useLabelStore, useLabelData, resetLabelStore } from "./use-label-store";
|
||||||
|
export { useTagStore, usePublicTagStore, useTagData, resetTagStore } from "./use-tag-store";
|
||||||
|
export { useToolStore, usePublicToolStore, useToolData, resetToolStore } from "./use-tool-store";
|
||||||
|
export { useUnitStore, useUnitData, resetUnitStore } from "./use-unit-store";
|
||||||
|
export { useCookbookStore, usePublicCookbookStore, resetCookbookStore } from "./use-cookbook-store";
|
||||||
|
export { useUserStore, resetUserStore } from "./use-user-store";
|
||||||
|
|
||||||
|
export function clearAllStores() {
|
||||||
|
resetCategoryStore();
|
||||||
|
resetFoodStore();
|
||||||
|
resetHouseholdStore();
|
||||||
|
resetLabelStore();
|
||||||
|
resetTagStore();
|
||||||
|
resetToolStore();
|
||||||
|
resetUnitStore();
|
||||||
|
resetCookbookStore();
|
||||||
|
resetUserStore();
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ const store: Ref<RecipeCategory[]> = ref([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const publicLoading = ref(false);
|
const publicLoading = ref(false);
|
||||||
|
|
||||||
|
export function resetCategoryStore() {
|
||||||
|
store.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
publicLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
export const useCategoryData = function () {
|
export const useCategoryData = function () {
|
||||||
return useData<RecipeCategory>({
|
return useData<RecipeCategory>({
|
||||||
id: "",
|
id: "",
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ const cookbooks: Ref<ReadCookBook[]> = ref([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const publicLoading = ref(false);
|
const publicLoading = ref(false);
|
||||||
|
|
||||||
|
export function resetCookbookStore() {
|
||||||
|
cookbooks.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
publicLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
export const useCookbookStore = function (i18n?: Composer) {
|
export const useCookbookStore = function (i18n?: Composer) {
|
||||||
const api = useUserApi(i18n);
|
const api = useUserApi(i18n);
|
||||||
const store = useStore<ReadCookBook>("cookbook", cookbooks, loading, api.cookbooks);
|
const store = useStore<ReadCookBook>("cookbook", cookbooks, loading, api.cookbooks);
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ const store: Ref<IngredientFood[]> = ref([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const publicLoading = ref(false);
|
const publicLoading = ref(false);
|
||||||
|
|
||||||
|
export function resetFoodStore() {
|
||||||
|
store.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
publicLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
export const useFoodData = function () {
|
export const useFoodData = function () {
|
||||||
return useData<IngredientFood>({
|
return useData<IngredientFood>({
|
||||||
id: "",
|
id: "",
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ const store: Ref<HouseholdSummary[]> = ref([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const publicLoading = ref(false);
|
const publicLoading = ref(false);
|
||||||
|
|
||||||
|
export function resetHouseholdStore() {
|
||||||
|
store.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
publicLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
export const useHouseholdStore = function (i18n?: Composer) {
|
export const useHouseholdStore = function (i18n?: Composer) {
|
||||||
const api = useUserApi(i18n);
|
const api = useUserApi(i18n);
|
||||||
return useReadOnlyStore<HouseholdSummary>("household", store, loading, api.households);
|
return useReadOnlyStore<HouseholdSummary>("household", store, loading, api.households);
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { useUserApi } from "~/composables/api";
|
|||||||
const store: Ref<MultiPurposeLabelOut[]> = ref([]);
|
const store: Ref<MultiPurposeLabelOut[]> = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
export function resetLabelStore() {
|
||||||
|
store.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
export const useLabelData = function () {
|
export const useLabelData = function () {
|
||||||
return useData<MultiPurposeLabelOut>({
|
return useData<MultiPurposeLabelOut>({
|
||||||
groupId: "",
|
groupId: "",
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ const store: Ref<RecipeTag[]> = ref([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const publicLoading = ref(false);
|
const publicLoading = ref(false);
|
||||||
|
|
||||||
|
export function resetTagStore() {
|
||||||
|
store.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
publicLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
export const useTagData = function () {
|
export const useTagData = function () {
|
||||||
return useData<RecipeTag>({
|
return useData<RecipeTag>({
|
||||||
id: "",
|
id: "",
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ const store: Ref<RecipeTool[]> = ref([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const publicLoading = ref(false);
|
const publicLoading = ref(false);
|
||||||
|
|
||||||
|
export function resetToolStore() {
|
||||||
|
store.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
publicLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
export const useToolData = function () {
|
export const useToolData = function () {
|
||||||
return useData<RecipeToolWithOnHand>({
|
return useData<RecipeToolWithOnHand>({
|
||||||
id: "",
|
id: "",
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { useUserApi } from "~/composables/api";
|
|||||||
const store: Ref<IngredientUnit[]> = ref([]);
|
const store: Ref<IngredientUnit[]> = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
export function resetUnitStore() {
|
||||||
|
store.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
export const useUnitData = function () {
|
export const useUnitData = function () {
|
||||||
return useData<IngredientUnit>({
|
return useData<IngredientUnit>({
|
||||||
id: "",
|
id: "",
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
|
|||||||
const store: Ref<UserSummary[]> = ref([]);
|
const store: Ref<UserSummary[]> = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
export function resetUserStore() {
|
||||||
|
store.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
class GroupUserAPIReadOnly extends BaseCRUDAPIReadOnly<UserSummary> {
|
class GroupUserAPIReadOnly extends BaseCRUDAPIReadOnly<UserSummary> {
|
||||||
baseRoute = "/api/groups/members";
|
baseRoute = "/api/groups/members";
|
||||||
itemRoute = (idOrUsername: string | number) => `/groups/members/${idOrUsername}`;
|
itemRoute = (idOrUsername: string | number) => `/groups/members/${idOrUsername}`;
|
||||||
|
|||||||
140
frontend/composables/use-auth-backend.ts
Normal file
140
frontend/composables/use-auth-backend.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { ref, computed } from "vue";
|
||||||
|
import type { UserOut } from "~/lib/api/types/user";
|
||||||
|
import { clearAllStores } from "~/composables/store";
|
||||||
|
|
||||||
|
interface AuthData {
|
||||||
|
value: UserOut | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthStatus {
|
||||||
|
value: "loading" | "authenticated" | "unauthenticated";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
data: AuthData;
|
||||||
|
status: AuthStatus;
|
||||||
|
signIn: (credentials: FormData, options?: { redirect?: boolean }) => Promise<void>;
|
||||||
|
signOut: (callbackUrl?: string) => Promise<void>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
getSession: () => Promise<void>;
|
||||||
|
setToken: (token: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUser = ref<UserOut | null>(null);
|
||||||
|
const authStatus = ref<"loading" | "authenticated" | "unauthenticated">("loading");
|
||||||
|
|
||||||
|
export const useAuthBackend = function (): AuthState {
|
||||||
|
const { $appInfo, $axios } = useNuxtApp();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const runtimeConfig = useRuntimeConfig();
|
||||||
|
const tokenName = runtimeConfig.public.AUTH_TOKEN;
|
||||||
|
const tokenCookie = useCookie(tokenName, {
|
||||||
|
maxAge: $appInfo.tokenTime * 60 * 60,
|
||||||
|
secure: $appInfo.production && window?.location?.protocol === "https:",
|
||||||
|
});
|
||||||
|
|
||||||
|
function setToken(token: string | null) {
|
||||||
|
tokenCookie.value = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthError(error: any, redirect = false) {
|
||||||
|
// Only clear token on auth errors, not network errors
|
||||||
|
if (error?.response?.status === 401) {
|
||||||
|
setToken(null);
|
||||||
|
authUser.value = null;
|
||||||
|
authStatus.value = "unauthenticated";
|
||||||
|
if (redirect) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSession(): Promise<void> {
|
||||||
|
if (!tokenCookie.value) {
|
||||||
|
authUser.value = null;
|
||||||
|
authStatus.value = "unauthenticated";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
authStatus.value = "loading";
|
||||||
|
try {
|
||||||
|
const { data } = await $axios.get<UserOut>("/api/users/self");
|
||||||
|
authUser.value = data;
|
||||||
|
authStatus.value = "authenticated";
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
console.error("Failed to fetch user session:", error);
|
||||||
|
handleAuthError(error);
|
||||||
|
authStatus.value = "unauthenticated";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(credentials: FormData): Promise<void> {
|
||||||
|
authStatus.value = "loading";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $axios.post("/api/auth/token", credentials, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { access_token } = response.data;
|
||||||
|
setToken(access_token);
|
||||||
|
await getSession();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
authStatus.value = "unauthenticated";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut(callbackUrl: string = ""): Promise<void> {
|
||||||
|
try {
|
||||||
|
await $axios.post("/api/auth/logout");
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// Continue with logout even if API call fails
|
||||||
|
console.warn("Logout API call failed:", error);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setToken(null);
|
||||||
|
authUser.value = null;
|
||||||
|
authStatus.value = "unauthenticated";
|
||||||
|
|
||||||
|
// Clear all cached store data to prevent data leakage between users
|
||||||
|
clearAllStores();
|
||||||
|
|
||||||
|
// Clear Nuxt's useAsyncData cache
|
||||||
|
clearNuxtData();
|
||||||
|
|
||||||
|
await router.push(callbackUrl || "/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
if (!tokenCookie.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $axios.get("/api/auth/refresh");
|
||||||
|
const { access_token } = response.data;
|
||||||
|
setToken(access_token);
|
||||||
|
await getSession();
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
handleAuthError(error, true);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: computed(() => authUser.value),
|
||||||
|
status: computed(() => authStatus.value),
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
refresh,
|
||||||
|
getSession,
|
||||||
|
setToken,
|
||||||
|
};
|
||||||
|
};
|
||||||
63
frontend/composables/use-default-activity.ts
Normal file
63
frontend/composables/use-default-activity.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Activity, I18n, TranslationResult } from "~/lib/api/types/activity";
|
||||||
|
import { ActivityKey } from "~/lib/api/types/activity";
|
||||||
|
|
||||||
|
export const DEFAULT_ACTIVITY = "/g/home" as const;
|
||||||
|
|
||||||
|
type ActivityRegistry = {
|
||||||
|
recipes: Activity;
|
||||||
|
mealplanner: Activity;
|
||||||
|
shopping_list: Activity;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectableActivities: ActivityRegistry = {
|
||||||
|
recipes: {
|
||||||
|
key: ActivityKey.RECIPES,
|
||||||
|
route: groupSlug => groupSlug ? `/g/${groupSlug}` : DEFAULT_ACTIVITY,
|
||||||
|
label: i18n => i18n.t("general.recipes"),
|
||||||
|
},
|
||||||
|
mealplanner: {
|
||||||
|
key: ActivityKey.MEALPLANNER,
|
||||||
|
route: () => "/household/mealplan/planner/view",
|
||||||
|
label: i18n => i18n.t("meal-plan.meal-planner"),
|
||||||
|
},
|
||||||
|
shopping_list: {
|
||||||
|
key: ActivityKey.SHOPPING_LIST,
|
||||||
|
route: () => "/shopping-lists",
|
||||||
|
label: i18n => i18n.t("shopping-list.shopping-lists"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDefaultActivityRoute(activityKey?: ActivityKey, groupSlug?: string): string {
|
||||||
|
if (!activityKey) {
|
||||||
|
return DEFAULT_ACTIVITY;
|
||||||
|
}
|
||||||
|
const route = selectableActivities[activityKey]?.route ?? (() => DEFAULT_ACTIVITY);
|
||||||
|
return route(groupSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultActivityLabels(i18n: I18n): TranslationResult[] {
|
||||||
|
return Object.values(selectableActivities).map(
|
||||||
|
({ label }) => label(i18n),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityKey(i18n: I18n, target: TranslationResult = ""): ActivityKey | undefined {
|
||||||
|
return Object.values(selectableActivities)
|
||||||
|
.find(({ label }) => label(i18n) === target)?.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityLabel(i18n: I18n, target?: ActivityKey): TranslationResult {
|
||||||
|
return Object.values(selectableActivities)
|
||||||
|
.find(({ key }) => key === target)
|
||||||
|
?.label(i18n) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useDefaultActivity() {
|
||||||
|
return {
|
||||||
|
selectableActivities,
|
||||||
|
getDefaultActivityRoute,
|
||||||
|
getDefaultActivityLabels,
|
||||||
|
getActivityKey,
|
||||||
|
getActivityLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ export function usePlanTypeOptions() {
|
|||||||
{ text: i18n.t("meal-plan.lunch"), value: "lunch" },
|
{ text: i18n.t("meal-plan.lunch"), value: "lunch" },
|
||||||
{ text: i18n.t("meal-plan.dinner"), value: "dinner" },
|
{ text: i18n.t("meal-plan.dinner"), value: "dinner" },
|
||||||
{ text: i18n.t("meal-plan.side"), value: "side" },
|
{ text: i18n.t("meal-plan.side"), value: "side" },
|
||||||
|
{ text: i18n.t("meal-plan.snack"), value: "snack" },
|
||||||
|
{ text: i18n.t("meal-plan.drink"), value: "drink" },
|
||||||
|
{ text: i18n.t("meal-plan.dessert"), value: "dessert" },
|
||||||
] as PlanOption[];
|
] as PlanOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "Українська (Ukrainian)",
|
name: "Українська (Ukrainian)",
|
||||||
value: "uk-UA",
|
value: "uk-UA",
|
||||||
progress: 44,
|
progress: 99,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -33,7 +33,7 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "Svenska (Swedish)",
|
name: "Svenska (Swedish)",
|
||||||
value: "sv-SE",
|
value: "sv-SE",
|
||||||
progress: 52,
|
progress: 67,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -45,7 +45,7 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "Slovenščina (Slovenian)",
|
name: "Slovenščina (Slovenian)",
|
||||||
value: "sl-SI",
|
value: "sl-SI",
|
||||||
progress: 40,
|
progress: 41,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -57,43 +57,43 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "Pусский (Russian)",
|
name: "Pусский (Russian)",
|
||||||
value: "ru-RU",
|
value: "ru-RU",
|
||||||
progress: 40,
|
progress: 46,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Română (Romanian)",
|
name: "Română (Romanian)",
|
||||||
value: "ro-RO",
|
value: "ro-RO",
|
||||||
progress: 37,
|
progress: 41,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Português (Portuguese)",
|
name: "Português (Portuguese)",
|
||||||
value: "pt-PT",
|
value: "pt-PT",
|
||||||
progress: 38,
|
progress: 40,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Português do Brasil (Brazilian Portuguese)",
|
name: "Português do Brasil (Brazilian Portuguese)",
|
||||||
value: "pt-BR",
|
value: "pt-BR",
|
||||||
progress: 45,
|
progress: 46,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Polski (Polish)",
|
name: "Polski (Polish)",
|
||||||
value: "pl-PL",
|
value: "pl-PL",
|
||||||
progress: 42,
|
progress: 53,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Norsk (Norwegian)",
|
name: "Norsk (Norwegian)",
|
||||||
value: "no-NO",
|
value: "no-NO",
|
||||||
progress: 39,
|
progress: 41,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Nederlands (Dutch)",
|
name: "Nederlands (Dutch)",
|
||||||
value: "nl-NL",
|
value: "nl-NL",
|
||||||
progress: 49,
|
progress: 55,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,31 +117,31 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "日本語 (Japanese)",
|
name: "日本語 (Japanese)",
|
||||||
value: "ja-JP",
|
value: "ja-JP",
|
||||||
progress: 37,
|
progress: 36,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Italiano (Italian)",
|
name: "Italiano (Italian)",
|
||||||
value: "it-IT",
|
value: "it-IT",
|
||||||
progress: 41,
|
progress: 47,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Íslenska (Icelandic)",
|
name: "Íslenska (Icelandic)",
|
||||||
value: "is-IS",
|
value: "is-IS",
|
||||||
progress: 3,
|
progress: 44,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Magyar (Hungarian)",
|
name: "Magyar (Hungarian)",
|
||||||
value: "hu-HU",
|
value: "hu-HU",
|
||||||
progress: 45,
|
progress: 47,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Hrvatski (Croatian)",
|
name: "Hrvatski (Croatian)",
|
||||||
value: "hr-HR",
|
value: "hr-HR",
|
||||||
progress: 28,
|
progress: 29,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -159,37 +159,37 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "Français (French)",
|
name: "Français (French)",
|
||||||
value: "fr-FR",
|
value: "fr-FR",
|
||||||
progress: 66,
|
progress: 69,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Français canadien (Canadian French)",
|
name: "Français canadien (Canadian French)",
|
||||||
value: "fr-CA",
|
value: "fr-CA",
|
||||||
progress: 38,
|
progress: 99,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Belge (Belgian)",
|
name: "Belge (Belgian)",
|
||||||
value: "fr-BE",
|
value: "fr-BE",
|
||||||
progress: 41,
|
progress: 40,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Suomi (Finnish)",
|
name: "Suomi (Finnish)",
|
||||||
value: "fi-FI",
|
value: "fi-FI",
|
||||||
progress: 37,
|
progress: 41,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Eesti (Estonian)",
|
name: "Eesti (Estonian)",
|
||||||
value: "et-EE",
|
value: "et-EE",
|
||||||
progress: 37,
|
progress: 47,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Español (Spanish)",
|
name: "Español (Spanish)",
|
||||||
value: "es-ES",
|
value: "es-ES",
|
||||||
progress: 42,
|
progress: 46,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -201,25 +201,25 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "British English",
|
name: "British English",
|
||||||
value: "en-GB",
|
value: "en-GB",
|
||||||
progress: 43,
|
progress: 44,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Ελληνικά (Greek)",
|
name: "Ελληνικά (Greek)",
|
||||||
value: "el-GR",
|
value: "el-GR",
|
||||||
progress: 40,
|
progress: 41,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Deutsch (German)",
|
name: "Deutsch (German)",
|
||||||
value: "de-DE",
|
value: "de-DE",
|
||||||
progress: 78,
|
progress: 97,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Dansk (Danish)",
|
name: "Dansk (Danish)",
|
||||||
value: "da-DK",
|
value: "da-DK",
|
||||||
progress: 40,
|
progress: 52,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -237,7 +237,7 @@ export const LOCALES = [
|
|||||||
{
|
{
|
||||||
name: "Български (Bulgarian)",
|
name: "Български (Bulgarian)",
|
||||||
value: "bg-BG",
|
value: "bg-BG",
|
||||||
progress: 44,
|
progress: 49,
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ref, watch, computed } from "vue";
|
import { ref, watch, computed } from "vue";
|
||||||
|
import { useAuthBackend } from "~/composables/use-auth-backend";
|
||||||
import type { UserOut } from "~/lib/api/types/user";
|
import type { UserOut } from "~/lib/api/types/user";
|
||||||
|
|
||||||
export const useMealieAuth = function () {
|
export const useMealieAuth = function () {
|
||||||
const auth = useAuth();
|
const auth = useAuthBackend();
|
||||||
const { setToken } = useAuthState();
|
|
||||||
const { $axios } = useNuxtApp();
|
const { $axios } = useNuxtApp();
|
||||||
|
|
||||||
// User Management
|
// User Management
|
||||||
@@ -40,7 +40,7 @@ export const useMealieAuth = function () {
|
|||||||
async function oauthSignIn() {
|
async function oauthSignIn() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const { data: token } = await $axios.get<{ access_token: string; token_type: "bearer" }>("/api/auth/oauth/callback", { params });
|
const { data: token } = await $axios.get<{ access_token: string; token_type: "bearer" }>("/api/auth/oauth/callback", { params });
|
||||||
setToken(token.access_token);
|
auth.setToken(token.access_token);
|
||||||
await auth.getSession();
|
await auth.getSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,6 @@ export const useMealieAuth = function () {
|
|||||||
loggedIn,
|
loggedIn,
|
||||||
signIn: auth.signIn,
|
signIn: auth.signIn,
|
||||||
signOut: auth.signOut,
|
signOut: auth.signOut,
|
||||||
signUp: auth.signUp,
|
|
||||||
refresh: auth.refresh,
|
refresh: auth.refresh,
|
||||||
oauthSignIn,
|
oauthSignIn,
|
||||||
};
|
};
|
||||||
@@ -168,6 +168,7 @@ export function useQueryFilterBuilder() {
|
|||||||
|| type === Organizer.Tool
|
|| type === Organizer.Tool
|
||||||
|| type === Organizer.Food
|
|| type === Organizer.Food
|
||||||
|| type === Organizer.Household
|
|| type === Organizer.Household
|
||||||
|
|| type === Organizer.User
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface RecipeExplorerSearchState {
|
|||||||
requireAllTags: boolean;
|
requireAllTags: boolean;
|
||||||
requireAllTools: boolean;
|
requireAllTools: boolean;
|
||||||
requireAllFoods: boolean;
|
requireAllFoods: boolean;
|
||||||
|
randomSeed: number;
|
||||||
}>;
|
}>;
|
||||||
selectedCategories: Ref<NoUndefinedField<RecipeCategory>[]>;
|
selectedCategories: Ref<NoUndefinedField<RecipeCategory>[]>;
|
||||||
selectedFoods: Ref<IngredientFood[]>;
|
selectedFoods: Ref<IngredientFood[]>;
|
||||||
@@ -41,6 +42,7 @@ interface RecipeExplorerSearchState {
|
|||||||
reset: () => void;
|
reset: () => void;
|
||||||
toggleOrderDirection: () => void;
|
toggleOrderDirection: () => void;
|
||||||
setOrderBy: (value: string) => void;
|
setOrderBy: (value: string) => void;
|
||||||
|
setRandomOrderBy: () => void;
|
||||||
filterItems: (item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) => void;
|
filterItems: (item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) => void;
|
||||||
initialize: () => Promise<void>;
|
initialize: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -67,6 +69,7 @@ function createRecipeExplorerSearchState(groupSlug: ComputedRef<string>): Recipe
|
|||||||
requireAllTags: false,
|
requireAllTags: false,
|
||||||
requireAllTools: false,
|
requireAllTools: false,
|
||||||
requireAllFoods: false,
|
requireAllFoods: false,
|
||||||
|
randomSeed: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store references
|
// Store references
|
||||||
@@ -131,9 +134,16 @@ function createRecipeExplorerSearchState(groupSlug: ComputedRef<string>): Recipe
|
|||||||
return {
|
return {
|
||||||
...passedQuery.value,
|
...passedQuery.value,
|
||||||
_searchSeed: Date.now().toString(),
|
_searchSeed: Date.now().toString(),
|
||||||
|
_randomSeed: state.value.randomSeed,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update the seed to trigger a new search
|
||||||
|
function setRandomOrderBy() {
|
||||||
|
state.value.orderBy = "random";
|
||||||
|
state.value.randomSeed = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
// Wait utility for async hydration
|
// Wait utility for async hydration
|
||||||
function waitUntilAndExecute(
|
function waitUntilAndExecute(
|
||||||
condition: () => boolean,
|
condition: () => boolean,
|
||||||
@@ -442,6 +452,7 @@ function createRecipeExplorerSearchState(groupSlug: ComputedRef<string>): Recipe
|
|||||||
reset,
|
reset,
|
||||||
toggleOrderDirection,
|
toggleOrderDirection,
|
||||||
setOrderBy,
|
setOrderBy,
|
||||||
|
setRandomOrderBy,
|
||||||
filterItems,
|
filterItems,
|
||||||
initialize,
|
initialize,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -112,34 +112,46 @@ export function useShoppingListItemActions(shoppingListId: string) {
|
|||||||
|
|
||||||
async function getList() {
|
async function getList() {
|
||||||
const response = await api.shopping.lists.getOne(shoppingListId);
|
const response = await api.shopping.lists.getOne(shoppingListId);
|
||||||
if (!isOnline.value && response.data) {
|
if (response.data) {
|
||||||
|
// Merge pending local changes (both online and offline)
|
||||||
const createAndUpdateQueues = mergeListItemsByLatest(queue.update, queue.create);
|
const createAndUpdateQueues = mergeListItemsByLatest(queue.update, queue.create);
|
||||||
response.data.listItems = mergeListItemsByLatest(response.data.listItems ?? [], createAndUpdateQueues);
|
const deleteQueueIds = new Set(queue.delete.map(item => item.id));
|
||||||
|
|
||||||
|
const filteredLocalChanges = createAndUpdateQueues.filter(item => !deleteQueueIds.has(item.id));
|
||||||
|
let mergedItems = mergeListItemsByLatest(response.data.listItems ?? [], filteredLocalChanges);
|
||||||
|
mergedItems = mergedItems.filter(item => !deleteQueueIds.has(item.id));
|
||||||
|
|
||||||
|
response.data.listItems = mergedItems;
|
||||||
}
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createItem(item: ShoppingListItemOut) {
|
function createItem(item: ShoppingListItemOut) {
|
||||||
removeFromQueue(queue.create, item);
|
removeFromQueue(queue.create, item);
|
||||||
|
removeFromQueue(queue.update, item);
|
||||||
|
removeFromQueue(queue.delete, item);
|
||||||
|
|
||||||
queue.create.push(item);
|
queue.create.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateItem(item: ShoppingListItemOut) {
|
function updateItem(item: ShoppingListItemOut) {
|
||||||
const removedFromCreate = removeFromQueue(queue.create, item);
|
const removedFromCreate = removeFromQueue(queue.create, item);
|
||||||
if (removedFromCreate) {
|
|
||||||
// this item hasn't been created yet, so we don't need to update it
|
|
||||||
queue.create.push(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFromQueue(queue.update, item);
|
removeFromQueue(queue.update, item);
|
||||||
queue.update.push(item);
|
removeFromQueue(queue.delete, item);
|
||||||
|
|
||||||
|
if (removedFromCreate) {
|
||||||
|
// This item hasn't been created yet, so keep it in create queue with updated data
|
||||||
|
queue.create.push(item);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
queue.update.push(item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteItem(item: ShoppingListItemOut) {
|
function deleteItem(item: ShoppingListItemOut) {
|
||||||
const removedFromCreate = removeFromQueue(queue.create, item);
|
const removedFromCreate = removeFromQueue(queue.create, item);
|
||||||
if (removedFromCreate) {
|
if (removedFromCreate) {
|
||||||
// this item hasn't been created yet, so we don't need to delete it
|
// This item hasn't been created yet, so we don't need to delete it
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,10 +210,12 @@ export function useShoppingListItemActions(shoppingListId: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const itemsToProcess = [...queueItems];
|
const itemsToProcess = [...queueItems];
|
||||||
|
const itemIdsToProcess = itemsToProcess.map(item => item.id);
|
||||||
|
|
||||||
await action(itemsToProcess)
|
await action(itemsToProcess)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (isOnline.value) {
|
if (isOnline.value) {
|
||||||
clearQueueItems(itemQueueType, itemsToProcess.map(item => item.id));
|
clearQueueItems(itemQueueType, itemIdsToProcess);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
|
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
|
||||||
|
import { ActivityKey } from "~/lib/api/types/activity";
|
||||||
import type { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe";
|
import type { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe";
|
||||||
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ export interface UserPrintPreferences {
|
|||||||
showDescription: boolean;
|
showDescription: boolean;
|
||||||
showNotes: boolean;
|
showNotes: boolean;
|
||||||
showNutrition: boolean;
|
showNutrition: boolean;
|
||||||
|
expandChildRecipes: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserSearchQuery {
|
export interface UserSearchQuery {
|
||||||
@@ -65,6 +67,10 @@ export interface UserRecipeCreatePreferences {
|
|||||||
parseRecipe: boolean;
|
parseRecipe: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserActivityPreferences {
|
||||||
|
defaultActivity: ActivityKey;
|
||||||
|
}
|
||||||
|
|
||||||
export function useUserMealPlanPreferences(): Ref<UserMealPlanPreferences> {
|
export function useUserMealPlanPreferences(): Ref<UserMealPlanPreferences> {
|
||||||
const fromStorage = useLocalStorage(
|
const fromStorage = useLocalStorage(
|
||||||
"meal-planner-preferences",
|
"meal-planner-preferences",
|
||||||
@@ -86,6 +92,7 @@ export function useUserPrintPreferences(): Ref<UserPrintPreferences> {
|
|||||||
imagePosition: "left",
|
imagePosition: "left",
|
||||||
showDescription: true,
|
showDescription: true,
|
||||||
showNotes: true,
|
showNotes: true,
|
||||||
|
expandChildRecipes: false,
|
||||||
},
|
},
|
||||||
{ mergeDefaults: true },
|
{ mergeDefaults: true },
|
||||||
// we cast to a Ref because by default it will return an optional type ref
|
// we cast to a Ref because by default it will return an optional type ref
|
||||||
@@ -115,6 +122,20 @@ export function useUserSortPreferences(): Ref<UserRecipePreferences> {
|
|||||||
return fromStorage;
|
return fromStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUserActivityPreferences(): Ref<UserActivityPreferences> {
|
||||||
|
const fromStorage = useLocalStorage(
|
||||||
|
"activity-preferences",
|
||||||
|
{
|
||||||
|
defaultActivity: ActivityKey.RECIPES,
|
||||||
|
},
|
||||||
|
{ mergeDefaults: true },
|
||||||
|
// we cast to a Ref because by default it will return an optional type ref
|
||||||
|
// but since we pass defaults we know all properties are set.
|
||||||
|
) as Ref<UserActivityPreferences>;
|
||||||
|
|
||||||
|
return fromStorage;
|
||||||
|
}
|
||||||
|
|
||||||
export function useUserSearchQuerySession(): Ref<UserSearchQuery> {
|
export function useUserSearchQuerySession(): Ref<UserSearchQuery> {
|
||||||
const fromStorage = useSessionStorage(
|
const fromStorage = useSessionStorage(
|
||||||
"search-query",
|
"search-query",
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ const userRatings = ref<UserRatingSummary[]>([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const ready = ref(false);
|
const ready = ref(false);
|
||||||
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
|
|
||||||
export const useUserSelfRatings = function () {
|
export const useUserSelfRatings = function () {
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
|
||||||
async function refreshUserRatings() {
|
async function refreshUserRatings() {
|
||||||
if (!$auth.user.value || loading.value) {
|
if (!$auth.user.value || loading.value) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
34
frontend/composables/use-utils.test.ts
Normal file
34
frontend/composables/use-utils.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { normalize, normalizeFilter } from "./use-utils";
|
||||||
|
|
||||||
|
describe("test normalize", () => {
|
||||||
|
test("base case", () => {
|
||||||
|
expect(normalize("banana")).not.toEqual(normalize("Potatoes"));
|
||||||
|
});
|
||||||
|
test("diacritics", () => {
|
||||||
|
expect(normalize("Rátàtôuile")).toEqual("ratatouile");
|
||||||
|
});
|
||||||
|
test("ligatures", () => {
|
||||||
|
expect(normalize("IJ")).toEqual("ij");
|
||||||
|
expect(normalize("æ")).toEqual("ae");
|
||||||
|
expect(normalize("œ")).toEqual("oe");
|
||||||
|
expect(normalize("ff")).toEqual("ff");
|
||||||
|
expect(normalize("fi")).toEqual("fi");
|
||||||
|
expect(normalize("st")).toEqual("st");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("test normalize filter", () => {
|
||||||
|
test("base case", () => {
|
||||||
|
const patternA = "Escargots persillés";
|
||||||
|
const patternB = "persillés";
|
||||||
|
|
||||||
|
expect(normalizeFilter(patternA, patternB)).toBeTruthy();
|
||||||
|
expect(normalizeFilter(patternB, patternA)).toBeFalsy();
|
||||||
|
});
|
||||||
|
test("normalize", () => {
|
||||||
|
const value = "Cœur de bœuf";
|
||||||
|
const query = "coeur";
|
||||||
|
expect(normalizeFilter(value, query)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useDark, useToggle } from "@vueuse/core";
|
import { useDark, useToggle } from "@vueuse/core";
|
||||||
|
import type { FilterFunction } from "vuetify";
|
||||||
|
|
||||||
export const useToggleDarkMode = () => {
|
export const useToggleDarkMode = () => {
|
||||||
const isDark = useDark();
|
const isDark = useDark();
|
||||||
@@ -18,6 +19,38 @@ export const titleCase = function (str: string) {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const replaceAllBuilder = (map: Map<string, string>): ((str: string) => string) => {
|
||||||
|
const re = new RegExp(Array.from(map.keys()).join("|"), "gi");
|
||||||
|
return str => str.replace(re, matched => map.get(matched)!);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeLigatures = replaceAllBuilder(new Map([
|
||||||
|
["œ", "oe"],
|
||||||
|
["æ", "ae"],
|
||||||
|
["ij", "ij"],
|
||||||
|
["ff", "ff"],
|
||||||
|
["fi", "fi"],
|
||||||
|
["fl", "fl"],
|
||||||
|
["st", "st"],
|
||||||
|
]));
|
||||||
|
|
||||||
|
export const normalize = (str: string) => {
|
||||||
|
if (!str) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = str.normalize("NFKD").toLowerCase();
|
||||||
|
normalized = normalized.replace(/\p{Diacritic}/gu, "");
|
||||||
|
normalized = normalizeLigatures(normalized);
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeFilter: FilterFunction = (value: string, query: string) => {
|
||||||
|
const normalizedValue = normalize(value);
|
||||||
|
const normalizeQuery = normalize(query);
|
||||||
|
return normalizedValue.includes(normalizeQuery);
|
||||||
|
};
|
||||||
|
|
||||||
export function uuid4() {
|
export function uuid4() {
|
||||||
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
|
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
|
||||||
(parseInt(c) ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (parseInt(c) / 4)))).toString(16),
|
(parseInt(c) ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (parseInt(c) / 4)))).toString(16),
|
||||||
|
|||||||
@@ -342,6 +342,9 @@
|
|||||||
"breakfast": "Ontbyt",
|
"breakfast": "Ontbyt",
|
||||||
"lunch": "Middagete",
|
"lunch": "Middagete",
|
||||||
"dinner": "Aandete",
|
"dinner": "Aandete",
|
||||||
|
"snack": "Snack",
|
||||||
|
"drink": "Drink",
|
||||||
|
"dessert": "Dessert",
|
||||||
"type-any": "Enige",
|
"type-any": "Enige",
|
||||||
"day-any": "Enige",
|
"day-any": "Enige",
|
||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
@@ -399,6 +402,10 @@
|
|||||||
"description-long": "Mealie kan resepte van Tandoor invoer. Voer jou data uit in die \"Standaardformaat\" en laai dan die .zip-lêer op.",
|
"description-long": "Mealie kan resepte van Tandoor invoer. Voer jou data uit in die \"Standaardformaat\" en laai dan die .zip-lêer op.",
|
||||||
"title": "Tandoor resepte"
|
"title": "Tandoor resepte"
|
||||||
},
|
},
|
||||||
|
"cookn": {
|
||||||
|
"description-long": "Mealie can import recipes from DVO Cook'n X3. Export a cookbook or menu in the \"Cook'n\" format, rename the export extension to .zip, then upload the .zip below.",
|
||||||
|
"title": "DVO Cook'n X3"
|
||||||
|
},
|
||||||
"recipe-data-migrations": "Resep migrasie",
|
"recipe-data-migrations": "Resep migrasie",
|
||||||
"recipe-data-migrations-explanation": "Jy kan resepte vanaf 'n ander program in Mealie invoer. Op hierdie manier kan jy vinnig aan die gang kom.",
|
"recipe-data-migrations-explanation": "Jy kan resepte vanaf 'n ander program in Mealie invoer. Op hierdie manier kan jy vinnig aan die gang kom.",
|
||||||
"coming-from-another-application-or-an-even-older-version-of-mealie": "Coming from another application or an even older version of Mealie? Check out migrations and see if your data can be imported.",
|
"coming-from-another-application-or-an-even-older-version-of-mealie": "Coming from another application or an even older version of Mealie? Check out migrations and see if your data can be imported.",
|
||||||
@@ -444,7 +451,9 @@
|
|||||||
"split-by-numbered-line-description": "Pogings om 'n paragraaf te verdeel deur die '1)' of '1.' patrone om te gebruik",
|
"split-by-numbered-line-description": "Pogings om 'n paragraaf te verdeel deur die '1)' of '1.' patrone om te gebruik",
|
||||||
"import-by-url": "Voer 'n resep vanaf 'n webwerf in",
|
"import-by-url": "Voer 'n resep vanaf 'n webwerf in",
|
||||||
"create-manually": "Skep 'n resep met die hand",
|
"create-manually": "Skep 'n resep met die hand",
|
||||||
"make-recipe-image": "Maak dit die prentjie vir hierdie resep"
|
"make-recipe-image": "Maak dit die prentjie vir hierdie resep",
|
||||||
|
"add-food": "Add Food",
|
||||||
|
"add-recipe": "Add Recipe"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"404-page-not-found": "404 Bladsy nie gevind nie",
|
"404-page-not-found": "404 Bladsy nie gevind nie",
|
||||||
@@ -511,6 +520,9 @@
|
|||||||
"recipe-deleted": "Resep uitgevee",
|
"recipe-deleted": "Resep uitgevee",
|
||||||
"recipe-image": "Resep foto",
|
"recipe-image": "Resep foto",
|
||||||
"recipe-image-updated": "Resep foto is opgedateer",
|
"recipe-image-updated": "Resep foto is opgedateer",
|
||||||
|
"delete-image": "Delete Recipe Image",
|
||||||
|
"delete-image-confirmation": "Are you sure you want to delete this recipe image?",
|
||||||
|
"recipe-image-deleted": "Recipe image deleted",
|
||||||
"recipe-name": "Resepnaam",
|
"recipe-name": "Resepnaam",
|
||||||
"recipe-settings": "Resep verstellings",
|
"recipe-settings": "Resep verstellings",
|
||||||
"recipe-update-failed": "Kon nie resep opdateer nie",
|
"recipe-update-failed": "Kon nie resep opdateer nie",
|
||||||
@@ -556,6 +568,7 @@
|
|||||||
"choose-unit": "Kies 'n eenheid",
|
"choose-unit": "Kies 'n eenheid",
|
||||||
"press-enter-to-create": "Druk Enter om te skep",
|
"press-enter-to-create": "Druk Enter om te skep",
|
||||||
"choose-food": "Keuse van kos",
|
"choose-food": "Keuse van kos",
|
||||||
|
"choose-recipe": "Choose Recipe",
|
||||||
"notes": "Notas",
|
"notes": "Notas",
|
||||||
"toggle-section": "Wissel afdeling",
|
"toggle-section": "Wissel afdeling",
|
||||||
"see-original-text": "Sien oorspronklike teks",
|
"see-original-text": "Sien oorspronklike teks",
|
||||||
@@ -583,6 +596,7 @@
|
|||||||
"made-this": "Ek het dit gemaak",
|
"made-this": "Ek het dit gemaak",
|
||||||
"how-did-it-turn-out": "Hoe het dit uitgedraai?",
|
"how-did-it-turn-out": "Hoe het dit uitgedraai?",
|
||||||
"user-made-this": "{user} het dit gemaak",
|
"user-made-this": "{user} het dit gemaak",
|
||||||
|
"made-for-recipe": "Made for {recipe}",
|
||||||
"added-to-timeline": "Added to timeline",
|
"added-to-timeline": "Added to timeline",
|
||||||
"failed-to-add-to-timeline": "Failed to add to timeline",
|
"failed-to-add-to-timeline": "Failed to add to timeline",
|
||||||
"failed-to-update-recipe": "Failed to update recipe",
|
"failed-to-update-recipe": "Failed to update recipe",
|
||||||
@@ -622,6 +636,8 @@
|
|||||||
"scrape-recipe-suggest-bulk-importer": "Try out the bulk importer",
|
"scrape-recipe-suggest-bulk-importer": "Try out the bulk importer",
|
||||||
"scrape-recipe-have-raw-html-or-json-data": "Have raw HTML or JSON data?",
|
"scrape-recipe-have-raw-html-or-json-data": "Have raw HTML or JSON data?",
|
||||||
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
|
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
|
||||||
|
"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-keywords-as-tags": "Voer oorspronklike sleutelwoorde as merkers in",
|
||||||
"stay-in-edit-mode": "Bly in redigeer modus",
|
"stay-in-edit-mode": "Bly in redigeer modus",
|
||||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||||
@@ -684,7 +700,10 @@
|
|||||||
"upload-images": "Upload images",
|
"upload-images": "Upload images",
|
||||||
"upload-more-images": "Upload more images",
|
"upload-more-images": "Upload more images",
|
||||||
"set-as-cover-image": "Set as recipe cover image",
|
"set-as-cover-image": "Set as recipe cover image",
|
||||||
"cover-image": "Cover image"
|
"cover-image": "Cover image",
|
||||||
|
"include-linked-recipes": "Include Linked Recipes",
|
||||||
|
"include-linked-recipe-ingredients": "Include Linked Recipe Ingredients",
|
||||||
|
"toggle-recipe": "Toggle Recipe"
|
||||||
},
|
},
|
||||||
"recipe-finder": {
|
"recipe-finder": {
|
||||||
"recipe-finder": "Recipe Finder",
|
"recipe-finder": "Recipe Finder",
|
||||||
@@ -721,7 +740,8 @@
|
|||||||
"search-hint": "Druk '/'",
|
"search-hint": "Druk '/'",
|
||||||
"advanced": "Gevorderd",
|
"advanced": "Gevorderd",
|
||||||
"auto-search": "Outomatiese soektog",
|
"auto-search": "Outomatiese soektog",
|
||||||
"no-results": "No results found"
|
"no-results": "No results found",
|
||||||
|
"type-to-search": "Type to search..."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"add-a-new-theme": "Voeg 'n nuwe tema by",
|
"add-a-new-theme": "Voeg 'n nuwe tema by",
|
||||||
@@ -1059,7 +1079,9 @@
|
|||||||
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Don't want to see this anymore? Be sure to change your email in your user settings!",
|
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Don't want to see this anymore? Be sure to change your email in your user settings!",
|
||||||
"forgot-password": "Forgot Password",
|
"forgot-password": "Forgot Password",
|
||||||
"forgot-password-text": "Please enter your email address and we will send you a link to reset your password.",
|
"forgot-password-text": "Please enter your email address and we will send you a link to reset your password.",
|
||||||
"changes-reflected-immediately": "Changes to this user will be reflected immediately."
|
"changes-reflected-immediately": "Changes to this user will be reflected immediately.",
|
||||||
|
"default-activity": "Default Activity",
|
||||||
|
"default-activity-hint": "Select which page you'd like to navigate to upon logging in from this device"
|
||||||
},
|
},
|
||||||
"language-dialog": {
|
"language-dialog": {
|
||||||
"translated": "vertaal",
|
"translated": "vertaal",
|
||||||
|
|||||||
@@ -342,6 +342,9 @@
|
|||||||
"breakfast": "الإفطار",
|
"breakfast": "الإفطار",
|
||||||
"lunch": "الغداء",
|
"lunch": "الغداء",
|
||||||
"dinner": "العشاء",
|
"dinner": "العشاء",
|
||||||
|
"snack": "Snack",
|
||||||
|
"drink": "Drink",
|
||||||
|
"dessert": "Dessert",
|
||||||
"type-any": "أي",
|
"type-any": "أي",
|
||||||
"day-any": "أي",
|
"day-any": "أي",
|
||||||
"editor": "المحرر",
|
"editor": "المحرر",
|
||||||
@@ -399,6 +402,10 @@
|
|||||||
"description-long": "يمكن لميلي استيراد الوصفات من تندور. يجب تصدير بياناتك بالتنسيق \"الافتراضي\"، ثم يجب تحميل المِلَفّ المضغوط أدناه.",
|
"description-long": "يمكن لميلي استيراد الوصفات من تندور. يجب تصدير بياناتك بالتنسيق \"الافتراضي\"، ثم يجب تحميل المِلَفّ المضغوط أدناه.",
|
||||||
"title": "وصفات تاندور"
|
"title": "وصفات تاندور"
|
||||||
},
|
},
|
||||||
|
"cookn": {
|
||||||
|
"description-long": "Mealie can import recipes from DVO Cook'n X3. Export a cookbook or menu in the \"Cook'n\" format, rename the export extension to .zip, then upload the .zip below.",
|
||||||
|
"title": "DVO Cook'n X3"
|
||||||
|
},
|
||||||
"recipe-data-migrations": "وصفة 2",
|
"recipe-data-migrations": "وصفة 2",
|
||||||
"recipe-data-migrations-explanation": "يمكن نقل الوصفات من تطبيق آخر مدعوم إلى ميلي. هذه طريقة رائعة للبدء مع ميلي.",
|
"recipe-data-migrations-explanation": "يمكن نقل الوصفات من تطبيق آخر مدعوم إلى ميلي. هذه طريقة رائعة للبدء مع ميلي.",
|
||||||
"coming-from-another-application-or-an-even-older-version-of-mealie": "هل تأتي من تطبيق آخر أو حتى إصدار قديم من ميلي؟ يجب التحقق من عمليات الترحيل لمعرفة ما إذا كان يمكن استيراد بياناتك.",
|
"coming-from-another-application-or-an-even-older-version-of-mealie": "هل تأتي من تطبيق آخر أو حتى إصدار قديم من ميلي؟ يجب التحقق من عمليات الترحيل لمعرفة ما إذا كان يمكن استيراد بياناتك.",
|
||||||
@@ -444,7 +451,9 @@
|
|||||||
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns",
|
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns",
|
||||||
"import-by-url": "استيراد وصفة عن طريق عنوان URL",
|
"import-by-url": "استيراد وصفة عن طريق عنوان URL",
|
||||||
"create-manually": "إنشاء وصفة يدوياً",
|
"create-manually": "إنشاء وصفة يدوياً",
|
||||||
"make-recipe-image": "اجعل هذه صورة الوصفة"
|
"make-recipe-image": "اجعل هذه صورة الوصفة",
|
||||||
|
"add-food": "Add Food",
|
||||||
|
"add-recipe": "Add Recipe"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"404-page-not-found": "404: لم يتم العثور على الصفحة",
|
"404-page-not-found": "404: لم يتم العثور على الصفحة",
|
||||||
@@ -511,6 +520,9 @@
|
|||||||
"recipe-deleted": "تم حذف الوصفة",
|
"recipe-deleted": "تم حذف الوصفة",
|
||||||
"recipe-image": "صورة الوصفة",
|
"recipe-image": "صورة الوصفة",
|
||||||
"recipe-image-updated": "تم تحديث صورة الوصفة",
|
"recipe-image-updated": "تم تحديث صورة الوصفة",
|
||||||
|
"delete-image": "Delete Recipe Image",
|
||||||
|
"delete-image-confirmation": "Are you sure you want to delete this recipe image?",
|
||||||
|
"recipe-image-deleted": "Recipe image deleted",
|
||||||
"recipe-name": "اسم الوصفة",
|
"recipe-name": "اسم الوصفة",
|
||||||
"recipe-settings": "إعدادات الوصفة",
|
"recipe-settings": "إعدادات الوصفة",
|
||||||
"recipe-update-failed": "فشل تحديث الوصفة",
|
"recipe-update-failed": "فشل تحديث الوصفة",
|
||||||
@@ -556,6 +568,7 @@
|
|||||||
"choose-unit": "اختر الوحدة",
|
"choose-unit": "اختر الوحدة",
|
||||||
"press-enter-to-create": "",
|
"press-enter-to-create": "",
|
||||||
"choose-food": "اختيار الطعام",
|
"choose-food": "اختيار الطعام",
|
||||||
|
"choose-recipe": "Choose Recipe",
|
||||||
"notes": "ملاحظات",
|
"notes": "ملاحظات",
|
||||||
"toggle-section": "",
|
"toggle-section": "",
|
||||||
"see-original-text": "عرض النص الأصلي",
|
"see-original-text": "عرض النص الأصلي",
|
||||||
@@ -583,6 +596,7 @@
|
|||||||
"made-this": "لقد طبخت هذا",
|
"made-this": "لقد طبخت هذا",
|
||||||
"how-did-it-turn-out": "كيف كانت النتيجة؟",
|
"how-did-it-turn-out": "كيف كانت النتيجة؟",
|
||||||
"user-made-this": "{user} طبخ هذه",
|
"user-made-this": "{user} طبخ هذه",
|
||||||
|
"made-for-recipe": "Made for {recipe}",
|
||||||
"added-to-timeline": "Added to timeline",
|
"added-to-timeline": "Added to timeline",
|
||||||
"failed-to-add-to-timeline": "Failed to add to timeline",
|
"failed-to-add-to-timeline": "Failed to add to timeline",
|
||||||
"failed-to-update-recipe": "Failed to update recipe",
|
"failed-to-update-recipe": "Failed to update recipe",
|
||||||
@@ -622,6 +636,8 @@
|
|||||||
"scrape-recipe-suggest-bulk-importer": "جرب الإضافة بالجملة",
|
"scrape-recipe-suggest-bulk-importer": "جرب الإضافة بالجملة",
|
||||||
"scrape-recipe-have-raw-html-or-json-data": "هل لديك بيانات HTML أو JSON خام؟",
|
"scrape-recipe-have-raw-html-or-json-data": "هل لديك بيانات HTML أو JSON خام؟",
|
||||||
"scrape-recipe-you-can-import-from-raw-data-directly": "يمكنك الإضافة مباشرة باستخدام بيانات خام",
|
"scrape-recipe-you-can-import-from-raw-data-directly": "يمكنك الإضافة مباشرة باستخدام بيانات خام",
|
||||||
|
"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": "استيراد الكلمات المفتاحية الأصلية كوسوم",
|
||||||
"stay-in-edit-mode": "البقاء في وضع التعديل",
|
"stay-in-edit-mode": "البقاء في وضع التعديل",
|
||||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||||
@@ -684,7 +700,10 @@
|
|||||||
"upload-images": "Upload images",
|
"upload-images": "Upload images",
|
||||||
"upload-more-images": "Upload more images",
|
"upload-more-images": "Upload more images",
|
||||||
"set-as-cover-image": "Set as recipe cover image",
|
"set-as-cover-image": "Set as recipe cover image",
|
||||||
"cover-image": "Cover image"
|
"cover-image": "Cover image",
|
||||||
|
"include-linked-recipes": "Include Linked Recipes",
|
||||||
|
"include-linked-recipe-ingredients": "Include Linked Recipe Ingredients",
|
||||||
|
"toggle-recipe": "Toggle Recipe"
|
||||||
},
|
},
|
||||||
"recipe-finder": {
|
"recipe-finder": {
|
||||||
"recipe-finder": "البحث عن الوصفات",
|
"recipe-finder": "البحث عن الوصفات",
|
||||||
@@ -721,7 +740,8 @@
|
|||||||
"search-hint": "اضغط '/'",
|
"search-hint": "اضغط '/'",
|
||||||
"advanced": "الإعدادات المتقدمة",
|
"advanced": "الإعدادات المتقدمة",
|
||||||
"auto-search": "البحث التلقائي",
|
"auto-search": "البحث التلقائي",
|
||||||
"no-results": "لم يتم العثور على نتائج"
|
"no-results": "لم يتم العثور على نتائج",
|
||||||
|
"type-to-search": "Type to search..."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"add-a-new-theme": "إضافة سمة جديدة",
|
"add-a-new-theme": "إضافة سمة جديدة",
|
||||||
@@ -1059,7 +1079,9 @@
|
|||||||
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "لا تريد رؤية هذه الرسالة بعد الآن؟ تأكد من تغيير بريدك الإلكتروني في إعدادات المستخدم الخاصة بك!",
|
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "لا تريد رؤية هذه الرسالة بعد الآن؟ تأكد من تغيير بريدك الإلكتروني في إعدادات المستخدم الخاصة بك!",
|
||||||
"forgot-password": "نسيت كلمة المرور",
|
"forgot-password": "نسيت كلمة المرور",
|
||||||
"forgot-password-text": "الرجاء إدخال بريدك الإلكتروني وسنرسل لك رابطًا لإعادة تعيين كلمة المرور الخاصة بك.",
|
"forgot-password-text": "الرجاء إدخال بريدك الإلكتروني وسنرسل لك رابطًا لإعادة تعيين كلمة المرور الخاصة بك.",
|
||||||
"changes-reflected-immediately": "التغييرات التي ستطرأ على هذا المستخدم ستنعكس على الفور."
|
"changes-reflected-immediately": "التغييرات التي ستطرأ على هذا المستخدم ستنعكس على الفور.",
|
||||||
|
"default-activity": "Default Activity",
|
||||||
|
"default-activity-hint": "Select which page you'd like to navigate to upon logging in from this device"
|
||||||
},
|
},
|
||||||
"language-dialog": {
|
"language-dialog": {
|
||||||
"translated": "مترجم",
|
"translated": "مترجم",
|
||||||
|
|||||||
@@ -4,25 +4,25 @@
|
|||||||
"about-mealie": "Относно Mealie",
|
"about-mealie": "Относно Mealie",
|
||||||
"api-docs": "API Документация",
|
"api-docs": "API Документация",
|
||||||
"api-port": "API Порт",
|
"api-port": "API Порт",
|
||||||
"application-mode": "Режим на приложение",
|
"application-mode": "Статус на приложението",
|
||||||
"database-type": "Тип на база данни",
|
"database-type": "Тип на база данни",
|
||||||
"database-url": "URL адрес база данни",
|
"database-url": "URL адрес база данни",
|
||||||
"default-group": "Група по подразбиране",
|
"default-group": "Група по подразбиране",
|
||||||
"default-household": "Домакинство по подразбиране",
|
"default-household": "Домакинство по подразбиране",
|
||||||
"demo": "Демо",
|
"demo": "Демо",
|
||||||
"demo-status": "Статус на Демото",
|
"demo-status": "Статус на демо-версията",
|
||||||
"development": "Разработване",
|
"development": "Разработване",
|
||||||
"docs": "Документи",
|
"docs": "Документи",
|
||||||
"download-log": "Дневник на изтеглянията",
|
"download-log": "Дневник на изтеглянията",
|
||||||
"download-recipe-json": "Последно обработен json файл",
|
"download-recipe-json": "Последно обработен json файл",
|
||||||
"github": "GitHub",
|
"github": "GitHub",
|
||||||
"log-lines": "Редове от лога",
|
"log-lines": "Редове от лога",
|
||||||
"not-demo": "Не е демо",
|
"not-demo": "Не е демо-версия",
|
||||||
"portfolio": "Портфолио",
|
"portfolio": "Портфолио",
|
||||||
"production": "Производствена среда",
|
"production": "Производствена среда",
|
||||||
"support": "Поддръжка",
|
"support": "Поддръжка",
|
||||||
"version": "Версия",
|
"version": "Версия",
|
||||||
"unknown-version": "неизвестно",
|
"unknown-version": "неизвестен",
|
||||||
"sponsor": "Спонсори"
|
"sponsor": "Спонсори"
|
||||||
},
|
},
|
||||||
"asset": {
|
"asset": {
|
||||||
@@ -55,16 +55,16 @@
|
|||||||
"database": "База данни",
|
"database": "База данни",
|
||||||
"delete-event": "Изтриване на събитие",
|
"delete-event": "Изтриване на събитие",
|
||||||
"event-delete-confirmation": "Наистина ли искате да премахнете това събитие?",
|
"event-delete-confirmation": "Наистина ли искате да премахнете това събитие?",
|
||||||
"event-deleted": "Събитието изтрито",
|
"event-deleted": "Събитието бе изтрито",
|
||||||
"event-updated": "Събитие Обновено",
|
"event-updated": "Събитието бе обновено",
|
||||||
"new-notification-form-description": "Mealie използва библиотеката Apprise да генерира нотификации. Тя предлага много опции за услуги и нотификации. Прегледайте нейната Wiki страница за подробен гайд как да създадете URL за вашата услуга. Ако е налично, селектирайки типа на нотификацията може да са налични допълнителни функционалности.",
|
"new-notification-form-description": "Mealie използва библиотеката Apprise да генерира нотификации. Тя предлага много опции за услуги и нотификации. Прегледайте нейната Wiki страница за подробен гайд как да създадете URL за вашата услуга. Ако е налично, селектирайки типа на нотификацията може да са налични допълнителни функционалности.",
|
||||||
"new-version": "Налична е нова версия!",
|
"new-version": "Налична е нова версия!",
|
||||||
"notification": "Известие",
|
"notification": "Известие",
|
||||||
"refresh": "Опресни",
|
"refresh": "Опресняване",
|
||||||
"scheduled": "Планирано",
|
"scheduled": "Планирано",
|
||||||
"something-went-wrong": "Нещо се обърка!",
|
"something-went-wrong": "Нещо се обърка!",
|
||||||
"subscribed-events": "Планирани събития",
|
"subscribed-events": "Планирани събития",
|
||||||
"test-message-sent": "Тестово съобщение е изпратено",
|
"test-message-sent": "Тестовото съобщение бе изпратено",
|
||||||
"message-sent": "Съобщението е изпратено",
|
"message-sent": "Съобщението е изпратено",
|
||||||
"new-notification": "Ново известие",
|
"new-notification": "Ново известие",
|
||||||
"event-notifiers": "Известия за събитие",
|
"event-notifiers": "Известия за събитие",
|
||||||
@@ -97,11 +97,11 @@
|
|||||||
"custom": "Персонализиран",
|
"custom": "Персонализиран",
|
||||||
"dashboard": "Табло",
|
"dashboard": "Табло",
|
||||||
"delete": "Изтриване",
|
"delete": "Изтриване",
|
||||||
"disabled": "Деактивирано",
|
"disabled": "Изключено",
|
||||||
"download": "Изтегли",
|
"download": "Изтегли",
|
||||||
"duplicate": "Дублиране",
|
"duplicate": "Дублиране",
|
||||||
"edit": "Редактирай",
|
"edit": "Редактирай",
|
||||||
"enabled": "Активиран",
|
"enabled": "Включено",
|
||||||
"exception": "Грешка",
|
"exception": "Грешка",
|
||||||
"failed-count": "Неуспешни: {count}",
|
"failed-count": "Неуспешни: {count}",
|
||||||
"failure-uploading-file": "Неуспешно качване на файл",
|
"failure-uploading-file": "Неуспешно качване на файл",
|
||||||
@@ -128,8 +128,8 @@
|
|||||||
"message": "Съобщение",
|
"message": "Съобщение",
|
||||||
"monday": "Понеделник",
|
"monday": "Понеделник",
|
||||||
"name": "Име",
|
"name": "Име",
|
||||||
"new": "Нов",
|
"new": "Добавяне",
|
||||||
"never": "никога",
|
"never": "няма данни",
|
||||||
"no": "Не",
|
"no": "Не",
|
||||||
"no-recipe-found": "Няма намерени рецепти",
|
"no-recipe-found": "Няма намерени рецепти",
|
||||||
"ok": "Добре",
|
"ok": "Добре",
|
||||||
@@ -145,9 +145,9 @@
|
|||||||
"rename-object": "Преименувай {0}",
|
"rename-object": "Преименувай {0}",
|
||||||
"reset": "По подразбиране",
|
"reset": "По подразбиране",
|
||||||
"saturday": "Събота",
|
"saturday": "Събота",
|
||||||
"save": "Запази",
|
"save": "Запазване",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"share": "Сподели",
|
"share": "Споделяне",
|
||||||
"show-all": "Покажи всички",
|
"show-all": "Покажи всички",
|
||||||
"shuffle": "Разбъркано",
|
"shuffle": "Разбъркано",
|
||||||
"sort": "Сортирай",
|
"sort": "Сортирай",
|
||||||
@@ -159,13 +159,13 @@
|
|||||||
"submit": "Изпрати",
|
"submit": "Изпрати",
|
||||||
"success-count": "Успешни: {count}",
|
"success-count": "Успешни: {count}",
|
||||||
"sunday": "Неделя",
|
"sunday": "Неделя",
|
||||||
"system": "Система",
|
"system": "В хронологичен ред",
|
||||||
"templates": "Шаблони:",
|
"templates": "Шаблони:",
|
||||||
"test": "Тест",
|
"test": "Тест",
|
||||||
"themes": "Теми",
|
"themes": "Теми",
|
||||||
"thursday": "четвъртък",
|
"thursday": "четвъртък",
|
||||||
"title": "Заглавие",
|
"title": "Заглавие",
|
||||||
"token": "Токън",
|
"token": "Токен",
|
||||||
"tuesday": "Вторник",
|
"tuesday": "Вторник",
|
||||||
"type": "Тип",
|
"type": "Тип",
|
||||||
"update": "Актуализация",
|
"update": "Актуализация",
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
"back": "Назад",
|
"back": "Назад",
|
||||||
"next": "Напред",
|
"next": "Напред",
|
||||||
"start": "Начало",
|
"start": "Начало",
|
||||||
"toggle-view": "Превключване на режим",
|
"toggle-view": "Смяна на изгледа",
|
||||||
"date": "Дата",
|
"date": "Дата",
|
||||||
"id": "Id",
|
"id": "Id",
|
||||||
"owner": "Собственик",
|
"owner": "Собственик",
|
||||||
@@ -340,8 +340,11 @@
|
|||||||
"rule-day": "Ден от седмицата",
|
"rule-day": "Ден от седмицата",
|
||||||
"meal-type": "Вид ястие",
|
"meal-type": "Вид ястие",
|
||||||
"breakfast": "Закуска",
|
"breakfast": "Закуска",
|
||||||
"lunch": "обяд",
|
"lunch": "Обяд",
|
||||||
"dinner": "Вечеря",
|
"dinner": "Вечеря",
|
||||||
|
"snack": "Закуска",
|
||||||
|
"drink": "Питие",
|
||||||
|
"dessert": "Десерт",
|
||||||
"type-any": "Всички",
|
"type-any": "Всички",
|
||||||
"day-any": "Всички",
|
"day-any": "Всички",
|
||||||
"editor": "Редактор",
|
"editor": "Редактор",
|
||||||
@@ -399,6 +402,10 @@
|
|||||||
"description-long": "Mealie може да импортира рецепти от Tandoor. Експортирайте данните в стандартния формат и ги качете като .zip файл по-долу.",
|
"description-long": "Mealie може да импортира рецепти от Tandoor. Експортирайте данните в стандартния формат и ги качете като .zip файл по-долу.",
|
||||||
"title": "Tandoor рецепти"
|
"title": "Tandoor рецепти"
|
||||||
},
|
},
|
||||||
|
"cookn": {
|
||||||
|
"description-long": "Mealie може да импортира рецепти от DVO Cook'n X3. Експортирайте готварска книга или меню във формат \"Cook'n\", преименувайте разширението за експортиране на .zip и след това качете .zip файла по-долу.",
|
||||||
|
"title": "DVO Cook'n X3"
|
||||||
|
},
|
||||||
"recipe-data-migrations": "Миграция на данни на рецепти",
|
"recipe-data-migrations": "Миграция на данни на рецепти",
|
||||||
"recipe-data-migrations-explanation": "Рецептите могат да бъдат мигрирани от други приложения поддържани от Mealie. Това е добър начин да започнете използването си на Mealie.",
|
"recipe-data-migrations-explanation": "Рецептите могат да бъдат мигрирани от други приложения поддържани от Mealie. Това е добър начин да започнете използването си на Mealie.",
|
||||||
"coming-from-another-application-or-an-even-older-version-of-mealie": "Идваш от друго приложение или дори по-стара версия на Mealie? Провери миграциите и виж дали данните ти могат да бъдат импорторани.",
|
"coming-from-another-application-or-an-even-older-version-of-mealie": "Идваш от друго приложение или дори по-стара версия на Mealie? Провери миграциите и виж дали данните ти могат да бъдат импорторани.",
|
||||||
@@ -444,7 +451,9 @@
|
|||||||
"split-by-numbered-line-description": "Опитва се да раздели параграф по '1)' or '1.' модел",
|
"split-by-numbered-line-description": "Опитва се да раздели параграф по '1)' or '1.' модел",
|
||||||
"import-by-url": "Импортиране на рецепта от линк",
|
"import-by-url": "Импортиране на рецепта от линк",
|
||||||
"create-manually": "Създай рецепта ръчно",
|
"create-manually": "Създай рецепта ръчно",
|
||||||
"make-recipe-image": "Задай като изображението на рецептата"
|
"make-recipe-image": "Задай като изображението на рецептата",
|
||||||
|
"add-food": "Добавяне на продукт",
|
||||||
|
"add-recipe": "Добавяне на рецепта"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"404-page-not-found": "404 Страницата не е намерена",
|
"404-page-not-found": "404 Страницата не е намерена",
|
||||||
@@ -471,7 +480,7 @@
|
|||||||
"categories": "Категории",
|
"categories": "Категории",
|
||||||
"cholesterol-content": "Холестерол",
|
"cholesterol-content": "Холестерол",
|
||||||
"comment-action": "Коментирай",
|
"comment-action": "Коментирай",
|
||||||
"comment": "Коментар",
|
"comment": "Добавен коментар",
|
||||||
"comments": "Коментари",
|
"comments": "Коментари",
|
||||||
"delete-confirmation": "Сигурни ли сте, че желаете да изтриете тази рецепта?",
|
"delete-confirmation": "Сигурни ли сте, че желаете да изтриете тази рецепта?",
|
||||||
"admin-delete-confirmation": "Ще изтриете рецепта, която не е ваша, използвайки администраторски права. Сигурни ли сте?",
|
"admin-delete-confirmation": "Ще изтриете рецепта, която не е ваша, използвайки администраторски права. Сигурни ли сте?",
|
||||||
@@ -490,8 +499,8 @@
|
|||||||
"insert-ingredient": "Въведете съставка",
|
"insert-ingredient": "Въведете съставка",
|
||||||
"insert-section": "Въведете раздел",
|
"insert-section": "Въведете раздел",
|
||||||
"insert-above": "Вмъкни отгоре",
|
"insert-above": "Вмъкни отгоре",
|
||||||
"insert-below": "Вмъкни по-долу",
|
"insert-below": "Вмъкни отдолу",
|
||||||
"instructions": "Инструкции",
|
"instructions": "Начин на приготвяне",
|
||||||
"key-name-required": "Ключовото име е задължително",
|
"key-name-required": "Ключовото име е задължително",
|
||||||
"landscape-view-coming-soon": "Пейзажен изглед",
|
"landscape-view-coming-soon": "Пейзажен изглед",
|
||||||
"milligrams": "милиграма",
|
"milligrams": "милиграма",
|
||||||
@@ -506,11 +515,14 @@
|
|||||||
"prep-time": "Време за подготовка",
|
"prep-time": "Време за подготовка",
|
||||||
"protein-content": "Белтъци",
|
"protein-content": "Белтъци",
|
||||||
"public-recipe": "Публична рецепта",
|
"public-recipe": "Публична рецепта",
|
||||||
"recipe-created": "Рецептата е създадена",
|
"recipe-created": "Нова рецепта",
|
||||||
"recipe-creation-failed": "Създаването на рецепта беше неуспешно",
|
"recipe-creation-failed": "Създаването на рецепта беше неуспешно",
|
||||||
"recipe-deleted": "Рецептата е изтрита",
|
"recipe-deleted": "Рецептата е изтрита",
|
||||||
"recipe-image": "Изображение на рецептата",
|
"recipe-image": "Изображение на рецептата",
|
||||||
"recipe-image-updated": "Изображението на рецептата беше обновено",
|
"recipe-image-updated": "Изображението на рецептата беше обновено",
|
||||||
|
"delete-image": "Итриване на изображението на рецептата",
|
||||||
|
"delete-image-confirmation": "Сигурни ли сте, че желаете да изтриете изображението на рецептата?",
|
||||||
|
"recipe-image-deleted": "Изображението на рецептата беше изтрито",
|
||||||
"recipe-name": "Наименование",
|
"recipe-name": "Наименование",
|
||||||
"recipe-settings": "Настройки на рецептата",
|
"recipe-settings": "Настройки на рецептата",
|
||||||
"recipe-update-failed": "Обновяването на рецептата беше неуспешно",
|
"recipe-update-failed": "Обновяването на рецептата беше неуспешно",
|
||||||
@@ -539,8 +551,8 @@
|
|||||||
"entry-type": "Тип на записа",
|
"entry-type": "Тип на записа",
|
||||||
"date-format-hint": "MM/DD/YYYY формат",
|
"date-format-hint": "MM/DD/YYYY формат",
|
||||||
"date-format-hint-yyyy-mm-dd": "YYYY-MM-DD формат",
|
"date-format-hint-yyyy-mm-dd": "YYYY-MM-DD формат",
|
||||||
"add-to-list": "Добавяне към списък",
|
"add-to-list": "Добавяне към списък за пазаруване",
|
||||||
"add-to-plan": "Добавяне към план",
|
"add-to-plan": "Добави към меню",
|
||||||
"add-to-timeline": "Добавяне към историята на събитията",
|
"add-to-timeline": "Добавяне към историята на събитията",
|
||||||
"recipe-added-to-list": "Рецептата е добавена към списъка",
|
"recipe-added-to-list": "Рецептата е добавена към списъка",
|
||||||
"recipes-added-to-list": "Рецептите са добавени към списъка",
|
"recipes-added-to-list": "Рецептите са добавени към списъка",
|
||||||
@@ -553,20 +565,21 @@
|
|||||||
"yields-amount-with-text": "Порции {amount} {text}",
|
"yields-amount-with-text": "Порции {amount} {text}",
|
||||||
"yield-text": "Забележка",
|
"yield-text": "Забележка",
|
||||||
"quantity": "Количество",
|
"quantity": "Количество",
|
||||||
"choose-unit": "Избери единица",
|
"choose-unit": "Избери мерна единица",
|
||||||
"press-enter-to-create": "Натисните Enter за да създадете",
|
"press-enter-to-create": "Натисните Enter за да създадете",
|
||||||
"choose-food": "Избери продукт",
|
"choose-food": "Избери продукт",
|
||||||
|
"choose-recipe": "Избор на рецепта",
|
||||||
"notes": "Бележки",
|
"notes": "Бележки",
|
||||||
"toggle-section": "Превключване на раздела",
|
"toggle-section": "Създай раздел",
|
||||||
"see-original-text": "Виж оригиналния текст",
|
"see-original-text": "Виж оригиналния текст",
|
||||||
"original-text-with-value": "Оригинален текст: {originalText}",
|
"original-text-with-value": "Оригинален текст: {originalText}",
|
||||||
"ingredient-linker": "Инструмент за свързване на съставки",
|
"ingredient-linker": "Инструмент за свързване на съставки",
|
||||||
"unlinked": "Все още не е свързано",
|
"unlinked": "Все още не е свързано",
|
||||||
"linked-to-other-step": "Свързано към друга стъпка",
|
"linked-to-other-step": "Свързано към друга стъпка",
|
||||||
"auto": "Автоматично",
|
"auto": "Автоматично",
|
||||||
"cook-mode": "Начин на приготвяне",
|
"cook-mode": "Инструкции",
|
||||||
"link-ingredients": "Свържи съставките",
|
"link-ingredients": "Свържи съставки",
|
||||||
"merge-above": "Обедини с по-горната",
|
"merge-above": "Обедини с по-горната стъпка",
|
||||||
"move-to-bottom": "Премести най-долу",
|
"move-to-bottom": "Премести най-долу",
|
||||||
"move-to-top": "Премести най-горе",
|
"move-to-top": "Премести най-горе",
|
||||||
"reset-scale": "Оригинален мащаб",
|
"reset-scale": "Оригинален мащаб",
|
||||||
@@ -579,10 +592,11 @@
|
|||||||
"timeline-is-empty": "Няма история на събитията. Опитайте да приготвите рецептата!",
|
"timeline-is-empty": "Няма история на събитията. Опитайте да приготвите рецептата!",
|
||||||
"timeline-no-events-found-try-adjusting-filters": "Няма намерени събития. Опитайте да промените филтрите си за търсене.",
|
"timeline-no-events-found-try-adjusting-filters": "Няма намерени събития. Опитайте да промените филтрите си за търсене.",
|
||||||
"group-global-timeline": "{groupName} История на събитията",
|
"group-global-timeline": "{groupName} История на събитията",
|
||||||
"open-timeline": "История на събитията",
|
"open-timeline": "Хронология на събитията",
|
||||||
"made-this": "Сготвих рецептата",
|
"made-this": "Сготвих рецептата",
|
||||||
"how-did-it-turn-out": "Как се получи?",
|
"how-did-it-turn-out": "Как се получи?",
|
||||||
"user-made-this": "{user} направи това",
|
"user-made-this": "{user} сготви",
|
||||||
|
"made-for-recipe": "Направено за {recipe}",
|
||||||
"added-to-timeline": "Добавено към историята на събитията",
|
"added-to-timeline": "Добавено към историята на събитията",
|
||||||
"failed-to-add-to-timeline": "Неуспешно добавяне към историята на събитията",
|
"failed-to-add-to-timeline": "Неуспешно добавяне към историята на събитията",
|
||||||
"failed-to-update-recipe": "Неуспешно актуализиране на рецептата",
|
"failed-to-update-recipe": "Неуспешно актуализиране на рецептата",
|
||||||
@@ -622,9 +636,11 @@
|
|||||||
"scrape-recipe-suggest-bulk-importer": "Пробвайте масовото импорторане",
|
"scrape-recipe-suggest-bulk-importer": "Пробвайте масовото импорторане",
|
||||||
"scrape-recipe-have-raw-html-or-json-data": "Имате ли сурови HTML или JSON данни?",
|
"scrape-recipe-have-raw-html-or-json-data": "Имате ли сурови HTML или JSON данни?",
|
||||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Можете да импортирате директно от сурови данни",
|
"scrape-recipe-you-can-import-from-raw-data-directly": "Можете да импортирате директно от сурови данни",
|
||||||
|
"scrape-recipe-website-being-blocked": "Блокиран ли е уебсайтът?",
|
||||||
|
"scrape-recipe-try-importing-raw-html-instead": "Опитайте вместо това да импортирате суровия HTML код.",
|
||||||
"import-original-keywords-as-tags": "Добави оригиналните ключови думи като етикети",
|
"import-original-keywords-as-tags": "Добави оригиналните ключови думи като етикети",
|
||||||
"stay-in-edit-mode": "Остани в режим на редакция",
|
"stay-in-edit-mode": "Остани в режим на редакция",
|
||||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
"parse-recipe-ingredients-after-import": "Анализиране на съставките на рецептата след импортиране",
|
||||||
"import-from-zip": "Импортирай от Zip",
|
"import-from-zip": "Импортирай от Zip",
|
||||||
"import-from-zip-description": "Импортирай рецепта, която е била експортирана от друга инстанция на Mealie.",
|
"import-from-zip-description": "Импортирай рецепта, която е била експортирана от друга инстанция на Mealie.",
|
||||||
"import-from-html-or-json": "Импортиране от HTML или JSON",
|
"import-from-html-or-json": "Импортиране от HTML или JSON",
|
||||||
@@ -671,12 +687,12 @@
|
|||||||
"this-unit-could-not-be-parsed-automatically": "Тази мерна единица не може да бъде анализирана автоматично",
|
"this-unit-could-not-be-parsed-automatically": "Тази мерна единица не може да бъде анализирана автоматично",
|
||||||
"this-food-could-not-be-parsed-automatically": "Тази храна не може да бъде анализирана автоматично",
|
"this-food-could-not-be-parsed-automatically": "Тази храна не може да бъде анализирана автоматично",
|
||||||
"no-food": "Не е зададен вид храна",
|
"no-food": "Не е зададен вид храна",
|
||||||
"review-parsed-ingredients": "Review parsed ingredients",
|
"review-parsed-ingredients": "Прегледайте анализираните съставки",
|
||||||
"confidence-score": "Confidence Score",
|
"confidence-score": "Индекс на доверие",
|
||||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
"ingredient-parser-description": "Съставките бяха успешно анализирани. Моля, прегледайте съставките, за които не сме сигурни.",
|
||||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
"ingredient-parser-final-review-description": "След като всички съставки бъдат прегледани, ще имате още една възможност да ги прегледате, преди да приложите промените към рецептата си.",
|
||||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
"add-text-as-alias-for-item": "Добавете „{text}“ като псевдоним за {item}",
|
||||||
"delete-item": "Delete Item"
|
"delete-item": "Изтриване"
|
||||||
},
|
},
|
||||||
"reset-servings-count": "Нулиране на броя на порциите",
|
"reset-servings-count": "Нулиране на броя на порциите",
|
||||||
"not-linked-ingredients": "Допълнителни съставки",
|
"not-linked-ingredients": "Допълнителни съставки",
|
||||||
@@ -684,7 +700,10 @@
|
|||||||
"upload-images": "Качване на изображения",
|
"upload-images": "Качване на изображения",
|
||||||
"upload-more-images": "Качете още изображения",
|
"upload-more-images": "Качете още изображения",
|
||||||
"set-as-cover-image": "Задай като изображение на корицата на рецептата",
|
"set-as-cover-image": "Задай като изображение на корицата на рецептата",
|
||||||
"cover-image": "Изображение на корицата"
|
"cover-image": "Изображение на корицата",
|
||||||
|
"include-linked-recipes": "Влючване на свързаните рецепти",
|
||||||
|
"include-linked-recipe-ingredients": "Включване на съставките от свързаните рецепти",
|
||||||
|
"toggle-recipe": "Вмъкни рецепта"
|
||||||
},
|
},
|
||||||
"recipe-finder": {
|
"recipe-finder": {
|
||||||
"recipe-finder": "Търсачка на рецепти",
|
"recipe-finder": "Търсачка на рецепти",
|
||||||
@@ -721,11 +740,12 @@
|
|||||||
"search-hint": "Натисни '/'",
|
"search-hint": "Натисни '/'",
|
||||||
"advanced": "Разширени",
|
"advanced": "Разширени",
|
||||||
"auto-search": "Автоматично търсене",
|
"auto-search": "Автоматично търсене",
|
||||||
"no-results": "Не са намерени резултати"
|
"no-results": "Не са намерени резултати",
|
||||||
|
"type-to-search": "Въведете текст за търсене..."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"add-a-new-theme": "Добавяне на нова тема",
|
"add-a-new-theme": "Добавяне на нова тема",
|
||||||
"admin-settings": "Административни настройки",
|
"admin-settings": "Системни настройки",
|
||||||
"backup": {
|
"backup": {
|
||||||
"backup-created": "Архивът е създаден успешно",
|
"backup-created": "Архивът е създаден успешно",
|
||||||
"backup-created-at-response-export_path": "Резервно копие е създадено в {path}",
|
"backup-created-at-response-export_path": "Резервно копие е създадено в {path}",
|
||||||
@@ -737,7 +757,7 @@
|
|||||||
"delete-backup": "Изтрий резервно копие",
|
"delete-backup": "Изтрий резервно копие",
|
||||||
"error-creating-backup-see-log-file": "Грешка при създаването на резервно копие. Виж лог файла",
|
"error-creating-backup-see-log-file": "Грешка при създаването на резервно копие. Виж лог файла",
|
||||||
"full-backup": "Пълно резервно копие",
|
"full-backup": "Пълно резервно копие",
|
||||||
"import-summary": "Обобщение на импортирането",
|
"import-summary": "Преглед на импортирането",
|
||||||
"partial-backup": "Частично резервно копие",
|
"partial-backup": "Частично резервно копие",
|
||||||
"unable-to-delete-backup": "Невъзможно е да се изтрие това резервно копие.",
|
"unable-to-delete-backup": "Невъзможно е да се изтрие това резервно копие.",
|
||||||
"experimental-description": "Резервните копия са моменти копия на базата данни и директорията за данни на сайта. Това включва цялата информация и е невъзможно да изключите определени раздели от информация. Може да гледате на това като моменти копия на Mealie за специфично време. Те служат като агностичен начин за експортиране на базата данни и импортиране на данни или архивиране на сайта към външна локация.",
|
"experimental-description": "Резервните копия са моменти копия на базата данни и директорията за данни на сайта. Това включва цялата информация и е невъзможно да изключите определени раздели от информация. Може да гледате на това като моменти копия на Mealie за специфично време. Те служат като агностичен начин за експортиране на базата данни и импортиране на данни или архивиране на сайта към външна локация.",
|
||||||
@@ -751,7 +771,7 @@
|
|||||||
},
|
},
|
||||||
"backup-and-exports": "Резервни копия",
|
"backup-and-exports": "Резервни копия",
|
||||||
"change-password": "Смяна на парола",
|
"change-password": "Смяна на парола",
|
||||||
"current": "Версия:",
|
"current": "Версия на приложението:",
|
||||||
"custom-pages": "Допълнителни страници",
|
"custom-pages": "Допълнителни страници",
|
||||||
"edit-page": "Редактиране на страница",
|
"edit-page": "Редактиране на страница",
|
||||||
"events": "Събития",
|
"events": "Събития",
|
||||||
@@ -788,7 +808,7 @@
|
|||||||
"error-creating-theme-see-log-file": "Грешка при създаването на темата. Виж лог файла.",
|
"error-creating-theme-see-log-file": "Грешка при създаването на темата. Виж лог файла.",
|
||||||
"error-deleting-theme": "Грешка при изтриването на темата",
|
"error-deleting-theme": "Грешка при изтриването на темата",
|
||||||
"error-updating-theme": "Грешка при актуализирането на темата",
|
"error-updating-theme": "Грешка при актуализирането на темата",
|
||||||
"info": "Инфо",
|
"info": "Информация",
|
||||||
"light": "Светла",
|
"light": "Светла",
|
||||||
"primary": "Основен",
|
"primary": "Основен",
|
||||||
"secondary": "Вторичен",
|
"secondary": "Вторичен",
|
||||||
@@ -853,7 +873,7 @@
|
|||||||
"failed": "Неуспешно",
|
"failed": "Неуспешно",
|
||||||
"general-about": "Основни настройки",
|
"general-about": "Основни настройки",
|
||||||
"application-version": "Версия на приложението",
|
"application-version": "Версия на приложението",
|
||||||
"application-version-error-text": "Вашата текуща версия ({0}) не съответства на най-новата версия. Обмисляте актуализиране до най-новата версия ({1}).",
|
"application-version-error-text": "Вашата текуща версия ({0}) не съответства на най-новата версия. Обмислете актуализиране до най-новата версия ({1}).",
|
||||||
"mealie-is-up-to-date": "Mealie е обновен до актуалната версия",
|
"mealie-is-up-to-date": "Mealie е обновен до актуалната версия",
|
||||||
"secure-site": "Сигурен сайт",
|
"secure-site": "Сигурен сайт",
|
||||||
"secure-site-error-text": "Сервирайте чрез localhost или защитено с https. Клипбордът и допълнителните API на браузъра може да не работят.",
|
"secure-site-error-text": "Сервирайте чрез localhost или защитено с https. Клипбордът и допълнителните API на браузъра може да не работят.",
|
||||||
@@ -864,7 +884,7 @@
|
|||||||
"ldap-ready": "Използва LDAP",
|
"ldap-ready": "Използва LDAP",
|
||||||
"ldap-ready-error-text": "Не всички LDAP стойности са конфигурирани. Това може да бъде игнорирано, ако не използвате LDAP удостоверяване.",
|
"ldap-ready-error-text": "Не всички LDAP стойности са конфигурирани. Това може да бъде игнорирано, ако не използвате LDAP удостоверяване.",
|
||||||
"ldap-ready-success-text": "Задължителните LDAP променливи са зададени.",
|
"ldap-ready-success-text": "Задължителните LDAP променливи са зададени.",
|
||||||
"build": "Компилинирана версия",
|
"build": "GitHub версия",
|
||||||
"recipe-scraper-version": "Версия на скрепер на рецепти",
|
"recipe-scraper-version": "Версия на скрепер на рецепти",
|
||||||
"oidc-ready": "Готов за OIDC",
|
"oidc-ready": "Готов за OIDC",
|
||||||
"oidc-ready-error-text": "Не всички OIDC стойности са конфигурирани. Това може да бъде игнорирано, ако не използвате OIDC удостоверяване.",
|
"oidc-ready-error-text": "Не всички OIDC стойности са конфигурирани. Това може да бъде игнорирано, ако не използвате OIDC удостоверяване.",
|
||||||
@@ -932,7 +952,7 @@
|
|||||||
},
|
},
|
||||||
"signup": {
|
"signup": {
|
||||||
"error-signing-up": "Грешка при регистирането",
|
"error-signing-up": "Грешка при регистирането",
|
||||||
"sign-up": "Регистриране",
|
"sign-up": "Регистрация",
|
||||||
"sign-up-link-created": "Линкът за регистриране е създаден",
|
"sign-up-link-created": "Линкът за регистриране е създаден",
|
||||||
"sign-up-link-creation-failed": "Линкът за регистриране не беше създаден",
|
"sign-up-link-creation-failed": "Линкът за регистриране не беше създаден",
|
||||||
"sign-up-links": "Линкове за регистриране",
|
"sign-up-links": "Линкове за регистриране",
|
||||||
@@ -954,14 +974,14 @@
|
|||||||
"tag": "Етикет"
|
"tag": "Етикет"
|
||||||
},
|
},
|
||||||
"tool": {
|
"tool": {
|
||||||
"tools": "Инструменти",
|
"tools": "Прибори",
|
||||||
"on-hand": "Наличности",
|
"on-hand": "Наличности",
|
||||||
"create-a-tool": "Създаване на инструмент",
|
"create-a-tool": "Създаване на инструмент",
|
||||||
"tool-name": "Име на инструмента",
|
"tool-name": "Име на инструмента",
|
||||||
"create-new-tool": "Създаване на нов инструмент",
|
"create-new-tool": "Създаване на нов инструмент",
|
||||||
"on-hand-checkbox-label": "Показване като налични (отметнато)",
|
"on-hand-checkbox-label": "Показване като налични (отметнато)",
|
||||||
"required-tools": "Задължителни инструменти",
|
"required-tools": "Задължителни инструменти",
|
||||||
"tool": "Инструменти"
|
"tool": "Прибори"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"admin": "Админ",
|
"admin": "Админ",
|
||||||
@@ -1001,8 +1021,8 @@
|
|||||||
"password-strength": "Сигурността на паролата е {strength}",
|
"password-strength": "Сигурността на паролата е {strength}",
|
||||||
"please-enter-password": "Моля, въведете новата си парола.",
|
"please-enter-password": "Моля, въведете новата си парола.",
|
||||||
"register": "Регистриране",
|
"register": "Регистриране",
|
||||||
"reset-password": "Нулиране на паролата",
|
"reset-password": "Забравена парола",
|
||||||
"sign-in": "Влизане",
|
"sign-in": "Вход в системата",
|
||||||
"total-mealplans": "Хранителни планове общо",
|
"total-mealplans": "Хранителни планове общо",
|
||||||
"total-users": "Общо потребители",
|
"total-users": "Общо потребители",
|
||||||
"upload-photo": "Качете снимка",
|
"upload-photo": "Качете снимка",
|
||||||
@@ -1041,7 +1061,7 @@
|
|||||||
"very-strong": "Много силна"
|
"very-strong": "Много силна"
|
||||||
},
|
},
|
||||||
"user-management": "Управление на потребителя",
|
"user-management": "Управление на потребителя",
|
||||||
"reset-locked-users": "Нулиране на заключените потребители",
|
"reset-locked-users": "Отключване на заключените потребители",
|
||||||
"admin-user-creation": "Създаване на администратор",
|
"admin-user-creation": "Създаване на администратор",
|
||||||
"admin-user-management": "Управление на администраторите",
|
"admin-user-management": "Управление на администраторите",
|
||||||
"user-details": "Детайли за потребителя",
|
"user-details": "Детайли за потребителя",
|
||||||
@@ -1059,7 +1079,9 @@
|
|||||||
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Искате ли да виждате това по-често? Уверете се, че сте конфигурирали настройките си за email известяване правилно!",
|
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Искате ли да виждате това по-често? Уверете се, че сте конфигурирали настройките си за email известяване правилно!",
|
||||||
"forgot-password": "Забравена Парола",
|
"forgot-password": "Забравена Парола",
|
||||||
"forgot-password-text": "Въведете Вашият имейл адрес и ние ще ви изпратим линк, с който да промените Вашата парола.",
|
"forgot-password-text": "Въведете Вашият имейл адрес и ние ще ви изпратим линк, с който да промените Вашата парола.",
|
||||||
"changes-reflected-immediately": "Промените по този потребител ще бъдат отразени моментално."
|
"changes-reflected-immediately": "Промените по този потребител ще бъдат отразени моментално.",
|
||||||
|
"default-activity": "Действие по подразбиране",
|
||||||
|
"default-activity-hint": "Изберете коя страница искате да отворите, след като влезете от това устройство"
|
||||||
},
|
},
|
||||||
"language-dialog": {
|
"language-dialog": {
|
||||||
"translated": "преведено",
|
"translated": "преведено",
|
||||||
@@ -1080,7 +1102,7 @@
|
|||||||
"create-food": "Създай продукт",
|
"create-food": "Създай продукт",
|
||||||
"food-label": "Заглавие на храната",
|
"food-label": "Заглавие на храната",
|
||||||
"edit-food": "Редактирай храна",
|
"edit-food": "Редактирай храна",
|
||||||
"food-data": "Данни за храните",
|
"food-data": "Продукти",
|
||||||
"example-food-singular": "пример: Домат",
|
"example-food-singular": "пример: Домат",
|
||||||
"example-food-plural": "пример: Домати",
|
"example-food-plural": "пример: Домати",
|
||||||
"label-overwrite-warning": "Това ще присвои избрания етикет на всички избрани храни и евентуално ще презапише съществуващите ви етикети.",
|
"label-overwrite-warning": "Това ще присвои избрания етикет на всички избрани храни и евентуално ще презапише съществуващите ви етикети.",
|
||||||
@@ -1251,8 +1273,8 @@
|
|||||||
"maintenance": {
|
"maintenance": {
|
||||||
"storage-details": "Подробности за мястото за съхранение",
|
"storage-details": "Подробности за мястото за съхранение",
|
||||||
"page-title": "Поддръжка на сайта",
|
"page-title": "Поддръжка на сайта",
|
||||||
"summary-title": "Обобщение",
|
"summary-title": "Преглед на ресурсите",
|
||||||
"button-label-get-summary": "Вземи обобщение",
|
"button-label-get-summary": "Опресняване",
|
||||||
"button-label-open-details": "Подробности",
|
"button-label-open-details": "Подробности",
|
||||||
"info-description-data-dir-size": "Размер на директорията с данни",
|
"info-description-data-dir-size": "Размер на директорията с данни",
|
||||||
"info-description-log-file-size": "Размер на лог файла",
|
"info-description-log-file-size": "Размер на лог файла",
|
||||||
@@ -1319,9 +1341,9 @@
|
|||||||
"welcome-user": "👋 Добре дошъл(а), {0}!",
|
"welcome-user": "👋 Добре дошъл(а), {0}!",
|
||||||
"description": "Настройки на профил, рецепти и настройки на групата.",
|
"description": "Настройки на профил, рецепти и настройки на групата.",
|
||||||
"invite-link": "Линк за Покана",
|
"invite-link": "Линк за Покана",
|
||||||
"get-invite-link": "Вземи линк за покана",
|
"get-invite-link": "Създай линк за покана",
|
||||||
"get-public-link": "Вземи публичен линк",
|
"get-public-link": "Създай публичен линк",
|
||||||
"account-summary": "Обобщение на акаунта",
|
"account-summary": "Преглед на потребителския профил",
|
||||||
"account-summary-description": "Обобщение на информацията за Вашата група.",
|
"account-summary-description": "Обобщение на информацията за Вашата група.",
|
||||||
"group-statistics": "Статистики на групата",
|
"group-statistics": "Статистики на групата",
|
||||||
"group-statistics-description": "Вашата статистика на групата дава известна представа как използвате Mealie.",
|
"group-statistics-description": "Вашата статистика на групата дава известна представа как използвате Mealie.",
|
||||||
@@ -1344,7 +1366,7 @@
|
|||||||
"members": "Участници",
|
"members": "Участници",
|
||||||
"members-description": "Вижте кой е във вашето домакинство и управлявайте техните разрешения.",
|
"members-description": "Вижте кой е във вашето домакинство и управлявайте техните разрешения.",
|
||||||
"webhooks-description": "Настройте webhooks, които се задействат в дните, в които имате планиран план за хранене.",
|
"webhooks-description": "Настройте webhooks, които се задействат в дните, в които имате планиран план за хранене.",
|
||||||
"notifiers": "Уведомители",
|
"notifiers": "Уведомления",
|
||||||
"notifiers-description": "Настройте имейл и push известия, които се задействат при конкретни събития.",
|
"notifiers-description": "Настройте имейл и push известия, които се задействат при конкретни събития.",
|
||||||
"manage-data": "Управление на данни",
|
"manage-data": "Управление на данни",
|
||||||
"manage-data-description": "Управлявай данните в Mealie: Храни, Единици, Категории, Тагове и други.",
|
"manage-data-description": "Управлявай данните в Mealie: Храни, Единици, Категории, Тагове и други.",
|
||||||
@@ -1362,7 +1384,7 @@
|
|||||||
"manage-cookbooks": "Управление на готварските книги",
|
"manage-cookbooks": "Управление на готварските книги",
|
||||||
"manage-members": "Управление на потребителите",
|
"manage-members": "Управление на потребителите",
|
||||||
"manage-webhooks": "Управление на Webhooks",
|
"manage-webhooks": "Управление на Webhooks",
|
||||||
"manage-notifiers": "Управление на уведомители",
|
"manage-notifiers": "Настройки на уведомленията",
|
||||||
"manage-data-migrations": "Управление на миграцията на данни"
|
"manage-data-migrations": "Управление на миграцията на данни"
|
||||||
},
|
},
|
||||||
"cookbook": {
|
"cookbook": {
|
||||||
|
|||||||
@@ -342,6 +342,9 @@
|
|||||||
"breakfast": "Esmorzar",
|
"breakfast": "Esmorzar",
|
||||||
"lunch": "Dinar",
|
"lunch": "Dinar",
|
||||||
"dinner": "Sopar",
|
"dinner": "Sopar",
|
||||||
|
"snack": "Piscolabis",
|
||||||
|
"drink": "Beguda",
|
||||||
|
"dessert": "Postres",
|
||||||
"type-any": "Qualsevol",
|
"type-any": "Qualsevol",
|
||||||
"day-any": "Qualsevol",
|
"day-any": "Qualsevol",
|
||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
@@ -399,6 +402,10 @@
|
|||||||
"description-long": "Mealie pot importar les receptes de Tandoor. Exporta les dades en format \"Default\", i carrega el .zip a sota.",
|
"description-long": "Mealie pot importar les receptes de Tandoor. Exporta les dades en format \"Default\", i carrega el .zip a sota.",
|
||||||
"title": "Tandoor Recipes"
|
"title": "Tandoor Recipes"
|
||||||
},
|
},
|
||||||
|
"cookn": {
|
||||||
|
"description-long": "Mealie can import recipes from DVO Cook'n X3. Export a cookbook or menu in the \"Cook'n\" format, rename the export extension to .zip, then upload the .zip below.",
|
||||||
|
"title": "DVO Cook'n X3"
|
||||||
|
},
|
||||||
"recipe-data-migrations": "Migració de receptes",
|
"recipe-data-migrations": "Migració de receptes",
|
||||||
"recipe-data-migrations-explanation": "Les receptes es poden migrar des d'una altra aplicació suportada cap a Mealie. És una manera genial de començar a utilitzar el Mealie.",
|
"recipe-data-migrations-explanation": "Les receptes es poden migrar des d'una altra aplicació suportada cap a Mealie. És una manera genial de començar a utilitzar el Mealie.",
|
||||||
"coming-from-another-application-or-an-even-older-version-of-mealie": "Véns d'una altra aplicació o una versió més antiga de Mealia? Fés un cop d'ull a migracions i mira si pots importar les teves dades.",
|
"coming-from-another-application-or-an-even-older-version-of-mealie": "Véns d'una altra aplicació o una versió més antiga de Mealia? Fés un cop d'ull a migracions i mira si pots importar les teves dades.",
|
||||||
@@ -444,7 +451,9 @@
|
|||||||
"split-by-numbered-line-description": "Intenta separar per paràgrafs, utilitzant com a patró '1)' o '1.'",
|
"split-by-numbered-line-description": "Intenta separar per paràgrafs, utilitzant com a patró '1)' o '1.'",
|
||||||
"import-by-url": "Importa per URL",
|
"import-by-url": "Importa per URL",
|
||||||
"create-manually": "Crea una recepta manualment",
|
"create-manually": "Crea una recepta manualment",
|
||||||
"make-recipe-image": "Fes-la la imatge de la recepta"
|
"make-recipe-image": "Fes-la la imatge de la recepta",
|
||||||
|
"add-food": "Afegeix Aliment",
|
||||||
|
"add-recipe": "Afegeix Recepta"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"404-page-not-found": "404 - Pàgina no trobada",
|
"404-page-not-found": "404 - Pàgina no trobada",
|
||||||
@@ -474,7 +483,7 @@
|
|||||||
"comment": "Comentari",
|
"comment": "Comentari",
|
||||||
"comments": "Comentaris",
|
"comments": "Comentaris",
|
||||||
"delete-confirmation": "Estàs segur que vols suprimir-la?",
|
"delete-confirmation": "Estàs segur que vols suprimir-la?",
|
||||||
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
|
"admin-delete-confirmation": "Estàs a punt d'eliminar una recepta que no és teva utilitzant permisos d'administrador. N'estàs segur?",
|
||||||
"delete-recipe": "Suprimeix la recepta",
|
"delete-recipe": "Suprimeix la recepta",
|
||||||
"description": "Descripció",
|
"description": "Descripció",
|
||||||
"disable-amount": "Oculta les quantitats",
|
"disable-amount": "Oculta les quantitats",
|
||||||
@@ -511,6 +520,9 @@
|
|||||||
"recipe-deleted": "S'ha suprimit la recepta",
|
"recipe-deleted": "S'ha suprimit la recepta",
|
||||||
"recipe-image": "Imatge de la recepta",
|
"recipe-image": "Imatge de la recepta",
|
||||||
"recipe-image-updated": "S'ha actualitzat la imatge de la recepta",
|
"recipe-image-updated": "S'ha actualitzat la imatge de la recepta",
|
||||||
|
"delete-image": "Suprimir la imatge de la recepta",
|
||||||
|
"delete-image-confirmation": "Estàs segur que vols suprimir la imatge d'aquesta recepta?",
|
||||||
|
"recipe-image-deleted": "S'ha suprimit la imatge de la recepta",
|
||||||
"recipe-name": "Nom de la recepta",
|
"recipe-name": "Nom de la recepta",
|
||||||
"recipe-settings": "Opcions de la recepta",
|
"recipe-settings": "Opcions de la recepta",
|
||||||
"recipe-update-failed": "S'ha produït un error a l'actualitzar la recepta",
|
"recipe-update-failed": "S'ha produït un error a l'actualitzar la recepta",
|
||||||
@@ -556,6 +568,7 @@
|
|||||||
"choose-unit": "Tria el tipus d'unitat",
|
"choose-unit": "Tria el tipus d'unitat",
|
||||||
"press-enter-to-create": "Premeu enter per a crear-lo",
|
"press-enter-to-create": "Premeu enter per a crear-lo",
|
||||||
"choose-food": "Tria un aliment",
|
"choose-food": "Tria un aliment",
|
||||||
|
"choose-recipe": "Tria la recepta",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"toggle-section": "Nova secció",
|
"toggle-section": "Nova secció",
|
||||||
"see-original-text": "Mostra el text original",
|
"see-original-text": "Mostra el text original",
|
||||||
@@ -583,14 +596,15 @@
|
|||||||
"made-this": "Ho he fet",
|
"made-this": "Ho he fet",
|
||||||
"how-did-it-turn-out": "Com ha sortit?",
|
"how-did-it-turn-out": "Com ha sortit?",
|
||||||
"user-made-this": "{user} ha fet això",
|
"user-made-this": "{user} ha fet això",
|
||||||
|
"made-for-recipe": "Made for {recipe}",
|
||||||
"added-to-timeline": "Added to timeline",
|
"added-to-timeline": "Added to timeline",
|
||||||
"failed-to-add-to-timeline": "Failed to add to timeline",
|
"failed-to-add-to-timeline": "Failed to add to timeline",
|
||||||
"failed-to-update-recipe": "Failed to update recipe",
|
"failed-to-update-recipe": "Failed to update recipe",
|
||||||
"added-to-timeline-but-failed-to-add-image": "Added to timeline, but failed to add image",
|
"added-to-timeline-but-failed-to-add-image": "S'ha afegit a la línia de temps, però no s'ha pogut afegir la imatge",
|
||||||
"api-extras-description": "Els extres de receptes són una funcionalitat clau de l'API de Mealie. Permeten crear parells clau/valor JSON personalitzats dins una recepta, per referenciar-los des d'aplicacions de tercers. Pots emprar aquestes claus per proveir informació, per exemple per a desencadenar automatitzacions o missatges personlitzats per a propagar al teu dispositiu desitjat.",
|
"api-extras-description": "Els extres de receptes són una funcionalitat clau de l'API de Mealie. Permeten crear parells clau/valor JSON personalitzats dins una recepta, per referenciar-los des d'aplicacions de tercers. Pots emprar aquestes claus per proveir informació, per exemple per a desencadenar automatitzacions o missatges personlitzats per a propagar al teu dispositiu desitjat.",
|
||||||
"message-key": "Clau del missatge",
|
"message-key": "Clau del missatge",
|
||||||
"parse": "Analitzar",
|
"parse": "Analitzar",
|
||||||
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
|
"ingredients-not-parsed-description": "Sembla que els teus ingredients encara no s'han analitzat. Feu clic al botó \"{parse}\" de sota per transformar els vostres ingredients en aliments estructurats.",
|
||||||
"attach-images-hint": "Afegeix imatges arrossegant i deixant anar la imatge a l'editor",
|
"attach-images-hint": "Afegeix imatges arrossegant i deixant anar la imatge a l'editor",
|
||||||
"drop-image": "Deixa anar la imatge",
|
"drop-image": "Deixa anar la imatge",
|
||||||
"enable-ingredient-amounts-to-use-this-feature": "Habilita les quantitats d'ingredients per a poder fer servir aquesta característica",
|
"enable-ingredient-amounts-to-use-this-feature": "Habilita les quantitats d'ingredients per a poder fer servir aquesta característica",
|
||||||
@@ -608,10 +622,10 @@
|
|||||||
"create-recipe-from-an-image": "Crear una recepta a partir d'una imatge",
|
"create-recipe-from-an-image": "Crear una recepta a partir d'una imatge",
|
||||||
"create-recipe-from-an-image-description": "Crear una recepta pujant una imatge d'ella. Mealie intentarà extreure el text de la imatge mitjançant IA i crear-ne la recepta.",
|
"create-recipe-from-an-image-description": "Crear una recepta pujant una imatge d'ella. Mealie intentarà extreure el text de la imatge mitjançant IA i crear-ne la recepta.",
|
||||||
"crop-and-rotate-the-image": "Retalla i rota la imatge, per tal que només el text sigui visible, i estigui orientat correctament.",
|
"crop-and-rotate-the-image": "Retalla i rota la imatge, per tal que només el text sigui visible, i estigui orientat correctament.",
|
||||||
"create-from-images": "Create from Images",
|
"create-from-images": "Crear una recepta a partir d'una imatge",
|
||||||
"should-translate-description": "Tradueix la recepta a la meva llengua",
|
"should-translate-description": "Tradueix la recepta a la meva llengua",
|
||||||
"please-wait-image-procesing": "Si us plau, esperi, la imatge s'està processant. Això pot tardar un temps.",
|
"please-wait-image-procesing": "Si us plau, esperi, la imatge s'està processant. Això pot tardar un temps.",
|
||||||
"please-wait-images-processing": "Please wait, the images are processing. This may take some time.",
|
"please-wait-images-processing": "Espereu, les imatges s'estan processant. Això pot trigar una estona.",
|
||||||
"bulk-url-import": "Importació d'URL en massa",
|
"bulk-url-import": "Importació d'URL en massa",
|
||||||
"debug-scraper": "Rastrejador de depuració",
|
"debug-scraper": "Rastrejador de depuració",
|
||||||
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Crea la recepta proporcionant-ne un nom. Totes les receptes han de tenir un nom únic.",
|
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Crea la recepta proporcionant-ne un nom. Totes les receptes han de tenir un nom únic.",
|
||||||
@@ -622,9 +636,11 @@
|
|||||||
"scrape-recipe-suggest-bulk-importer": "Prova l'importador a granel",
|
"scrape-recipe-suggest-bulk-importer": "Prova l'importador a granel",
|
||||||
"scrape-recipe-have-raw-html-or-json-data": "Teniu dades HTML o JSON pla?",
|
"scrape-recipe-have-raw-html-or-json-data": "Teniu dades HTML o JSON pla?",
|
||||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Podeu importar directament des de les dades planes",
|
"scrape-recipe-you-can-import-from-raw-data-directly": "Podeu importar directament des de les dades planes",
|
||||||
|
"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": "Importa les paraules clau originals com a tags",
|
"import-original-keywords-as-tags": "Importa les paraules clau originals com a tags",
|
||||||
"stay-in-edit-mode": "Segueix en el mode d'edició",
|
"stay-in-edit-mode": "Segueix en el mode d'edició",
|
||||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
"parse-recipe-ingredients-after-import": "Analitza els ingredients de la recepta després d'importar",
|
||||||
"import-from-zip": "Importa des d'un ZIP",
|
"import-from-zip": "Importa des d'un ZIP",
|
||||||
"import-from-zip-description": "Importa una sola recepta que ha estat importada d'una altra instància de Mealie.",
|
"import-from-zip-description": "Importa una sola recepta que ha estat importada d'una altra instància de Mealie.",
|
||||||
"import-from-html-or-json": "Importar des d'un HTML o JSON",
|
"import-from-html-or-json": "Importar des d'un HTML o JSON",
|
||||||
@@ -668,23 +684,26 @@
|
|||||||
"no-unit": "Sense unitat",
|
"no-unit": "Sense unitat",
|
||||||
"missing-unit": "Crear unitat que manca: {unit}",
|
"missing-unit": "Crear unitat que manca: {unit}",
|
||||||
"missing-food": "Crear menjar que manca: {food}",
|
"missing-food": "Crear menjar que manca: {food}",
|
||||||
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
|
"this-unit-could-not-be-parsed-automatically": "Aquesta unitat no s'ha pogut analitzar automàticament",
|
||||||
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
|
"this-food-could-not-be-parsed-automatically": "Aquest aliment no s'ha pogut analitzar automàticament",
|
||||||
"no-food": "Sense menjar",
|
"no-food": "Sense menjar",
|
||||||
"review-parsed-ingredients": "Review parsed ingredients",
|
"review-parsed-ingredients": "Revisió d'ingredients analitzats",
|
||||||
"confidence-score": "Confidence Score",
|
"confidence-score": "Puntuació de confiança",
|
||||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
"ingredient-parser-description": "Els teus ingredients s'han analitzat correctament. Si us plau, revisa els ingredients dels quals no estem segurs.",
|
||||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
"ingredient-parser-final-review-description": "Un cop revisats tots els ingredients, tindràs una oportunitat més de revisar tots els ingredients abans d'aplicar els canvis a la teva recepta.",
|
||||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
"add-text-as-alias-for-item": "Afegeix \"{text}\" com a àlies de {item}",
|
||||||
"delete-item": "Delete Item"
|
"delete-item": "Eliminar element"
|
||||||
},
|
},
|
||||||
"reset-servings-count": "Reiniciar racions servides",
|
"reset-servings-count": "Reiniciar racions servides",
|
||||||
"not-linked-ingredients": "Ingredients addicionals",
|
"not-linked-ingredients": "Ingredients addicionals",
|
||||||
"upload-another-image": "Upload another image",
|
"upload-another-image": "Puja una altra imatge",
|
||||||
"upload-images": "Upload images",
|
"upload-images": "Puja imatges",
|
||||||
"upload-more-images": "Upload more images",
|
"upload-more-images": "Puja més imatges",
|
||||||
"set-as-cover-image": "Set as recipe cover image",
|
"set-as-cover-image": "Estableix com a imatge de portada de recepta",
|
||||||
"cover-image": "Cover image"
|
"cover-image": "Imatge de portada",
|
||||||
|
"include-linked-recipes": "Inclou les receptes enllaçades",
|
||||||
|
"include-linked-recipe-ingredients": "Inclou els ingredients de la recepta enllaçada",
|
||||||
|
"toggle-recipe": "Alternar recepta"
|
||||||
},
|
},
|
||||||
"recipe-finder": {
|
"recipe-finder": {
|
||||||
"recipe-finder": "Cercador de receptes",
|
"recipe-finder": "Cercador de receptes",
|
||||||
@@ -721,7 +740,8 @@
|
|||||||
"search-hint": "Prem '/'",
|
"search-hint": "Prem '/'",
|
||||||
"advanced": "Avançat",
|
"advanced": "Avançat",
|
||||||
"auto-search": "Cerca automàtica",
|
"auto-search": "Cerca automàtica",
|
||||||
"no-results": "No s'han trobat resultats"
|
"no-results": "No s'han trobat resultats",
|
||||||
|
"type-to-search": "Escriviu per cercar..."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"add-a-new-theme": "Afegiu un nou tema",
|
"add-a-new-theme": "Afegiu un nou tema",
|
||||||
@@ -1059,7 +1079,9 @@
|
|||||||
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "No vols tornar a veure això? Assegura't de canviar el teu correu electrònic a les configuracions del teu usuari!",
|
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "No vols tornar a veure això? Assegura't de canviar el teu correu electrònic a les configuracions del teu usuari!",
|
||||||
"forgot-password": "Contrasenya oblidada",
|
"forgot-password": "Contrasenya oblidada",
|
||||||
"forgot-password-text": "Introdueix siusplau la teva adreça de correu electrònic i t'enviarem un enllaç per restablir la teva contrassenya.",
|
"forgot-password-text": "Introdueix siusplau la teva adreça de correu electrònic i t'enviarem un enllaç per restablir la teva contrassenya.",
|
||||||
"changes-reflected-immediately": "Els canvis en aquest usuari s'actualitzaran immediatament."
|
"changes-reflected-immediately": "Els canvis en aquest usuari s'actualitzaran immediatament.",
|
||||||
|
"default-activity": "Activitat per defecte",
|
||||||
|
"default-activity-hint": "Seleccioneu a quina pàgina voleu navegar en iniciar sessió des d'aquest dispositiu"
|
||||||
},
|
},
|
||||||
"language-dialog": {
|
"language-dialog": {
|
||||||
"translated": "traduït",
|
"translated": "traduït",
|
||||||
@@ -1177,7 +1199,7 @@
|
|||||||
"group-details": "Detalls del grup",
|
"group-details": "Detalls del grup",
|
||||||
"group-details-description": "Abans de crear un compte heu de crear un grup. Al grup només hi serà vostè, però després podeu convidar d'altres. Els membres d'un grup poden compartir menús, llistes de la compra, receptes i molt més!",
|
"group-details-description": "Abans de crear un compte heu de crear un grup. Al grup només hi serà vostè, però després podeu convidar d'altres. Els membres d'un grup poden compartir menús, llistes de la compra, receptes i molt més!",
|
||||||
"use-seed-data": "Afegiu dades predeterminades",
|
"use-seed-data": "Afegiu dades predeterminades",
|
||||||
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes. These are translated into the language you currently have selected. You can always add to or modify this data later.",
|
"use-seed-data-description": "Mealie disposa d'una col·lecció d'aliments, unitats i etiquetes que es poden utilitzar per omplir el vostre grup amb dades útils per organitzar les vostres receptes. Aquests es tradueixen a l'idioma que heu seleccionat actualment. Sempre podeu afegir o modificar aquestes dades més endavant.",
|
||||||
"account-details": "Detalls del compte"
|
"account-details": "Detalls del compte"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
|
|||||||
@@ -342,6 +342,9 @@
|
|||||||
"breakfast": "Snídaně",
|
"breakfast": "Snídaně",
|
||||||
"lunch": "Oběd",
|
"lunch": "Oběd",
|
||||||
"dinner": "Večeře",
|
"dinner": "Večeře",
|
||||||
|
"snack": "Snack",
|
||||||
|
"drink": "Drink",
|
||||||
|
"dessert": "Dessert",
|
||||||
"type-any": "Libovolné",
|
"type-any": "Libovolné",
|
||||||
"day-any": "Libovolný",
|
"day-any": "Libovolný",
|
||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
@@ -399,6 +402,10 @@
|
|||||||
"description-long": "Mealie může importovat recepty z Tandoor. Exportujte data ve výchozím formátu, poté nahrajte soubor ve formátu .zip.",
|
"description-long": "Mealie může importovat recepty z Tandoor. Exportujte data ve výchozím formátu, poté nahrajte soubor ve formátu .zip.",
|
||||||
"title": "Recepty Tandoor"
|
"title": "Recepty Tandoor"
|
||||||
},
|
},
|
||||||
|
"cookn": {
|
||||||
|
"description-long": "Mealie can import recipes from DVO Cook'n X3. Export a cookbook or menu in the \"Cook'n\" format, rename the export extension to .zip, then upload the .zip below.",
|
||||||
|
"title": "DVO Cook'n X3"
|
||||||
|
},
|
||||||
"recipe-data-migrations": "Migrace dat receptů",
|
"recipe-data-migrations": "Migrace dat receptů",
|
||||||
"recipe-data-migrations-explanation": "Recepty mohou být migrovány z jiné podporované aplikace na Mealie. To je skvělý způsob, jak začít s Mealie.",
|
"recipe-data-migrations-explanation": "Recepty mohou být migrovány z jiné podporované aplikace na Mealie. To je skvělý způsob, jak začít s Mealie.",
|
||||||
"coming-from-another-application-or-an-even-older-version-of-mealie": "Migrujete z jiné aplikace nebo starší verze Mealie? Podívejte se na migrace a zjistěte, zda lze vaše data importovat.",
|
"coming-from-another-application-or-an-even-older-version-of-mealie": "Migrujete z jiné aplikace nebo starší verze Mealie? Podívejte se na migrace a zjistěte, zda lze vaše data importovat.",
|
||||||
@@ -444,7 +451,9 @@
|
|||||||
"split-by-numbered-line-description": "Pokusí se rozdělit odstavec na místech odpovídajících vzorům '1)' a '1.'",
|
"split-by-numbered-line-description": "Pokusí se rozdělit odstavec na místech odpovídajících vzorům '1)' a '1.'",
|
||||||
"import-by-url": "Importovat recept podle URL",
|
"import-by-url": "Importovat recept podle URL",
|
||||||
"create-manually": "Vytvořit recept ručně",
|
"create-manually": "Vytvořit recept ručně",
|
||||||
"make-recipe-image": "Nastavit jako obrázek receptu"
|
"make-recipe-image": "Nastavit jako obrázek receptu",
|
||||||
|
"add-food": "Add Food",
|
||||||
|
"add-recipe": "Add Recipe"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"404-page-not-found": "404 Stránka nebyla nalezena",
|
"404-page-not-found": "404 Stránka nebyla nalezena",
|
||||||
@@ -511,6 +520,9 @@
|
|||||||
"recipe-deleted": "Recept smazán",
|
"recipe-deleted": "Recept smazán",
|
||||||
"recipe-image": "Obrázek receptu",
|
"recipe-image": "Obrázek receptu",
|
||||||
"recipe-image-updated": "Obrázek receptu aktualizován",
|
"recipe-image-updated": "Obrázek receptu aktualizován",
|
||||||
|
"delete-image": "Delete Recipe Image",
|
||||||
|
"delete-image-confirmation": "Are you sure you want to delete this recipe image?",
|
||||||
|
"recipe-image-deleted": "Recipe image deleted",
|
||||||
"recipe-name": "Název receptu",
|
"recipe-name": "Název receptu",
|
||||||
"recipe-settings": "Nastavení receptu",
|
"recipe-settings": "Nastavení receptu",
|
||||||
"recipe-update-failed": "Aktualizace receptu se nezdařila",
|
"recipe-update-failed": "Aktualizace receptu se nezdařila",
|
||||||
@@ -556,6 +568,7 @@
|
|||||||
"choose-unit": "Vybrat jednotku",
|
"choose-unit": "Vybrat jednotku",
|
||||||
"press-enter-to-create": "Stiskněte enter pro vytvoření",
|
"press-enter-to-create": "Stiskněte enter pro vytvoření",
|
||||||
"choose-food": "Zvolte jídlo",
|
"choose-food": "Zvolte jídlo",
|
||||||
|
"choose-recipe": "Choose Recipe",
|
||||||
"notes": "Poznámky",
|
"notes": "Poznámky",
|
||||||
"toggle-section": "Přidat/odebrat název sekce",
|
"toggle-section": "Přidat/odebrat název sekce",
|
||||||
"see-original-text": "Zobrazit původní text",
|
"see-original-text": "Zobrazit původní text",
|
||||||
@@ -583,6 +596,7 @@
|
|||||||
"made-this": "Toto jsem uvařil",
|
"made-this": "Toto jsem uvařil",
|
||||||
"how-did-it-turn-out": "Jak to dopadlo?",
|
"how-did-it-turn-out": "Jak to dopadlo?",
|
||||||
"user-made-this": "{user} udělal toto",
|
"user-made-this": "{user} udělal toto",
|
||||||
|
"made-for-recipe": "Made for {recipe}",
|
||||||
"added-to-timeline": "Přidáno na časovou osu",
|
"added-to-timeline": "Přidáno na časovou osu",
|
||||||
"failed-to-add-to-timeline": "Přidání na časovou osu selhalo",
|
"failed-to-add-to-timeline": "Přidání na časovou osu selhalo",
|
||||||
"failed-to-update-recipe": "Aktualizace receptu selhala",
|
"failed-to-update-recipe": "Aktualizace receptu selhala",
|
||||||
@@ -622,6 +636,8 @@
|
|||||||
"scrape-recipe-suggest-bulk-importer": "Vyzkoušejte hromadný import",
|
"scrape-recipe-suggest-bulk-importer": "Vyzkoušejte hromadný import",
|
||||||
"scrape-recipe-have-raw-html-or-json-data": "Máte surová data HTML nebo JSON?",
|
"scrape-recipe-have-raw-html-or-json-data": "Máte surová data HTML nebo JSON?",
|
||||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Můžete importovat přímo ze surových dat",
|
"scrape-recipe-you-can-import-from-raw-data-directly": "Můžete importovat přímo ze surových dat",
|
||||||
|
"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": "Importovat původní klíčová slova jako štítky",
|
"import-original-keywords-as-tags": "Importovat původní klíčová slova jako štítky",
|
||||||
"stay-in-edit-mode": "Zůstat v režimu úprav",
|
"stay-in-edit-mode": "Zůstat v režimu úprav",
|
||||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||||
@@ -684,7 +700,10 @@
|
|||||||
"upload-images": "Nahrát obrázky",
|
"upload-images": "Nahrát obrázky",
|
||||||
"upload-more-images": "Nahrát více obrázků",
|
"upload-more-images": "Nahrát více obrázků",
|
||||||
"set-as-cover-image": "Nastavit recept jako úvodní obrázek",
|
"set-as-cover-image": "Nastavit recept jako úvodní obrázek",
|
||||||
"cover-image": "Úvodní obrázek"
|
"cover-image": "Úvodní obrázek",
|
||||||
|
"include-linked-recipes": "Include Linked Recipes",
|
||||||
|
"include-linked-recipe-ingredients": "Include Linked Recipe Ingredients",
|
||||||
|
"toggle-recipe": "Toggle Recipe"
|
||||||
},
|
},
|
||||||
"recipe-finder": {
|
"recipe-finder": {
|
||||||
"recipe-finder": "Vyhledávač receptů",
|
"recipe-finder": "Vyhledávač receptů",
|
||||||
@@ -721,7 +740,8 @@
|
|||||||
"search-hint": "Stiskněte '/'",
|
"search-hint": "Stiskněte '/'",
|
||||||
"advanced": "Pokročilé",
|
"advanced": "Pokročilé",
|
||||||
"auto-search": "Automatické vyhledávání",
|
"auto-search": "Automatické vyhledávání",
|
||||||
"no-results": "Nebyly nalezeny žádné výsledky"
|
"no-results": "Nebyly nalezeny žádné výsledky",
|
||||||
|
"type-to-search": "Type to search..."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"add-a-new-theme": "Přidat nový motiv",
|
"add-a-new-theme": "Přidat nový motiv",
|
||||||
@@ -1059,7 +1079,9 @@
|
|||||||
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Už to nechcete vidět? Nezapomeňte si změnit svůj e-mail v uživatelském nastavení!",
|
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Už to nechcete vidět? Nezapomeňte si změnit svůj e-mail v uživatelském nastavení!",
|
||||||
"forgot-password": "Zapomenuté heslo",
|
"forgot-password": "Zapomenuté heslo",
|
||||||
"forgot-password-text": "Zadejte prosím svou e-mailovou adresu a my vám zašleme odkaz pro obnovení hesla.",
|
"forgot-password-text": "Zadejte prosím svou e-mailovou adresu a my vám zašleme odkaz pro obnovení hesla.",
|
||||||
"changes-reflected-immediately": "Změny tohoto uživatele budou okamžitě zohledněny."
|
"changes-reflected-immediately": "Změny tohoto uživatele budou okamžitě zohledněny.",
|
||||||
|
"default-activity": "Default Activity",
|
||||||
|
"default-activity-hint": "Select which page you'd like to navigate to upon logging in from this device"
|
||||||
},
|
},
|
||||||
"language-dialog": {
|
"language-dialog": {
|
||||||
"translated": "přeloženo",
|
"translated": "přeloženo",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"database-type": "Database-type",
|
"database-type": "Database-type",
|
||||||
"database-url": "Database-url",
|
"database-url": "Database-url",
|
||||||
"default-group": "Standardgruppe",
|
"default-group": "Standardgruppe",
|
||||||
"default-household": "Standard Husholdning",
|
"default-household": "Standard Husstand",
|
||||||
"demo": "Demo",
|
"demo": "Demo",
|
||||||
"demo-status": "Demo status",
|
"demo-status": "Demo status",
|
||||||
"development": "Udvikling",
|
"development": "Udvikling",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"event-delete-confirmation": "Er du sikker på, at du vil slette denne begivenhed?",
|
"event-delete-confirmation": "Er du sikker på, at du vil slette denne begivenhed?",
|
||||||
"event-deleted": "Hændelse slettet",
|
"event-deleted": "Hændelse slettet",
|
||||||
"event-updated": "Hændelse opdateret",
|
"event-updated": "Hændelse opdateret",
|
||||||
"new-notification-form-description": "Mealie bruger Apprise-biblioteket for at generere notifikationer. De giver mange muligheder for notifikationer til tjenester. Kig i deres wiki for en gennemgående guide til, hvordan en URL oprettes i din situation. Hvis muligt, kan valget af din type af notifikation omfatte flere ekstrafunktioner.",
|
"new-notification-form-description": "Mealie bruger Apprise biblioteket for at generere notifikationer. De giver mange muligheder for notifikationer til tjenester. Kig i deres wiki for en gennemgående guide til, hvordan en URL oprettes i din situation. Hvis muligt, kan valget af din type af notifikation omfatte flere ekstrafunktioner.",
|
||||||
"new-version": "Ny opdatering er tilgængelig!",
|
"new-version": "Ny opdatering er tilgængelig!",
|
||||||
"notification": "Notifikation",
|
"notification": "Notifikation",
|
||||||
"refresh": "Opdater",
|
"refresh": "Opdater",
|
||||||
@@ -248,7 +248,7 @@
|
|||||||
"manage-members": "Administrer medlemmer",
|
"manage-members": "Administrer medlemmer",
|
||||||
"manage-members-description": "Administrer tilladelser for medlemmerne i din husstand. {manage} giver brugeren adgang til datastyringssiden, og {invite} giver brugeren mulighed for at generere invitationslinks til andre brugere. Gruppeejere kan ikke ændre deres egne tilladelser.",
|
"manage-members-description": "Administrer tilladelser for medlemmerne i din husstand. {manage} giver brugeren adgang til datastyringssiden, og {invite} giver brugeren mulighed for at generere invitationslinks til andre brugere. Gruppeejere kan ikke ændre deres egne tilladelser.",
|
||||||
"manage": "Administrer",
|
"manage": "Administrer",
|
||||||
"manage-household": "Administrer husholdning",
|
"manage-household": "Administrer Husstand",
|
||||||
"invite": "Invitér",
|
"invite": "Invitér",
|
||||||
"looking-to-update-your-profile": "Ønsker du at opdatere din profil?",
|
"looking-to-update-your-profile": "Ønsker du at opdatere din profil?",
|
||||||
"default-recipe-preferences-description": "Dette er standardindstillingerne, når en ny opskrift oprettes i din gruppe. Indstillingerne kan ændres for en opskrift i menuen Opskriftindstillinger.",
|
"default-recipe-preferences-description": "Dette er standardindstillingerne, når en ny opskrift oprettes i din gruppe. Indstillingerne kan ændres for en opskrift i menuen Opskriftindstillinger.",
|
||||||
@@ -278,30 +278,30 @@
|
|||||||
"admin-group-management": "Administratorgruppeadministration",
|
"admin-group-management": "Administratorgruppeadministration",
|
||||||
"admin-group-management-text": "Ændringer i denne gruppe vil træde i kraft øjeblikkeligt.",
|
"admin-group-management-text": "Ændringer i denne gruppe vil træde i kraft øjeblikkeligt.",
|
||||||
"group-id-value": "Gruppe-ID: {0}",
|
"group-id-value": "Gruppe-ID: {0}",
|
||||||
"total-households": "Husholdninger i Alt",
|
"total-households": "Husstande i Alt",
|
||||||
"you-must-select-a-group-before-selecting-a-household": "Du skal vælge en gruppe, før du vælger en husstand"
|
"you-must-select-a-group-before-selecting-a-household": "Du skal vælge en gruppe, før du vælger en husstand"
|
||||||
},
|
},
|
||||||
"household": {
|
"household": {
|
||||||
"household": "Husholdning",
|
"household": "Husstand",
|
||||||
"households": "Husholdninger",
|
"households": "Husstande",
|
||||||
"user-household": "Husholdning",
|
"user-household": "Bruger Husstand",
|
||||||
"create-household": "Opret husholdning",
|
"create-household": "Opret Husstand",
|
||||||
"household-name": "Husholdningens navn",
|
"household-name": "Husstandens Navn",
|
||||||
"household-group": "Husholdnings Gruppe",
|
"household-group": "Husstandens Gruppe",
|
||||||
"household-management": "Husholdningsadministration",
|
"household-management": "Husstands Administration",
|
||||||
"manage-households": "Administrer husholdninger",
|
"manage-households": "Administrer Husstande",
|
||||||
"admin-household-management": "Husholdningsadministration",
|
"admin-household-management": "Admin Husstands Administration",
|
||||||
"admin-household-management-text": "Ændringer ved denne husholdning vil træde i kraft øjeblikkeligt.",
|
"admin-household-management-text": "Ændringer ved denne husholdning vil træde i kraft øjeblikkeligt.",
|
||||||
"household-id-value": "Id: {0}",
|
"household-id-value": "Husstand Id: {0}",
|
||||||
"private-household": "Privat husholdning",
|
"private-household": "Privat Husstand",
|
||||||
"private-household-description": "Sættes din husholdning til private vil det deaktivere alle indstillinger for offentlig visning. Dette tilsidesætter individuelle indstillinger for offentlig visning",
|
"private-household-description": "Sættes din Husstand til privat, vil det deaktivere alle indstillinger for offentlig visning. Dette overskriver individuelle indstillinger for offentlig visning",
|
||||||
"lock-recipe-edits-from-other-households": "Lås ændringer fra andre husholdninger",
|
"lock-recipe-edits-from-other-households": "Lås opskriftredigeringer fra andre husstande",
|
||||||
"lock-recipe-edits-from-other-households-description": "Når aktiveret kan kun husholdningens brugere ændre dens opskrifter",
|
"lock-recipe-edits-from-other-households-description": "Når denne funktion er aktiveret, kan kun brugere i din husstand redigere opskrifter, der er oprettet af din husstand.",
|
||||||
"household-recipe-preferences": "Husholdningens opskriftspræferencer",
|
"household-recipe-preferences": "Husstandens opskriftspræferencer",
|
||||||
"default-recipe-preferences-description": "Disse er standardindstillingerne, når en ny opskrift er oprettet i din husstand. Disse kan ændres for individuelle opskrifter i menuen Opsætninger.",
|
"default-recipe-preferences-description": "Dette er standardindstillingerne, når der oprettes en ny opskrift i din husstand. Disse kan ændres for individuelle opskrifter i menuen med opskriftindstillinger.",
|
||||||
"allow-users-outside-of-your-household-to-see-your-recipes": "Tillad brugere udenfor din husholdning at se dine opskrifter",
|
"allow-users-outside-of-your-household-to-see-your-recipes": "Tillad brugere udenfor din husholdning at se dine opskrifter",
|
||||||
"allow-users-outside-of-your-household-to-see-your-recipes-description": "Når det er aktiveret kan du bruge et link til offentlig deling af specifikke opskrifter uden at godkende brugeren. Når deaktiveret, kan du kun dele opskrifter med brugere, der er i din husstand eller med et forudgenereret privat link",
|
"allow-users-outside-of-your-household-to-see-your-recipes-description": "Når denne funktion er aktiveret, kan du bruge et offentligt delingslink til at dele bestemte opskrifter uden at godkende brugeren. Når funktionen er deaktiveret, kan du kun dele opskrifter med brugere, der bor i din husstand, eller med et forudgenereret privat link.",
|
||||||
"household-preferences": "Præferencer"
|
"household-preferences": "Husstands præferencer"
|
||||||
},
|
},
|
||||||
"meal-plan": {
|
"meal-plan": {
|
||||||
"create-a-new-meal-plan": "Opret madplan",
|
"create-a-new-meal-plan": "Opret madplan",
|
||||||
@@ -326,7 +326,7 @@
|
|||||||
"mealplan-households-description": "Hvis ingen husstand er valgt, kan opskrifter tilføjes fra enhver husstand",
|
"mealplan-households-description": "Hvis ingen husstand er valgt, kan opskrifter tilføjes fra enhver husstand",
|
||||||
"any-category": "Alle kategorier",
|
"any-category": "Alle kategorier",
|
||||||
"any-tag": "Alle tags",
|
"any-tag": "Alle tags",
|
||||||
"any-household": "Alle husholdninger",
|
"any-household": "Alle husstande",
|
||||||
"no-meal-plan-defined-yet": "Ingen madplaner er oprettet endnu",
|
"no-meal-plan-defined-yet": "Ingen madplaner er oprettet endnu",
|
||||||
"no-meal-planned-for-today": "Ingen ret er planlagt til i dag",
|
"no-meal-planned-for-today": "Ingen ret er planlagt til i dag",
|
||||||
"numberOfDays-hint": "Antal dage ved sideindlæsning",
|
"numberOfDays-hint": "Antal dage ved sideindlæsning",
|
||||||
@@ -342,6 +342,9 @@
|
|||||||
"breakfast": "Morgenmad",
|
"breakfast": "Morgenmad",
|
||||||
"lunch": "Frokost",
|
"lunch": "Frokost",
|
||||||
"dinner": "Aftensmad",
|
"dinner": "Aftensmad",
|
||||||
|
"snack": "Snack",
|
||||||
|
"drink": "Drink",
|
||||||
|
"dessert": "Dessert",
|
||||||
"type-any": "Alle",
|
"type-any": "Alle",
|
||||||
"day-any": "Alle",
|
"day-any": "Alle",
|
||||||
"editor": "Redigeringsværktøj",
|
"editor": "Redigeringsværktøj",
|
||||||
@@ -399,6 +402,10 @@
|
|||||||
"description-long": "Mealie kan importere opskrifter fra Tandoor. Eksporter dine data i \"Standard\" format, og upload derefter Zip filen nedenfor.",
|
"description-long": "Mealie kan importere opskrifter fra Tandoor. Eksporter dine data i \"Standard\" format, og upload derefter Zip filen nedenfor.",
|
||||||
"title": "Tandoor Recipes"
|
"title": "Tandoor Recipes"
|
||||||
},
|
},
|
||||||
|
"cookn": {
|
||||||
|
"description-long": "Mealie kan importere opskrifter fra DVO Cook'n X3. Eksportér en kogebog eller menu i \"Cook'n\" formatet, omdøb filtypen .dvo til .zip, og upload derefter .zip nedenfor.",
|
||||||
|
"title": "DVO Cook'n X3"
|
||||||
|
},
|
||||||
"recipe-data-migrations": "Migrering af opskrifter",
|
"recipe-data-migrations": "Migrering af opskrifter",
|
||||||
"recipe-data-migrations-explanation": "Opskrifter kan migreres fra et andet understøttet program til Mealie. Dette er en fantastisk måde at komme i gang med Mealie.",
|
"recipe-data-migrations-explanation": "Opskrifter kan migreres fra et andet understøttet program til Mealie. Dette er en fantastisk måde at komme i gang med Mealie.",
|
||||||
"coming-from-another-application-or-an-even-older-version-of-mealie": "Kommer fra en anden applikation eller en endnu ældre version af Mealie? Tjek migrationer og se om dine data kan importeres.",
|
"coming-from-another-application-or-an-even-older-version-of-mealie": "Kommer fra en anden applikation eller en endnu ældre version af Mealie? Tjek migrationer og se om dine data kan importeres.",
|
||||||
@@ -444,7 +451,9 @@
|
|||||||
"split-by-numbered-line-description": "Forsøger at opdele et afsnit ved at matche '1)' eller '1.' mønstre",
|
"split-by-numbered-line-description": "Forsøger at opdele et afsnit ved at matche '1)' eller '1.' mønstre",
|
||||||
"import-by-url": "Importér opskrift fra en webside",
|
"import-by-url": "Importér opskrift fra en webside",
|
||||||
"create-manually": "Opret opskrift manuelt",
|
"create-manually": "Opret opskrift manuelt",
|
||||||
"make-recipe-image": "Gør dette til opskriftsbillede"
|
"make-recipe-image": "Gør dette til opskriftsbillede",
|
||||||
|
"add-food": "Tilføj Mad",
|
||||||
|
"add-recipe": "Tilføj opskrift"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"404-page-not-found": "404 Siden blev ikke fundet",
|
"404-page-not-found": "404 Siden blev ikke fundet",
|
||||||
@@ -511,6 +520,9 @@
|
|||||||
"recipe-deleted": "Opskrift slettet",
|
"recipe-deleted": "Opskrift slettet",
|
||||||
"recipe-image": "Opskriftsbillede",
|
"recipe-image": "Opskriftsbillede",
|
||||||
"recipe-image-updated": "Opskriftsbillede ændret",
|
"recipe-image-updated": "Opskriftsbillede ændret",
|
||||||
|
"delete-image": "Slet Opskrift Billede",
|
||||||
|
"delete-image-confirmation": "Er du sikker på, du vil slette dette opskrift billede?",
|
||||||
|
"recipe-image-deleted": "Opskrift billede slettet",
|
||||||
"recipe-name": "Opskriftens navn",
|
"recipe-name": "Opskriftens navn",
|
||||||
"recipe-settings": "Opskriftsindstillinger",
|
"recipe-settings": "Opskriftsindstillinger",
|
||||||
"recipe-update-failed": "Opdatering af opskrift fejlede",
|
"recipe-update-failed": "Opdatering af opskrift fejlede",
|
||||||
@@ -556,6 +568,7 @@
|
|||||||
"choose-unit": "Vælg enhed",
|
"choose-unit": "Vælg enhed",
|
||||||
"press-enter-to-create": "Tryk enter for at oprette",
|
"press-enter-to-create": "Tryk enter for at oprette",
|
||||||
"choose-food": "Vælg fødevarer",
|
"choose-food": "Vælg fødevarer",
|
||||||
|
"choose-recipe": "Vælg Opskrift",
|
||||||
"notes": "Kommentarer",
|
"notes": "Kommentarer",
|
||||||
"toggle-section": "Sektion",
|
"toggle-section": "Sektion",
|
||||||
"see-original-text": "Vis den oprindelige tekst",
|
"see-original-text": "Vis den oprindelige tekst",
|
||||||
@@ -583,6 +596,7 @@
|
|||||||
"made-this": "Jeg har lavet denne",
|
"made-this": "Jeg har lavet denne",
|
||||||
"how-did-it-turn-out": "Hvordan blev det?",
|
"how-did-it-turn-out": "Hvordan blev det?",
|
||||||
"user-made-this": "{user} lavede denne",
|
"user-made-this": "{user} lavede denne",
|
||||||
|
"made-for-recipe": "Lavet til {recipe}",
|
||||||
"added-to-timeline": "Tilføjet til tidslinjen",
|
"added-to-timeline": "Tilføjet til tidslinjen",
|
||||||
"failed-to-add-to-timeline": "Kunne ikke tilføje til tidslinjen",
|
"failed-to-add-to-timeline": "Kunne ikke tilføje til tidslinjen",
|
||||||
"failed-to-update-recipe": "Kunne ikke opdatere opskrift",
|
"failed-to-update-recipe": "Kunne ikke opdatere opskrift",
|
||||||
@@ -622,9 +636,11 @@
|
|||||||
"scrape-recipe-suggest-bulk-importer": "Prøv masseimport",
|
"scrape-recipe-suggest-bulk-importer": "Prøv masseimport",
|
||||||
"scrape-recipe-have-raw-html-or-json-data": "Har rå HTML- eller JSON-data?",
|
"scrape-recipe-have-raw-html-or-json-data": "Har rå HTML- eller JSON-data?",
|
||||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Du kan importere direkte fra rå data",
|
"scrape-recipe-you-can-import-from-raw-data-directly": "Du kan importere direkte fra rå data",
|
||||||
|
"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ér originale nøgleord som mærker",
|
"import-original-keywords-as-tags": "Importér originale nøgleord som mærker",
|
||||||
"stay-in-edit-mode": "Bliv i redigeringstilstand",
|
"stay-in-edit-mode": "Bliv i redigeringstilstand",
|
||||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
"parse-recipe-ingredients-after-import": "Fortolk opskrift ingredienser efter import",
|
||||||
"import-from-zip": "Importer fra zip-fil",
|
"import-from-zip": "Importer fra zip-fil",
|
||||||
"import-from-zip-description": "Importer en enkelt opskrift, der blev eksporteret fra en anden Mealie instans.",
|
"import-from-zip-description": "Importer en enkelt opskrift, der blev eksporteret fra en anden Mealie instans.",
|
||||||
"import-from-html-or-json": "Importer fra HTML eller JSON",
|
"import-from-html-or-json": "Importer fra HTML eller JSON",
|
||||||
@@ -671,12 +687,12 @@
|
|||||||
"this-unit-could-not-be-parsed-automatically": "Denne enhed kunne ikke fortolkes automatisk",
|
"this-unit-could-not-be-parsed-automatically": "Denne enhed kunne ikke fortolkes automatisk",
|
||||||
"this-food-could-not-be-parsed-automatically": "Denne fødevare kunne ikke fortolkes automatisk",
|
"this-food-could-not-be-parsed-automatically": "Denne fødevare kunne ikke fortolkes automatisk",
|
||||||
"no-food": "Ingen fødevarer",
|
"no-food": "Ingen fødevarer",
|
||||||
"review-parsed-ingredients": "Review parsed ingredients",
|
"review-parsed-ingredients": "Gennemgå fortolkede ingredienser",
|
||||||
"confidence-score": "Confidence Score",
|
"confidence-score": "Tillidsscore",
|
||||||
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
|
"ingredient-parser-description": "Dine ingredienser er blevet fortolket. Gennemgå de ingredienser, vi ikke er sikker på.",
|
||||||
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
|
"ingredient-parser-final-review-description": "Når alle ingredienser er blevet gennemgået, har du endnu en chance for at gennemgå alle ingredienser, før du anvender ændringerne til din opskrift.",
|
||||||
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
|
"add-text-as-alias-for-item": "Tilføj \"{text}\" som alias for {item}",
|
||||||
"delete-item": "Delete Item"
|
"delete-item": "Slet Element"
|
||||||
},
|
},
|
||||||
"reset-servings-count": "Nulstil antal serveringer",
|
"reset-servings-count": "Nulstil antal serveringer",
|
||||||
"not-linked-ingredients": "Yderligere ingredienser",
|
"not-linked-ingredients": "Yderligere ingredienser",
|
||||||
@@ -684,7 +700,10 @@
|
|||||||
"upload-images": "Upload billeder",
|
"upload-images": "Upload billeder",
|
||||||
"upload-more-images": "Upload flere billeder",
|
"upload-more-images": "Upload flere billeder",
|
||||||
"set-as-cover-image": "Angiv som opskriftens coverbillede",
|
"set-as-cover-image": "Angiv som opskriftens coverbillede",
|
||||||
"cover-image": "Coverbillede"
|
"cover-image": "Coverbillede",
|
||||||
|
"include-linked-recipes": "Inkluder Relaterede Opskrifter",
|
||||||
|
"include-linked-recipe-ingredients": "Inkluder Relaterede Opskrift Ingredienser",
|
||||||
|
"toggle-recipe": "Vis/Skjul Opskrift"
|
||||||
},
|
},
|
||||||
"recipe-finder": {
|
"recipe-finder": {
|
||||||
"recipe-finder": "Opskriftssøger",
|
"recipe-finder": "Opskriftssøger",
|
||||||
@@ -721,7 +740,8 @@
|
|||||||
"search-hint": "Tryk '/'",
|
"search-hint": "Tryk '/'",
|
||||||
"advanced": "Avanceret",
|
"advanced": "Avanceret",
|
||||||
"auto-search": "Automatisk søgning",
|
"auto-search": "Automatisk søgning",
|
||||||
"no-results": "Ingen resultater fundet"
|
"no-results": "Ingen resultater fundet",
|
||||||
|
"type-to-search": "Skriv for at søge..."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"add-a-new-theme": "Tilføj et nyt tema",
|
"add-a-new-theme": "Tilføj et nyt tema",
|
||||||
@@ -1052,14 +1072,16 @@
|
|||||||
"administrator": "Administrator",
|
"administrator": "Administrator",
|
||||||
"user-can-invite-other-to-group": "Bruger kan invitere andre til gruppen",
|
"user-can-invite-other-to-group": "Bruger kan invitere andre til gruppen",
|
||||||
"user-can-manage-group": "Bruger kan administrere gruppen",
|
"user-can-manage-group": "Bruger kan administrere gruppen",
|
||||||
"user-can-manage-household": "Bruger kan administrere husholdningen",
|
"user-can-manage-household": "Brugeren kan administrere husstande",
|
||||||
"user-can-organize-group-data": "Bruger kan organisere gruppedata",
|
"user-can-organize-group-data": "Bruger kan organisere gruppedata",
|
||||||
"enable-advanced-features": "Aktiver avancerede funktioner",
|
"enable-advanced-features": "Aktiver avancerede funktioner",
|
||||||
"it-looks-like-this-is-your-first-time-logging-in": "Det ser ud til, at det er første gang, at du logger ind.",
|
"it-looks-like-this-is-your-first-time-logging-in": "Det ser ud til, at det er første gang, at du logger ind.",
|
||||||
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Vil du ikke længere se dette? Sørg for at ændre din e-mail i dine brugerindstillinger!",
|
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Vil du ikke længere se dette? Sørg for at ændre din e-mail i dine brugerindstillinger!",
|
||||||
"forgot-password": "Glemt adgangskode",
|
"forgot-password": "Glemt adgangskode",
|
||||||
"forgot-password-text": "Indtast venligst din e-mail-adresse. Vi sender dig en e-mail, så at du kan nulstille din adgangskode.",
|
"forgot-password-text": "Indtast venligst din e-mail-adresse. Vi sender dig en e-mail, så at du kan nulstille din adgangskode.",
|
||||||
"changes-reflected-immediately": "Ændringer til denne bruger vil have effekt med det samme."
|
"changes-reflected-immediately": "Ændringer til denne bruger vil have effekt med det samme.",
|
||||||
|
"default-activity": "",
|
||||||
|
"default-activity-hint": "Vælg den side, du vil navigere til, når du logger ind fra denne enhed"
|
||||||
},
|
},
|
||||||
"language-dialog": {
|
"language-dialog": {
|
||||||
"translated": "oversat",
|
"translated": "oversat",
|
||||||
@@ -1176,7 +1198,7 @@
|
|||||||
"provide-registration-token-description": "Angiv venligst det registreringstoken, der er knyttet til den gruppe, du gerne vil deltage i. Du skal indhente dette fra et eksisterende gruppemedlem.",
|
"provide-registration-token-description": "Angiv venligst det registreringstoken, der er knyttet til den gruppe, du gerne vil deltage i. Du skal indhente dette fra et eksisterende gruppemedlem.",
|
||||||
"group-details": "Gruppeoplysninger",
|
"group-details": "Gruppeoplysninger",
|
||||||
"group-details-description": "Før du opretter en konto, skal du oprette en gruppe. Din gruppe vil kun indeholde dig, men du vil kunne invitere andre senere. Medlemmer i din gruppe kan dele madplaner, indkøbslister, opskrifter og meget mere!",
|
"group-details-description": "Før du opretter en konto, skal du oprette en gruppe. Din gruppe vil kun indeholde dig, men du vil kunne invitere andre senere. Medlemmer i din gruppe kan dele madplaner, indkøbslister, opskrifter og meget mere!",
|
||||||
"use-seed-data": "Anved standard data",
|
"use-seed-data": "Anvend standard data",
|
||||||
"use-seed-data-description": "Mealie kommer med en samling af Fødevarer, Enheder, og Etiketter som kan blive brugt til at udfylde din gruppe med nyttig data til at organisere dine opskrifter. De er oversat til det sprog, du i øjeblikket har valgt. Du kan altid tilføje og ændre disse data senere.",
|
"use-seed-data-description": "Mealie kommer med en samling af Fødevarer, Enheder, og Etiketter som kan blive brugt til at udfylde din gruppe med nyttig data til at organisere dine opskrifter. De er oversat til det sprog, du i øjeblikket har valgt. Du kan altid tilføje og ændre disse data senere.",
|
||||||
"account-details": "Kontodetaljer"
|
"account-details": "Kontodetaljer"
|
||||||
},
|
},
|
||||||
@@ -1312,8 +1334,8 @@
|
|||||||
"debug-openai-services-description": "Brug denne side til at fejlsøge OpenAI-tjenester. Du kan teste din OpenAI-forbindelse og se resultaterne her. Hvis du har billedetjenester aktiveret, kan du også prøve med et billede.",
|
"debug-openai-services-description": "Brug denne side til at fejlsøge OpenAI-tjenester. Du kan teste din OpenAI-forbindelse og se resultaterne her. Hvis du har billedetjenester aktiveret, kan du også prøve med et billede.",
|
||||||
"run-test": "Kør test",
|
"run-test": "Kør test",
|
||||||
"test-results": "Testresultater",
|
"test-results": "Testresultater",
|
||||||
"group-delete-note": "Grupper med brugere eller husholdninger kan ikke slettes",
|
"group-delete-note": "Grupper med brugere eller husstande kan ikke slettes",
|
||||||
"household-delete-note": "Husholdninger med brugere kan ikke slettes"
|
"household-delete-note": "Husstande med brugere kan ikke slettes"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"welcome-user": "👋 Velkommen, {0}!",
|
"welcome-user": "👋 Velkommen, {0}!",
|
||||||
@@ -1325,8 +1347,8 @@
|
|||||||
"account-summary-description": "Her er en oversigt over din gruppes oplysninger.",
|
"account-summary-description": "Her er en oversigt over din gruppes oplysninger.",
|
||||||
"group-statistics": "Gruppestatistik",
|
"group-statistics": "Gruppestatistik",
|
||||||
"group-statistics-description": "Din gruppestatistik giver indsigt i, hvordan du bruger Mealie.",
|
"group-statistics-description": "Din gruppestatistik giver indsigt i, hvordan du bruger Mealie.",
|
||||||
"household-statistics": "Husholdnings Statistikker",
|
"household-statistics": "Husstands Statistikker",
|
||||||
"household-statistics-description": "Dine husstandsstatistikker giver lidt indsigt i, hvordan du bruger Mealie.",
|
"household-statistics-description": "Din husstandsstatistik giver et indblik i, hvordan du bruger Mealie.",
|
||||||
"storage-capacity": "Lagerkapacitet",
|
"storage-capacity": "Lagerkapacitet",
|
||||||
"storage-capacity-description": "Din lagerkapacitet er en beregning af de billeder og elementer, du har uploadet.",
|
"storage-capacity-description": "Din lagerkapacitet er en beregning af de billeder og elementer, du har uploadet.",
|
||||||
"personal": "Personlig",
|
"personal": "Personlig",
|
||||||
@@ -1337,9 +1359,9 @@
|
|||||||
"group-description": "Disse elementer deles i din gruppe. Redigering af et af dem vil ændre det for hele gruppen!",
|
"group-description": "Disse elementer deles i din gruppe. Redigering af et af dem vil ændre det for hele gruppen!",
|
||||||
"group-settings": "Gruppeindstillinger",
|
"group-settings": "Gruppeindstillinger",
|
||||||
"group-settings-description": "Administrer dine fælles gruppeindstillinger, såsom privatlivsindstillinger.",
|
"group-settings-description": "Administrer dine fælles gruppeindstillinger, såsom privatlivsindstillinger.",
|
||||||
"household-description": "Disse elementer deles i din husstand. Redigering af en af dem vil ændre det for hele husstanden!",
|
"household-description": "Disse elementer deles inden for din husstand. Hvis du redigerer et af dem, ændres det for hele husstanden!",
|
||||||
"household-settings": "Husholdningsindstillinger",
|
"household-settings": "Husstands indstillinger",
|
||||||
"household-settings-description": "Administrer dine husholdningsindstillinger, såsom madplan og privatlivsindstillinger.",
|
"household-settings-description": "Administrer din husstands indstillinger, såsom madplan og privatlivsindstillinger.",
|
||||||
"cookbooks-description": "Administrer en samling af kategorier og opret sider til dem.",
|
"cookbooks-description": "Administrer en samling af kategorier og opret sider til dem.",
|
||||||
"members": "Medlemmer",
|
"members": "Medlemmer",
|
||||||
"members-description": "Se, hvem der er i din husstand og administrer deres tilladelser.",
|
"members-description": "Se, hvem der er i din husstand og administrer deres tilladelser.",
|
||||||
@@ -1368,7 +1390,7 @@
|
|||||||
"cookbook": {
|
"cookbook": {
|
||||||
"cookbooks": "Kogebøger",
|
"cookbooks": "Kogebøger",
|
||||||
"description": "Kogebøger er en anden måde at organisere opskrifter ved at skabe tværsnit af opskrifter, arrangører, og andre filtre. Oprettelse af en kogebog vil tilføje et link i sidemenuen, og alle opskrifter med de valgte filtre vil blive vist i kogebogen.",
|
"description": "Kogebøger er en anden måde at organisere opskrifter ved at skabe tværsnit af opskrifter, arrangører, og andre filtre. Oprettelse af en kogebog vil tilføje et link i sidemenuen, og alle opskrifter med de valgte filtre vil blive vist i kogebogen.",
|
||||||
"hide-cookbooks-from-other-households": "Skjul kogebøger fra andre husholdninger",
|
"hide-cookbooks-from-other-households": "Skjul kogebøger fra andre husstande",
|
||||||
"hide-cookbooks-from-other-households-description": "Når aktiveret, kun kogebøger fra din husstand vises på sidepanelet",
|
"hide-cookbooks-from-other-households-description": "Når aktiveret, kun kogebøger fra din husstand vises på sidepanelet",
|
||||||
"public-cookbook": "Offentlig kogebog",
|
"public-cookbook": "Offentlig kogebog",
|
||||||
"public-cookbook-description": "Offentlige kogebøger kan deles med personer, der ikke er oprettet som brugere i Mealie og vil blive vist på din gruppe side.",
|
"public-cookbook-description": "Offentlige kogebøger kan deles med personer, der ikke er oprettet som brugere i Mealie og vil blive vist på din gruppe side.",
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
"category-updated": "Kategorie aktualisiert",
|
"category-updated": "Kategorie aktualisiert",
|
||||||
"uncategorized-count": "Nicht kategorisiert {count}",
|
"uncategorized-count": "Nicht kategorisiert {count}",
|
||||||
"create-a-category": "Eine Kategorie erstellen",
|
"create-a-category": "Eine Kategorie erstellen",
|
||||||
"category-name": "Name der Kategorie",
|
"category-name": "Kategoriename",
|
||||||
"category": "Kategorie"
|
"category": "Kategorie"
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
@@ -342,6 +342,9 @@
|
|||||||
"breakfast": "Frühstück",
|
"breakfast": "Frühstück",
|
||||||
"lunch": "Mittagessen",
|
"lunch": "Mittagessen",
|
||||||
"dinner": "Abendessen",
|
"dinner": "Abendessen",
|
||||||
|
"snack": "Snack",
|
||||||
|
"drink": "Drink",
|
||||||
|
"dessert": "Dessert",
|
||||||
"type-any": "Alle",
|
"type-any": "Alle",
|
||||||
"day-any": "Alle",
|
"day-any": "Alle",
|
||||||
"editor": "Bearbeiten",
|
"editor": "Bearbeiten",
|
||||||
@@ -399,6 +402,10 @@
|
|||||||
"description-long": "Mealie kann Rezepte von Tandoor importieren. Exportiere deine Daten im 'Default' Format und lade dann unten die .zip Datei hoch.",
|
"description-long": "Mealie kann Rezepte von Tandoor importieren. Exportiere deine Daten im 'Default' Format und lade dann unten die .zip Datei hoch.",
|
||||||
"title": "Tandoor Rezepte"
|
"title": "Tandoor Rezepte"
|
||||||
},
|
},
|
||||||
|
"cookn": {
|
||||||
|
"description-long": "Mealie kann Rezepte von DVO Cook'n X3 importieren. Exportieren Sie ein Kochbuch oder ein Menü im \"Cook'n\"-Format, benennen Sie die Export-Erweiterung in .zip um, dann laden Sie die .zip unten hoch.",
|
||||||
|
"title": "DVO Cook'n X3"
|
||||||
|
},
|
||||||
"recipe-data-migrations": "Rezeptdatenmigration",
|
"recipe-data-migrations": "Rezeptdatenmigration",
|
||||||
"recipe-data-migrations-explanation": "Rezepte können aus unterstützten Programmen nach Mealie migriert werden. Das ist eine gute Möglichkeit, um mit Mealie loszulegen.",
|
"recipe-data-migrations-explanation": "Rezepte können aus unterstützten Programmen nach Mealie migriert werden. Das ist eine gute Möglichkeit, um mit Mealie loszulegen.",
|
||||||
"coming-from-another-application-or-an-even-older-version-of-mealie": "Wechselst du von einer anderen Anwendung oder einer noch älteren Version von Mealie? Prüfe die Migrations-Optionen, vielleicht können deine Daten importiert werden.",
|
"coming-from-another-application-or-an-even-older-version-of-mealie": "Wechselst du von einer anderen Anwendung oder einer noch älteren Version von Mealie? Prüfe die Migrations-Optionen, vielleicht können deine Daten importiert werden.",
|
||||||
@@ -444,7 +451,9 @@
|
|||||||
"split-by-numbered-line-description": "Absätze nach dem Schema '1)' oder '1.' aufzuteilen versuchen",
|
"split-by-numbered-line-description": "Absätze nach dem Schema '1)' oder '1.' aufzuteilen versuchen",
|
||||||
"import-by-url": "Ein Rezept von einer Webseite importieren",
|
"import-by-url": "Ein Rezept von einer Webseite importieren",
|
||||||
"create-manually": "Ein Rezept manuell erstellen",
|
"create-manually": "Ein Rezept manuell erstellen",
|
||||||
"make-recipe-image": "Als Rezept-Titelbild verwenden"
|
"make-recipe-image": "Als Rezept-Titelbild verwenden",
|
||||||
|
"add-food": "Lebensmittel",
|
||||||
|
"add-recipe": "Rezepte hinzufügen"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"404-page-not-found": "404 Seite nicht gefunden",
|
"404-page-not-found": "404 Seite nicht gefunden",
|
||||||
@@ -511,6 +520,9 @@
|
|||||||
"recipe-deleted": "Rezept entfernt",
|
"recipe-deleted": "Rezept entfernt",
|
||||||
"recipe-image": "Rezeptbild",
|
"recipe-image": "Rezeptbild",
|
||||||
"recipe-image-updated": "Rezeptbild aktualisiert",
|
"recipe-image-updated": "Rezeptbild aktualisiert",
|
||||||
|
"delete-image": "Rezeptbild löschen",
|
||||||
|
"delete-image-confirmation": "Bist du dir sicher, dass du dieses Rezept löschen möchtest?",
|
||||||
|
"recipe-image-deleted": "Rezeptbild gelöscht",
|
||||||
"recipe-name": "Rezeptname",
|
"recipe-name": "Rezeptname",
|
||||||
"recipe-settings": "Rezepteinstellungen",
|
"recipe-settings": "Rezepteinstellungen",
|
||||||
"recipe-update-failed": "Aktualisieren des Rezepts fehlgeschlagen",
|
"recipe-update-failed": "Aktualisieren des Rezepts fehlgeschlagen",
|
||||||
@@ -556,6 +568,7 @@
|
|||||||
"choose-unit": "Einheit wählen",
|
"choose-unit": "Einheit wählen",
|
||||||
"press-enter-to-create": "Zum Erstellen Eingabetaste drücken",
|
"press-enter-to-create": "Zum Erstellen Eingabetaste drücken",
|
||||||
"choose-food": "Lebensmittel wählen",
|
"choose-food": "Lebensmittel wählen",
|
||||||
|
"choose-recipe": "Rezept wählen",
|
||||||
"notes": "Notizen",
|
"notes": "Notizen",
|
||||||
"toggle-section": "Überschrift ein-/ausblenden",
|
"toggle-section": "Überschrift ein-/ausblenden",
|
||||||
"see-original-text": "Originaltext anzeigen",
|
"see-original-text": "Originaltext anzeigen",
|
||||||
@@ -583,6 +596,7 @@
|
|||||||
"made-this": "Ich hab's gemacht",
|
"made-this": "Ich hab's gemacht",
|
||||||
"how-did-it-turn-out": "Wie ist es geworden?",
|
"how-did-it-turn-out": "Wie ist es geworden?",
|
||||||
"user-made-this": "{user} hat's gemacht",
|
"user-made-this": "{user} hat's gemacht",
|
||||||
|
"made-for-recipe": "Erstellt für {recipe}",
|
||||||
"added-to-timeline": "Zur Zeitleiste hinzugefügt",
|
"added-to-timeline": "Zur Zeitleiste hinzugefügt",
|
||||||
"failed-to-add-to-timeline": "Fehler beim Hinzufügen zur Zeitleiste",
|
"failed-to-add-to-timeline": "Fehler beim Hinzufügen zur Zeitleiste",
|
||||||
"failed-to-update-recipe": "Fehler beim Aktualisieren des Rezepts",
|
"failed-to-update-recipe": "Fehler beim Aktualisieren des Rezepts",
|
||||||
@@ -622,6 +636,8 @@
|
|||||||
"scrape-recipe-suggest-bulk-importer": "Probiere den Massenimporter aus",
|
"scrape-recipe-suggest-bulk-importer": "Probiere den Massenimporter aus",
|
||||||
"scrape-recipe-have-raw-html-or-json-data": "Hast du Roh-HTML oder JSON Daten?",
|
"scrape-recipe-have-raw-html-or-json-data": "Hast du Roh-HTML oder JSON Daten?",
|
||||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Du kannst direkt von Rohdaten importieren",
|
"scrape-recipe-you-can-import-from-raw-data-directly": "Du kannst direkt von Rohdaten importieren",
|
||||||
|
"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": "Importiere ursprüngliche Stichwörter als Schlagwörter",
|
"import-original-keywords-as-tags": "Importiere ursprüngliche Stichwörter als Schlagwörter",
|
||||||
"stay-in-edit-mode": "Im Bearbeitungsmodus bleiben",
|
"stay-in-edit-mode": "Im Bearbeitungsmodus bleiben",
|
||||||
"parse-recipe-ingredients-after-import": "Zutaten nach dem Import parsen",
|
"parse-recipe-ingredients-after-import": "Zutaten nach dem Import parsen",
|
||||||
@@ -676,7 +692,7 @@
|
|||||||
"ingredient-parser-description": "Deine Zutaten wurden erfolgreich geparst. Bitte überprüfe die Zutaten, bei denen wir uns nicht sicher sind.",
|
"ingredient-parser-description": "Deine Zutaten wurden erfolgreich geparst. Bitte überprüfe die Zutaten, bei denen wir uns nicht sicher sind.",
|
||||||
"ingredient-parser-final-review-description": "Sobald alle Zutaten überprüft wurden, kannst du nochmal alle Zutaten kontrollieren, bevor die Änderungen ins Rezept übernommen werden.",
|
"ingredient-parser-final-review-description": "Sobald alle Zutaten überprüft wurden, kannst du nochmal alle Zutaten kontrollieren, bevor die Änderungen ins Rezept übernommen werden.",
|
||||||
"add-text-as-alias-for-item": "Füge \"{text}\" als Alias für {item} hinzu",
|
"add-text-as-alias-for-item": "Füge \"{text}\" als Alias für {item} hinzu",
|
||||||
"delete-item": "Delete Item"
|
"delete-item": "Element löschen"
|
||||||
},
|
},
|
||||||
"reset-servings-count": "Portionen zurücksetzen",
|
"reset-servings-count": "Portionen zurücksetzen",
|
||||||
"not-linked-ingredients": "Zusätzliche Zutaten",
|
"not-linked-ingredients": "Zusätzliche Zutaten",
|
||||||
@@ -684,7 +700,10 @@
|
|||||||
"upload-images": "Bilder hochladen",
|
"upload-images": "Bilder hochladen",
|
||||||
"upload-more-images": "Weitere Bilder hochladen",
|
"upload-more-images": "Weitere Bilder hochladen",
|
||||||
"set-as-cover-image": "Als Rezept-Titelbild setzen",
|
"set-as-cover-image": "Als Rezept-Titelbild setzen",
|
||||||
"cover-image": "Titelbild"
|
"cover-image": "Titelbild",
|
||||||
|
"include-linked-recipes": "Verknüpfte Rezepte einbeziehen",
|
||||||
|
"include-linked-recipe-ingredients": "Zutaten verknüpfter Rezepte einbeziehen",
|
||||||
|
"toggle-recipe": "Rezept ein/aus"
|
||||||
},
|
},
|
||||||
"recipe-finder": {
|
"recipe-finder": {
|
||||||
"recipe-finder": "Rezept-Suche",
|
"recipe-finder": "Rezept-Suche",
|
||||||
@@ -721,7 +740,8 @@
|
|||||||
"search-hint": "'/' drücken",
|
"search-hint": "'/' drücken",
|
||||||
"advanced": "Erweitert",
|
"advanced": "Erweitert",
|
||||||
"auto-search": "Automatische Suche",
|
"auto-search": "Automatische Suche",
|
||||||
"no-results": "Keine Ergebnisse gefunden"
|
"no-results": "Keine Ergebnisse gefunden",
|
||||||
|
"type-to-search": "Suchbegriff eingeben..."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"add-a-new-theme": "Neues Thema hinzufügen",
|
"add-a-new-theme": "Neues Thema hinzufügen",
|
||||||
@@ -1059,7 +1079,9 @@
|
|||||||
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Möchtest du das hier nicht mehr sehen? Bitte ändere deine E-Mail in den Benutzereinstellungen!",
|
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Möchtest du das hier nicht mehr sehen? Bitte ändere deine E-Mail in den Benutzereinstellungen!",
|
||||||
"forgot-password": "Passwort vergessen",
|
"forgot-password": "Passwort vergessen",
|
||||||
"forgot-password-text": "Bitte gib Deine E-Mail-Adresse ein. Wir werden Dir eine E-Mail zusenden, damit Du Dein Passwort zurücksetzen kannst.",
|
"forgot-password-text": "Bitte gib Deine E-Mail-Adresse ein. Wir werden Dir eine E-Mail zusenden, damit Du Dein Passwort zurücksetzen kannst.",
|
||||||
"changes-reflected-immediately": "Änderungen an diesem Benutzer sind sofort wirksam."
|
"changes-reflected-immediately": "Änderungen an diesem Benutzer sind sofort wirksam.",
|
||||||
|
"default-activity": "Standardaktivität",
|
||||||
|
"default-activity-hint": "Wählen Sie die Seite, auf die Sie navigieren möchten, wenn Sie sich von diesem Gerät aus anmelden"
|
||||||
},
|
},
|
||||||
"language-dialog": {
|
"language-dialog": {
|
||||||
"translated": "übersetzt",
|
"translated": "übersetzt",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"about": {
|
"about": {
|
||||||
"about": "Σχετικά με",
|
"about": "Σχετικά",
|
||||||
"about-mealie": "Σχετικά με το Mealie",
|
"about-mealie": "Σχετικά με το Mealie",
|
||||||
"api-docs": "Τεκμηρίωση API",
|
"api-docs": "Τεκμηρίωση API",
|
||||||
"api-port": "Θύρα API",
|
"api-port": "Θύρα API",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"development": "Ανάπτυξη",
|
"development": "Ανάπτυξη",
|
||||||
"docs": "Τεκμηρίωση",
|
"docs": "Τεκμηρίωση",
|
||||||
"download-log": "Λήψη αρχείου καταγραφής",
|
"download-log": "Λήψη αρχείου καταγραφής",
|
||||||
"download-recipe-json": "Τελευταίο Scraped JSON",
|
"download-recipe-json": "Τελευταίο αντλημένο JSON",
|
||||||
"github": "GitHub",
|
"github": "GitHub",
|
||||||
"log-lines": "Γραμμές καταγραφής",
|
"log-lines": "Γραμμές καταγραφής",
|
||||||
"not-demo": "Εκτός Επίδειξης",
|
"not-demo": "Εκτός Επίδειξης",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"asset": {
|
"asset": {
|
||||||
"assets": "Στοιχεία",
|
"assets": "Στοιχεία",
|
||||||
"code": "Κώδικας",
|
"code": "Κωδικός",
|
||||||
"file": "Αρχείο",
|
"file": "Αρχείο",
|
||||||
"image": "Εικόνα",
|
"image": "Εικόνα",
|
||||||
"new-asset": "Νέο Στοιχείο",
|
"new-asset": "Νέο Στοιχείο",
|
||||||
@@ -38,15 +38,15 @@
|
|||||||
},
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"categories": "Κατηγορίες",
|
"categories": "Κατηγορίες",
|
||||||
"category-created": "Δημιουργήθηκε η κατηγορία",
|
"category-created": "Η κατηγορία δημιουργήθηκε",
|
||||||
"category-creation-failed": "Η δημιουργία κατηγορίας απέτυχε",
|
"category-creation-failed": "Η δημιουργία κατηγορίας απέτυχε",
|
||||||
"category-deleted": "Κατηγορία Διαγράφηκε",
|
"category-deleted": "Η κατηγορία διαγράφηκε",
|
||||||
"category-deletion-failed": "Η διαγραφή κατηγορίας απέτυχε",
|
"category-deletion-failed": "Η διαγραφή κατηγορίας απέτυχε",
|
||||||
"category-filter": "Φίλτρο κατηγορίας",
|
"category-filter": "Φίλτρο κατηγορίας",
|
||||||
"category-update-failed": "Η ενημέρωση της κατηγορίας απέτυχε",
|
"category-update-failed": "Η ενημέρωση της κατηγορίας απέτυχε",
|
||||||
"category-updated": "Η κατηγορία ενημερώθηκε",
|
"category-updated": "Η κατηγορία ενημερώθηκε",
|
||||||
"uncategorized-count": "Μη κατηγοριοποιημένο {count}",
|
"uncategorized-count": "Μη κατηγοριοποιημένο {count}",
|
||||||
"create-a-category": "Δημιουργήστε μια Κατηγορία",
|
"create-a-category": "Δημιουργία Κατηγορίας",
|
||||||
"category-name": "Όνομα Κατηγορίας",
|
"category-name": "Όνομα Κατηγορίας",
|
||||||
"category": "Κατηγορία"
|
"category": "Κατηγορία"
|
||||||
},
|
},
|
||||||
@@ -342,6 +342,9 @@
|
|||||||
"breakfast": "Πρωινό",
|
"breakfast": "Πρωινό",
|
||||||
"lunch": "Μεσημεριανό",
|
"lunch": "Μεσημεριανό",
|
||||||
"dinner": "Βραδινό",
|
"dinner": "Βραδινό",
|
||||||
|
"snack": "Σνακ",
|
||||||
|
"drink": "Ποτό",
|
||||||
|
"dessert": "Επιδόρπιο",
|
||||||
"type-any": "Οτιδήποτε",
|
"type-any": "Οτιδήποτε",
|
||||||
"day-any": "Οποιαδήποτε",
|
"day-any": "Οποιαδήποτε",
|
||||||
"editor": "Επεξεργαστής κειμένου",
|
"editor": "Επεξεργαστής κειμένου",
|
||||||
@@ -399,6 +402,10 @@
|
|||||||
"description-long": "Το Mealie μπορεί να εισάγει συνταγές από το Tandoor. Εξαγάγετε τα δεδομένα σας στην \"Προεπιλεγμένη\" (default) μορφή, στη συνέχεια, ανεβάστε το .zip παρακάτω.",
|
"description-long": "Το Mealie μπορεί να εισάγει συνταγές από το Tandoor. Εξαγάγετε τα δεδομένα σας στην \"Προεπιλεγμένη\" (default) μορφή, στη συνέχεια, ανεβάστε το .zip παρακάτω.",
|
||||||
"title": "Tandoor Recipes"
|
"title": "Tandoor Recipes"
|
||||||
},
|
},
|
||||||
|
"cookn": {
|
||||||
|
"description-long": "Το Mealie μπορεί να εισάγει συνταγές από το DVO Cook'n X3. Κάντε εξαγωγή ενός βιβλίου μαγειρικής ή ενός μενού σε μορφή \"Cook'n\", αλλάξτε την επέκταση του εξαγόμενου αρχείου σε .zip και, στη συνέχεια, ανεβάστε το .zip παρακάτω.",
|
||||||
|
"title": "DVO Cook'n X3"
|
||||||
|
},
|
||||||
"recipe-data-migrations": "Μετεγκατάσταση Δεδομένων Συνταγής",
|
"recipe-data-migrations": "Μετεγκατάσταση Δεδομένων Συνταγής",
|
||||||
"recipe-data-migrations-explanation": "Συνταγές μπορούν να μετεγκατασταθούν από άλλη υποστηριζόμενη εφαρμογή στο Mealie. Αυτός είναι ένας πολύ καλός τρόπος για να ξεκινήσετε με Mealie.",
|
"recipe-data-migrations-explanation": "Συνταγές μπορούν να μετεγκατασταθούν από άλλη υποστηριζόμενη εφαρμογή στο Mealie. Αυτός είναι ένας πολύ καλός τρόπος για να ξεκινήσετε με Mealie.",
|
||||||
"coming-from-another-application-or-an-even-older-version-of-mealie": "Ερχόμενοι από άλλη εφαρμογή ή ακόμα και παλαιότερη έκδοση του Mealie; Ελέγξτε τις μετεγκαταστάσεις και δείτε αν τα δεδομένα σας μπορούν να εισαχθούν.",
|
"coming-from-another-application-or-an-even-older-version-of-mealie": "Ερχόμενοι από άλλη εφαρμογή ή ακόμα και παλαιότερη έκδοση του Mealie; Ελέγξτε τις μετεγκαταστάσεις και δείτε αν τα δεδομένα σας μπορούν να εισαχθούν.",
|
||||||
@@ -444,7 +451,9 @@
|
|||||||
"split-by-numbered-line-description": "Προσπάθεια για χωρισμό μιας παραγράφου ταιριάζοντας μοτίβα '1)' ή '1.'",
|
"split-by-numbered-line-description": "Προσπάθεια για χωρισμό μιας παραγράφου ταιριάζοντας μοτίβα '1)' ή '1.'",
|
||||||
"import-by-url": "Εισαγωγή μιας συνταγής από URL",
|
"import-by-url": "Εισαγωγή μιας συνταγής από URL",
|
||||||
"create-manually": "Δημιουργήστε μια συνταγή χειροκίνητα",
|
"create-manually": "Δημιουργήστε μια συνταγή χειροκίνητα",
|
||||||
"make-recipe-image": "Ορισμός ως εικόνας συνταγής"
|
"make-recipe-image": "Ορισμός ως εικόνας συνταγής",
|
||||||
|
"add-food": "Προσθήκη τρόφιμου",
|
||||||
|
"add-recipe": "Προσθήκη συνταγής"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"404-page-not-found": "404. η σελίδα δεν βρέθηκε",
|
"404-page-not-found": "404. η σελίδα δεν βρέθηκε",
|
||||||
@@ -511,6 +520,9 @@
|
|||||||
"recipe-deleted": "Η συνταγή διαγράφηκε",
|
"recipe-deleted": "Η συνταγή διαγράφηκε",
|
||||||
"recipe-image": "Εικόνα Συνταγής",
|
"recipe-image": "Εικόνα Συνταγής",
|
||||||
"recipe-image-updated": "Η εικόνα συνταγής ενημερώθηκε",
|
"recipe-image-updated": "Η εικόνα συνταγής ενημερώθηκε",
|
||||||
|
"delete-image": "Διαγραφή Εικόνας Συνταγής",
|
||||||
|
"delete-image-confirmation": "Θέλετε σίγουρα να διαγράψετε αυτή την εικόνα συνταγής;",
|
||||||
|
"recipe-image-deleted": "Η εικόνα συνταγής διαγράφηκε",
|
||||||
"recipe-name": "Όνομα συνταγής",
|
"recipe-name": "Όνομα συνταγής",
|
||||||
"recipe-settings": "Ρυθμίσεις Συνταγής",
|
"recipe-settings": "Ρυθμίσεις Συνταγής",
|
||||||
"recipe-update-failed": "Η ενημέρωση συνταγής απέτυχε",
|
"recipe-update-failed": "Η ενημέρωση συνταγής απέτυχε",
|
||||||
@@ -556,6 +568,7 @@
|
|||||||
"choose-unit": "Επιλέξτε μονάδα",
|
"choose-unit": "Επιλέξτε μονάδα",
|
||||||
"press-enter-to-create": "Πατήστε Enter για δημιουργία",
|
"press-enter-to-create": "Πατήστε Enter για δημιουργία",
|
||||||
"choose-food": "Επιλέξτε τρόφιμο",
|
"choose-food": "Επιλέξτε τρόφιμο",
|
||||||
|
"choose-recipe": "Επιλέξτε συνταγή",
|
||||||
"notes": "Σημειώσεις",
|
"notes": "Σημειώσεις",
|
||||||
"toggle-section": "Ενεργοποίηση/απενεργοποίηση τμήματος",
|
"toggle-section": "Ενεργοποίηση/απενεργοποίηση τμήματος",
|
||||||
"see-original-text": "Προβολή Αρχικού Κειμένου",
|
"see-original-text": "Προβολή Αρχικού Κειμένου",
|
||||||
@@ -583,6 +596,7 @@
|
|||||||
"made-this": "Το έφτιαξα",
|
"made-this": "Το έφτιαξα",
|
||||||
"how-did-it-turn-out": "Ποιό ήταν το αποτέλεσμα;",
|
"how-did-it-turn-out": "Ποιό ήταν το αποτέλεσμα;",
|
||||||
"user-made-this": "Ο/η {user} έφτιαξε αυτό",
|
"user-made-this": "Ο/η {user} έφτιαξε αυτό",
|
||||||
|
"made-for-recipe": "Φτιαγμένο για {recipe}",
|
||||||
"added-to-timeline": "Προστέθηκε στο χρονολόγιο",
|
"added-to-timeline": "Προστέθηκε στο χρονολόγιο",
|
||||||
"failed-to-add-to-timeline": "Αποτυχία προσθήκης στο χρονολόγιο",
|
"failed-to-add-to-timeline": "Αποτυχία προσθήκης στο χρονολόγιο",
|
||||||
"failed-to-update-recipe": "Αποτυχία ενημέρωσης συνταγής",
|
"failed-to-update-recipe": "Αποτυχία ενημέρωσης συνταγής",
|
||||||
@@ -591,7 +605,7 @@
|
|||||||
"message-key": "Κλειδί Μηνύματος",
|
"message-key": "Κλειδί Μηνύματος",
|
||||||
"parse": "Ανάλυση",
|
"parse": "Ανάλυση",
|
||||||
"ingredients-not-parsed-description": "Φαίνεται ότι τα συστατικά σας δεν έχουν αναλυθεί ακόμα. Κάντε κλικ στο κουμπί \"{parse}\" παρακάτω για να αναλύσετε τα συστατικά σας σε δομημένα τρόφιμα.",
|
"ingredients-not-parsed-description": "Φαίνεται ότι τα συστατικά σας δεν έχουν αναλυθεί ακόμα. Κάντε κλικ στο κουμπί \"{parse}\" παρακάτω για να αναλύσετε τα συστατικά σας σε δομημένα τρόφιμα.",
|
||||||
"attach-images-hint": "Επισυνάψτε εικόνες σύροντας τις & αφήνοντάς τις στον επεξεργαστή",
|
"attach-images-hint": "Επισυνάψτε εικόνες σύροντας & αφήνοντάς τες στον επεξεργαστή",
|
||||||
"drop-image": "Απόθεση εικόνας",
|
"drop-image": "Απόθεση εικόνας",
|
||||||
"enable-ingredient-amounts-to-use-this-feature": "Ενεργοποιήστε τις ποσότητες συστατικών για να χρησιμοποιήσετε αυτήν τη δυνατότητα",
|
"enable-ingredient-amounts-to-use-this-feature": "Ενεργοποιήστε τις ποσότητες συστατικών για να χρησιμοποιήσετε αυτήν τη δυνατότητα",
|
||||||
"recipes-with-units-or-foods-defined-cannot-be-parsed": "Δεν είναι δυνατή η ανάλυση συνταγών με καθορισμένες μονάδες ή τρόφιμα.",
|
"recipes-with-units-or-foods-defined-cannot-be-parsed": "Δεν είναι δυνατή η ανάλυση συνταγών με καθορισμένες μονάδες ή τρόφιμα.",
|
||||||
@@ -622,6 +636,8 @@
|
|||||||
"scrape-recipe-suggest-bulk-importer": "Δοκιμάστε τον μαζικό εισαγωγέα συνταγών μας",
|
"scrape-recipe-suggest-bulk-importer": "Δοκιμάστε τον μαζικό εισαγωγέα συνταγών μας",
|
||||||
"scrape-recipe-have-raw-html-or-json-data": "Εχουν ακατέργαστα δεδομένα HTML ή JSON;",
|
"scrape-recipe-have-raw-html-or-json-data": "Εχουν ακατέργαστα δεδομένα HTML ή JSON;",
|
||||||
"scrape-recipe-you-can-import-from-raw-data-directly": "Μπορείτε να κάνετε εισαγωγή απευθείας από ακατέργαστα δεδομένα",
|
"scrape-recipe-you-can-import-from-raw-data-directly": "Μπορείτε να κάνετε εισαγωγή απευθείας από ακατέργαστα δεδομένα",
|
||||||
|
"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": "Εισαγωγή αρχικών λέξεων-κλειδιών ως ετικέτες",
|
||||||
"stay-in-edit-mode": "Παραμονή σε λειτουργία επεξεργασίας",
|
"stay-in-edit-mode": "Παραμονή σε λειτουργία επεξεργασίας",
|
||||||
"parse-recipe-ingredients-after-import": "Ανάλυση συστατικών συνταγής μετά την εισαγωγή",
|
"parse-recipe-ingredients-after-import": "Ανάλυση συστατικών συνταγής μετά την εισαγωγή",
|
||||||
@@ -676,7 +692,7 @@
|
|||||||
"ingredient-parser-description": "Τα συστατικά σας έχουν αναλυθεί επιτυχώς. Παρακαλούμε ελέγξτε τα συστατικά για τα οποία δεν είμαστε σίγουροι.",
|
"ingredient-parser-description": "Τα συστατικά σας έχουν αναλυθεί επιτυχώς. Παρακαλούμε ελέγξτε τα συστατικά για τα οποία δεν είμαστε σίγουροι.",
|
||||||
"ingredient-parser-final-review-description": "Μόλις εξεταστούν όλα τα συστατικά, θα έχετε μία ακόμη ευκαιρία να επανεξετάσετε όλα τα συστατικά πριν εφαρμόσετε τις αλλαγές στη συνταγή σας.",
|
"ingredient-parser-final-review-description": "Μόλις εξεταστούν όλα τα συστατικά, θα έχετε μία ακόμη ευκαιρία να επανεξετάσετε όλα τα συστατικά πριν εφαρμόσετε τις αλλαγές στη συνταγή σας.",
|
||||||
"add-text-as-alias-for-item": "Προσθήκη \"{text}\" ως ψευδώνυμο για το {item}",
|
"add-text-as-alias-for-item": "Προσθήκη \"{text}\" ως ψευδώνυμο για το {item}",
|
||||||
"delete-item": "Delete Item"
|
"delete-item": "Διαγραφή αντικειμένου"
|
||||||
},
|
},
|
||||||
"reset-servings-count": "Επαναφορά μέτρησης μερίδων",
|
"reset-servings-count": "Επαναφορά μέτρησης μερίδων",
|
||||||
"not-linked-ingredients": "Πρόσθετα συστατικά",
|
"not-linked-ingredients": "Πρόσθετα συστατικά",
|
||||||
@@ -684,7 +700,10 @@
|
|||||||
"upload-images": "Ανέβασμα εικόνων",
|
"upload-images": "Ανέβασμα εικόνων",
|
||||||
"upload-more-images": "Ανέβασμα περισσότερων εικόνων",
|
"upload-more-images": "Ανέβασμα περισσότερων εικόνων",
|
||||||
"set-as-cover-image": "Ορισμός ως εικόνα εξώφυλλου συνταγής",
|
"set-as-cover-image": "Ορισμός ως εικόνα εξώφυλλου συνταγής",
|
||||||
"cover-image": "Εικόνα εξώφυλλου"
|
"cover-image": "Εικόνα εξώφυλλου",
|
||||||
|
"include-linked-recipes": "Συμπερίληψη συνδεδεμένων συνταγών",
|
||||||
|
"include-linked-recipe-ingredients": "Συμπερίληψη συστατικών συνδεδεμένης συνταγής",
|
||||||
|
"toggle-recipe": "Εναλλαγή συνταγής"
|
||||||
},
|
},
|
||||||
"recipe-finder": {
|
"recipe-finder": {
|
||||||
"recipe-finder": "Εύρεση συνταγών",
|
"recipe-finder": "Εύρεση συνταγών",
|
||||||
@@ -721,7 +740,8 @@
|
|||||||
"search-hint": "Πατήστε '/'",
|
"search-hint": "Πατήστε '/'",
|
||||||
"advanced": "Για προχωρημένους",
|
"advanced": "Για προχωρημένους",
|
||||||
"auto-search": "Αυτόματη Αναζήτηση",
|
"auto-search": "Αυτόματη Αναζήτηση",
|
||||||
"no-results": "Δε βρέθηκαν αποτελέσματα"
|
"no-results": "Δε βρέθηκαν αποτελέσματα",
|
||||||
|
"type-to-search": "Πληκτρολογήστε για αναζήτηση…"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"add-a-new-theme": "Προσθήκη νέου θέματος",
|
"add-a-new-theme": "Προσθήκη νέου θέματος",
|
||||||
@@ -1059,7 +1079,9 @@
|
|||||||
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Δεν θέλετε να το δείτε αυτό ξανά; Φροντίστε να αλλάξετε το email σας στις ρυθμίσεις χρήστη!",
|
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Δεν θέλετε να το δείτε αυτό ξανά; Φροντίστε να αλλάξετε το email σας στις ρυθμίσεις χρήστη!",
|
||||||
"forgot-password": "Ξέχασα τον κωδικό πρόσβασης",
|
"forgot-password": "Ξέχασα τον κωδικό πρόσβασης",
|
||||||
"forgot-password-text": "Παρακαλώ εισάγετε τη διεύθυνση email σας και θα σας στείλουμε έναν σύνδεσμο για να επαναφέρετε τον κωδικό πρόσβασής σας.",
|
"forgot-password-text": "Παρακαλώ εισάγετε τη διεύθυνση email σας και θα σας στείλουμε έναν σύνδεσμο για να επαναφέρετε τον κωδικό πρόσβασής σας.",
|
||||||
"changes-reflected-immediately": "Οι αλλαγές σε αυτόν τον χρήστη θα αντικατοπτρίζονται αμέσως."
|
"changes-reflected-immediately": "Οι αλλαγές σε αυτόν τον χρήστη θα αντικατοπτρίζονται αμέσως.",
|
||||||
|
"default-activity": "Προεπιλεγμένη Δραστηριότητα",
|
||||||
|
"default-activity-hint": "Επιλέξτε σε ποια σελίδα θα θέλατε να μεταβείτε κατά τη σύνδεση από αυτήν τη συσκευή"
|
||||||
},
|
},
|
||||||
"language-dialog": {
|
"language-dialog": {
|
||||||
"translated": "μεταφρασμένο",
|
"translated": "μεταφρασμένο",
|
||||||
|
|||||||
@@ -342,6 +342,9 @@
|
|||||||
"breakfast": "Breakfast",
|
"breakfast": "Breakfast",
|
||||||
"lunch": "Lunch",
|
"lunch": "Lunch",
|
||||||
"dinner": "Dinner",
|
"dinner": "Dinner",
|
||||||
|
"snack": "Snack",
|
||||||
|
"drink": "Drink",
|
||||||
|
"dessert": "Dessert",
|
||||||
"type-any": "Any",
|
"type-any": "Any",
|
||||||
"day-any": "Any",
|
"day-any": "Any",
|
||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
@@ -399,6 +402,10 @@
|
|||||||
"description-long": "Mealie can import recipes from Tandoor. Export your data in the \"Default\" format, then upload the .zip below.",
|
"description-long": "Mealie can import recipes from Tandoor. Export your data in the \"Default\" format, then upload the .zip below.",
|
||||||
"title": "Tandoor Recipes"
|
"title": "Tandoor Recipes"
|
||||||
},
|
},
|
||||||
|
"cookn": {
|
||||||
|
"description-long": "Mealie can import recipes from DVO Cook'n X3. Export a cookbook or menu in the \"Cook'n\" format, rename the export extension to .zip, then upload the .zip below.",
|
||||||
|
"title": "DVO Cook'n X3"
|
||||||
|
},
|
||||||
"recipe-data-migrations": "Recipe Data Migrations",
|
"recipe-data-migrations": "Recipe Data Migrations",
|
||||||
"recipe-data-migrations-explanation": "Recipes can be migrated from another supported application to Mealie. This is a great way to get started with Mealie.",
|
"recipe-data-migrations-explanation": "Recipes can be migrated from another supported application to Mealie. This is a great way to get started with Mealie.",
|
||||||
"coming-from-another-application-or-an-even-older-version-of-mealie": "Coming from another application or an even older version of Mealie? Check out migrations and see if your data can be imported.",
|
"coming-from-another-application-or-an-even-older-version-of-mealie": "Coming from another application or an even older version of Mealie? Check out migrations and see if your data can be imported.",
|
||||||
@@ -444,7 +451,9 @@
|
|||||||
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns",
|
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns",
|
||||||
"import-by-url": "Import a recipe by URL",
|
"import-by-url": "Import a recipe by URL",
|
||||||
"create-manually": "Create a recipe manually",
|
"create-manually": "Create a recipe manually",
|
||||||
"make-recipe-image": "Make this the recipe image"
|
"make-recipe-image": "Make this the recipe image",
|
||||||
|
"add-food": "Add Food",
|
||||||
|
"add-recipe": "Add Recipe"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"404-page-not-found": "404 Page not found",
|
"404-page-not-found": "404 Page not found",
|
||||||
@@ -511,6 +520,9 @@
|
|||||||
"recipe-deleted": "Recipe deleted",
|
"recipe-deleted": "Recipe deleted",
|
||||||
"recipe-image": "Recipe Image",
|
"recipe-image": "Recipe Image",
|
||||||
"recipe-image-updated": "Recipe image updated",
|
"recipe-image-updated": "Recipe image updated",
|
||||||
|
"delete-image": "Delete Recipe Image",
|
||||||
|
"delete-image-confirmation": "Are you sure you want to delete this recipe image?",
|
||||||
|
"recipe-image-deleted": "Recipe image deleted",
|
||||||
"recipe-name": "Recipe Name",
|
"recipe-name": "Recipe Name",
|
||||||
"recipe-settings": "Recipe Settings",
|
"recipe-settings": "Recipe Settings",
|
||||||
"recipe-update-failed": "Recipe update failed",
|
"recipe-update-failed": "Recipe update failed",
|
||||||
@@ -556,6 +568,7 @@
|
|||||||
"choose-unit": "Choose Unit",
|
"choose-unit": "Choose Unit",
|
||||||
"press-enter-to-create": "Press Enter to Create",
|
"press-enter-to-create": "Press Enter to Create",
|
||||||
"choose-food": "Choose Food",
|
"choose-food": "Choose Food",
|
||||||
|
"choose-recipe": "Choose Recipe",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"toggle-section": "Toggle Section",
|
"toggle-section": "Toggle Section",
|
||||||
"see-original-text": "See Original Text",
|
"see-original-text": "See Original Text",
|
||||||
@@ -583,6 +596,7 @@
|
|||||||
"made-this": "I Made This",
|
"made-this": "I Made This",
|
||||||
"how-did-it-turn-out": "How did it turn out?",
|
"how-did-it-turn-out": "How did it turn out?",
|
||||||
"user-made-this": "{user} made this",
|
"user-made-this": "{user} made this",
|
||||||
|
"made-for-recipe": "Made for {recipe}",
|
||||||
"added-to-timeline": "Added to timeline",
|
"added-to-timeline": "Added to timeline",
|
||||||
"failed-to-add-to-timeline": "Failed to add to timeline",
|
"failed-to-add-to-timeline": "Failed to add to timeline",
|
||||||
"failed-to-update-recipe": "Failed to update recipe",
|
"failed-to-update-recipe": "Failed to update recipe",
|
||||||
@@ -622,6 +636,8 @@
|
|||||||
"scrape-recipe-suggest-bulk-importer": "Try out the bulk importer",
|
"scrape-recipe-suggest-bulk-importer": "Try out the bulk importer",
|
||||||
"scrape-recipe-have-raw-html-or-json-data": "Have raw HTML or JSON data?",
|
"scrape-recipe-have-raw-html-or-json-data": "Have raw HTML or JSON data?",
|
||||||
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
|
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
|
||||||
|
"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-keywords-as-tags": "Import original keywords as tags",
|
||||||
"stay-in-edit-mode": "Stay in Edit mode",
|
"stay-in-edit-mode": "Stay in Edit mode",
|
||||||
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
|
||||||
@@ -684,7 +700,10 @@
|
|||||||
"upload-images": "Upload images",
|
"upload-images": "Upload images",
|
||||||
"upload-more-images": "Upload more images",
|
"upload-more-images": "Upload more images",
|
||||||
"set-as-cover-image": "Set as recipe cover image",
|
"set-as-cover-image": "Set as recipe cover image",
|
||||||
"cover-image": "Cover image"
|
"cover-image": "Cover image",
|
||||||
|
"include-linked-recipes": "Include Linked Recipes",
|
||||||
|
"include-linked-recipe-ingredients": "Include Linked Recipe Ingredients",
|
||||||
|
"toggle-recipe": "Toggle Recipe"
|
||||||
},
|
},
|
||||||
"recipe-finder": {
|
"recipe-finder": {
|
||||||
"recipe-finder": "Recipe Finder",
|
"recipe-finder": "Recipe Finder",
|
||||||
@@ -721,7 +740,8 @@
|
|||||||
"search-hint": "Press '/'",
|
"search-hint": "Press '/'",
|
||||||
"advanced": "Advanced",
|
"advanced": "Advanced",
|
||||||
"auto-search": "Auto Search",
|
"auto-search": "Auto Search",
|
||||||
"no-results": "No results found"
|
"no-results": "No results found",
|
||||||
|
"type-to-search": "Type to search..."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"add-a-new-theme": "Add a New Theme",
|
"add-a-new-theme": "Add a New Theme",
|
||||||
@@ -1059,7 +1079,9 @@
|
|||||||
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Don't want to see this any more? Be sure to change your email in your user settings!",
|
"dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Don't want to see this any more? Be sure to change your email in your user settings!",
|
||||||
"forgot-password": "Forgot Password",
|
"forgot-password": "Forgot Password",
|
||||||
"forgot-password-text": "Please enter your email address and we will send you a link to reset your password.",
|
"forgot-password-text": "Please enter your email address and we will send you a link to reset your password.",
|
||||||
"changes-reflected-immediately": "Changes to this user will be reflected immediately."
|
"changes-reflected-immediately": "Changes to this user will be reflected immediately.",
|
||||||
|
"default-activity": "Default Activity",
|
||||||
|
"default-activity-hint": "Select which page you'd like to navigate to upon logging in from this device"
|
||||||
},
|
},
|
||||||
"language-dialog": {
|
"language-dialog": {
|
||||||
"translated": "translated",
|
"translated": "translated",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user