mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-01-16 02:09:33 -05:00
Compare commits
313 Commits
v3.3.2
...
renovate/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84d44791c9 | ||
|
|
5ae35c3500 | ||
|
|
08666e6c21 | ||
|
|
5ae530a637 | ||
|
|
2b07497486 | ||
|
|
3b65642325 | ||
|
|
fdd1057e79 | ||
|
|
f1afebcc04 | ||
|
|
e711be7efa | ||
|
|
ec94b8179c | ||
|
|
a7c1d6f486 | ||
|
|
df0b792c52 | ||
|
|
1f5054fcbd | ||
|
|
ca483b9cbe | ||
|
|
03dc459162 | ||
|
|
cf8f5fe2a2 | ||
|
|
760350ef88 | ||
|
|
706d4ee0b5 | ||
|
|
5fd8545cbe | ||
|
|
3397c06db2 | ||
|
|
22df7a1ec7 | ||
|
|
e87b0c75b6 | ||
|
|
b406b7fa16 | ||
|
|
7114ed1122 | ||
|
|
70b5865dce | ||
|
|
3be7056f2c | ||
|
|
1b57310535 | ||
|
|
2b15d9a515 | ||
|
|
adc9c0b970 | ||
|
|
bec1708891 | ||
|
|
66bb545454 | ||
|
|
c1ebf04291 | ||
|
|
3166060644 | ||
|
|
bde7cf6f9d | ||
|
|
8ea9bb19f6 | ||
|
|
6d0f9b0d35 | ||
|
|
df541c1924 | ||
|
|
9af92ff397 | ||
|
|
554d50b079 | ||
|
|
a00e2e8b68 | ||
|
|
4fcfbaff3b | ||
|
|
7792f0504d | ||
|
|
3ca6c67f25 | ||
|
|
2eb0fdc863 | ||
|
|
192d48c4a6 | ||
|
|
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 🧪
|
||||
|
||||
6
.gitignore
vendored
6
.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/*
|
||||
|
||||
@@ -69,8 +70,11 @@ wheels/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# packaged output - temporarily written here by `uv build`
|
||||
/mealie-*
|
||||
|
||||
# frontend copied into Python module for packaging purposes
|
||||
/mealie/frontend/
|
||||
/mealie/frontend
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
|
||||
@@ -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.11
|
||||
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:b2b2184ba9b78c022e1d6a7924ec6fba577adf28f15c9d9c457730cc4ad3807a \
|
||||
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)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many Identity Providers (IdP), including:
|
||||
|
||||
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
||||
- [Authentik](https://integrations.goauthentik.io/documentation/mealie/)
|
||||
- [Authelia](https://www.authelia.com/integration/openid-connect/mealie/)
|
||||
- [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc)
|
||||
- [Okta](https://www.okta.com/openid-connect/)
|
||||
@@ -68,7 +68,6 @@ Example configurations for several Identity Providers have been provided by the
|
||||
|
||||
If you don't see your provider and have successfully set it up, please consider [creating your own example](https://github.com/mealie-recipes/mealie/discussions/new?category=oauth-provider-example) so that others can have a smoother setup.
|
||||
|
||||
|
||||
## Migration from Mealie v1.x
|
||||
|
||||
**High level changes**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -84,13 +85,13 @@ The meal planner has the concept of plan rules. These offer a flexible way to us
|
||||
|
||||
The shopping lists feature is a great way to keep track of what you need to buy for your next meal. You can add items directly to the shopping list or link a recipe and all of it's ingredients to track meals during the week.
|
||||
|
||||
Managing shopping lists can be done from the Sidebar > Shopping Lists.
|
||||
Managing shopping lists can be done from the Sidebar > Shopping Lists.
|
||||
|
||||
Here you will be able to:
|
||||
|
||||
- See items already on the Shopping List
|
||||
- See linked recipes with ingredients
|
||||
- Toggling via the 'Pot' icon will show you the linked recipe, allowing you to click to access it.
|
||||
- Toggling via the 'Pot' icon will show you the linked recipe, allowing you to click to access it.
|
||||
- Check off an item
|
||||
- Add / Change / Remove / Sort Items via the grid icon
|
||||
- Be sure if you are modifying an ingredient to click the 'Save' icon.
|
||||
@@ -102,13 +103,10 @@ Here you will be able to:
|
||||
|
||||
!!! tip
|
||||
You can use Labels to categorize your ingredients. You may want to Label by Food Type (Frozen, Fresh, etc), by Store, Tool, Recipe, or more. Play around with this to see what works best for you.
|
||||
|
||||
!!! tip
|
||||
You can toggle 'Food' on items so that if you add multiple of the same food / ingredient, Mealie will automatically combine them together. Do this by editing an item in the Shopping List and clicking the 'Apple' icon. If you then have recipes that contain "1 | cup | cheese" and "2 | cup | cheese" this would be combined to show "3 cups of cheese."
|
||||
|
||||
[See FAQ for more information](../getting-started/faq.md)
|
||||
|
||||
|
||||
|
||||
[Shopping List Demo](https://demo.mealie.io/shopping-lists){ .md-button .md-button--primary }
|
||||
|
||||
## Integrations
|
||||
@@ -197,7 +195,7 @@ Mealie lets you fully customize how you organize your users. You can use Groups
|
||||
|
||||
### Groups
|
||||
|
||||
Groups are fully isolated instances of Mealie. Think of a goup as a completely separate, fully self-contained site. There is no data shared between groups. Each group has its own users, recipes, tags, categories, etc. A user logged-in to one group cannot make any changes to another.
|
||||
Groups are fully isolated instances of Mealie. Think of a group as a completely separate, fully self-contained site. There is no data shared between groups. Each group has its own users, recipes, tags, categories, etc. A user logged-in to one group cannot make any changes to another.
|
||||
|
||||
Common use cases for groups include:
|
||||
|
||||
|
||||
@@ -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.9.2`
|
||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||
4. Restart the container
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.3.1 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.9.2 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.3.1 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.9.2 # (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>
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
density="compact"
|
||||
class="mt-0 flex-shrink-0"
|
||||
color="null"
|
||||
@change="$emit('checked', listItem)"
|
||||
@click="() => {
|
||||
listItem.checked = !listItem.checked
|
||||
$emit('checked', listItem)
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="ml-2 text-truncate"
|
||||
@@ -130,9 +133,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>
|
||||
|
||||
@@ -4,13 +4,22 @@
|
||||
<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"
|
||||
control-variant="stacked"
|
||||
inset
|
||||
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 +167,15 @@ export default defineNuxtComponent({
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue.quantity,
|
||||
() => {
|
||||
if (!props.modelValue.quantity) {
|
||||
listItem.value.quantity = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.modelValue.food,
|
||||
(newFood) => {
|
||||
|
||||
@@ -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,9 +29,9 @@ 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",
|
||||
"scrolling", "cite", "datetime", "name", "abbr", "target", "border", "start",
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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: 41,
|
||||
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: 44,
|
||||
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: 53,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Norsk (Norwegian)",
|
||||
value: "no-NO",
|
||||
progress: 40,
|
||||
progress: 42,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Nederlands (Dutch)",
|
||||
value: "nl-NL",
|
||||
progress: 52,
|
||||
progress: 59,
|
||||
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: 38,
|
||||
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: 46,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Magyar (Hungarian)",
|
||||
value: "hu-HU",
|
||||
progress: 45,
|
||||
progress: 48,
|
||||
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: 71,
|
||||
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: 47,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -201,43 +201,43 @@ export const LOCALES = [
|
||||
{
|
||||
name: "British English",
|
||||
value: "en-GB",
|
||||
progress: 43,
|
||||
progress: 45,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
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: 44,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Català (Catalan)",
|
||||
value: "ca-ES",
|
||||
progress: 37,
|
||||
progress: 40,
|
||||
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 () {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user