mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-23 22:48:20 -05:00
Compare commits
268 Commits
v3.3.2
...
mealie-nex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4f38685b3 | ||
|
|
d02023e12c | ||
|
|
64d8786d8f | ||
|
|
0971d59fa6 | ||
|
|
9b799ca441 | ||
|
|
193b823688 | ||
|
|
c64c2d25e7 | ||
|
|
8b4111d68f | ||
|
|
9d601ea4b5 | ||
|
|
95e1bbce2b | ||
|
|
7b32508201 | ||
|
|
6ed85d72d7 | ||
|
|
cd2a522f25 | ||
|
|
6bd6400aba | ||
|
|
8b92d6ee04 | ||
|
|
7cc2ed75e5 | ||
|
|
cb7f46c0ad | ||
|
|
cb12aedf72 | ||
|
|
8c35a26ab0 | ||
|
|
b2d0f46dd2 | ||
|
|
2c4b7bf611 | ||
|
|
38e542bcd3 | ||
|
|
e53452c19c | ||
|
|
13213476d8 | ||
|
|
9925450173 | ||
|
|
efb9dae681 | ||
|
|
cee93d2a87 | ||
|
|
0d4a8654c1 | ||
|
|
95b1be07bb | ||
|
|
a6fc98fc82 | ||
|
|
6f03010f6c | ||
|
|
69397c91b8 | ||
|
|
798792dcdc | ||
|
|
cc32dd9fa6 | ||
|
|
0c64eb29f9 | ||
|
|
8baa5cc315 | ||
|
|
6f3a5c6c8f | ||
|
|
778078590b | ||
|
|
53c82e5491 | ||
|
|
fef114d97f | ||
|
|
e80cbfad7f | ||
|
|
99527ce738 | ||
|
|
08ccced734 | ||
|
|
43c2c9552b | ||
|
|
db5741c7ee | ||
|
|
a1e394cf36 | ||
|
|
bdbef1ab9e | ||
|
|
e5276f6c20 | ||
|
|
20a6e71b31 | ||
|
|
24c111af7b | ||
|
|
ab4559319e | ||
|
|
2f8625ac44 | ||
|
|
dd146afa57 | ||
|
|
91d15f671e | ||
|
|
7008b13246 | ||
|
|
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 |
@@ -8,28 +8,14 @@ FROM mcr.microsoft.com/devcontainers/python:${VARIANT}
|
||||
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
|
||||
|
||||
# install poetry - respects $POETRY_VERSION & $POETRY_HOME
|
||||
|
||||
RUN echo "export PROMPT_COMMAND='history -a'" >> /home/vscode/.bashrc \
|
||||
&& echo "export HISTFILE=~/commandhistory/.bash_history" >> /home/vscode/.bashrc \
|
||||
&& chown vscode:vscode -R /home/vscode/
|
||||
|
||||
RUN npm install -g @go-task/cli
|
||||
RUN npm install -g json-schema-to-typescript
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
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
|
||||
|
||||
# Install additional OS packages
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
curl \
|
||||
@@ -39,5 +25,9 @@ RUN apt-get update \
|
||||
libsasl2-dev libldap2-dev libssl-dev \
|
||||
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
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
|
||||
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
|
||||
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
|
||||
"mypy.runUsingActiveInterpreter": true
|
||||
},
|
||||
@@ -31,10 +30,10 @@
|
||||
"charliermarsh.ruff",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"matangover.mypy",
|
||||
"ms-python.black-formatter",
|
||||
"ms-python.pylint",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"streetsidesoftware.code-spell-checker-cspell-bundled-dictionaries",
|
||||
"Vue.volar"
|
||||
]
|
||||
}
|
||||
@@ -42,13 +41,14 @@
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
8000, // used by mkdocs
|
||||
9000,
|
||||
9091, // used by docker production
|
||||
24678 // used by nuxt when hot-reloading using polling
|
||||
],
|
||||
// Use 'onCreateCommand' to run commands at the end of container creation.
|
||||
// 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.
|
||||
"remoteUser": "vscode",
|
||||
"features": {
|
||||
@@ -56,5 +56,8 @@
|
||||
"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:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
with:
|
||||
virtualenvs-create: true
|
||||
virtualenvs-in-project: true
|
||||
plugins: |
|
||||
poetry-plugin-export
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Retrieve built frontend
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
50
.github/workflows/docs.yml
vendored
Normal file
50
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [mealie-next]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --only-group docs --no-install-project
|
||||
|
||||
- name: Build docs
|
||||
run: uv run --no-project mkdocs build -d site
|
||||
working-directory: docs
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/site
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
21
.github/workflows/locale-sync.yml
vendored
21
.github/workflows/locale-sync.yml
vendored
@@ -25,24 +25,21 @@ jobs:
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
with:
|
||||
virtualenvs-create: true
|
||||
virtualenvs-in-project: true
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Load cached venv
|
||||
id: cached-poetry-dependencies
|
||||
id: cached-python-dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
|
||||
|
||||
- name: Check venv cache
|
||||
id: cache-validate
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
|
||||
if: steps.cached-python-dependencies.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
||||
echo "import fastapi;print('venv good?')" > test.py && uv run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
||||
rm test.py
|
||||
continue-on-error: true
|
||||
|
||||
@@ -50,13 +47,13 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
||||
poetry install
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||
uv sync --group dev
|
||||
if: steps.cached-python-dependencies.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Run locale generation
|
||||
run: |
|
||||
cd dev/code-generation
|
||||
poetry run python main.py locales
|
||||
uv run python main.py locales
|
||||
env:
|
||||
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]
|
||||
|
||||
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:
|
||||
name: "Backend Server Tests"
|
||||
uses: ./.github/workflows/test-backend.yml
|
||||
needs:
|
||||
- commit-version-bump
|
||||
|
||||
frontend-tests:
|
||||
name: "Frontend Tests"
|
||||
uses: ./.github/workflows/test-frontend.yml
|
||||
needs:
|
||||
- commit-version-bump
|
||||
|
||||
build-package:
|
||||
name: Build Package
|
||||
uses: ./.github/workflows/build-package.yml
|
||||
needs:
|
||||
- commit-version-bump
|
||||
with:
|
||||
tag: ${{ github.event.release.tag_name }}
|
||||
|
||||
@@ -43,10 +99,48 @@ jobs:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
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:
|
||||
name: Notify Discord
|
||||
needs:
|
||||
- publish
|
||||
if: success()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Discord notification
|
||||
@@ -55,41 +149,3 @@ jobs:
|
||||
uses: Ilshidur/action-discord@0.3.2
|
||||
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 }}"
|
||||
|
||||
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/\("version": "\)[^"]*"/\1${{ 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:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
with:
|
||||
virtualenvs-create: true
|
||||
virtualenvs-in-project: true
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Load cached venv
|
||||
id: cached-poetry-dependencies
|
||||
id: cached-python-dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
|
||||
|
||||
- name: Check venv cache
|
||||
id: cache-validate
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
|
||||
if: steps.cached-python-dependencies.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
||||
echo "import fastapi;print('venv good?')" > test.py && uv run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
||||
rm test.py
|
||||
continue-on-error: true
|
||||
|
||||
@@ -74,13 +71,12 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
||||
poetry install
|
||||
poetry add "psycopg2-binary==2.9.9"
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' || steps.cache-validate.outputs.cache-hit-success != 'true'
|
||||
uv sync --group dev --extra pgsql
|
||||
if: steps.cached-python-dependencies.outputs.cache-hit != 'true' || steps.cache-validate.outputs.cache-hit-success != 'true'
|
||||
|
||||
- name: Formatting (Ruff)
|
||||
run: |
|
||||
poetry run ruff format . --check
|
||||
uv run ruff format . --check
|
||||
|
||||
- name: Lint (Ruff)
|
||||
run: |
|
||||
|
||||
2
.github/workflows/test-frontend.yml
vendored
2
.github/workflows/test-frontend.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
working-directory: "frontend"
|
||||
|
||||
- name: Run linter 👀
|
||||
run: yarn lint
|
||||
run: yarn lint --max-warnings=0
|
||||
working-directory: "frontend"
|
||||
|
||||
- name: Run tests 🧪
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,6 +20,7 @@ dev/data/backups/*
|
||||
dev/data/debug/*
|
||||
dev/data/img/*
|
||||
dev/data/migration/*
|
||||
dev/data/templates/*
|
||||
dev/data/users/*
|
||||
dev/data/groups/*
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ repos:
|
||||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.13.3
|
||||
rev: v0.14.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -55,7 +55,7 @@
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"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",
|
||||
"README.md": "LICENSE, SECURITY.md"
|
||||
},
|
||||
|
||||
48
Taskfile.yml
48
Taskfile.yml
@@ -28,7 +28,7 @@ tasks:
|
||||
docs:gen:
|
||||
desc: runs the API documentation generator
|
||||
cmds:
|
||||
- poetry run python dev/code-generation/gen_docs_api.py
|
||||
- uv run python dev/code-generation/gen_docs_api.py
|
||||
|
||||
docs:
|
||||
desc: runs the documentation server
|
||||
@@ -36,7 +36,7 @@ tasks:
|
||||
deps:
|
||||
- docs:gen
|
||||
cmds:
|
||||
- poetry run python -m mkdocs serve
|
||||
- uv run python -m mkdocs serve
|
||||
|
||||
setup:ui:
|
||||
desc: setup frontend dependencies
|
||||
@@ -54,10 +54,10 @@ tasks:
|
||||
desc: setup python dependencies
|
||||
run: once
|
||||
cmds:
|
||||
- poetry install --with main,dev,postgres
|
||||
- poetry run pre-commit install
|
||||
- uv sync --extra pgsql --group dev
|
||||
- uv run pre-commit install
|
||||
sources:
|
||||
- poetry.lock
|
||||
- uv.lock
|
||||
- pyproject.toml
|
||||
- .pre-commit-config.yaml
|
||||
|
||||
@@ -70,7 +70,7 @@ tasks:
|
||||
dev:generate:
|
||||
desc: run code generators
|
||||
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: py:format
|
||||
|
||||
@@ -96,22 +96,22 @@ tasks:
|
||||
py:mypy:
|
||||
desc: runs python type checking
|
||||
cmds:
|
||||
- poetry run mypy mealie
|
||||
- uv run mypy mealie
|
||||
|
||||
py:test:
|
||||
desc: runs python tests (support args after '--')
|
||||
cmds:
|
||||
- poetry run pytest {{ .CLI_ARGS }}
|
||||
- uv run pytest {{ .CLI_ARGS }}
|
||||
|
||||
py:format:
|
||||
desc: runs python code formatter
|
||||
cmds:
|
||||
- poetry run ruff format .
|
||||
- uv run ruff format .
|
||||
|
||||
py:lint:
|
||||
desc: runs python linter
|
||||
cmds:
|
||||
- poetry run ruff check mealie
|
||||
- uv run ruff check mealie
|
||||
|
||||
py:check:
|
||||
desc: runs all linters, type checkers, and formatters
|
||||
@@ -124,10 +124,10 @@ tasks:
|
||||
py:coverage:
|
||||
desc: runs python coverage and generates html report
|
||||
cmds:
|
||||
- poetry run pytest
|
||||
- poetry run coverage report -m
|
||||
- poetry run coveragepy-lcov
|
||||
- poetry run coverage html
|
||||
- uv run pytest
|
||||
- uv run coverage report -m
|
||||
- uv run coveragepy-lcov
|
||||
- uv run coverage html
|
||||
- open htmlcov/index.html
|
||||
|
||||
py:package:copy-frontend:
|
||||
@@ -147,17 +147,17 @@ tasks:
|
||||
desc: Generate requirements file to pin all packages, effectively a "pip freeze" before installation begins
|
||||
internal: true
|
||||
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
|
||||
- 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
|
||||
- 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:
|
||||
MEALIE_VERSION:
|
||||
sh: poetry version --short
|
||||
sh: python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])"
|
||||
sources:
|
||||
- poetry.lock
|
||||
- uv.lock
|
||||
- pyproject.toml
|
||||
- dist/mealie-*.whl
|
||||
- dist/mealie-*.tar.gz
|
||||
@@ -184,13 +184,13 @@ tasks:
|
||||
deps:
|
||||
- py:package:deps
|
||||
cmds:
|
||||
- poetry build -n --output=dist
|
||||
- uv build --out-dir dist
|
||||
- task: py:package:generate-requirements
|
||||
|
||||
py:
|
||||
desc: runs the backend server
|
||||
cmds:
|
||||
- poetry run python mealie/app.py
|
||||
- uv run python mealie/app.py
|
||||
|
||||
py:postgres:
|
||||
desc: runs the backend server configured for containerized postgres
|
||||
@@ -202,12 +202,12 @@ tasks:
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DB: mealie
|
||||
cmds:
|
||||
- poetry run python mealie/app.py
|
||||
- uv run python mealie/app.py
|
||||
|
||||
py:migrate:
|
||||
desc: generates a new database migration file e.g. task py:migrate -- "add new column"
|
||||
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
|
||||
|
||||
ui:build:
|
||||
@@ -228,7 +228,7 @@ tasks:
|
||||
desc: runs the frontend linter
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn lint
|
||||
- yarn lint --max-warnings=0
|
||||
|
||||
ui:test:
|
||||
desc: runs the frontend tests
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from freezegun import freeze_time
|
||||
|
||||
from mealie.app import app
|
||||
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")
|
||||
CONSTANT_DT = datetime(2025, 10, 24, 15, 53, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def normalize_timestamps(s: dict[str, Any]) -> dict[str, Any]:
|
||||
field_format = s.get("format")
|
||||
is_timestamp = field_format in ["date-time", "date", "time"]
|
||||
has_default = s.get("default")
|
||||
|
||||
if not is_timestamp:
|
||||
for k, v in s.items():
|
||||
if isinstance(v, dict):
|
||||
s[k] = normalize_timestamps(v)
|
||||
elif isinstance(v, list):
|
||||
s[k] = [normalize_timestamps(i) if isinstance(i, dict) else i for i in v]
|
||||
|
||||
return s
|
||||
elif not has_default:
|
||||
return s
|
||||
|
||||
if field_format == "date-time":
|
||||
s["default"] = CONSTANT_DT.isoformat()
|
||||
elif field_format == "date":
|
||||
s["default"] = CONSTANT_DT.date().isoformat()
|
||||
elif field_format == "time":
|
||||
s["default"] = CONSTANT_DT.time().isoformat()
|
||||
|
||||
return s
|
||||
|
||||
|
||||
def generate_api_docs(my_app: FastAPI):
|
||||
openapi_schema = my_app.openapi()
|
||||
openapi_schema = normalize_timestamps(openapi_schema)
|
||||
|
||||
with open(HTML_PATH, "w") as fd:
|
||||
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(my_app.openapi()))
|
||||
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(openapi_schema))
|
||||
fd.write(text)
|
||||
|
||||
|
||||
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},
|
||||
)
|
||||
|
||||
subprocess.run(["poetry", "run", "ruff", "check", str(out_path), "--fix"])
|
||||
subprocess.run(["poetry", "run", "ruff", "format", str(out_path)])
|
||||
subprocess.run(["uv", "run", "ruff", "check", str(out_path), "--fix"])
|
||||
subprocess.run(["uv", "run", "ruff", "format", str(out_path)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -100,8 +100,8 @@ def main() -> None:
|
||||
render_python_template(template, template_path, {"module": module})
|
||||
|
||||
path_args = (str(p) for p in template_paths)
|
||||
subprocess.run(["poetry", "run", "ruff", "check", *path_args, "--fix"])
|
||||
subprocess.run(["poetry", "run", "ruff", "format", *path_args])
|
||||
subprocess.run(["uv", "run", "ruff", "check", *path_args, "--fix"])
|
||||
subprocess.run(["uv", "run", "ruff", "format", *path_args])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###############################################
|
||||
# Frontend Build
|
||||
###############################################
|
||||
FROM node:22@sha256:2bb201f33898d2c0ce638505b426f4dd038cc00e5b2b4cbba17b069f0fff1496 \
|
||||
FROM node:24@sha256:20988bcdc6dc76690023eb2505dd273bdeefddcd0bde4bfd1efe4ebf8707f747 \
|
||||
AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
@@ -50,40 +50,29 @@ RUN apt-get update \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV POETRY_HOME="/opt/poetry" \
|
||||
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"
|
||||
RUN pip install uv
|
||||
|
||||
WORKDIR /mealie
|
||||
|
||||
# copy project files here to ensure they will be cached.
|
||||
COPY poetry.lock pyproject.toml ./
|
||||
COPY uv.lock pyproject.toml ./
|
||||
COPY mealie ./mealie
|
||||
|
||||
# Copy frontend to package it into the wheel
|
||||
COPY --from=frontend-builder /frontend/dist ./mealie/frontend
|
||||
|
||||
# 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
|
||||
# its pinned dependencies later. mealie is included to ensure the built one is
|
||||
# what's installed.
|
||||
RUN export MEALIE_VERSION=$(poetry version --short) \
|
||||
&& poetry export --only=main --extras=pgsql --output=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 \
|
||||
RUN uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file 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 \
|
||||
&& pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> 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
|
||||
|
||||
@@ -12,13 +12,13 @@ yarnpkg generate
|
||||
popd
|
||||
rm -r mealie/frontend
|
||||
cp -a frontend/dist mealie/frontend
|
||||
poetry build
|
||||
poetry export -n --only=main --extras=pgsql --output=dist/requirements.txt
|
||||
MEALIE_VERSION=$(poetry version --short)
|
||||
uv build --out-dir dist
|
||||
uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file 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
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -33,7 +33,7 @@ Make sure the VSCode Dev Containers extension is installed, then select "Dev Con
|
||||
### Prerequisites
|
||||
|
||||
- [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/)
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)
|
||||
- [task](https://taskfile.dev/#/installation)
|
||||
|
||||
@@ -22,6 +22,7 @@ Mealie supports importing recipes from a few other sources besides websites. Cur
|
||||
- Recipe Keeper
|
||||
- Copy Me That
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -4,22 +4,22 @@
|
||||
|
||||
### General
|
||||
|
||||
| Variables | Default | Description |
|
||||
| ----------------------------- | :-------------------: | -------------------------------------------------------------------------------------------------- |
|
||||
| PUID | 911 | UserID permissions between host OS and container |
|
||||
| PGID | 911 | GroupID permissions between host OS and container |
|
||||
| DEFAULT_GROUP | Home | The default group for users |
|
||||
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
|
||||
| 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). |
|
||||
| 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 |
|
||||
| TZ | UTC | Must be set to get correct date/time on the server |
|
||||
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
|
||||
| ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication |
|
||||
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
|
||||
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
|
||||
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
|
||||
| Variables | Default | Description |
|
||||
| ----------------------------- | :-------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| PUID | 911 | UserID permissions between host OS and container |
|
||||
| PGID | 911 | GroupID permissions between host OS and container |
|
||||
| DEFAULT_GROUP | Home | The default group for users |
|
||||
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
|
||||
| BASE_URL | http://localhost:8080 | Used for Notifications |
|
||||
| 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_DOCS | True | Turns on/off access to the API documentation locally |
|
||||
| TZ | UTC | Must be set to get correct date/time on the server |
|
||||
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
|
||||
| ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication |
|
||||
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
|
||||
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
|
||||
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
|
||||
|
||||
<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`
|
||||
|
||||
| Variables | Default | Description |
|
||||
| --------------------- | :-----: | --------------------------- |
|
||||
| THEME_LIGHT_PRIMARY | #E58325 | Light Theme Config Variable |
|
||||
| THEME_LIGHT_ACCENT | #007A99 | Light Theme Config Variable |
|
||||
| THEME_LIGHT_SECONDARY | #973542 | Light Theme Config Variable |
|
||||
| THEME_LIGHT_SUCCESS | #43A047 | Light Theme Config Variable |
|
||||
| THEME_LIGHT_INFO | #1976D2 | Light Theme Config Variable |
|
||||
| THEME_LIGHT_WARNING | #FF6D00 | Light Theme Config Variable |
|
||||
| THEME_LIGHT_ERROR | #EF5350 | Light Theme Config Variable |
|
||||
| THEME_DARK_PRIMARY | #E58325 | Dark Theme Config Variable |
|
||||
| THEME_DARK_ACCENT | #007A99 | Dark Theme Config Variable |
|
||||
| THEME_DARK_SECONDARY | #973542 | Dark Theme Config Variable |
|
||||
| THEME_DARK_SUCCESS | #43A047 | Dark Theme Config Variable |
|
||||
| THEME_DARK_INFO | #1976D2 | Dark Theme Config Variable |
|
||||
| THEME_DARK_WARNING | #FF6D00 | Dark Theme Config Variable |
|
||||
| THEME_DARK_ERROR | #EF5350 | Dark Theme Config Variable |
|
||||
| Variables | Default | Description |
|
||||
| --------------------- | :-----: | ---------------------------------- |
|
||||
| THEME_LIGHT_PRIMARY | #E58325 | Main brand color and headers |
|
||||
| THEME_LIGHT_ACCENT | #007A99 | Buttons and interactive elements |
|
||||
| THEME_LIGHT_SECONDARY | #973542 | Navigation and sidebar backgrounds |
|
||||
| THEME_LIGHT_SUCCESS | #43A047 | Success messages and confirmations |
|
||||
| THEME_LIGHT_INFO | #1976D2 | Information alerts and tooltips |
|
||||
| THEME_LIGHT_WARNING | #FF6D00 | Warning notifications |
|
||||
| THEME_LIGHT_ERROR | #EF5350 | Error messages and alerts |
|
||||
| THEME_DARK_PRIMARY | #E58325 | Main brand color and headers |
|
||||
| THEME_DARK_ACCENT | #007A99 | Buttons and interactive elements |
|
||||
| THEME_DARK_SECONDARY | #973542 | Navigation and sidebar backgrounds |
|
||||
| THEME_DARK_SUCCESS | #43A047 | Success messages and confirmations |
|
||||
| THEME_DARK_INFO | #1976D2 | Information alerts and tooltips |
|
||||
| THEME_DARK_WARNING | #FF6D00 | Warning notifications |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
||||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||
|
||||
1. Take a backup just in case!
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.3.1`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.8.0`
|
||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||
4. Restart the container
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.3.1 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.8.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.3.1 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.8.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -28,6 +28,7 @@ Mealie is a self hosted recipe manager and meal planner with a RestAPI backend a
|
||||
- Copy Me That
|
||||
- Paprika
|
||||
- Tandoor Recipes
|
||||
- DVO Cook'n X3
|
||||
- Random Meal Plan generation
|
||||
- Advanced rule configuration to fine tune random recipes
|
||||
|
||||
|
||||
@@ -9,6 +9,23 @@
|
||||
- Create a Backup and Download from the UI
|
||||
- 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
|
||||
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
@@ -32,8 +32,8 @@ theme:
|
||||
|
||||
markdown_extensions:
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||
- def_list
|
||||
- pymdownx.highlight
|
||||
- pymdownx.superfences
|
||||
|
||||
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"),
|
||||
type: Organizer.Household,
|
||||
},
|
||||
{
|
||||
name: "user_id",
|
||||
label: i18n.t("user.users"),
|
||||
type: Organizer.User,
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
label: i18n.t("general.date-created"),
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<BaseButton
|
||||
download
|
||||
size="small"
|
||||
:download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"
|
||||
:download-url="`/api/recipes/bulk-actions/export/${item.id}/download`"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
@@ -58,6 +58,9 @@ const MEAL_TYPE_OPTIONS = [
|
||||
{ title: i18n.t("meal-plan.lunch"), value: "lunch" },
|
||||
{ title: i18n.t("meal-plan.dinner"), value: "dinner" },
|
||||
{ title: i18n.t("meal-plan.side"), value: "side" },
|
||||
{ title: i18n.t("meal-plan.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" },
|
||||
];
|
||||
|
||||
@@ -103,6 +106,11 @@ const fieldDefs: FieldDefinition[] = [
|
||||
label: i18n.t("household.households"),
|
||||
type: Organizer.Household,
|
||||
},
|
||||
{
|
||||
name: "user_id",
|
||||
label: i18n.t("user.users"),
|
||||
type: Organizer.User,
|
||||
},
|
||||
{
|
||||
name: "last_made",
|
||||
label: i18n.t("general.last-made"),
|
||||
|
||||
@@ -1,283 +1,299 @@
|
||||
<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-container fluid class="ma-0 pa-0">
|
||||
<VueDraggable
|
||||
v-model="fields"
|
||||
handle=".handle"
|
||||
:delay="250"
|
||||
:delay-on-touch-only="true"
|
||||
v-bind="{
|
||||
animation: 200,
|
||||
group: 'recipe-instructions',
|
||||
ghostClass: 'ghost',
|
||||
}"
|
||||
@start="drag = true"
|
||||
@end="onDragEnd"
|
||||
<VueDraggable
|
||||
v-model="fields"
|
||||
handle=".handle"
|
||||
:delay="250"
|
||||
:delay-on-touch-only="true"
|
||||
v-bind="{
|
||||
animation: 200,
|
||||
group: 'recipe-instructions',
|
||||
ghostClass: 'ghost',
|
||||
}"
|
||||
@start="drag = true"
|
||||
@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
|
||||
v-for="(field, index) in fields"
|
||||
:key="field.id"
|
||||
class="d-flex flex-nowrap"
|
||||
style="max-width: 100%;"
|
||||
<!-- drag handle -->
|
||||
<v-col
|
||||
:cols="config.items.icon.cols(index)"
|
||||
:sm="config.items.icon.sm(index)"
|
||||
:class="$vuetify.display.smAndDown ? 'd-flex pa-0' : 'd-flex justify-end pr-6'"
|
||||
>
|
||||
<!-- drag handle -->
|
||||
<v-col
|
||||
:cols="config.items.icon.cols"
|
||||
:class="config.col.class"
|
||||
:style="config.items.icon.style"
|
||||
<v-icon class="handle my-auto" :size="28" style="cursor: move;">
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</v-col>
|
||||
|
||||
<!-- 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
|
||||
class="handle"
|
||||
:size="24"
|
||||
style="cursor: move;margin: auto;"
|
||||
>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</v-col>
|
||||
<!-- and / or -->
|
||||
<v-col
|
||||
:cols="config.items.logicalOperator.cols"
|
||||
:class="config.col.class"
|
||||
:style="config.items.logicalOperator.style"
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
|
||||
<!-- left parenthesis -->
|
||||
<v-col
|
||||
v-if="showAdvanced"
|
||||
:cols="config.items.leftParens.cols(index)"
|
||||
: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
|
||||
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)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<!-- left parenthesis -->
|
||||
<v-col
|
||||
v-if="showAdvanced"
|
||||
:cols="config.items.leftParens.cols"
|
||||
:class="config.col.class"
|
||||
:style="config.items.leftParens.style"
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
|
||||
<!-- field name -->
|
||||
<v-col
|
||||
:cols="config.items.fieldName.cols(index)"
|
||||
:sm="config.items.fieldName.sm(index)"
|
||||
:class="config.col.class"
|
||||
>
|
||||
<v-select
|
||||
chips
|
||||
:model-value="field.label"
|
||||
:items="fieldDefs"
|
||||
variant="underlined"
|
||||
item-title="label"
|
||||
@update:model-value="setField(index, $event)"
|
||||
>
|
||||
<v-select
|
||||
:model-value="field.leftParenthesis"
|
||||
:items="['', '(', '((', '(((']"
|
||||
variant="underlined"
|
||||
@update:model-value="setLeftParenthesisValue(field, index, $event)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<!-- field name -->
|
||||
<v-col
|
||||
:cols="config.items.fieldName.cols"
|
||||
:class="config.col.class"
|
||||
:style="config.items.fieldName.style"
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
|
||||
<!-- relational operator -->
|
||||
<v-col
|
||||
:cols="config.items.relationalOperator.cols(index)"
|
||||
:sm="config.items.relationalOperator.sm(index)"
|
||||
:class="config.col.class"
|
||||
>
|
||||
<v-select
|
||||
v-if="field.type !== 'boolean'"
|
||||
:model-value="field.relationalOperatorValue"
|
||||
: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
|
||||
chips
|
||||
:model-value="field.label"
|
||||
:items="fieldDefs"
|
||||
variant="underlined"
|
||||
item-title="label"
|
||||
@update:model-value="setField(index, $event)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<!-- relational operator -->
|
||||
<v-col
|
||||
:cols="config.items.relationalOperator.cols"
|
||||
:class="config.col.class"
|
||||
:style="config.items.relationalOperator.style"
|
||||
<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(index)"
|
||||
:sm="config.items.fieldValue.sm(index)"
|
||||
:class="config.col.class"
|
||||
>
|
||||
<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-number-input
|
||||
v-else-if="field.type === 'number'"
|
||||
:model-value="field.value"
|
||||
variant="underlined"
|
||||
control-variant="stacked"
|
||||
inset
|
||||
:precision="null"
|
||||
@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
|
||||
v-if="field.type !== 'boolean'"
|
||||
:model-value="field.relationalOperatorValue"
|
||||
:items="field.relationalOperatorOptions"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
variant="underlined"
|
||||
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
|
||||
>
|
||||
<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) : '')"
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
variant="underlined"
|
||||
color="primary"
|
||||
v-bind="activatorProps"
|
||||
readonly
|
||||
/>
|
||||
</v-menu>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Category"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Category"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
</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) : '')"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Tag"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Tag"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Tool"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Tool"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Food"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Food"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Household"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Household"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
/>
|
||||
</v-col>
|
||||
<!-- right parenthesis -->
|
||||
<v-col
|
||||
v-if="showAdvanced"
|
||||
:cols="config.items.rightParens.cols"
|
||||
:class="config.col.class"
|
||||
:style="config.items.rightParens.style"
|
||||
</v-menu>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Category"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Category"
|
||||
:show-add="false"
|
||||
: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.Tag"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Tag"
|
||||
:show-add="false"
|
||||
: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.Tool"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Tool"
|
||||
:show-add="false"
|
||||
: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.Food"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Food"
|
||||
:show-add="false"
|
||||
: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.Household"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Household"
|
||||
:show-add="false"
|
||||
: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
|
||||
:model-value="field.rightParenthesis"
|
||||
:items="['', ')', '))', ')))']"
|
||||
variant="underlined"
|
||||
@update:model-value="setRightParenthesisValue(field, index, $event)"
|
||||
>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<!-- field actions -->
|
||||
<v-col
|
||||
:cols="config.items.fieldActions.cols"
|
||||
:class="config.col.class"
|
||||
:style="config.items.fieldActions.style"
|
||||
>
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
disabled: fields.length === 1,
|
||||
},
|
||||
]"
|
||||
class="my-auto"
|
||||
@delete="removeField(index)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</VueDraggable>
|
||||
</v-container>
|
||||
<template #chip="{ item }">
|
||||
<span :class="config.select.textClass" style="width: 100%;">
|
||||
{{ item.raw }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
|
||||
<!-- field actions -->
|
||||
<v-col
|
||||
v-if="!$vuetify.display.smAndDown || index === fields.length - 1"
|
||||
:cols="config.items.fieldActions.cols(index)"
|
||||
:sm="config.items.fieldActions.sm(index)"
|
||||
:class="config.col.class"
|
||||
>
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
disabled: fields.length === 1,
|
||||
},
|
||||
]"
|
||||
class="my-auto"
|
||||
@delete="removeField(index)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</VueDraggable>
|
||||
</v-card-text>
|
||||
<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-checkbox
|
||||
v-model="showAdvanced"
|
||||
@@ -305,6 +321,7 @@ import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerS
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { useUserStore } from "~/composables/store/use-user-store";
|
||||
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -344,6 +361,7 @@ const storeMap = {
|
||||
[Organizer.Tool]: useToolStore(),
|
||||
[Organizer.Food]: useFoodStore(),
|
||||
[Organizer.Household]: useHouseholdStore(),
|
||||
[Organizer.User]: useUserStore(),
|
||||
};
|
||||
|
||||
function onDragEnd(event: any) {
|
||||
@@ -602,46 +620,56 @@ function buildQueryFilterJSON(): QueryFilterJSON {
|
||||
}
|
||||
|
||||
const config = computed(() => {
|
||||
const baseColMaxWidth = 55;
|
||||
const multiple = fields.value.length > 1;
|
||||
const adv = state.showAdvanced;
|
||||
|
||||
return {
|
||||
col: {
|
||||
class: "d-flex justify-center align-end field-col pa-1",
|
||||
class: "d-flex justify-center align-end py-0",
|
||||
},
|
||||
select: {
|
||||
textClass: "d-flex justify-center text-center",
|
||||
},
|
||||
items: {
|
||||
icon: {
|
||||
cols: 1,
|
||||
cols: (_index: number) => 2,
|
||||
sm: (_index: number) => 1,
|
||||
style: "width: fit-content;",
|
||||
},
|
||||
leftParens: {
|
||||
cols: state.showAdvanced ? 1 : 0,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||
cols: (index: number) => (adv ? (index === 0 ? 2 : 0) : 0),
|
||||
sm: (_index: number) => (adv ? 1 : 0),
|
||||
},
|
||||
logicalOperator: {
|
||||
cols: 1,
|
||||
style: `min-width: ${baseColMaxWidth}px;`,
|
||||
cols: (_index: number) => 0,
|
||||
sm: (_index: number) => (multiple ? 1 : 0),
|
||||
},
|
||||
fieldName: {
|
||||
cols: state.showAdvanced ? 2 : 3,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||
cols: (index: number) => {
|
||||
if (adv) return index === 0 ? 8 : 12;
|
||||
return index === 0 ? 10 : 12;
|
||||
},
|
||||
sm: (_index: number) => (adv ? 2 : 3),
|
||||
},
|
||||
relationalOperator: {
|
||||
cols: 2,
|
||||
style: `min-width: ${baseColMaxWidth * 2}px;`,
|
||||
cols: (_index: number) => 12,
|
||||
sm: (_index: number) => 2,
|
||||
},
|
||||
fieldValue: {
|
||||
cols: state.showAdvanced ? 3 : 4,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||
cols: (index: number) => {
|
||||
const last = index === fields.value.length - 1;
|
||||
if (adv) return last ? 8 : 10;
|
||||
return last ? 10 : 12;
|
||||
},
|
||||
sm: (_index: number) => (adv ? 3 : 4),
|
||||
},
|
||||
rightParens: {
|
||||
cols: state.showAdvanced ? 1 : 0,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||
cols: (index: number) => (adv ? (index === fields.value.length - 1 ? 2 : 0) : 0),
|
||||
sm: (_index: number) => (adv ? 1 : 0),
|
||||
},
|
||||
fieldActions: {
|
||||
cols: 1,
|
||||
style: `min-width: ${baseColMaxWidth}px;`,
|
||||
cols: (index: number) => (index === fields.value.length - 1 ? 2 : 0),
|
||||
sm: (_index: number) => 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -651,5 +679,14 @@ const config = computed(() => {
|
||||
<style scoped>
|
||||
* {
|
||||
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>
|
||||
|
||||
@@ -126,7 +126,7 @@ withDefaults(defineProps<Props>(), {
|
||||
canEdit: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["print", "input", "delete", "close", "edit"]);
|
||||
const emit = defineEmits(["print", "input", "save", "delete", "close", "json", "edit"]);
|
||||
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
|
||||
@@ -28,11 +28,12 @@
|
||||
<v-list-item-title class="pl-2">
|
||||
{{ item.name }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-action>
|
||||
<template #append>
|
||||
<v-btn
|
||||
v-if="!edit"
|
||||
color="primary"
|
||||
icon
|
||||
size="small"
|
||||
:href="assetURL(item.fileName ?? '')"
|
||||
target="_blank"
|
||||
top
|
||||
@@ -43,6 +44,7 @@
|
||||
<v-btn
|
||||
color="error"
|
||||
icon
|
||||
size="small"
|
||||
top
|
||||
@click="model.splice(i, 1)"
|
||||
>
|
||||
@@ -53,7 +55,7 @@
|
||||
:copy-text="assetEmbed(item.fileName ?? '')"
|
||||
/>
|
||||
</div>
|
||||
</v-list-item-action>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
@@ -90,13 +92,12 @@
|
||||
item-value="name"
|
||||
class="mr-2"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<v-avatar>
|
||||
<v-icon class="mr-auto">
|
||||
{{ item.raw.icon }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
{{ item.title }}
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #prepend>
|
||||
<v-icon>{{ item.raw.icon }}</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-select>
|
||||
<AppButtonUpload
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
@click.self="$emit('click')"
|
||||
>
|
||||
<RecipeCardImage
|
||||
small
|
||||
:icon-size="imageHeight"
|
||||
:height="imageHeight"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
size="small"
|
||||
:image-version="image"
|
||||
>
|
||||
<v-expand-transition v-if="description">
|
||||
@@ -49,7 +49,6 @@
|
||||
>
|
||||
<RecipeFavoriteBadge
|
||||
v-if="isOwnGroup"
|
||||
class="absolute"
|
||||
:recipe-id="recipeId"
|
||||
show-always
|
||||
/>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
cover
|
||||
>
|
||||
<RecipeCardImage
|
||||
tiny
|
||||
:icon-size="100"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
size="small"
|
||||
:image-version="image"
|
||||
:height="height"
|
||||
/>
|
||||
@@ -41,11 +41,11 @@
|
||||
name="avatar"
|
||||
>
|
||||
<RecipeCardImage
|
||||
tiny
|
||||
:icon-size="100"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
:image-version="image"
|
||||
size="small"
|
||||
width="125"
|
||||
:height="height"
|
||||
/>
|
||||
|
||||
@@ -90,6 +90,14 @@
|
||||
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
|
||||
</div>
|
||||
</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-menu>
|
||||
<ContextMenu
|
||||
@@ -223,6 +231,7 @@ const displayTitleIcon = computed(() => {
|
||||
});
|
||||
|
||||
const sortLoading = ref(false);
|
||||
const randomSeed = ref(Date.now().toString());
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
@@ -256,13 +265,18 @@ const queryFilter = computed(() => {
|
||||
async function fetchRecipes(pageCount = 1) {
|
||||
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
|
||||
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(
|
||||
page.value,
|
||||
perPage * pageCount,
|
||||
props.query?.orderBy || preferences.value.orderBy,
|
||||
orderBy,
|
||||
orderDir,
|
||||
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
|
||||
queryFilter.value,
|
||||
);
|
||||
@@ -288,6 +302,9 @@ watch(
|
||||
);
|
||||
|
||||
async function initRecipes() {
|
||||
if (preferences.value.orderBy === "random") {
|
||||
randomSeed.value = Date.now().toString();
|
||||
}
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
|
||||
@@ -380,6 +397,15 @@ async function sortRecipes(sortType: string) {
|
||||
true,
|
||||
);
|
||||
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:
|
||||
console.log("Unknown Event", sortType);
|
||||
return;
|
||||
|
||||
@@ -45,31 +45,15 @@
|
||||
@confirm="addRecipeToPlan()"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-menu
|
||||
v-model="pickerMenu"
|
||||
: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="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-date-picker
|
||||
v-model="newMealdate"
|
||||
class="mx-auto mb-3"
|
||||
hide-header
|
||||
show-adjacent-months
|
||||
color="primary"
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
/>
|
||||
<v-select
|
||||
v-model="newMealType"
|
||||
:return-object="false"
|
||||
@@ -207,7 +191,6 @@ const loading = ref(false);
|
||||
const menuItems = ref<ContextMenuItem[]>([]);
|
||||
const newMealdate = ref(new Date());
|
||||
const newMealType = ref<PlanEntryType>("dinner");
|
||||
const pickerMenu = ref(false);
|
||||
|
||||
const newMealdateString = computed(() => {
|
||||
// Format the date to YYYY-MM-DD in the same timezone as newMealdate
|
||||
@@ -377,11 +360,14 @@ async function deleteRecipe() {
|
||||
const download = useDownloader();
|
||||
|
||||
async function handleDownloadEvent() {
|
||||
const { data } = await api.recipes.getZipToken(props.slug);
|
||||
|
||||
if (data) {
|
||||
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
|
||||
const { data: shareToken } = await api.recipes.share.createOne({ recipeId: props.recipeId });
|
||||
if (!shareToken) {
|
||||
console.error("No share token received");
|
||||
alert.error(i18n.t("events.something-went-wrong"));
|
||||
return;
|
||||
}
|
||||
|
||||
download(api.recipes.share.getZipRedirectUrl(shareToken.id), `${props.slug}.zip`);
|
||||
}
|
||||
|
||||
async function addRecipeToPlan() {
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #[`item.dateAdded`]="{ item }">
|
||||
{{ formatDate(item.dateAdded!) }}
|
||||
{{ item.dateAdded ? $d(new Date(item.dateAdded)) : '' }}
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
@@ -153,15 +153,6 @@ const headers = computed(() => {
|
||||
return hdrs;
|
||||
});
|
||||
|
||||
function formatDate(date: string) {
|
||||
try {
|
||||
return i18n.d(Date.parse(date), "medium");
|
||||
}
|
||||
catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Group Members
|
||||
const api = useUserApi();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:title="$t('recipe.add-to-list')"
|
||||
:icon="$globals.icons.cartCheck"
|
||||
>
|
||||
<v-container v-if="!shoppingListChoices.length">
|
||||
<v-container v-if="!filteredShoppingLists.length">
|
||||
<BasePageTitle>
|
||||
<template #title>
|
||||
{{ $t('shopping-list.no-shopping-lists-found') }}
|
||||
@@ -15,7 +15,7 @@
|
||||
</v-container>
|
||||
<v-card-text>
|
||||
<v-card
|
||||
v-for="list in shoppingListChoices"
|
||||
v-for="list in filteredShoppingLists"
|
||||
:key="list.id"
|
||||
hover
|
||||
class="my-2 left-border"
|
||||
@@ -139,7 +139,7 @@
|
||||
color="secondary"
|
||||
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
|
||||
:ingredient="ingredientData.ingredient"
|
||||
:scale="recipeSection.recipeScale"
|
||||
@@ -222,6 +222,10 @@ const api = useUserApi();
|
||||
const preferences = useShoppingListPreferences();
|
||||
const ready = ref(false);
|
||||
|
||||
// Capture values at initialization to avoid reactive updates
|
||||
const currentHouseholdSlug = ref("");
|
||||
const filteredShoppingLists = ref<ShoppingListSummary[]>([]);
|
||||
|
||||
const state = reactive({
|
||||
shoppingListDialog: true,
|
||||
shoppingListIngredientDialog: false,
|
||||
@@ -230,31 +234,25 @@ const state = reactive({
|
||||
|
||||
const { shoppingListDialog, shoppingListIngredientDialog, shoppingListShowAllToggled: _shoppingListShowAllToggled } = toRefs(state);
|
||||
|
||||
const userHousehold = computed(() => {
|
||||
return $auth.user.value?.householdSlug || "";
|
||||
});
|
||||
|
||||
const shoppingListChoices = computed(() => {
|
||||
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
|
||||
});
|
||||
|
||||
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
||||
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
selectedShoppingList.value = shoppingListChoices.value[0];
|
||||
watch(dialog, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
currentHouseholdSlug.value = $auth.user.value?.householdSlug || "";
|
||||
filteredShoppingLists.value = props.shoppingLists.filter(
|
||||
list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id,
|
||||
);
|
||||
|
||||
if (filteredShoppingLists.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
selectedShoppingList.value = filteredShoppingLists.value[0];
|
||||
openShoppingListIngredientDialog(selectedShoppingList.value);
|
||||
}
|
||||
else {
|
||||
ready.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
}
|
||||
else if (!newVal) {
|
||||
initState();
|
||||
}
|
||||
});
|
||||
@@ -274,25 +272,53 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
|
||||
const { data } = await api.recipes.getOne(recipe.slug);
|
||||
// Create a local copy to avoid mutating props
|
||||
let recipeData = { ...recipe };
|
||||
if (!(recipeData.id && recipeData.name && recipeData.recipeIngredient)) {
|
||||
const { data } = await api.recipes.getOne(recipeData.slug);
|
||||
if (!data?.recipeIngredient?.length) {
|
||||
continue;
|
||||
}
|
||||
recipe.id = data.id || "";
|
||||
recipe.name = data.name || "";
|
||||
recipe.recipeIngredient = data.recipeIngredient;
|
||||
recipeData = {
|
||||
...recipeData,
|
||||
id: data.id || "",
|
||||
name: data.name || "",
|
||||
recipeIngredient: data.recipeIngredient,
|
||||
};
|
||||
}
|
||||
else if (!recipe.recipeIngredient.length) {
|
||||
else if (!recipeData.recipeIngredient.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
|
||||
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
|
||||
return {
|
||||
checked: !householdsWithFood.includes(userHousehold.value),
|
||||
ingredient: ing,
|
||||
};
|
||||
const shoppingListIngredients: ShoppingListIngredient[] = [];
|
||||
function flattenRecipeIngredients(ing: RecipeIngredient, parentTitle = ""): ShoppingListIngredient[] {
|
||||
if (ing.referencedRecipe) {
|
||||
// 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
|
||||
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
|
||||
return [{
|
||||
checked: !householdsWithFood.includes(currentHouseholdSlug.value),
|
||||
ingredient: {
|
||||
...ing,
|
||||
title: ing.title || parentTitle,
|
||||
},
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
recipeData.recipeIngredient.forEach((ing) => {
|
||||
const flattened = flattenRecipeIngredients(ing, "");
|
||||
shoppingListIngredients.push(...flattened);
|
||||
});
|
||||
|
||||
let currentTitle = "";
|
||||
@@ -301,6 +327,9 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
if (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 (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
|
||||
@@ -316,8 +345,8 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
}
|
||||
|
||||
// Store the on-hand ingredients for later
|
||||
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
|
||||
if (householdsWithFood.includes(userHousehold.value)) {
|
||||
const householdsWithFood = (ing.ingredient?.food?.householdsWithIngredientFood || []);
|
||||
if (householdsWithFood.includes(currentHouseholdSlug.value)) {
|
||||
onHandIngs.push(ing);
|
||||
return sections;
|
||||
}
|
||||
@@ -331,9 +360,9 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
|
||||
|
||||
recipeSectionMap.set(recipe.slug, {
|
||||
recipeId: recipe.id,
|
||||
recipeName: recipe.name,
|
||||
recipeScale: recipe.scale,
|
||||
recipeId: recipeData.id,
|
||||
recipeName: recipeData.name,
|
||||
recipeScale: recipeData.scale,
|
||||
ingredientSections: shoppingListIngredientSections,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,6 +141,13 @@ function save() {
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
function open() {
|
||||
dialog.value = true;
|
||||
}
|
||||
function close() {
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const utilities = [
|
||||
@@ -160,4 +167,10 @@ const utilities = [
|
||||
action: splitByNumberedLine,
|
||||
},
|
||||
];
|
||||
|
||||
// Expose functions to parent components
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -69,7 +69,14 @@
|
||||
:label="$t('recipe.nutrition')"
|
||||
/>
|
||||
</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-row>
|
||||
</v-container>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
v-model="expirationDateString"
|
||||
:model-value="$d(expirationDate)"
|
||||
:label="$t('recipe-share.expiration-date')"
|
||||
:hint="$t('recipe-share.default-30-days')"
|
||||
persistent-hint
|
||||
@@ -59,11 +59,8 @@
|
||||
|
||||
<div class="pl-3 flex-grow-1">
|
||||
<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-subtitle>
|
||||
{{ $d(new Date(token.expiresAt!), "long") }}
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
@@ -111,10 +108,6 @@ const datePickerMenu = ref(false);
|
||||
const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
|
||||
const tokens = ref<RecipeShareToken[]>([]);
|
||||
|
||||
const expirationDateString = computed(() => {
|
||||
return expirationDate.value.toISOString().substring(0, 10);
|
||||
});
|
||||
|
||||
whenever(
|
||||
() => dialog.value,
|
||||
() => {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
v-bind="props"
|
||||
>
|
||||
<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>
|
||||
{{ $vuetify.display.xs ? null : sortText }}
|
||||
</v-btn>
|
||||
@@ -42,7 +42,7 @@
|
||||
<v-list-item
|
||||
slim
|
||||
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')"
|
||||
@click="toggleOrderDirection"
|
||||
/>
|
||||
@@ -53,10 +53,23 @@
|
||||
:active="state.orderBy === v.value"
|
||||
slim
|
||||
density="comfortable"
|
||||
:prepend-icon="v.icon"
|
||||
:title="v.name"
|
||||
@click="setOrderBy(v.value)"
|
||||
/>
|
||||
@click="v.value === 'random' ? setRandomOrderByWrapper() : 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-card>
|
||||
</v-menu>
|
||||
@@ -131,6 +144,7 @@ const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const { $globals } = useNuxtApp();
|
||||
const i18n = useI18n();
|
||||
const showRandomLoading = ref(false);
|
||||
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
@@ -141,6 +155,7 @@ const {
|
||||
reset,
|
||||
toggleOrderDirection,
|
||||
setOrderBy,
|
||||
setRandomOrderBy,
|
||||
filterItems,
|
||||
initialize,
|
||||
} = useRecipeExplorerSearch(groupSlug);
|
||||
@@ -205,6 +220,14 @@ const input: Ref<any> = ref(null);
|
||||
function hideKeyboard() {
|
||||
input.value?.blur();
|
||||
}
|
||||
|
||||
// function to show refresh icon
|
||||
async function setRandomOrderByWrapper() {
|
||||
if (!showRandomLoading.value) {
|
||||
showRandomLoading.value = true;
|
||||
}
|
||||
await setRandomOrderBy();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -101,4 +101,14 @@ const { store: tags } = isOwnGroup.value ? useTagStore() : usePublicTagStore(gro
|
||||
const { store: tools } = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
|
||||
const { store: foods } = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
|
||||
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
|
||||
|
||||
watch(
|
||||
households,
|
||||
() => {
|
||||
// if exactly one household exists, then we shouldn't be filtering by household
|
||||
if (households.value.length == 1) {
|
||||
selectedHouseholds.value = [];
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -20,18 +20,36 @@
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card width="400">
|
||||
<v-card-title class="headline flex mb-0">
|
||||
<v-card-title class="headline flex-wrap mb-0">
|
||||
<div>
|
||||
{{ $t("recipe.recipe-image") }}
|
||||
</div>
|
||||
<AppButtonUpload
|
||||
class="ml-auto"
|
||||
url="none"
|
||||
file-name="image"
|
||||
:text-btn="false"
|
||||
:post="false"
|
||||
@uploaded="uploadImage"
|
||||
/>
|
||||
<div class="d-flex gap-2">
|
||||
<AppButtonUpload
|
||||
url="none"
|
||||
file-name="image"
|
||||
:text-btn="false"
|
||||
:post="false"
|
||||
@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-text class="mt-n5">
|
||||
<div>
|
||||
@@ -62,38 +80,58 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
const REFRESH_EVENT = "refresh";
|
||||
const UPLOAD_EVENT = "upload";
|
||||
const DELETE_EVENT = "delete";
|
||||
|
||||
const props = defineProps<{ slug: string }>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
upload: [fileObject: File];
|
||||
delete: [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const api = useUserApi();
|
||||
|
||||
const url = ref("");
|
||||
const loading = ref(false);
|
||||
const menu = ref(false);
|
||||
const dialogDeleteImage = ref(false);
|
||||
|
||||
function uploadImage(fileObject: File) {
|
||||
emit(UPLOAD_EVENT, fileObject);
|
||||
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() {
|
||||
loading.value = true;
|
||||
if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
|
||||
emit(REFRESH_EVENT);
|
||||
emit(DELETE_EVENT);
|
||||
}
|
||||
loading.value = false;
|
||||
menu.value = false;
|
||||
}
|
||||
|
||||
const i18n = useI18n();
|
||||
const messages = computed(() =>
|
||||
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
|
||||
);
|
||||
|
||||
@@ -22,12 +22,15 @@
|
||||
cols="12"
|
||||
class="flex-grow-0 flex-shrink-0"
|
||||
>
|
||||
<v-text-field
|
||||
<v-number-input
|
||||
v-model="model.quantity"
|
||||
variant="solo"
|
||||
:precision="null"
|
||||
:min="0"
|
||||
hide-details
|
||||
control-variant="stacked"
|
||||
inset
|
||||
density="compact"
|
||||
type="number"
|
||||
:placeholder="$t('recipe.quantity')"
|
||||
@keypress="quantityFilter"
|
||||
>
|
||||
@@ -38,9 +41,10 @@
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-number-input>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="!state.isRecipe"
|
||||
sm="12"
|
||||
md="3"
|
||||
cols="12"
|
||||
@@ -55,6 +59,7 @@
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="units || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
item-title="name"
|
||||
class="mx-1"
|
||||
:placeholder="$t('recipe.choose-unit')"
|
||||
@@ -97,6 +102,7 @@
|
||||
|
||||
<!-- Foods Input -->
|
||||
<v-col
|
||||
v-if="!state.isRecipe"
|
||||
m="12"
|
||||
md="3"
|
||||
cols="12"
|
||||
@@ -112,6 +118,7 @@
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="foods || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
item-title="name"
|
||||
class="mx-1 py-0"
|
||||
:placeholder="$t('recipe.choose-food')"
|
||||
@@ -151,6 +158,36 @@
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</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
|
||||
sm="12"
|
||||
md=""
|
||||
@@ -173,6 +210,7 @@
|
||||
class="my-auto d-flex"
|
||||
:buttons="btns"
|
||||
@toggle-section="toggleTitle"
|
||||
@toggle-subrecipe="toggleIsRecipe"
|
||||
@insert-above="$emit('insert-above')"
|
||||
@insert-below="$emit('insert-below')"
|
||||
@delete="$emit('delete')"
|
||||
@@ -193,8 +231,11 @@ import { ref, computed, reactive, toRefs } from "vue";
|
||||
import { useDisplay } from "vuetify";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||
import { normalizeFilter } from "~/composables/use-utils";
|
||||
import { useNuxtApp } from "#app";
|
||||
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
|
||||
const model = defineModel<RecipeIngredient>({ required: true });
|
||||
@@ -204,6 +245,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: "body",
|
||||
},
|
||||
isRecipe: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
unitError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -247,6 +292,7 @@ const { $globals } = useNuxtApp();
|
||||
|
||||
const state = reactive({
|
||||
showTitle: false,
|
||||
isRecipe: props.isRecipe,
|
||||
});
|
||||
|
||||
const contextMenuOptions = computed(() => {
|
||||
@@ -255,6 +301,10 @@ const contextMenuOptions = computed(() => {
|
||||
text: i18n.t("recipe.toggle-section"),
|
||||
event: "toggle-section",
|
||||
},
|
||||
{
|
||||
text: i18n.t("recipe.toggle-recipe"),
|
||||
event: "toggle-subrecipe",
|
||||
},
|
||||
{
|
||||
text: i18n.t("recipe.insert-above"),
|
||||
event: "insert-above",
|
||||
@@ -303,6 +353,25 @@ async function createAssignFood() {
|
||||
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
|
||||
const unitStore = useUnitStore();
|
||||
const unitsData = useUnitData();
|
||||
@@ -323,6 +392,17 @@ function toggleTitle() {
|
||||
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() {
|
||||
if (
|
||||
model.value.unit === undefined
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
class="text-bold d-inline"
|
||||
: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>
|
||||
<SafeMarkdown
|
||||
v-if="parsedIng.name"
|
||||
@@ -39,9 +43,12 @@ interface Props {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
});
|
||||
const route = useRoute();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
const parsedIng = computed(() => {
|
||||
return useParsedIngredientText(props.ingredient, props.scale);
|
||||
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
:title="$t('recipe.made-this')"
|
||||
:submit-text="$t('recipe.add-to-timeline')"
|
||||
can-submit
|
||||
disable-submit-on-enter
|
||||
@submit="createTimelineEvent"
|
||||
>
|
||||
<v-card-text>
|
||||
@@ -20,6 +21,29 @@
|
||||
persistent-hint
|
||||
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-row>
|
||||
<v-col cols="6">
|
||||
@@ -32,7 +56,7 @@
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
v-model="newTimelineEventTimestampString"
|
||||
:model-value="$d(newTimelineEventTimestamp)"
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="activatorProps"
|
||||
readonly
|
||||
@@ -102,7 +126,7 @@
|
||||
<span class="text-body-1 opacity-80">
|
||||
<b>{{ $t("general.last-made") }}</b>
|
||||
<br>
|
||||
{{ lastMade ? new Date(lastMade).toLocaleDateString($i18n.locale) : $t("general.never") }}
|
||||
{{ lastMade ? $d(new Date(lastMade)) : $t("general.never") }}
|
||||
</span>
|
||||
<v-icon end size="large" color="primary">
|
||||
{{ $globals.icons.createAlt }}
|
||||
@@ -166,6 +190,21 @@ onMounted(async () => {
|
||||
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(
|
||||
() => 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
|
||||
let imageError = false;
|
||||
if (newTimelineEventImage.value) {
|
||||
@@ -268,7 +338,6 @@ async function createTimelineEvent() {
|
||||
console.error("Failed to upload image for timeline event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageError) {
|
||||
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
|
||||
}
|
||||
|
||||
@@ -10,14 +10,17 @@
|
||||
v-for="(item, key, index) in modelValue"
|
||||
:key="index"
|
||||
>
|
||||
<v-text-field
|
||||
density="compact"
|
||||
<v-number-input
|
||||
:model-value="modelValue[key]"
|
||||
:label="labels[key].label"
|
||||
:suffix="labels[key].suffix"
|
||||
type="number"
|
||||
density="compact"
|
||||
autocomplete="off"
|
||||
variant="underlined"
|
||||
control-variant="stacked"
|
||||
inset
|
||||
:precision="null"
|
||||
:min="0"
|
||||
@update:model-value="updateValue(key, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -105,10 +105,9 @@
|
||||
<v-icon>
|
||||
{{ 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 }}
|
||||
</v-card-title>
|
||||
<v-spacer />
|
||||
<ContextMenu
|
||||
:items="[presets.delete, presets.edit]"
|
||||
@delete="confirmDelete(item)"
|
||||
|
||||
@@ -4,17 +4,19 @@
|
||||
v-bind="inputAttrs"
|
||||
v-model:search="searchInput"
|
||||
:items="items"
|
||||
:custom-filter="normalizeFilter"
|
||||
:label="label"
|
||||
chips
|
||||
closable-chips
|
||||
item-title="name"
|
||||
:item-title="itemTitle"
|
||||
item-value="name"
|
||||
multiple
|
||||
:variant="variant"
|
||||
:prepend-inner-icon="icon"
|
||||
:append-icon="showAdd ? $globals.icons.create : undefined"
|
||||
return-object
|
||||
auto-select-first
|
||||
class="pa-0"
|
||||
class="pa-0 ma-0"
|
||||
@update:model-value="resetSearchInput"
|
||||
@click:append="dialog = true"
|
||||
>
|
||||
@@ -32,7 +34,6 @@
|
||||
{{ item.value }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="showAdd"
|
||||
#append
|
||||
@@ -47,16 +48,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
||||
import type { RecipeTool } from "~/lib/api/types/admin";
|
||||
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
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 {
|
||||
selectorType: RecipeOrganizer;
|
||||
inputAttrs?: Record<string, any>;
|
||||
returnObject?: boolean;
|
||||
showAdd?: boolean;
|
||||
showLabel?: boolean;
|
||||
showIcon?: boolean;
|
||||
@@ -65,7 +67,6 @@ interface Props {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
inputAttrs: () => ({}),
|
||||
returnObject: true,
|
||||
showAdd: true,
|
||||
showLabel: true,
|
||||
showIcon: true,
|
||||
@@ -78,7 +79,7 @@ const selected = defineModel<(
|
||||
| RecipeCategory
|
||||
| RecipeTool
|
||||
| IngredientFood
|
||||
| string
|
||||
| UserSummary
|
||||
)[] | undefined>({ required: true });
|
||||
|
||||
onMounted(() => {
|
||||
@@ -106,6 +107,8 @@ const label = computed(() => {
|
||||
return i18n.t("general.foods");
|
||||
case Organizer.Household:
|
||||
return i18n.t("household.households");
|
||||
case Organizer.User:
|
||||
return i18n.t("user.users");
|
||||
default:
|
||||
return i18n.t("general.organizer");
|
||||
}
|
||||
@@ -127,11 +130,19 @@ const icon = computed(() => {
|
||||
return $globals.icons.foods;
|
||||
case Organizer.Household:
|
||||
return $globals.icons.household;
|
||||
case Organizer.User:
|
||||
return $globals.icons.user;
|
||||
default:
|
||||
return $globals.icons.tags;
|
||||
}
|
||||
});
|
||||
|
||||
const itemTitle = computed(() =>
|
||||
props.selectorType === Organizer.User
|
||||
? (i: any) => i?.fullName ?? i?.name ?? ""
|
||||
: "name",
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// Store & Items Setup
|
||||
|
||||
@@ -141,24 +152,24 @@ const storeMap = {
|
||||
[Organizer.Tool]: useToolStore(),
|
||||
[Organizer.Food]: useFoodStore(),
|
||||
[Organizer.Household]: useHouseholdStore(),
|
||||
[Organizer.User]: useUserStore(),
|
||||
};
|
||||
|
||||
const store = computed(() => {
|
||||
const activeStore = computed(() => {
|
||||
const { store } = storeMap[props.selectorType];
|
||||
return store.value;
|
||||
});
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.returnObject) {
|
||||
return store.value.map(item => item.name);
|
||||
}
|
||||
return store.value;
|
||||
const items = computed<any[]>(() => {
|
||||
const list = (activeStore.value as unknown as any[]) ?? [];
|
||||
return list;
|
||||
});
|
||||
|
||||
function removeByIndex(index: number) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSelected = selected.value.filter((_, i) => i !== index);
|
||||
selected.value = [...newSelected];
|
||||
}
|
||||
|
||||
@@ -95,9 +95,12 @@
|
||||
<RecipePrintContainer :recipe="recipe" :scale="scale" />
|
||||
</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 -->
|
||||
<!-- The calc is to account for the navabar height (48px) -->
|
||||
<v-sheet
|
||||
v-show="isCookMode && !hasLinkedIngredients"
|
||||
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 -->
|
||||
<v-row style="height: 100%" no-gutters class="overflow-hidden">
|
||||
@@ -290,10 +293,13 @@ watch(isParsing, () => {
|
||||
*/
|
||||
|
||||
async function saveRecipe() {
|
||||
const { data } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||
setMode(PageMode.VIEW);
|
||||
const { data, error } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||
if (!error) {
|
||||
setMode(PageMode.VIEW);
|
||||
}
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
||||
recipe.value = data as NoUndefinedField<Recipe>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:slug="recipe.slug"
|
||||
@upload="uploadImage"
|
||||
@refresh="imageKey++"
|
||||
@delete="deleteImage"
|
||||
/>
|
||||
<RecipeSettingsMenu
|
||||
v-model="recipe.settings"
|
||||
@@ -78,4 +79,10 @@ async function uploadImage(fileObject: File) {
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -47,7 +47,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
landscape: false,
|
||||
});
|
||||
|
||||
defineEmits(["save", "delete"]);
|
||||
defineEmits(["save", "delete", "print"]);
|
||||
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
||||
|
||||
@@ -28,7 +28,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
});
|
||||
|
||||
const display = useDisplay();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { recipeImage, recipeSmallImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const { user } = usePageUser();
|
||||
|
||||
@@ -46,7 +46,9 @@ const imageHeight = 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(
|
||||
|
||||
@@ -11,27 +11,27 @@
|
||||
<v-container class="ma-0 pa-0">
|
||||
<v-row>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
:model-value="recipeServings"
|
||||
type="number"
|
||||
<v-number-input
|
||||
:model-value="recipe.recipeServings"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
:precision="null"
|
||||
density="compact"
|
||||
:label="$t('recipe.servings')"
|
||||
variant="underlined"
|
||||
@update:model-value="validateInput($event, 'recipeServings')"
|
||||
control-variant="hidden"
|
||||
@update:model-value="recipe.recipeServings = $event"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
:model-value="recipeYieldQuantity"
|
||||
type="number"
|
||||
<v-number-input
|
||||
:model-value="recipe.recipeYieldQuantity"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
:precision="null"
|
||||
density="compact"
|
||||
:label="$t('recipe.yield')"
|
||||
variant="underlined"
|
||||
@update:model-value="validateInput($event, 'recipeYieldQuantity')"
|
||||
control-variant="hidden"
|
||||
@update:model-value="recipe.recipeYieldQuantity = $event"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
@@ -85,37 +85,4 @@ import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
const recipeServings = computed<number>({
|
||||
get() {
|
||||
return recipe.value.recipeServings;
|
||||
},
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeServings");
|
||||
},
|
||||
});
|
||||
|
||||
const recipeYieldQuantity = computed<number>({
|
||||
get() {
|
||||
return recipe.value.recipeYieldQuantity;
|
||||
},
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeYieldQuantity");
|
||||
},
|
||||
});
|
||||
|
||||
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
|
||||
if (!value) {
|
||||
recipe.value[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const number = parseFloat(value.replace(/[^0-9.]/g, ""));
|
||||
if (isNaN(number) || number <= 0) {
|
||||
recipe.value[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
recipe.value[property] = number;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
v-for="(ingredient, index) in recipe.recipeIngredient"
|
||||
:key="ingredient.referenceId"
|
||||
v-model="recipe.recipeIngredient[index]"
|
||||
:is-recipe="ingredientIsRecipe(ingredient)"
|
||||
enable-drag-handle
|
||||
enable-context-menu
|
||||
class="list-group-item"
|
||||
@@ -69,15 +70,59 @@
|
||||
<span>{{ parserToolTip }}</span>
|
||||
</v-tooltip>
|
||||
<RecipeDialogBulkAdd
|
||||
ref="domBulkAddDialog"
|
||||
class="mx-1 mb-1"
|
||||
style="display: none"
|
||||
@bulk-data="addIngredient"
|
||||
/>
|
||||
<BaseButton
|
||||
class="mb-1"
|
||||
@click="addIngredient"
|
||||
>
|
||||
{{ $t("general.add") }}
|
||||
</BaseButton>
|
||||
<div class="d-inline-flex">
|
||||
<!-- Main button: Add Food -->
|
||||
<v-btn
|
||||
color="success"
|
||||
class="split-main ml-2"
|
||||
@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>
|
||||
</template>
|
||||
@@ -85,16 +130,18 @@
|
||||
<script setup lang="ts">
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
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 RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { uuid4 } from "~/composables/use-utils";
|
||||
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
const ingredientsWithRecipe = new Map<string, boolean>();
|
||||
const i18n = useI18n();
|
||||
|
||||
const drag = ref(false);
|
||||
const domBulkAddDialog = ref<InstanceType<typeof RecipeDialogBulkAdd> | null>(null);
|
||||
const { toggleIsParsing } = usePageState(recipe.value.slug);
|
||||
|
||||
const hasFoodOrUnit = computed(() => {
|
||||
@@ -118,6 +165,22 @@ const parserToolTip = computed(() => {
|
||||
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) {
|
||||
if (ingredients?.length) {
|
||||
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) {
|
||||
recipe.value.recipeIngredient.splice(dest, 0, {
|
||||
referenceId: uuid4(),
|
||||
@@ -163,3 +261,17 @@ function insertNewIngredient(dest: number) {
|
||||
});
|
||||
}
|
||||
</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 { Parser } from "~/lib/api/user/recipes/recipe";
|
||||
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 { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
|
||||
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||
@@ -213,9 +213,9 @@ const emit = defineEmits<{
|
||||
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
|
||||
}>();
|
||||
|
||||
const { $appInfo } = useNuxtApp();
|
||||
const i18n = useGlobalI18n();
|
||||
const api = useUserApi();
|
||||
const appInfo = useAppInfo();
|
||||
const drag = ref(false);
|
||||
|
||||
const unitStore = useUnitStore();
|
||||
@@ -238,7 +238,7 @@ const availableParsers = computed(() => {
|
||||
{
|
||||
text: i18n.t("recipe.parser.openai-parser"),
|
||||
value: "openai",
|
||||
hide: !appInfo.value?.enableOpenai,
|
||||
hide: !$appInfo.enableOpenai,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -268,6 +268,11 @@ const state = reactive({
|
||||
function shouldReview(ing: ParsedIngredient): boolean {
|
||||
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) {
|
||||
console.debug("Needs review due to low confidence:", ing.confidence?.average);
|
||||
return true;
|
||||
@@ -364,12 +369,21 @@ async function parseIngredients() {
|
||||
}
|
||||
state.loading.parser = true;
|
||||
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);
|
||||
if (error || !data) {
|
||||
throw new Error("Failed to parse ingredients");
|
||||
}
|
||||
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.allReviewed = false;
|
||||
createdUnits.clear();
|
||||
|
||||
@@ -262,32 +262,55 @@ const ingredientSections = computed<IngredientSection[]>(() => {
|
||||
if (!props.recipe.recipeIngredient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
|
||||
// if title append new section to the end of the array
|
||||
if (ingredient.title) {
|
||||
sections.push({
|
||||
sectionName: ingredient.title,
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
|
||||
return sections;
|
||||
const addIngredientsToSections = (ingredients: RecipeIngredient[], sections: IngredientSection[], title: string | null) => {
|
||||
// If title is set, ensure the section exists before adding ingredients
|
||||
let section: IngredientSection | undefined;
|
||||
if (title) {
|
||||
section = sections.find(sec => sec.sectionName === title);
|
||||
if (!section) {
|
||||
section = { sectionName: title, ingredients: [] };
|
||||
sections.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
// append new section if first
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
ingredients.forEach((ingredient) => {
|
||||
if (preferences.value.expandChildRecipes && ingredient.referencedRecipe?.recipeIngredient?.length) {
|
||||
// Recursively add to the section for this referenced recipe
|
||||
addIngredientsToSections(
|
||||
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;
|
||||
}
|
||||
|
||||
// otherwise add ingredient to last section in the array
|
||||
sections[sections.length - 1].ingredients.push(ingredient);
|
||||
return sections;
|
||||
}, [] as IngredientSection[]);
|
||||
const sections: IngredientSection[] = [];
|
||||
addIngredientsToSections(props.recipe.recipeIngredient, sections, null);
|
||||
return sections;
|
||||
});
|
||||
|
||||
// Group instructions by section so we can style them independently
|
||||
|
||||
@@ -65,13 +65,13 @@
|
||||
</v-card-title>
|
||||
<v-card-text class="mt-n5">
|
||||
<div class="mt-4 d-flex align-center">
|
||||
<v-text-field
|
||||
<v-number-input
|
||||
:model-value="yieldQuantity"
|
||||
type="number"
|
||||
:precision="null"
|
||||
:min="0"
|
||||
variant="underlined"
|
||||
hide-spin-buttons
|
||||
@update:model-value="recalculateScale(parseFloat($event) || 0)"
|
||||
control-variant="hidden"
|
||||
@update:model-value="recalculateScale($event || 0)"
|
||||
/>
|
||||
<v-tooltip
|
||||
location="end"
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
left
|
||||
color="primary"
|
||||
>
|
||||
{{ $globals.icons.knfife }}
|
||||
{{ $globals.icons.knife }}
|
||||
</v-icon>
|
||||
<p class="my-0">
|
||||
<span class="font-weight-bold opacity-80">{{ validatePrepTime.name }}</span><br>{{ validatePrepTime.value }}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:title="$t('recipe.edit-timeline-event')"
|
||||
:icon="$globals.icons.edit"
|
||||
can-submit
|
||||
disable-submit-on-enter
|
||||
:submit-text="$t('general.save')"
|
||||
@submit="submitEdit"
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<v-icon class="mr-1">
|
||||
{{ $globals.icons.calendar }}
|
||||
</v-icon>
|
||||
{{ new Date(event.timestamp).toLocaleDateString($i18n.locale) }}
|
||||
{{ $d(new Date(event.timestamp)) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<v-card
|
||||
@@ -22,7 +22,7 @@
|
||||
<v-col v-if="useMobileFormat" align-self="center" class="pr-0">
|
||||
<v-chip label>
|
||||
<v-icon> {{ $globals.icons.calendar }} </v-icon>
|
||||
{{ new Date(event.timestamp || "").toLocaleDateString($i18n.locale) }}
|
||||
{{ $d(new Date(event.timestamp || "")) }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col v-else cols="9" style="margin: auto; text-align: center">
|
||||
@@ -119,7 +119,7 @@ defineEmits<{
|
||||
|
||||
const { $globals } = useNuxtApp();
|
||||
const display = useDisplay();
|
||||
const { recipeTimelineEventImage } = useStaticRoutes();
|
||||
const { recipeTimelineEventSmallImage } = useStaticRoutes();
|
||||
const { eventTypeOptions } = useTimelineEventTypes();
|
||||
|
||||
const { user: currentUser } = useMealieAuth();
|
||||
@@ -173,7 +173,7 @@ const eventImageUrl = computed<string>(() => {
|
||||
return "";
|
||||
}
|
||||
|
||||
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
|
||||
return recipeTimelineEventSmallImage(props.event.recipeId, props.event.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,8 +7,16 @@
|
||||
no-gutters
|
||||
class="flex-nowrap align-center"
|
||||
>
|
||||
<v-col :cols="itemLabelCols">
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-card
|
||||
flat
|
||||
link
|
||||
class="grow"
|
||||
@click="() => {
|
||||
listItem.checked = !listItem.checked
|
||||
$emit('checked', listItem)
|
||||
}"
|
||||
>
|
||||
<div class="d-flex align-center flex-nowrap grow">
|
||||
<v-checkbox
|
||||
v-model="listItem.checked"
|
||||
hide-details
|
||||
@@ -25,84 +33,78 @@
|
||||
<RecipeIngredientListItem :ingredient="listItem" />
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
<v-col
|
||||
cols="auto"
|
||||
class="text-right"
|
||||
</v-card>
|
||||
<div
|
||||
v-if="!listItem.checked"
|
||||
style="min-width: 72px"
|
||||
>
|
||||
<div
|
||||
v-if="!listItem.checked"
|
||||
style="min-width: 72px"
|
||||
<v-menu
|
||||
offset-x
|
||||
start
|
||||
min-width="125px"
|
||||
>
|
||||
<v-menu
|
||||
offset-x
|
||||
start
|
||||
min-width="125px"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-tooltip
|
||||
v-if="recipeList && recipeList.length"
|
||||
open-delay="200"
|
||||
transition="slide-x-reverse-transition"
|
||||
density="compact"
|
||||
location="end"
|
||||
content-class="text-caption"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
icon
|
||||
v-bind="tooltipProps"
|
||||
@click="displayRecipeRefs = !displayRecipeRefs"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>Toggle Recipes</span>
|
||||
</v-tooltip>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
icon
|
||||
@click="toggleEdit(true)"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.edit }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="handle"
|
||||
icon
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="action in contextMenu"
|
||||
:key="action.event"
|
||||
density="compact"
|
||||
@click="contextHandler(action.event)"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ action.text }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</v-col>
|
||||
<template #activator="{ props }">
|
||||
<v-tooltip
|
||||
v-if="recipeList && recipeList.length"
|
||||
open-delay="200"
|
||||
transition="slide-x-reverse-transition"
|
||||
density="compact"
|
||||
location="end"
|
||||
content-class="text-caption"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
icon
|
||||
v-bind="tooltipProps"
|
||||
@click="displayRecipeRefs = !displayRecipeRefs"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>Toggle Recipes</span>
|
||||
</v-tooltip>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
icon
|
||||
@click="toggleEdit(true)"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.edit }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="handle"
|
||||
icon
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="action in contextMenu"
|
||||
:key="action.event"
|
||||
density="compact"
|
||||
@click="contextHandler(action.event)"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ action.text }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</v-row>
|
||||
<v-row
|
||||
v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs"
|
||||
@@ -130,9 +132,8 @@
|
||||
<v-col cols="auto">
|
||||
<div class="text-caption font-weight-light font-italic">
|
||||
{{ $t("shopping-list.completed-on", {
|
||||
date: new Date(listItem.updatedAt
|
||||
|| "").toLocaleDateString($i18n.locale) })
|
||||
}}
|
||||
date: listItem.updatedAt ? $d(new Date(listItem.updatedAt)) : '',
|
||||
}) }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -312,4 +313,8 @@ export default defineNuxtComponent({
|
||||
.strike-through {
|
||||
text-decoration: line-through !important;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,13 +4,25 @@
|
||||
<v-card-text class="pb-3 pt-1">
|
||||
<div class="d-md-flex align-center mb-2" style="gap: 20px">
|
||||
<div>
|
||||
<InputQuantity v-model="listItem.quantity" />
|
||||
<v-number-input
|
||||
v-model="listItem.quantity"
|
||||
hide-details
|
||||
:label="$t('form.quantity-label-abbreviated')"
|
||||
:min="0"
|
||||
:precision="null"
|
||||
variant="plain"
|
||||
control-variant="stacked"
|
||||
inset
|
||||
density="compact"
|
||||
class="centered-number-input"
|
||||
style="width: 100px;"
|
||||
/>
|
||||
</div>
|
||||
<InputLabelType
|
||||
v-model="listItem.unit"
|
||||
v-model:item-id="listItem.unitId!"
|
||||
:items="units"
|
||||
:label="$t('general.units')"
|
||||
:label="$t('recipe.unit')"
|
||||
:icon="$globals.icons.units"
|
||||
create
|
||||
@create="createAssignUnit"
|
||||
@@ -158,6 +170,15 @@ export default defineNuxtComponent({
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue.quantity,
|
||||
() => {
|
||||
if (!props.modelValue.quantity) {
|
||||
listItem.value.quantity = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.modelValue.food,
|
||||
(newFood) => {
|
||||
@@ -221,3 +242,10 @@ export default defineNuxtComponent({
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.centered-number-input :deep(.v-field) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -97,7 +97,6 @@
|
||||
<script lang="ts">
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import type { SideBarLink } from "~/types/application-types";
|
||||
import { useAppInfo } from "~/composables/api";
|
||||
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
||||
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
@@ -105,7 +104,7 @@ import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
export default defineNuxtComponent({
|
||||
setup() {
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { $appInfo, $globals } = useNuxtApp();
|
||||
const display = useDisplay();
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
@@ -135,9 +134,7 @@ export default defineNuxtComponent({
|
||||
return [];
|
||||
});
|
||||
|
||||
const appInfo = useAppInfo();
|
||||
const showImageImport = computed(() => appInfo.value?.enableOpenaiImageServices);
|
||||
|
||||
const showImageImport = computed(() => $appInfo.enableOpenaiImageServices);
|
||||
const languageDialog = ref<boolean>(false);
|
||||
|
||||
const sidebar = ref<boolean>(false);
|
||||
|
||||
@@ -149,6 +149,6 @@ export default defineNuxtComponent({
|
||||
|
||||
<style scoped>
|
||||
.v-toolbar {
|
||||
z-index: 1010 !important;
|
||||
z-index: 2010 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
<BaseButton
|
||||
v-if="canDelete"
|
||||
delete
|
||||
secondary
|
||||
@click="deleteEvent"
|
||||
/>
|
||||
<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"
|
||||
return-object
|
||||
:items="items"
|
||||
:custom-filter="normalizeFilter"
|
||||
:prepend-icon="icon || $globals.icons.tags"
|
||||
auto-select-first
|
||||
clearable
|
||||
@@ -52,6 +53,7 @@
|
||||
|
||||
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
import { normalizeFilter } from "~/composables/use-utils";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
@@ -122,6 +124,7 @@ export default defineNuxtComponent({
|
||||
itemIdVal,
|
||||
searchInput,
|
||||
emitCreate,
|
||||
normalizeFilter,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="d-flex align-center"
|
||||
style="max-width: 60px"
|
||||
>
|
||||
<v-text-field
|
||||
v-model.number="quantity"
|
||||
hide-details
|
||||
:label="$t('form.quantity-label-abbreviated')"
|
||||
:min="min"
|
||||
:max="max"
|
||||
type="number"
|
||||
variant="plain"
|
||||
density="compact"
|
||||
style="width: 60px;"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineNuxtComponent({
|
||||
name: "VInputNumber",
|
||||
props: {
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 9999,
|
||||
},
|
||||
rules: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
step: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const quantity = computed({
|
||||
get: () => {
|
||||
return Number(props.modelValue);
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
quantity,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -9,6 +9,7 @@
|
||||
<v-autocomplete
|
||||
v-model="selectedLocale"
|
||||
:items="locales"
|
||||
:custom-filter="normalizeFilter"
|
||||
item-title="name"
|
||||
item-value="value"
|
||||
class="my-3"
|
||||
@@ -44,6 +45,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { useLocales } from "~/composables/use-locales";
|
||||
import { normalizeFilter } from "~/composables/use-utils";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
@@ -83,6 +85,7 @@ export default defineNuxtComponent({
|
||||
locale,
|
||||
selectedLocale,
|
||||
onLocaleSelect,
|
||||
normalizeFilter,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ export default defineNuxtComponent({
|
||||
"ul", "ol", "li", "dl", "dt", "dd", "abbr", "a", "img", "blockquote", "iframe",
|
||||
"del", "ins", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup",
|
||||
],
|
||||
ADD_ATTR: [
|
||||
ALLOWED_ATTR: [
|
||||
"href", "src", "alt", "height", "width", "class", "allow", "title", "allowfullscreen", "frameborder",
|
||||
"scrolling", "cite", "datetime", "name", "abbr", "target", "border",
|
||||
],
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { useAppInfo } from "./use-app-info";
|
||||
export { useStaticRoutes } from "./static-routes";
|
||||
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() {
|
||||
function download(url: string, filename: string) {
|
||||
useFetch(url, {
|
||||
method: "GET",
|
||||
responseType: "blob",
|
||||
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 link = document.createElement("a");
|
||||
link.href = url;
|
||||
|
||||
58
frontend/composables/partials/use-actions-factory.test.ts
Normal file
58
frontend/composables/partials/use-actions-factory.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { useStoreActions } from "./use-actions-factory";
|
||||
import type { BaseCRUDAPI } from "~/lib/api/base/base-clients";
|
||||
|
||||
describe("useStoreActions", () => {
|
||||
const mockApi = {
|
||||
getAll: vi.fn(),
|
||||
createOne: vi.fn(),
|
||||
updateOne: vi.fn(),
|
||||
deleteOne: vi.fn(),
|
||||
} as unknown as BaseCRUDAPI<unknown, unknown, unknown>;
|
||||
|
||||
const mockStore = ref([]);
|
||||
const mockLoading = ref(false);
|
||||
|
||||
test("deleteMany calls deleteOne for each ID and refreshes once", async () => {
|
||||
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
|
||||
|
||||
mockApi.deleteOne = vi.fn().mockResolvedValue({ response: { data: {} } });
|
||||
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
const ids = ["1", "2", "3"];
|
||||
await actions.deleteMany(ids);
|
||||
|
||||
expect(mockApi.deleteOne).toHaveBeenCalledTimes(3);
|
||||
expect(mockApi.deleteOne).toHaveBeenCalledWith("1");
|
||||
expect(mockApi.deleteOne).toHaveBeenCalledWith("2");
|
||||
expect(mockApi.deleteOne).toHaveBeenCalledWith("3");
|
||||
|
||||
expect(mockApi.getAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("deleteMany handles empty array", async () => {
|
||||
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
|
||||
|
||||
mockApi.deleteOne = vi.fn();
|
||||
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
await actions.deleteMany([]);
|
||||
|
||||
expect(mockApi.deleteOne).not.toHaveBeenCalled();
|
||||
expect(mockApi.getAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("deleteMany sets loading state", async () => {
|
||||
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
|
||||
|
||||
mockApi.deleteOne = vi.fn().mockResolvedValue({});
|
||||
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
const promise = actions.deleteMany(["1"]);
|
||||
expect(mockLoading.value).toBe(true);
|
||||
|
||||
await promise;
|
||||
expect(mockLoading.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ interface StoreActions<T extends BoundT> extends ReadOnlyStoreActions<T> {
|
||||
createOne(createData: T): Promise<T | null>;
|
||||
updateOne(updateData: T): Promise<T | null>;
|
||||
deleteOne(id: string | number): Promise<T | null>;
|
||||
deleteMany(ids: (string | number)[]): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,11 +166,23 @@ export function useStoreActions<T extends BoundT>(
|
||||
return response?.data || null;
|
||||
}
|
||||
|
||||
async function deleteMany(ids: (string | number)[]) {
|
||||
loading.value = true;
|
||||
for (const id of ids) {
|
||||
await api.deleteOne(id);
|
||||
}
|
||||
if (allRef?.value) {
|
||||
await refresh();
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
getAll,
|
||||
refresh,
|
||||
createOne,
|
||||
updateOne,
|
||||
deleteOne,
|
||||
deleteMany,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const useStore = function <T extends BoundT>(
|
||||
return await storeActions.refresh(1, -1, params);
|
||||
},
|
||||
flushStore() {
|
||||
store = ref([]);
|
||||
store.value = [];
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
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();
|
||||
|
||||
@@ -36,8 +36,28 @@ function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, us
|
||||
return returnVal;
|
||||
}
|
||||
|
||||
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true) {
|
||||
const { quantity, food, unit, note, title } = ingredient;
|
||||
function useRecipeLink(recipe: Recipe | undefined, groupSlug: string | undefined): string | undefined {
|
||||
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 usePluralFood = (!quantity) || quantity * scale > 1;
|
||||
|
||||
@@ -63,14 +83,14 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1,
|
||||
}
|
||||
|
||||
const unitName = useUnitName(unit || undefined, usePluralUnit);
|
||||
const foodName = useFoodName(food || undefined, usePluralFood);
|
||||
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
|
||||
|
||||
return {
|
||||
title: title ? sanitizeIngredientHTML(title) : undefined,
|
||||
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
|
||||
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
|
||||
name: foodName ? sanitizeIngredientHTML(foodName) : undefined,
|
||||
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
|
||||
note: note ? sanitizeIngredientHTML(note) : undefined,
|
||||
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -97,13 +97,8 @@ export function useShoppingListCrud(
|
||||
.sort(sortCheckedItems);
|
||||
}
|
||||
|
||||
// Update the item if it's checked, otherwise updateUncheckedListItems will handle it
|
||||
if (item.checked) {
|
||||
shoppingListItemActions.updateItem(item);
|
||||
}
|
||||
|
||||
shoppingListItemActions.updateItem(item);
|
||||
updateListItemOrder();
|
||||
updateUncheckedListItems();
|
||||
}
|
||||
|
||||
function deleteListItem(item: ShoppingListItemOut) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
@@ -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 });
|
||||
|
||||
function toggleShowLabel(key: string) {
|
||||
labelOpenState.value[key] = !labelOpenState.value[key];
|
||||
}
|
||||
|
||||
function getLabelColor(item: ShoppingListItemOut | null) {
|
||||
return item?.label?.color;
|
||||
function getLabelColor(label: string) {
|
||||
return labelColorByName.value[label];
|
||||
}
|
||||
|
||||
const presentLabels = computed(() => {
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
export { useCategoryStore, usePublicCategoryStore, useCategoryData } from "./use-category-store";
|
||||
export { useFoodStore, usePublicFoodStore, useFoodData } from "./use-food-store";
|
||||
export { useHouseholdStore, usePublicHouseholdStore } from "./use-household-store";
|
||||
export { useLabelStore, useLabelData } from "./use-label-store";
|
||||
export { useTagStore, usePublicTagStore, useTagData } from "./use-tag-store";
|
||||
export { useToolStore, usePublicToolStore, useToolData } from "./use-tool-store";
|
||||
export { useUnitStore, useUnitData } from "./use-unit-store";
|
||||
import { resetCategoryStore } from "./use-category-store";
|
||||
import { resetFoodStore } from "./use-food-store";
|
||||
import { resetHouseholdStore } from "./use-household-store";
|
||||
import { resetLabelStore } from "./use-label-store";
|
||||
import { resetTagStore } from "./use-tag-store";
|
||||
import { resetToolStore } from "./use-tool-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 publicLoading = ref(false);
|
||||
|
||||
export function resetCategoryStore() {
|
||||
store.value = [];
|
||||
loading.value = false;
|
||||
publicLoading.value = false;
|
||||
}
|
||||
|
||||
export const useCategoryData = function () {
|
||||
return useData<RecipeCategory>({
|
||||
id: "",
|
||||
|
||||
@@ -7,6 +7,12 @@ const cookbooks: Ref<ReadCookBook[]> = ref([]);
|
||||
const loading = ref(false);
|
||||
const publicLoading = ref(false);
|
||||
|
||||
export function resetCookbookStore() {
|
||||
cookbooks.value = [];
|
||||
loading.value = false;
|
||||
publicLoading.value = false;
|
||||
}
|
||||
|
||||
export const useCookbookStore = function (i18n?: Composer) {
|
||||
const api = useUserApi(i18n);
|
||||
const store = useStore<ReadCookBook>("cookbook", cookbooks, loading, api.cookbooks);
|
||||
|
||||
@@ -7,6 +7,12 @@ const store: Ref<IngredientFood[]> = ref([]);
|
||||
const loading = ref(false);
|
||||
const publicLoading = ref(false);
|
||||
|
||||
export function resetFoodStore() {
|
||||
store.value = [];
|
||||
loading.value = false;
|
||||
publicLoading.value = false;
|
||||
}
|
||||
|
||||
export const useFoodData = function () {
|
||||
return useData<IngredientFood>({
|
||||
id: "",
|
||||
|
||||
@@ -7,6 +7,12 @@ const store: Ref<HouseholdSummary[]> = ref([]);
|
||||
const loading = ref(false);
|
||||
const publicLoading = ref(false);
|
||||
|
||||
export function resetHouseholdStore() {
|
||||
store.value = [];
|
||||
loading.value = false;
|
||||
publicLoading.value = false;
|
||||
}
|
||||
|
||||
export const useHouseholdStore = function (i18n?: Composer) {
|
||||
const api = useUserApi(i18n);
|
||||
return useReadOnlyStore<HouseholdSummary>("household", store, loading, api.households);
|
||||
|
||||
@@ -6,6 +6,11 @@ import { useUserApi } from "~/composables/api";
|
||||
const store: Ref<MultiPurposeLabelOut[]> = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
export function resetLabelStore() {
|
||||
store.value = [];
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
export const useLabelData = function () {
|
||||
return useData<MultiPurposeLabelOut>({
|
||||
groupId: "",
|
||||
|
||||
@@ -7,6 +7,12 @@ const store: Ref<RecipeTag[]> = ref([]);
|
||||
const loading = ref(false);
|
||||
const publicLoading = ref(false);
|
||||
|
||||
export function resetTagStore() {
|
||||
store.value = [];
|
||||
loading.value = false;
|
||||
publicLoading.value = false;
|
||||
}
|
||||
|
||||
export const useTagData = function () {
|
||||
return useData<RecipeTag>({
|
||||
id: "",
|
||||
|
||||
@@ -11,6 +11,12 @@ const store: Ref<RecipeTool[]> = ref([]);
|
||||
const loading = ref(false);
|
||||
const publicLoading = ref(false);
|
||||
|
||||
export function resetToolStore() {
|
||||
store.value = [];
|
||||
loading.value = false;
|
||||
publicLoading.value = false;
|
||||
}
|
||||
|
||||
export const useToolData = function () {
|
||||
return useData<RecipeToolWithOnHand>({
|
||||
id: "",
|
||||
|
||||
@@ -6,6 +6,11 @@ import { useUserApi } from "~/composables/api";
|
||||
const store: Ref<IngredientUnit[]> = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
export function resetUnitStore() {
|
||||
store.value = [];
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
export const useUnitData = function () {
|
||||
return useData<IngredientUnit>({
|
||||
id: "",
|
||||
|
||||
@@ -7,6 +7,11 @@ import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
|
||||
const store: Ref<UserSummary[]> = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
export function resetUserStore() {
|
||||
store.value = [];
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
class GroupUserAPIReadOnly extends BaseCRUDAPIReadOnly<UserSummary> {
|
||||
baseRoute = "/api/groups/members";
|
||||
itemRoute = (idOrUsername: string | number) => `/groups/members/${idOrUsername}`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, computed } from "vue";
|
||||
import type { UserOut } from "~/lib/api/types/user";
|
||||
import { clearAllStores } from "~/composables/store";
|
||||
|
||||
interface AuthData {
|
||||
value: UserOut | null;
|
||||
@@ -23,10 +24,15 @@ const authUser = ref<UserOut | null>(null);
|
||||
const authStatus = ref<"loading" | "authenticated" | "unauthenticated">("loading");
|
||||
|
||||
export const useAuthBackend = function (): AuthState {
|
||||
const { $axios } = useNuxtApp();
|
||||
const { $appInfo, $axios } = useNuxtApp();
|
||||
const router = useRouter();
|
||||
const tokenName = useRuntimeConfig().public.AUTH_TOKEN;
|
||||
const tokenCookie = useCookie(tokenName);
|
||||
|
||||
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;
|
||||
@@ -96,6 +102,13 @@ export const useAuthBackend = function (): AuthState {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -115,30 +128,6 @@ export const useAuthBackend = function (): AuthState {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh user data periodically when authenticated
|
||||
if (import.meta.client) {
|
||||
let refreshInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
watch(() => authStatus.value, (status) => {
|
||||
if (status === "authenticated") {
|
||||
refreshInterval = setInterval(() => {
|
||||
if (tokenCookie.value) {
|
||||
getSession().catch(() => {
|
||||
// Ignore errors in background refresh
|
||||
});
|
||||
}
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
}
|
||||
else {
|
||||
// Clear interval when not authenticated
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
}
|
||||
}, { immediate: true });
|
||||
}
|
||||
|
||||
return {
|
||||
data: computed(() => authUser.value),
|
||||
status: computed(() => authStatus.value),
|
||||
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.dinner"), value: "dinner" },
|
||||
{ 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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -21,19 +21,19 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Українська (Ukrainian)",
|
||||
value: "uk-UA",
|
||||
progress: 55,
|
||||
progress: 99,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Türkçe (Turkish)",
|
||||
value: "tr-TR",
|
||||
progress: 36,
|
||||
progress: 39,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Svenska (Swedish)",
|
||||
value: "sv-SE",
|
||||
progress: 53,
|
||||
progress: 67,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -45,7 +45,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Slovenščina (Slovenian)",
|
||||
value: "sl-SI",
|
||||
progress: 40,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -57,19 +57,19 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Pусский (Russian)",
|
||||
value: "ru-RU",
|
||||
progress: 41,
|
||||
progress: 46,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Română (Romanian)",
|
||||
value: "ro-RO",
|
||||
progress: 37,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Português (Portuguese)",
|
||||
value: "pt-PT",
|
||||
progress: 38,
|
||||
progress: 39,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -81,25 +81,25 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Polski (Polish)",
|
||||
value: "pl-PL",
|
||||
progress: 42,
|
||||
progress: 52,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Norsk (Norwegian)",
|
||||
value: "no-NO",
|
||||
progress: 40,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Nederlands (Dutch)",
|
||||
value: "nl-NL",
|
||||
progress: 52,
|
||||
progress: 55,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Latviešu (Latvian)",
|
||||
value: "lv-LV",
|
||||
progress: 36,
|
||||
progress: 35,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -111,43 +111,43 @@ export const LOCALES = [
|
||||
{
|
||||
name: "한국어 (Korean)",
|
||||
value: "ko-KR",
|
||||
progress: 9,
|
||||
progress: 22,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "日本語 (Japanese)",
|
||||
value: "ja-JP",
|
||||
progress: 37,
|
||||
progress: 36,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Italiano (Italian)",
|
||||
value: "it-IT",
|
||||
progress: 43,
|
||||
progress: 48,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Íslenska (Icelandic)",
|
||||
value: "is-IS",
|
||||
progress: 10,
|
||||
progress: 45,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Magyar (Hungarian)",
|
||||
value: "hu-HU",
|
||||
progress: 45,
|
||||
progress: 47,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Hrvatski (Croatian)",
|
||||
value: "hr-HR",
|
||||
progress: 28,
|
||||
progress: 29,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "עברית (Hebrew)",
|
||||
value: "he-IL",
|
||||
progress: 73,
|
||||
progress: 72,
|
||||
dir: "rtl",
|
||||
},
|
||||
{
|
||||
@@ -159,37 +159,37 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Français (French)",
|
||||
value: "fr-FR",
|
||||
progress: 67,
|
||||
progress: 69,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Français canadien (Canadian French)",
|
||||
value: "fr-CA",
|
||||
progress: 38,
|
||||
progress: 99,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Belge (Belgian)",
|
||||
value: "fr-BE",
|
||||
progress: 41,
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Suomi (Finnish)",
|
||||
value: "fi-FI",
|
||||
progress: 37,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Eesti (Estonian)",
|
||||
value: "et-EE",
|
||||
progress: 37,
|
||||
progress: 47,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Español (Spanish)",
|
||||
value: "es-ES",
|
||||
progress: 45,
|
||||
progress: 46,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -207,37 +207,37 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Ελληνικά (Greek)",
|
||||
value: "el-GR",
|
||||
progress: 41,
|
||||
progress: 42,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Deutsch (German)",
|
||||
value: "de-DE",
|
||||
progress: 80,
|
||||
progress: 97,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Dansk (Danish)",
|
||||
value: "da-DK",
|
||||
progress: 43,
|
||||
progress: 52,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Čeština (Czech)",
|
||||
value: "cs-CZ",
|
||||
progress: 42,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Català (Catalan)",
|
||||
value: "ca-ES",
|
||||
progress: 37,
|
||||
progress: 39,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Български (Bulgarian)",
|
||||
value: "bg-BG",
|
||||
progress: 44,
|
||||
progress: 51,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { useAuthBackend } from "~/composables/useAuthBackend";
|
||||
import { useAuthBackend } from "~/composables/use-auth-backend";
|
||||
import type { UserOut } from "~/lib/api/types/user";
|
||||
|
||||
export const useMealieAuth = function () {
|
||||
@@ -168,6 +168,7 @@ export function useQueryFilterBuilder() {
|
||||
|| type === Organizer.Tool
|
||||
|| type === Organizer.Food
|
||||
|| type === Organizer.Household
|
||||
|| type === Organizer.User
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user