* Extended functionalities
* Added different UIs
This commit is contained in:
17
meal-plan-frontend-angular/.editorconfig
Normal file
17
meal-plan-frontend-angular/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
42
meal-plan-frontend-angular/.gitignore
vendored
Normal file
42
meal-plan-frontend-angular/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
4
meal-plan-frontend-angular/.vscode/extensions.json
vendored
Normal file
4
meal-plan-frontend-angular/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
meal-plan-frontend-angular/.vscode/launch.json
vendored
Normal file
20
meal-plan-frontend-angular/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
meal-plan-frontend-angular/.vscode/tasks.json
vendored
Normal file
42
meal-plan-frontend-angular/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
30
meal-plan-frontend-angular/Dockerfile
Normal file
30
meal-plan-frontend-angular/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
# ---------- Build stage ----------
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci || npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---------- Serve stage ----------
|
||||
FROM nginx:alpine AS final
|
||||
|
||||
RUN apk add --no-cache openssl && \
|
||||
mkdir -p /etc/nginx/certs && \
|
||||
openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
|
||||
-keyout /etc/nginx/certs/server.key \
|
||||
-out /etc/nginx/certs/server.crt \
|
||||
-subj "/CN=dailymeals.local" && \
|
||||
chmod 600 /etc/nginx/certs/server.key
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=build /app/dist/meal-plan-frontend-angular/browser /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80 443
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget --no-verbose --no-check-certificate --tries=1 --spider https://localhost/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
56
meal-plan-frontend-angular/README.md
Normal file
56
meal-plan-frontend-angular/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# DailyMeals — Angular frontend
|
||||
|
||||
Angular 19 rewrite of the React `meal-plan-frontend` app.
|
||||
|
||||
## Stack
|
||||
|
||||
- Angular 19 (standalone components)
|
||||
- Tailwind CSS 3
|
||||
- `@ngx-translate` (EN / PL)
|
||||
- Reactive forms
|
||||
- JWT auth with silent refresh (`AuthInterceptor`)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd meal-plan-frontend-angular
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
App runs at **http://localhost:4200** with API proxied to `http://localhost:5000` (`proxy.conf.json`).
|
||||
|
||||
Ensure the ASP.NET API is running locally before signing in.
|
||||
|
||||
## Production build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Output: `dist/meal-plan-frontend-angular/browser/`
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t dailymeals-angular .
|
||||
```
|
||||
|
||||
Uses nginx with HTTPS and `/api` reverse proxy (same pattern as the React frontend).
|
||||
|
||||
## Routes
|
||||
|
||||
| Path | Page |
|
||||
|------|------|
|
||||
| `/login`, `/register` | Auth (public) |
|
||||
| `/` | Dashboard |
|
||||
| `/breakfast` … `/dinner` | Category lists |
|
||||
| `/all-meals` | Search, select, PDF export |
|
||||
| `/manage-meals` | Manual add + Excel import |
|
||||
| `/recipes/:id` | Recipe detail |
|
||||
|
||||
## i18n
|
||||
|
||||
Translation files: `public/i18n/en.json`, `public/i18n/pl.json`
|
||||
|
||||
Language preference stored in `localStorage` key `dailymeals.lang`.
|
||||
127
meal-plan-frontend-angular/angular.json
Normal file
127
meal-plan-frontend-angular/angular.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"meal-plan-frontend-angular": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:class": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:component": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"skipTests": true
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/meal-plan-frontend-angular",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "1.5MB",
|
||||
"maximumError": "2MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "meal-plan-frontend-angular:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "meal-plan-frontend-angular:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
}
|
||||
}
|
||||
84
meal-plan-frontend-angular/nginx.conf
Normal file
84
meal-plan-frontend-angular/nginx.conf
Normal file
@@ -0,0 +1,84 @@
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
server_tokens off;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml
|
||||
font/woff2;
|
||||
|
||||
upstream api_upstream {
|
||||
server api:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/server.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/server.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /i18n/ {
|
||||
expires 1h;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
16757
meal-plan-frontend-angular/package-lock.json
generated
Normal file
16757
meal-plan-frontend-angular/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
meal-plan-frontend-angular/package.json
Normal file
44
meal-plan-frontend-angular/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "meal-plan-frontend-angular",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/compiler": "^19.2.0",
|
||||
"@angular/core": "^19.2.0",
|
||||
"@angular/forms": "^19.2.0",
|
||||
"@angular/platform-browser": "^19.2.0",
|
||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"@ngx-translate/core": "^18.0.0",
|
||||
"@ngx-translate/http-loader": "^18.0.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.2.27",
|
||||
"@angular/cli": "^19.2.27",
|
||||
"@angular/compiler-cli": "^19.2.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"jasmine-core": "~5.6.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "~5.7.2"
|
||||
}
|
||||
}
|
||||
6
meal-plan-frontend-angular/postcss.config.js
Normal file
6
meal-plan-frontend-angular/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
7
meal-plan-frontend-angular/proxy.conf.json
Normal file
7
meal-plan-frontend-angular/proxy.conf.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
BIN
meal-plan-frontend-angular/public/favicon.ico
Normal file
BIN
meal-plan-frontend-angular/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
7
meal-plan-frontend-angular/public/favicon.svg
Normal file
7
meal-plan-frontend-angular/public/favicon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#16774c" />
|
||||
<path
|
||||
d="M11 7v8a3 3 0 0 0 3 3v7a1 1 0 0 0 2 0v-7a3 3 0 0 0 3-3V7a1 1 0 0 0-2 0v5a1 1 0 0 1-2 0V7a1 1 0 0 0-2 0v5a1 1 0 0 1-2 0V7a1 1 0 0 0-2 0Z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 297 B |
434
meal-plan-frontend-angular/public/i18n/en.json
Normal file
434
meal-plan-frontend-angular/public/i18n/en.json
Normal file
@@ -0,0 +1,434 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "DailyMeals"
|
||||
},
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"en": "English",
|
||||
"pl": "Polish"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"sidebar": "Sidebar",
|
||||
"topnav": "Top navigation",
|
||||
"compact": "Compact sidebar",
|
||||
"wide": "Wide sidebar",
|
||||
"wideTagline": "Plan your meals with ease"
|
||||
},
|
||||
"appearance": {
|
||||
"label": "Appearance",
|
||||
"forest": "Forest Classic",
|
||||
"forestDesc": "Green palette, outline icons",
|
||||
"ocean": "Ocean Fresh",
|
||||
"oceanDesc": "Blue palette, outline icons",
|
||||
"sunset": "Sunset Kitchen",
|
||||
"sunsetDesc": "Warm amber palette, emoji icons",
|
||||
"berry": "Berry Modern",
|
||||
"berryDesc": "Violet palette, bold icons"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"breakfast": "Breakfast",
|
||||
"secondBreakfast": "Second Breakfast",
|
||||
"lunch": "Lunch",
|
||||
"dinner": "Dinner",
|
||||
"allMeals": "All Meals",
|
||||
"manageMeals": "Manage Meals",
|
||||
"mealPlanner": "Meal Planner",
|
||||
"shoppingList": "Shopping List",
|
||||
"dietTracker": "My Diet",
|
||||
"ingredientCatalog": "Ingredients",
|
||||
"dietGenerator": "Diet Generator",
|
||||
"recipeGenerator": "Recipe Generator",
|
||||
"logout": "Logout",
|
||||
"user": "User",
|
||||
"toggleNav": "Toggle navigation",
|
||||
"toggleTheme": "Toggle theme",
|
||||
"lightMode": "Switch to light mode",
|
||||
"darkMode": "Switch to dark mode"
|
||||
},
|
||||
"categories": {
|
||||
"breakfast": "Breakfast",
|
||||
"secondBreakfast": "Second Breakfast",
|
||||
"lunch": "Lunch",
|
||||
"dinner": "Dinner",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"tryAgain": "Try again",
|
||||
"back": "Back",
|
||||
"add": "Add",
|
||||
"category": "Category",
|
||||
"all": "All",
|
||||
"search": "Search",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"optional": "optional",
|
||||
"recipeCount_one": "{{count}} recipe",
|
||||
"recipeCount_other": "{{count}} recipes",
|
||||
"selectedCount_one": "{{count}} selected",
|
||||
"selectedCount_other": "{{count}} selected"
|
||||
},
|
||||
"auth": {
|
||||
"signInSubtitle": "Sign in to plan your meals",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"signIn": "Sign in",
|
||||
"signingIn": "Signing in...",
|
||||
"noAccount": "Don't have an account?",
|
||||
"createOne": "Create one",
|
||||
"createAccountTitle": "Create account",
|
||||
"createAccountSubtitle": "Join DailyMeals to browse and plan meals",
|
||||
"displayName": "Display name",
|
||||
"displayNamePlaceholder": "Piotr",
|
||||
"confirmPassword": "Confirm password",
|
||||
"passwordHint": "At least 8 characters with uppercase, lowercase, and a number.",
|
||||
"createAccount": "Create account",
|
||||
"creatingAccount": "Creating account...",
|
||||
"hasAccount": "Already have an account?"
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "Email is required.",
|
||||
"emailInvalid": "Enter a valid email address.",
|
||||
"passwordRequired": "Password is required.",
|
||||
"displayNameTooLong": "Display name is too long.",
|
||||
"passwordMinLength": "Password must be at least 8 characters.",
|
||||
"passwordUppercase": "Include at least one uppercase letter.",
|
||||
"passwordLowercase": "Include at least one lowercase letter.",
|
||||
"passwordDigit": "Include at least one digit.",
|
||||
"confirmPasswordRequired": "Please confirm your password.",
|
||||
"passwordsMismatch": "Passwords do not match.",
|
||||
"recipeNameRequired": "Recipe name is required.",
|
||||
"ingredientNameRequired": "Ingredient name is required.",
|
||||
"stepNumberMin": "Step number must be at least 1.",
|
||||
"stepDescriptionRequired": "Step description is required.",
|
||||
"stepsMin": "Add at least one preparation step."
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Something went wrong.",
|
||||
"signInFailed": "Unable to sign in.",
|
||||
"registerFailed": "Unable to create account.",
|
||||
"loadCategoriesFailed": "Failed to load categories.",
|
||||
"loadRecipesFailed": "Failed to load recipes.",
|
||||
"loadRecipeFailed": "Failed to load recipe.",
|
||||
"loadDietFailed": "Failed to load diet data.",
|
||||
"loadMealPlanFailed": "Failed to load meal plan.",
|
||||
"loadShoppingListFailed": "Failed to load shopping list.",
|
||||
"loadIngredientCatalogFailed": "Failed to load ingredient catalog.",
|
||||
"saveIngredientFailed": "Failed to save ingredient.",
|
||||
"saveRecipeFailed": "Failed to save recipe.",
|
||||
"recalculateMacrosFailed": "Failed to recalculate recipe macros.",
|
||||
"importFailed": "Import failed.",
|
||||
"pdfLoadFailed": "Could not load recipes for export.",
|
||||
"pdfGenerateFailed": "PDF generation failed.",
|
||||
"pdfRenderAreaMissing": "PDF render area not found.",
|
||||
"pdfNothingToExport": "Nothing to export."
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Welcome back",
|
||||
"welcomeNamed": "Welcome back, {{name}}",
|
||||
"subtitle": "Browse recipes by meal category or explore everything at once.",
|
||||
"viewRecipes": "View recipes"
|
||||
},
|
||||
"recipes": {
|
||||
"allMealsTitle": "All Meals",
|
||||
"allMealsSubtitle": "Select recipes and export them with a combined shopping list.",
|
||||
"selectAllVisible": "Select all visible",
|
||||
"deselectAll": "Deselect all",
|
||||
"searchRecipes": "Search recipes...",
|
||||
"searchByIngredient": "Search by ingredient...",
|
||||
"searchByIngredientLabel": "Search by ingredient",
|
||||
"searchByNameAria": "Search recipes by name",
|
||||
"searchByIngredientAria": "Search recipes by ingredient",
|
||||
"noMatchingTitle": "No matching recipes",
|
||||
"noMatchingIngredient": "No recipes contain that ingredient. Try a different ingredient name.",
|
||||
"noMatchingFilter": "Try adjusting your search or category filter.",
|
||||
"emptyCategoryTitle": "No {{category}} recipes yet",
|
||||
"emptyCategoryDescription": "There are currently no recipes in this category.",
|
||||
"selectRecipe": "Select {{name}}",
|
||||
"prepTime": "{{count}} min preparation",
|
||||
"calories": "Calories",
|
||||
"protein": "Protein",
|
||||
"fat": "Fat",
|
||||
"carbs": "Carbs",
|
||||
"ingredients": "Ingredients",
|
||||
"preparation": "Preparation",
|
||||
"noIngredients": "No ingredients listed.",
|
||||
"noSteps": "No steps listed.",
|
||||
"kcal": "{{count}} kcal",
|
||||
"minutes": "{{count}} min"
|
||||
},
|
||||
"manage": {
|
||||
"title": "Manage Meals",
|
||||
"subtitle": "Add recipes manually or import them from an Excel spreadsheet.",
|
||||
"tabManual": "Add manually",
|
||||
"tabEdit": "Edit recipes",
|
||||
"tabImport": "Import Excel",
|
||||
"savedSuccess": "\"{{name}}\" saved successfully.",
|
||||
"basicInfo": "Basic info",
|
||||
"recipeName": "Recipe name",
|
||||
"recipeNamePlaceholder": "Grilled chicken salad",
|
||||
"prepTimeMin": "Prep time (min)",
|
||||
"caloriesKcal": "Calories (kcal)",
|
||||
"proteinG": "Protein (g)",
|
||||
"fatG": "Fat (g)",
|
||||
"carbsG": "Carbs (g)",
|
||||
"preparationSteps": "Preparation steps",
|
||||
"addStep": "Add step",
|
||||
"stepPlaceholder": "Describe this step...",
|
||||
"ingredientName": "Name",
|
||||
"ingredientGrams": "Grams",
|
||||
"ingredientUnit": "Unit",
|
||||
"removeIngredient": "Remove ingredient",
|
||||
"removeStep": "Remove step",
|
||||
"saveRecipe": "Save recipe",
|
||||
"updateRecipe": "Update recipe",
|
||||
"updatedSuccess": "\"{{name}}\" updated successfully.",
|
||||
"selectRecipeToEdit": "Select a recipe from the list to edit.",
|
||||
"catalogLink": "Link to ingredient catalog",
|
||||
"estimatedFromCatalog": "Estimated from linked catalog items",
|
||||
"unlinkedIngredientsWarning": "{{count}} ingredient(s) have grams but no catalog link — macros may be incomplete.",
|
||||
"recalculateMacros": "Recalculate all macros",
|
||||
"recalculating": "Recalculating…",
|
||||
"recalculateSuccess": "Updated {{updated}} of {{processed}} recipes. {{unlinked}} ingredient row(s) still unlinked.",
|
||||
"macroHint": "Estimated: {{calories}} kcal · P {{protein}}g · F {{fat}}g · C {{carbs}}g",
|
||||
"saving": "Saving...",
|
||||
"importTitle": "Import from Excel",
|
||||
"importDescription": "Use the template with three sheets: Recipes, Ingredients, and Steps. Recipe names must match across sheets.",
|
||||
"downloadTemplate": "Download template",
|
||||
"uploadLabel": "Upload filled .xlsx file",
|
||||
"importComplete": "Import complete",
|
||||
"createdCount": "Created: {{count}}",
|
||||
"skippedCount": "Skipped: {{count}}",
|
||||
"importRecipes": "Import recipes",
|
||||
"importing": "Importing...",
|
||||
"excelFormatTitle": "Excel format",
|
||||
"excelRecipesSheet": "Recipes: Name, MealCategory (Breakfast / Lunch / 0–3), Calories, Protein, Fat, Carbs, PrepTimeMinutes",
|
||||
"excelIngredientsSheet": "Ingredients: RecipeName, Name, AmountGrams, Unit, SortOrder",
|
||||
"excelStepsSheet": "Steps: RecipeName, StepNumber, Description",
|
||||
"duplicateSkipped": "Duplicate recipe names are skipped."
|
||||
},
|
||||
"generators": {
|
||||
"diet": {
|
||||
"title": "Diet Generator",
|
||||
"subtitle": "Calculate targets from your profile, build a weekly plan from your recipe catalog, and save it to the meal planner.",
|
||||
"profileTitle": "Your profile",
|
||||
"heightCm": "Height (cm)",
|
||||
"weightKg": "Weight (kg)",
|
||||
"age": "Age",
|
||||
"sex": "Sex",
|
||||
"male": "Male",
|
||||
"female": "Female",
|
||||
"activity": "Activity level",
|
||||
"sedentary": "Sedentary",
|
||||
"light": "Light",
|
||||
"moderate": "Moderate",
|
||||
"active": "Active",
|
||||
"veryActive": "Very active",
|
||||
"goal": "Goal",
|
||||
"loseWeight": "Lose weight",
|
||||
"maintain": "Maintain",
|
||||
"gainMuscle": "Gain muscle",
|
||||
"recomp": "Recomposition",
|
||||
"allergies": "Allergies / notes",
|
||||
"continue": "Calculate targets",
|
||||
"macrosTitle": "Daily targets",
|
||||
"acceptAndGenerate": "Accept & generate 7-day plan",
|
||||
"planHint": "Each day should be within ±10 kcal of your target. Lock meals you like, regenerate others.",
|
||||
"lock": "Lock",
|
||||
"unlock": "Unlock",
|
||||
"viewRecipe": "Details",
|
||||
"noAlternativeMeals": "No other recipes are available for this meal — all suitable options are already in your plan.",
|
||||
"commit": "Save plan to calendar",
|
||||
"done": "Plan saved to your meal planner and daily goals.",
|
||||
"newPlan": "Create another plan",
|
||||
"error": "Diet generator failed."
|
||||
},
|
||||
"recipe": {
|
||||
"title": "Recipe Generator",
|
||||
"subtitle": "Create several recipe drafts with AI, review them, and save the ones you like.",
|
||||
"constraintsTitle": "What should we cook?",
|
||||
"prompt": "Describe the meal",
|
||||
"promptPlaceholder": "Light breakfast with oatmeal and fruit…",
|
||||
"targetCalories": "Target calories (kcal)",
|
||||
"targetCaloriesOptional": "e.g. 500",
|
||||
"targetCaloriesHint": "Ingredient amounts will be scaled to match (±10 kcal).",
|
||||
"caloriesWithTarget": "{{actual}} kcal (target: {{target}})",
|
||||
"maxPrep": "Max prep time (min)",
|
||||
"exclusions": "Exclude ingredients",
|
||||
"generate": "Generate recipe",
|
||||
"recipeCount": "How many recipes to generate",
|
||||
"reviewHint": "Review {{count}} generated recipes. Uncheck or remove ones you don't want, then save the rest.",
|
||||
"selectAll": "Select all",
|
||||
"remove": "Remove",
|
||||
"saveSelected": "Save selected ({{count}})",
|
||||
"doneMultiple": "{{count}} recipes saved to your library.",
|
||||
"generateMore": "Generate more recipes",
|
||||
"regenIngredients": "Regenerate ingredients",
|
||||
"regenSteps": "Regenerate steps",
|
||||
"regenFull": "Regenerate all",
|
||||
"save": "Save to library",
|
||||
"done": "Recipe saved.",
|
||||
"viewRecipe": "View recipe",
|
||||
"error": "Recipe generator failed."
|
||||
}
|
||||
},
|
||||
"pdf": {
|
||||
"generate": "Generate PDF",
|
||||
"preparing": "Preparing...",
|
||||
"generating": "Generating...",
|
||||
"prepTime": "Prep time: {{count}} min",
|
||||
"shoppingList": "Shopping List",
|
||||
"generatedOn_one": "Generated {{date}} · {{count}} recipe",
|
||||
"generatedOn_other": "Generated {{date}} · {{count}} recipes",
|
||||
"recipesInExport": "Recipes in this export",
|
||||
"toTaste": "to taste",
|
||||
"dayPlanTitle": "Daily meal plan — {{date}}",
|
||||
"emptySlot": "Not planned",
|
||||
"emptyShoppingList": "No shopping items for this day.",
|
||||
"plannedMealMeta": "{{slot}} · {{portions}}× portions",
|
||||
"dayShoppingSubtitle": "List for {{date}} · {{count}} meals"
|
||||
},
|
||||
"diet": {
|
||||
"title": "My Diet",
|
||||
"subtitle": "Track what you eat and drink, set daily targets, and stay on top of reminders.",
|
||||
"tabToday": "Today",
|
||||
"tabHistory": "History",
|
||||
"tabGoals": "Goals",
|
||||
"tabReminders": "Reminders",
|
||||
"goToday": "Today",
|
||||
"notToday": "not today",
|
||||
"quickLog": "Quick log",
|
||||
"copyYesterday": "Copy yesterday's meals",
|
||||
"recentMeals": "Recent",
|
||||
"favorites": "Favorites",
|
||||
"days": "days",
|
||||
"historyCalories": "Calories over time",
|
||||
"historyMax": "Peak: {{max}} kcal",
|
||||
"noHistory": "No history for this period.",
|
||||
"mealsOnDate": "Meals on {{date}}",
|
||||
"drinksOnDate": "Drinks on {{date}}",
|
||||
"editReminder": "Edit reminder",
|
||||
"calories": "Calories",
|
||||
"water": "Water",
|
||||
"snack": "Snack",
|
||||
"logDrink": "Log a drink",
|
||||
"logMeal": "Mark a meal as eaten",
|
||||
"searchRecipe": "From a recipe",
|
||||
"orCustomMeal": "Or enter a custom meal name",
|
||||
"markEaten": "Mark as eaten",
|
||||
"mealsToday": "Meals today",
|
||||
"drinksToday": "Drinks today",
|
||||
"nothingLogged": "Nothing logged yet.",
|
||||
"noMacros": "No nutrition info",
|
||||
"removeEntry": "Remove entry",
|
||||
"dailyGoals": "Daily goals",
|
||||
"goalsHint": "Set targets like Lifesum or MyFitnessPal — the Today tab shows what is left.",
|
||||
"calorieGoal": "Calorie goal (kcal)",
|
||||
"waterGoal": "Water goal (ml)",
|
||||
"saveGoals": "Save goals",
|
||||
"addReminder": "Add reminder",
|
||||
"reminderType": "Type",
|
||||
"reminderWater": "Drink water",
|
||||
"reminderMeal": "Meal time",
|
||||
"reminderCustom": "Custom",
|
||||
"reminderTitle": "Title",
|
||||
"reminderTitlePlaceholder": "Drink a glass of water",
|
||||
"reminderTime": "Time",
|
||||
"saveReminder": "Save reminder",
|
||||
"yourReminders": "Your reminders",
|
||||
"noReminders": "No reminders yet.",
|
||||
"remaining": "Left",
|
||||
"portions": "Portions",
|
||||
"fromCatalog": "From ingredient catalog",
|
||||
"noCatalogItem": "None",
|
||||
"grams": "Amount (g)",
|
||||
"mealPreview": "This meal adds",
|
||||
"remainingAfter": "After logging you will still have",
|
||||
"suggestionsTitle": "What to eat next",
|
||||
"suggestionsHint": "Based on what you still need today.",
|
||||
"logSuggestion": "Log",
|
||||
"suggestionReasons": {
|
||||
"highProtein": "High protein",
|
||||
"needCarbs": "Good carbs",
|
||||
"needFat": "Healthy fats",
|
||||
"needCalories": "Energy boost",
|
||||
"balanced": "Balanced choice"
|
||||
}
|
||||
},
|
||||
"mealPlanner": {
|
||||
"title": "Meal Planner",
|
||||
"subtitle": "Plan recipes for each day of the week.",
|
||||
"today": "This week",
|
||||
"addRecipe": "Add recipe",
|
||||
"dailyTarget": "Daily calorie target",
|
||||
"daySummary": "{{logged}} / {{target}} kcal planned",
|
||||
"remaining": "{{count}} kcal left",
|
||||
"slotHint": "Suggested ~{{count}} kcal for this meal",
|
||||
"suggestionsTitle": "Suggestions for this slot",
|
||||
"noSuggestions": "Set a daily calorie target to see suggestions.",
|
||||
"estimatedMeal": "This meal: ~{{count}} kcal",
|
||||
"selectedRecipe": "Selected: {{name}}",
|
||||
"clearSelection": "Clear selection",
|
||||
"exportDay": "Export day plan (PDF)",
|
||||
"includingSelection": "incl. current pick: {{count}} kcal"
|
||||
},
|
||||
"shoppingList": {
|
||||
"title": "Shopping List",
|
||||
"subtitle": "Aggregated ingredients from your meal plan.",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"load": "Load list",
|
||||
"clearChecks": "Clear checked items",
|
||||
"empty": "No items for this date range.",
|
||||
"checklistHint": "Checked items are saved locally in your browser."
|
||||
},
|
||||
"ingredientCatalog": {
|
||||
"title": "Ingredient catalog",
|
||||
"subtitle": "Nutrition values per 100 g, grouped by food category.",
|
||||
"allCategories": "All",
|
||||
"searchPlaceholder": "Search ingredients…",
|
||||
"noResultsTitle": "No ingredients found",
|
||||
"noResultsDescription": "Try another category or search term.",
|
||||
"per100gNote": "All values are per 100 g.",
|
||||
"addIngredient": "Add ingredient",
|
||||
"editIngredient": "Edit ingredient",
|
||||
"selectCategory": "Select category",
|
||||
"nameEn": "Name (English)",
|
||||
"namePlLabel": "Name (Polish)",
|
||||
"formInvalid": "Please fill in category and name.",
|
||||
"deleteConfirm": "Remove this ingredient from the catalog?",
|
||||
"columns": {
|
||||
"name": "Ingredient",
|
||||
"category": "Category",
|
||||
"calories": "kcal",
|
||||
"protein": "Protein (g)",
|
||||
"fat": "Fat (g)",
|
||||
"carbs": "Carbs (g)",
|
||||
"fiber": "Fiber (g)"
|
||||
},
|
||||
"categories": {
|
||||
"meat": "Meat",
|
||||
"poultry": "Poultry",
|
||||
"fish": "Fish",
|
||||
"seafood": "Seafood",
|
||||
"vegetables": "Vegetables",
|
||||
"fruits": "Fruits",
|
||||
"dairy": "Dairy",
|
||||
"eggs": "Eggs",
|
||||
"grains": "Grains",
|
||||
"legumes": "Legumes",
|
||||
"nuts": "Nuts & seeds",
|
||||
"oils": "Oils & fats"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Page not found",
|
||||
"description": "The page you're looking for doesn't exist.",
|
||||
"backToDashboard": "Back to dashboard"
|
||||
}
|
||||
}
|
||||
440
meal-plan-frontend-angular/public/i18n/pl.json
Normal file
440
meal-plan-frontend-angular/public/i18n/pl.json
Normal file
@@ -0,0 +1,440 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "DailyMeals"
|
||||
},
|
||||
"language": {
|
||||
"label": "Język",
|
||||
"en": "Angielski",
|
||||
"pl": "Polski"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Układ",
|
||||
"sidebar": "Panel boczny",
|
||||
"topnav": "Nawigacja górna",
|
||||
"compact": "Wąski panel",
|
||||
"wide": "Szeroki panel",
|
||||
"wideTagline": "Planuj posiłki wygodnie"
|
||||
},
|
||||
"appearance": {
|
||||
"label": "Wygląd",
|
||||
"forest": "Leśny klasyczny",
|
||||
"forestDesc": "Zielona paleta, ikony konturowe",
|
||||
"ocean": "Oceaniczna świeżość",
|
||||
"oceanDesc": "Niebieska paleta, ikony konturowe",
|
||||
"sunset": "Zachód kuchni",
|
||||
"sunsetDesc": "Ciepła bursztynowa paleta, emoji",
|
||||
"berry": "Jagodowy modern",
|
||||
"berryDesc": "Fioletowa paleta, pogrubione ikony"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Panel",
|
||||
"breakfast": "Śniadanie",
|
||||
"secondBreakfast": "Drugie śniadanie",
|
||||
"lunch": "Obiad",
|
||||
"dinner": "Kolacja",
|
||||
"allMeals": "Wszystkie posiłki",
|
||||
"manageMeals": "Zarządzaj posiłkami",
|
||||
"mealPlanner": "Plan posiłków",
|
||||
"shoppingList": "Lista zakupów",
|
||||
"dietTracker": "Moja dieta",
|
||||
"ingredientCatalog": "Składniki",
|
||||
"dietGenerator": "Generator diety",
|
||||
"recipeGenerator": "Generator przepisów",
|
||||
"logout": "Wyloguj",
|
||||
"user": "Użytkownik",
|
||||
"toggleNav": "Przełącz nawigację",
|
||||
"toggleTheme": "Przełącz motyw",
|
||||
"lightMode": "Przełącz na jasny motyw",
|
||||
"darkMode": "Przełącz na ciemny motyw"
|
||||
},
|
||||
"categories": {
|
||||
"breakfast": "Śniadanie",
|
||||
"secondBreakfast": "Drugie śniadanie",
|
||||
"lunch": "Obiad",
|
||||
"dinner": "Kolacja",
|
||||
"unknown": "Nieznana"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Ładowanie...",
|
||||
"tryAgain": "Spróbuj ponownie",
|
||||
"back": "Wstecz",
|
||||
"add": "Dodaj",
|
||||
"category": "Kategoria",
|
||||
"all": "Wszystkie",
|
||||
"search": "Szukaj",
|
||||
"save": "Zapisz",
|
||||
"cancel": "Anuluj",
|
||||
"edit": "Edytuj",
|
||||
"delete": "Usuń",
|
||||
"optional": "opcjonalnie",
|
||||
"recipeCount_one": "{{count}} przepis",
|
||||
"recipeCount_few": "{{count}} przepisy",
|
||||
"recipeCount_many": "{{count}} przepisów",
|
||||
"recipeCount_other": "{{count}} przepisów",
|
||||
"selectedCount_one": "Wybrano: {{count}}",
|
||||
"selectedCount_few": "Wybrano: {{count}}",
|
||||
"selectedCount_many": "Wybrano: {{count}}",
|
||||
"selectedCount_other": "Wybrano: {{count}}"
|
||||
},
|
||||
"auth": {
|
||||
"signInSubtitle": "Zaloguj się, aby planować posiłki",
|
||||
"email": "E-mail",
|
||||
"password": "Hasło",
|
||||
"emailPlaceholder": "ty@example.com",
|
||||
"signIn": "Zaloguj się",
|
||||
"signingIn": "Logowanie...",
|
||||
"noAccount": "Nie masz konta?",
|
||||
"createOne": "Utwórz je",
|
||||
"createAccountTitle": "Utwórz konto",
|
||||
"createAccountSubtitle": "Dołącz do DailyMeals, aby przeglądać i planować posiłki",
|
||||
"displayName": "Nazwa wyświetlana",
|
||||
"displayNamePlaceholder": "Piotr",
|
||||
"confirmPassword": "Potwierdź hasło",
|
||||
"passwordHint": "Co najmniej 8 znaków, w tym wielka litera, mała litera i cyfra.",
|
||||
"createAccount": "Utwórz konto",
|
||||
"creatingAccount": "Tworzenie konta...",
|
||||
"hasAccount": "Masz już konto?"
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "E-mail jest wymagany.",
|
||||
"emailInvalid": "Podaj prawidłowy adres e-mail.",
|
||||
"passwordRequired": "Hasło jest wymagane.",
|
||||
"displayNameTooLong": "Nazwa wyświetlana jest zbyt długa.",
|
||||
"passwordMinLength": "Hasło musi mieć co najmniej 8 znaków.",
|
||||
"passwordUppercase": "Dołącz co najmniej jedną wielką literę.",
|
||||
"passwordLowercase": "Dołącz co najmniej jedną małą literę.",
|
||||
"passwordDigit": "Dołącz co najmniej jedną cyfrę.",
|
||||
"confirmPasswordRequired": "Potwierdź hasło.",
|
||||
"passwordsMismatch": "Hasła nie są identyczne.",
|
||||
"recipeNameRequired": "Nazwa przepisu jest wymagana.",
|
||||
"ingredientNameRequired": "Nazwa składnika jest wymagana.",
|
||||
"stepNumberMin": "Numer kroku musi być co najmniej 1.",
|
||||
"stepDescriptionRequired": "Opis kroku jest wymagany.",
|
||||
"stepsMin": "Dodaj co najmniej jeden krok przygotowania."
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Coś poszło nie tak.",
|
||||
"signInFailed": "Nie udało się zalogować.",
|
||||
"registerFailed": "Nie udało się utworzyć konta.",
|
||||
"loadCategoriesFailed": "Nie udało się załadować kategorii.",
|
||||
"loadRecipesFailed": "Nie udało się załadować przepisów.",
|
||||
"loadRecipeFailed": "Nie udało się załadować przepisu.",
|
||||
"loadDietFailed": "Nie udało się załadować danych diety.",
|
||||
"loadMealPlanFailed": "Nie udało się załadować planu posiłków.",
|
||||
"loadShoppingListFailed": "Nie udało się załadować listy zakupów.",
|
||||
"loadIngredientCatalogFailed": "Nie udało się załadować katalogu składników.",
|
||||
"saveIngredientFailed": "Nie udało się zapisać składnika.",
|
||||
"saveRecipeFailed": "Nie udało się zapisać przepisu.",
|
||||
"recalculateMacrosFailed": "Nie udało się przeliczyć makroskładników przepisów.",
|
||||
"importFailed": "Import nie powiódł się.",
|
||||
"pdfLoadFailed": "Nie udało się załadować przepisów do eksportu.",
|
||||
"pdfGenerateFailed": "Generowanie PDF nie powiodło się.",
|
||||
"pdfRenderAreaMissing": "Nie znaleziono obszaru renderowania PDF.",
|
||||
"pdfNothingToExport": "Brak danych do eksportu."
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Witaj ponownie",
|
||||
"welcomeNamed": "Witaj ponownie, {{name}}",
|
||||
"subtitle": "Przeglądaj przepisy według kategorii posiłków lub odkrywaj wszystkie naraz.",
|
||||
"viewRecipes": "Zobacz przepisy"
|
||||
},
|
||||
"recipes": {
|
||||
"allMealsTitle": "Wszystkie posiłki",
|
||||
"allMealsSubtitle": "Wybierz przepisy i wyeksportuj je wraz ze wspólną listą zakupów.",
|
||||
"selectAllVisible": "Zaznacz widoczne",
|
||||
"deselectAll": "Odznacz wszystkie",
|
||||
"searchRecipes": "Szukaj przepisów...",
|
||||
"searchByIngredient": "Szukaj po składniku...",
|
||||
"searchByIngredientLabel": "Szukaj po składniku",
|
||||
"searchByNameAria": "Szukaj przepisów po nazwie",
|
||||
"searchByIngredientAria": "Szukaj przepisów po składniku",
|
||||
"noMatchingTitle": "Brak pasujących przepisów",
|
||||
"noMatchingIngredient": "Żaden przepis nie zawiera tego składnika. Spróbuj innej nazwy.",
|
||||
"noMatchingFilter": "Spróbuj zmienić wyszukiwanie lub filtr kategorii.",
|
||||
"emptyCategoryTitle": "Brak przepisów: {{category}}",
|
||||
"emptyCategoryDescription": "W tej kategorii nie ma jeszcze żadnych przepisów.",
|
||||
"selectRecipe": "Wybierz {{name}}",
|
||||
"prepTime": "{{count}} min przygotowania",
|
||||
"calories": "Kalorie",
|
||||
"protein": "Białko",
|
||||
"fat": "Tłuszcz",
|
||||
"carbs": "Węglowodany",
|
||||
"ingredients": "Składniki",
|
||||
"preparation": "Przygotowanie",
|
||||
"noIngredients": "Brak składników.",
|
||||
"noSteps": "Brak kroków.",
|
||||
"kcal": "{{count}} kcal",
|
||||
"minutes": "{{count}} min"
|
||||
},
|
||||
"manage": {
|
||||
"title": "Zarządzaj posiłkami",
|
||||
"subtitle": "Dodawaj przepisy ręcznie lub importuj je z arkusza Excel.",
|
||||
"tabManual": "Dodaj ręcznie",
|
||||
"tabEdit": "Edytuj przepisy",
|
||||
"tabImport": "Import Excel",
|
||||
"savedSuccess": "„{{name}}” zapisano pomyślnie.",
|
||||
"basicInfo": "Podstawowe informacje",
|
||||
"recipeName": "Nazwa przepisu",
|
||||
"recipeNamePlaceholder": "Sałatka z grillowanym kurczakiem",
|
||||
"prepTimeMin": "Czas przygotowania (min)",
|
||||
"caloriesKcal": "Kalorie (kcal)",
|
||||
"proteinG": "Białko (g)",
|
||||
"fatG": "Tłuszcz (g)",
|
||||
"carbsG": "Węglowodany (g)",
|
||||
"preparationSteps": "Kroki przygotowania",
|
||||
"addStep": "Dodaj krok",
|
||||
"stepPlaceholder": "Opisz ten krok...",
|
||||
"ingredientName": "Nazwa",
|
||||
"ingredientGrams": "Gramy",
|
||||
"ingredientUnit": "Jednostka",
|
||||
"removeIngredient": "Usuń składnik",
|
||||
"removeStep": "Usuń krok",
|
||||
"saveRecipe": "Zapisz przepis",
|
||||
"updateRecipe": "Aktualizuj przepis",
|
||||
"updatedSuccess": "„{{name}}” zaktualizowano pomyślnie.",
|
||||
"selectRecipeToEdit": "Wybierz przepis z listy, aby go edytować.",
|
||||
"catalogLink": "Powiąż z katalogiem składników",
|
||||
"estimatedFromCatalog": "Szacunkowo z powiązanych składników",
|
||||
"unlinkedIngredientsWarning": "{{count}} składnik(ów) ma gramy, ale brak powiązania z katalogiem — makra mogą być niepełne.",
|
||||
"recalculateMacros": "Przelicz wszystkie makra",
|
||||
"recalculating": "Przeliczanie…",
|
||||
"recalculateSuccess": "Zaktualizowano {{updated}} z {{processed}} przepisów. {{unlinked}} wierszy składników nadal niepowiązanych.",
|
||||
"macroHint": "Szacunkowo: {{calories}} kcal · B {{protein}}g · T {{fat}}g · W {{carbs}}g",
|
||||
"saving": "Zapisywanie...",
|
||||
"importTitle": "Import z Excela",
|
||||
"importDescription": "Użyj szablonu z trzema arkuszami: Recipes, Ingredients i Steps. Nazwy przepisów muszą się zgadzać między arkuszami.",
|
||||
"downloadTemplate": "Pobierz szablon",
|
||||
"uploadLabel": "Prześlij wypełniony plik .xlsx",
|
||||
"importComplete": "Import zakończony",
|
||||
"createdCount": "Utworzono: {{count}}",
|
||||
"skippedCount": "Pominięto: {{count}}",
|
||||
"importRecipes": "Importuj przepisy",
|
||||
"importing": "Importowanie...",
|
||||
"excelFormatTitle": "Format Excela",
|
||||
"excelRecipesSheet": "Recipes: Name, MealCategory (Breakfast / Lunch / 0–3), Calories, Protein, Fat, Carbs, PrepTimeMinutes",
|
||||
"excelIngredientsSheet": "Ingredients: RecipeName, Name, AmountGrams, Unit, SortOrder",
|
||||
"excelStepsSheet": "Steps: RecipeName, StepNumber, Description",
|
||||
"duplicateSkipped": "Duplikaty nazw przepisów są pomijane."
|
||||
},
|
||||
"generators": {
|
||||
"diet": {
|
||||
"title": "Generator diety",
|
||||
"subtitle": "Oblicz cele z profilu, zbuduj tygodniowy plan z katalogu przepisów i zapisz w planerze.",
|
||||
"profileTitle": "Twój profil",
|
||||
"heightCm": "Wzrost (cm)",
|
||||
"weightKg": "Waga (kg)",
|
||||
"age": "Wiek",
|
||||
"sex": "Płeć",
|
||||
"male": "Mężczyzna",
|
||||
"female": "Kobieta",
|
||||
"activity": "Poziom aktywności",
|
||||
"sedentary": "Siedzący",
|
||||
"light": "Lekka",
|
||||
"moderate": "Umiarkowana",
|
||||
"active": "Aktywna",
|
||||
"veryActive": "Bardzo aktywna",
|
||||
"goal": "Cel",
|
||||
"loseWeight": "Redukcja wagi",
|
||||
"maintain": "Utrzymanie",
|
||||
"gainMuscle": "Budowa mięśni",
|
||||
"recomp": "Rekompozycja",
|
||||
"allergies": "Alergie / uwagi",
|
||||
"continue": "Oblicz cele",
|
||||
"macrosTitle": "Dzienne cele",
|
||||
"acceptAndGenerate": "Akceptuj i generuj plan 7 dni",
|
||||
"planHint": "Każdy dzień powinien mieścić się w ±10 kcal od celu. Zablokuj lub wygeneruj ponownie posiłki.",
|
||||
"lock": "Zablokuj",
|
||||
"unlock": "Odblokuj",
|
||||
"viewRecipe": "Szczegóły",
|
||||
"noAlternativeMeals": "Brak innych przepisów na ten posiłek — wszystkie pasujące opcje są już w planie.",
|
||||
"commit": "Zapisz plan w kalendarzu",
|
||||
"done": "Plan zapisany w planerze i celach dziennych.",
|
||||
"newPlan": "Utwórz kolejny plan",
|
||||
"error": "Generator diety nie powiódł się."
|
||||
},
|
||||
"recipe": {
|
||||
"title": "Generator przepisów",
|
||||
"subtitle": "Wygeneruj kilka propozycji przepisów z AI, przejrzyj je i zapisz te, które Ci pasują.",
|
||||
"constraintsTitle": "Co ugotować?",
|
||||
"prompt": "Opisz posiłek",
|
||||
"promptPlaceholder": "Lekkie śniadanie z owsianką i owocami…",
|
||||
"targetCalories": "Docelowa kaloryczność (kcal)",
|
||||
"targetCaloriesOptional": "np. 500",
|
||||
"targetCaloriesHint": "Składniki zostaną dopasowane do tej wartości (±10 kcal).",
|
||||
"caloriesWithTarget": "{{actual}} kcal (cel: {{target}})",
|
||||
"maxPrep": "Maks. czas przygotowania (min)",
|
||||
"exclusions": "Wyklucz składniki",
|
||||
"generate": "Generuj przepis",
|
||||
"recipeCount": "Ile przepisów wygenerować",
|
||||
"reviewHint": "Przejrzyj {{count}} wygenerowanych przepisów. Odznacz lub usuń te, których nie chcesz, i zapisz resztę.",
|
||||
"selectAll": "Zaznacz wszystkie",
|
||||
"remove": "Usuń",
|
||||
"saveSelected": "Zapisz zaznaczone ({{count}})",
|
||||
"doneMultiple": "Zapisano {{count}} przepisów w bibliotece.",
|
||||
"generateMore": "Generuj kolejne przepisy",
|
||||
"regenIngredients": "Generuj składniki ponownie",
|
||||
"regenSteps": "Generuj kroki ponownie",
|
||||
"regenFull": "Generuj całość ponownie",
|
||||
"save": "Zapisz w bibliotece",
|
||||
"done": "Przepis zapisany.",
|
||||
"viewRecipe": "Zobacz przepis",
|
||||
"error": "Generator przepisów nie powiódł się."
|
||||
}
|
||||
},
|
||||
"pdf": {
|
||||
"generate": "Generuj PDF",
|
||||
"preparing": "Przygotowywanie...",
|
||||
"generating": "Generowanie...",
|
||||
"prepTime": "Czas przygotowania: {{count}} min",
|
||||
"shoppingList": "Lista zakupów",
|
||||
"generatedOn_one": "Wygenerowano {{date}} · {{count}} przepis",
|
||||
"generatedOn_few": "Wygenerowano {{date}} · {{count}} przepisy",
|
||||
"generatedOn_many": "Wygenerowano {{date}} · {{count}} przepisów",
|
||||
"generatedOn_other": "Wygenerowano {{date}} · {{count}} przepisów",
|
||||
"recipesInExport": "Przepisy w tym eksporcie",
|
||||
"toTaste": "do smaku",
|
||||
"dayPlanTitle": "Plan posiłków — {{date}}",
|
||||
"emptySlot": "Nie zaplanowano",
|
||||
"emptyShoppingList": "Brak składników do zakupu dla tego dnia.",
|
||||
"plannedMealMeta": "{{slot}} · {{portions}}× porcji",
|
||||
"dayShoppingSubtitle": "Lista na {{date}} · {{count}} posiłków"
|
||||
},
|
||||
"diet": {
|
||||
"title": "Moja dieta",
|
||||
"subtitle": "Śledź posiłki i napoje, ustaw cele dzienne i przypomnienia.",
|
||||
"tabToday": "Dziś",
|
||||
"tabHistory": "Historia",
|
||||
"tabGoals": "Cele",
|
||||
"tabReminders": "Przypomnienia",
|
||||
"goToday": "Dziś",
|
||||
"notToday": "inny dzień",
|
||||
"quickLog": "Szybkie logowanie",
|
||||
"copyYesterday": "Skopiuj wczorajsze posiłki",
|
||||
"recentMeals": "Ostatnie",
|
||||
"favorites": "Ulubione",
|
||||
"days": "dni",
|
||||
"historyCalories": "Kalorie w czasie",
|
||||
"historyMax": "Szczyt: {{max}} kcal",
|
||||
"noHistory": "Brak historii w tym okresie.",
|
||||
"mealsOnDate": "Posiłki {{date}}",
|
||||
"drinksOnDate": "Napoje {{date}}",
|
||||
"editReminder": "Edytuj przypomnienie",
|
||||
"calories": "Kalorie",
|
||||
"water": "Woda",
|
||||
"snack": "Przekąska",
|
||||
"logDrink": "Dodaj napój",
|
||||
"logMeal": "Oznacz zjedzony posiłek",
|
||||
"searchRecipe": "Z przepisu",
|
||||
"orCustomMeal": "Lub wpisz własną nazwę posiłku",
|
||||
"markEaten": "Oznacz jako zjedzone",
|
||||
"mealsToday": "Posiłki dziś",
|
||||
"drinksToday": "Napoje dziś",
|
||||
"nothingLogged": "Nic jeszcze nie zalogowano.",
|
||||
"noMacros": "Brak danych odżywczych",
|
||||
"removeEntry": "Usuń wpis",
|
||||
"dailyGoals": "Cele dzienne",
|
||||
"goalsHint": "Ustaw cele jak w Lifesum lub MyFitnessPal — zakładka Dziś pokaże, co zostało.",
|
||||
"calorieGoal": "Cel kalorii (kcal)",
|
||||
"waterGoal": "Cel wody (ml)",
|
||||
"saveGoals": "Zapisz cele",
|
||||
"addReminder": "Dodaj przypomnienie",
|
||||
"reminderType": "Typ",
|
||||
"reminderWater": "Pij wodę",
|
||||
"reminderMeal": "Pora posiłku",
|
||||
"reminderCustom": "Własne",
|
||||
"reminderTitle": "Tytuł",
|
||||
"reminderTitlePlaceholder": "Wypij szklankę wody",
|
||||
"reminderTime": "Godzina",
|
||||
"saveReminder": "Zapisz przypomnienie",
|
||||
"yourReminders": "Twoje przypomnienia",
|
||||
"noReminders": "Brak przypomnień.",
|
||||
"remaining": "Pozostało",
|
||||
"portions": "Porcje",
|
||||
"fromCatalog": "Z katalogu składników",
|
||||
"noCatalogItem": "Brak",
|
||||
"grams": "Ilość (g)",
|
||||
"mealPreview": "Ten posiłek dodaje",
|
||||
"remainingAfter": "Po zapisaniu zostanie Ci",
|
||||
"suggestionsTitle": "Co warto zjeść dalej",
|
||||
"suggestionsHint": "Na podstawie tego, czego jeszcze brakuje dziś.",
|
||||
"logSuggestion": "Zapisz",
|
||||
"suggestionReasons": {
|
||||
"highProtein": "Dużo białka",
|
||||
"needCarbs": "Dobre węglowodany",
|
||||
"needFat": "Zdrowe tłuszcze",
|
||||
"needCalories": "Energia",
|
||||
"balanced": "Zbilansowany wybór"
|
||||
}
|
||||
},
|
||||
"mealPlanner": {
|
||||
"title": "Plan posiłków",
|
||||
"subtitle": "Planuj przepisy na każdy dzień tygodnia.",
|
||||
"today": "Ten tydzień",
|
||||
"addRecipe": "Dodaj przepis",
|
||||
"dailyTarget": "Cel kalorii na dzień",
|
||||
"daySummary": "Zaplanowano {{logged}} / {{target}} kcal",
|
||||
"remaining": "Pozostało {{count}} kcal",
|
||||
"slotHint": "Sugerowane ~{{count}} kcal na ten posiłek",
|
||||
"suggestionsTitle": "Propozycje na ten posiłek",
|
||||
"noSuggestions": "Ustaw cel kalorii na dzień, aby zobaczyć propozycje.",
|
||||
"estimatedMeal": "Ten posiłek: ~{{count}} kcal",
|
||||
"selectedRecipe": "Wybrany: {{name}}",
|
||||
"clearSelection": "Wyczyść wybór",
|
||||
"exportDay": "Eksportuj plan dnia (PDF)",
|
||||
"includingSelection": "w tym bieżący wybór: {{count}} kcal"
|
||||
},
|
||||
"shoppingList": {
|
||||
"title": "Lista zakupów",
|
||||
"subtitle": "Składniki zebrane z planu posiłków.",
|
||||
"from": "Od",
|
||||
"to": "Do",
|
||||
"load": "Załaduj listę",
|
||||
"clearChecks": "Wyczyść zaznaczenia",
|
||||
"empty": "Brak pozycji w tym zakresie dat.",
|
||||
"checklistHint": "Zaznaczenia są zapisywane lokalnie w przeglądarce."
|
||||
},
|
||||
"ingredientCatalog": {
|
||||
"title": "Katalog składników",
|
||||
"subtitle": "Wartości odżywcze na 100 g, pogrupowane według kategorii.",
|
||||
"allCategories": "Wszystkie",
|
||||
"searchPlaceholder": "Szukaj składników…",
|
||||
"noResultsTitle": "Nie znaleziono składników",
|
||||
"noResultsDescription": "Spróbuj innej kategorii lub frazy.",
|
||||
"per100gNote": "Wszystkie wartości dotyczą 100 g.",
|
||||
"addIngredient": "Dodaj składnik",
|
||||
"editIngredient": "Edytuj składnik",
|
||||
"selectCategory": "Wybierz kategorię",
|
||||
"nameEn": "Nazwa (angielski)",
|
||||
"namePlLabel": "Nazwa (polski)",
|
||||
"formInvalid": "Wypełnij kategorię i nazwę.",
|
||||
"deleteConfirm": "Usunąć ten składnik z katalogu?",
|
||||
"columns": {
|
||||
"name": "Składnik",
|
||||
"category": "Kategoria",
|
||||
"calories": "kcal",
|
||||
"protein": "Białko (g)",
|
||||
"fat": "Tłuszcz (g)",
|
||||
"carbs": "Węglowodany (g)",
|
||||
"fiber": "Błonnik (g)"
|
||||
},
|
||||
"categories": {
|
||||
"meat": "Mięso",
|
||||
"poultry": "Drób",
|
||||
"fish": "Ryby",
|
||||
"seafood": "Owoce morza",
|
||||
"vegetables": "Warzywa",
|
||||
"fruits": "Owoce",
|
||||
"dairy": "Nabiał",
|
||||
"eggs": "Jaja",
|
||||
"grains": "Zboża",
|
||||
"legumes": "Rośliny strączkowe",
|
||||
"nuts": "Orzechy i nasiona",
|
||||
"oils": "Oleje i tłuszcze"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Nie znaleziono strony",
|
||||
"description": "Strona, której szukasz, nie istnieje.",
|
||||
"backToDashboard": "Wróć do panelu"
|
||||
}
|
||||
}
|
||||
10
meal-plan-frontend-angular/src/app/app.component.ts
Normal file
10
meal-plan-frontend-angular/src/app/app.component.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet],
|
||||
template: `<router-outlet />`,
|
||||
})
|
||||
export class AppComponent {}
|
||||
43
meal-plan-frontend-angular/src/app/app.config.ts
Normal file
43
meal-plan-frontend-angular/src/app/app.config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { APP_INITIALIZER, ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { provideTranslateService } from '@ngx-translate/core';
|
||||
import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { AuthInterceptor } from './core/interceptors/auth.interceptor';
|
||||
import { AppearanceService } from './core/services/appearance.service';
|
||||
import { AuthService } from './core/services/auth.service';
|
||||
import { LanguageService } from './core/services/language.service';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
|
||||
...provideTranslateService({
|
||||
fallbackLang: 'en',
|
||||
loader: provideTranslateHttpLoader({ prefix: './i18n/', suffix: '.json' }),
|
||||
}),
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: (language: LanguageService) => () => language.init(),
|
||||
deps: [LanguageService],
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: (auth: AuthService) => () => auth.restoreSession(),
|
||||
deps: [AuthService],
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: (appearance: AppearanceService) => () => appearance.init(),
|
||||
deps: [AppearanceService],
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
48
meal-plan-frontend-angular/src/app/app.routes.ts
Normal file
48
meal-plan-frontend-angular/src/app/app.routes.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard, guestGuard } from './core/guards/auth.guard';
|
||||
import { AppLayoutComponent } from './layout/app-layout.component';
|
||||
import { LoginComponent } from './features/auth/login.component';
|
||||
import { RegisterComponent } from './features/auth/register.component';
|
||||
import { DashboardComponent } from './features/dashboard/dashboard.component';
|
||||
import { BreakfastComponent } from './features/category/breakfast.component';
|
||||
import { SecondBreakfastComponent } from './features/category/second-breakfast.component';
|
||||
import { LunchComponent } from './features/category/lunch.component';
|
||||
import { DinnerComponent } from './features/category/dinner.component';
|
||||
import { AllMealsComponent } from './features/all-meals/all-meals.component';
|
||||
import { ManageMealsComponent } from './features/manage-meals/manage-meals.component';
|
||||
import { IngredientCatalogComponent } from './features/ingredient-catalog/ingredient-catalog.component';
|
||||
import { DietTrackerComponent } from './features/diet-tracker/diet-tracker.component';
|
||||
import { MealPlannerComponent } from './features/meal-planner/meal-planner.component';
|
||||
import { ShoppingListComponent } from './features/shopping-list/shopping-list.component';
|
||||
import { RecipeDetailPageComponent } from './features/recipe-detail/recipe-detail-page.component';
|
||||
import { NotFoundComponent } from './features/not-found/not-found.component';
|
||||
import { DietGeneratorComponent } from './features/diet-generator/diet-generator.component';
|
||||
import { RecipeGeneratorComponent } from './features/recipe-generator/recipe-generator.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: 'login', component: LoginComponent, canActivate: [guestGuard] },
|
||||
{ path: 'register', component: RegisterComponent, canActivate: [guestGuard] },
|
||||
{
|
||||
path: '',
|
||||
component: AppLayoutComponent,
|
||||
canActivate: [authGuard],
|
||||
children: [
|
||||
{ path: '', component: DashboardComponent },
|
||||
{ path: 'breakfast', component: BreakfastComponent },
|
||||
{ path: 'second-breakfast', component: SecondBreakfastComponent },
|
||||
{ path: 'lunch', component: LunchComponent },
|
||||
{ path: 'dinner', component: DinnerComponent },
|
||||
{ path: 'all-meals', component: AllMealsComponent },
|
||||
{ path: 'manage-meals', component: ManageMealsComponent },
|
||||
{ path: 'ingredients', component: IngredientCatalogComponent },
|
||||
{ path: 'diet', component: DietTrackerComponent },
|
||||
{ path: 'meal-planner', component: MealPlannerComponent },
|
||||
{ path: 'diet-generator', component: DietGeneratorComponent },
|
||||
{ path: 'recipe-generator', component: RecipeGeneratorComponent },
|
||||
{ path: 'shopping-list', component: ShoppingListComponent },
|
||||
{ path: 'recipes/:id', component: RecipeDetailPageComponent },
|
||||
{ path: '404', component: NotFoundComponent },
|
||||
{ path: '**', redirectTo: '404' },
|
||||
],
|
||||
},
|
||||
];
|
||||
21
meal-plan-frontend-angular/src/app/core/guards/auth.guard.ts
Normal file
21
meal-plan-frontend-angular/src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
if (auth.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
return router.createUrlTree(['/login']);
|
||||
};
|
||||
|
||||
export const guestGuard: CanActivateFn = () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
if (!auth.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
return router.createUrlTree(['/']);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
HttpBackend,
|
||||
HttpClient,
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, catchError, firstValueFrom, from, switchMap, throwError } from 'rxjs';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { TokenResponse } from '../models/auth.models';
|
||||
|
||||
@Injectable()
|
||||
export class AuthInterceptor implements HttpInterceptor {
|
||||
private isRefreshing = false;
|
||||
private queue: Array<(token: string | null) => void> = [];
|
||||
private readonly refreshClient: HttpClient;
|
||||
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
constructor(httpBackend: HttpBackend) {
|
||||
this.refreshClient = new HttpClient(httpBackend);
|
||||
}
|
||||
|
||||
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
const token = this.auth.getAccessToken();
|
||||
const authReq = token
|
||||
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
|
||||
: req;
|
||||
|
||||
return next.handle(authReq).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
const original = authReq;
|
||||
const isAuthEndpoint =
|
||||
original.url.includes('/auth/login') ||
|
||||
original.url.includes('/auth/register') ||
|
||||
original.url.includes('/auth/refresh');
|
||||
|
||||
if (error.status !== 401 || isAuthEndpoint || original.headers.has('X-Retry')) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
if (this.isRefreshing) {
|
||||
return new Observable<HttpEvent<unknown>>((subscriber) => {
|
||||
this.queue.push((newToken) => {
|
||||
if (!newToken) {
|
||||
subscriber.error(error);
|
||||
return;
|
||||
}
|
||||
next
|
||||
.handle(
|
||||
original.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
'X-Retry': 'true',
|
||||
},
|
||||
}),
|
||||
)
|
||||
.subscribe(subscriber);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.isRefreshing = true;
|
||||
return from(this.performRefresh()).pipe(
|
||||
switchMap((newToken) => {
|
||||
this.isRefreshing = false;
|
||||
this.flushQueue(newToken);
|
||||
if (!newToken) {
|
||||
this.redirectToLogin();
|
||||
return throwError(() => error);
|
||||
}
|
||||
return next.handle(
|
||||
original.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
'X-Retry': 'true',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async performRefresh(): Promise<string | null> {
|
||||
const refreshToken = this.auth.getRefreshToken();
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const tokens = await firstValueFrom(
|
||||
this.refreshClient.post<TokenResponse>('/api/auth/refresh', { refreshToken }),
|
||||
);
|
||||
if (!tokens) return null;
|
||||
this.auth.setTokens(tokens);
|
||||
return tokens.accessToken;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private flushQueue(token: string | null): void {
|
||||
this.queue.forEach((resolve) => resolve(token));
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
private redirectToLogin(): void {
|
||||
this.auth.clearAuth();
|
||||
const path = this.router.url.split('?')[0];
|
||||
if (path !== '/login' && path !== '/register') {
|
||||
void this.router.navigateByUrl('/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface TokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresInSeconds: number;
|
||||
tokenType: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
userName: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
userName?: string;
|
||||
}
|
||||
237
meal-plan-frontend-angular/src/app/core/models/diet.models.ts
Normal file
237
meal-plan-frontend-angular/src/app/core/models/diet.models.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
export interface UserDailyGoals {
|
||||
calorieGoal?: number | null;
|
||||
proteinGoalG?: number | null;
|
||||
fatGoalG?: number | null;
|
||||
carbsGoalG?: number | null;
|
||||
waterGoalMl?: number | null;
|
||||
}
|
||||
|
||||
export interface MacroRemaining {
|
||||
goal?: number | null;
|
||||
consumed: number;
|
||||
remaining?: number | null;
|
||||
progressPercent?: number | null;
|
||||
}
|
||||
|
||||
export interface DecimalMacroRemaining {
|
||||
goal?: number | null;
|
||||
consumed: number;
|
||||
remaining?: number | null;
|
||||
progressPercent?: number | null;
|
||||
}
|
||||
|
||||
export interface MealConsumption {
|
||||
id: number;
|
||||
recipeId?: number | null;
|
||||
mealCategory: number;
|
||||
logDate: string;
|
||||
consumedAt: string;
|
||||
name: string;
|
||||
portions: number;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface DrinkCatalogItem {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
defaultVolumeMl: number;
|
||||
caloriesPer100Ml?: number | null;
|
||||
iconEmoji?: string | null;
|
||||
}
|
||||
|
||||
export interface DrinkConsumption {
|
||||
id: number;
|
||||
drinkCatalogId?: number | null;
|
||||
logDate: string;
|
||||
consumedAt: string;
|
||||
name: string;
|
||||
volumeMl: number;
|
||||
calories?: number | null;
|
||||
}
|
||||
|
||||
export interface DailySummary {
|
||||
date: string;
|
||||
goals?: UserDailyGoals | null;
|
||||
calories: MacroRemaining;
|
||||
proteinG: DecimalMacroRemaining;
|
||||
fatG: DecimalMacroRemaining;
|
||||
carbsG: DecimalMacroRemaining;
|
||||
waterMl: MacroRemaining;
|
||||
meals: MealConsumption[];
|
||||
drinks: DrinkConsumption[];
|
||||
}
|
||||
|
||||
export interface LogMealInput {
|
||||
recipeId?: number;
|
||||
catalogItemId?: number;
|
||||
grams?: number;
|
||||
name?: string;
|
||||
mealCategory: number;
|
||||
logDate?: string;
|
||||
portions?: number;
|
||||
calories?: number;
|
||||
proteinG?: number;
|
||||
fatG?: number;
|
||||
carbsG?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface MealPreview {
|
||||
meal: {
|
||||
name: string;
|
||||
portions: number;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
};
|
||||
remainingAfter: {
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DietSuggestion {
|
||||
catalogItemId: number;
|
||||
name: string;
|
||||
namePl?: string | null;
|
||||
categoryCode: string;
|
||||
suggestedGrams: number;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
reasonKey: string;
|
||||
}
|
||||
|
||||
export interface LogDrinkInput {
|
||||
drinkCatalogId?: number;
|
||||
name?: string;
|
||||
volumeMl: number;
|
||||
logDate?: string;
|
||||
calories?: number;
|
||||
}
|
||||
|
||||
export interface DietReminder {
|
||||
id: number;
|
||||
reminderType: number;
|
||||
title: string;
|
||||
message?: string | null;
|
||||
timeOfDay: string;
|
||||
daysOfWeekMask: number;
|
||||
mealCategory?: number | null;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateDietReminderInput {
|
||||
reminderType: number;
|
||||
title: string;
|
||||
message?: string;
|
||||
timeOfDay: string;
|
||||
daysOfWeekMask?: number;
|
||||
mealCategory?: number;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateDietReminderInput = CreateDietReminderInput;
|
||||
|
||||
export interface DueReminder {
|
||||
id: number;
|
||||
reminderType: number;
|
||||
title: string;
|
||||
message?: string | null;
|
||||
timeOfDay: string;
|
||||
mealCategory?: number | null;
|
||||
}
|
||||
|
||||
export interface HistoryDay {
|
||||
date: string;
|
||||
calories: number;
|
||||
proteinG: number;
|
||||
fatG: number;
|
||||
carbsG: number;
|
||||
waterMl: number;
|
||||
mealCount: number;
|
||||
}
|
||||
|
||||
export interface RecentMeal {
|
||||
recipeId?: number | null;
|
||||
catalogItemId?: number | null;
|
||||
name: string;
|
||||
mealCategory: number;
|
||||
portions: number;
|
||||
grams?: number | null;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
lastLoggedAt: string;
|
||||
}
|
||||
|
||||
export interface FavoriteRecipe {
|
||||
recipeId: number;
|
||||
name: string;
|
||||
mealCategory: number;
|
||||
calories: number | null;
|
||||
}
|
||||
|
||||
export interface FavoriteCatalogItem {
|
||||
catalogItemId: number;
|
||||
name: string;
|
||||
namePl?: string | null;
|
||||
categoryCode: string;
|
||||
}
|
||||
|
||||
export interface CopyMealsResult {
|
||||
sourceDate: string;
|
||||
targetDate: string;
|
||||
copiedCount: number;
|
||||
}
|
||||
|
||||
export enum DietReminderType {
|
||||
Water = 0,
|
||||
Meal = 1,
|
||||
Snack = 2,
|
||||
Custom = 3,
|
||||
}
|
||||
|
||||
export const CONSUMPTION_CATEGORIES = [
|
||||
{ value: 0, labelKey: 'categories.breakfast' },
|
||||
{ value: 1, labelKey: 'categories.secondBreakfast' },
|
||||
{ value: 2, labelKey: 'categories.lunch' },
|
||||
{ value: 3, labelKey: 'categories.dinner' },
|
||||
{ value: 4, labelKey: 'diet.snack' },
|
||||
] as const;
|
||||
|
||||
export const REMINDER_DAYS_MASK_ALL = 127;
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDaysIso(iso: string, days: number): string {
|
||||
const d = new Date(`${iso}T12:00:00`);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function startOfWeekIso(iso: string): string {
|
||||
const d = new Date(`${iso}T12:00:00`);
|
||||
const day = d.getDay();
|
||||
const diff = (day === 0 ? -6 : 1) - day;
|
||||
d.setDate(d.getDate() + diff);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function endOfWeekIso(iso: string): string {
|
||||
return addDaysIso(startOfWeekIso(iso), 6);
|
||||
}
|
||||
|
||||
export { todayIso, addDaysIso, startOfWeekIso, endOfWeekIso };
|
||||
@@ -0,0 +1,149 @@
|
||||
export interface DietUserProfile {
|
||||
heightCm: number;
|
||||
weightKg: number;
|
||||
age: number;
|
||||
sex: 'male' | 'female';
|
||||
activityLevel: 'sedentary' | 'light' | 'moderate' | 'active' | 'very_active';
|
||||
goal: 'lose_weight' | 'maintain' | 'gain_muscle' | 'recomp';
|
||||
allergiesNotes?: string | null;
|
||||
dislikedFoods?: string | null;
|
||||
mealsPerDayMask: number;
|
||||
}
|
||||
|
||||
export interface MacroProposal {
|
||||
calories: number;
|
||||
proteinG: number;
|
||||
fatG: number;
|
||||
carbsG: number;
|
||||
rationale?: string | null;
|
||||
}
|
||||
|
||||
export interface DietGeneratorDraftMealIngredient {
|
||||
id: number;
|
||||
name: string;
|
||||
amountGrams?: number | null;
|
||||
unit?: string | null;
|
||||
sortOrder: number;
|
||||
sourceIngredientId?: number | null;
|
||||
}
|
||||
|
||||
export interface DietGeneratorDraftMealStep {
|
||||
id: number;
|
||||
stepNumber: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface DietGeneratorDraftMeal {
|
||||
id: number;
|
||||
planDate: string;
|
||||
mealCategory: number;
|
||||
recipeId: number;
|
||||
recipeName: string;
|
||||
portions: number;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
isLocked: boolean;
|
||||
prepTimeMinutes?: number | null;
|
||||
ingredients: DietGeneratorDraftMealIngredient[];
|
||||
steps: DietGeneratorDraftMealStep[];
|
||||
}
|
||||
|
||||
export interface DietGeneratorDaySummary {
|
||||
planDate: string;
|
||||
totalCalories: number;
|
||||
targetCalories: number;
|
||||
withinTolerance: boolean;
|
||||
}
|
||||
|
||||
export interface DietGeneratorSession {
|
||||
id: number;
|
||||
status: string;
|
||||
proposedMacros?: MacroProposal | null;
|
||||
planStartDate?: string | null;
|
||||
planDayCount: number;
|
||||
calorieToleranceKcal: number;
|
||||
draftMeals: DietGeneratorDraftMeal[];
|
||||
daySummaries: DietGeneratorDaySummary[];
|
||||
}
|
||||
|
||||
export interface CommitDietPlanResult {
|
||||
sessionId: number;
|
||||
planStartDate: string;
|
||||
planEndDate: string;
|
||||
mealsWritten: number;
|
||||
}
|
||||
|
||||
export interface RecipeGeneratorConstraints {
|
||||
prompt: string;
|
||||
mealCategory: number;
|
||||
targetCalories?: number | null;
|
||||
calorieToleranceKcal?: number;
|
||||
maxPrepTimeMinutes?: number | null;
|
||||
cuisineStyle?: string | null;
|
||||
dietStyle?: string | null;
|
||||
exclusions?: string | null;
|
||||
}
|
||||
|
||||
export interface GeneratedIngredient {
|
||||
name: string;
|
||||
amountGrams?: number | null;
|
||||
unit?: string | null;
|
||||
catalogItemId?: number | null;
|
||||
catalogName?: string | null;
|
||||
isLinked: boolean;
|
||||
}
|
||||
|
||||
export interface GeneratedStep {
|
||||
stepNumber: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface GeneratedRecipeDraft {
|
||||
draftId: string;
|
||||
name: string;
|
||||
mealCategory: number;
|
||||
prepTimeMinutes?: number | null;
|
||||
calories?: number | null;
|
||||
protein?: number | null;
|
||||
fat?: number | null;
|
||||
carbs?: number | null;
|
||||
ingredients: GeneratedIngredient[];
|
||||
steps: GeneratedStep[];
|
||||
unlinkedIngredientCount: number;
|
||||
}
|
||||
|
||||
export interface RecipeGeneratorSession {
|
||||
id: number;
|
||||
status: string;
|
||||
constraints: RecipeGeneratorConstraints;
|
||||
draft?: GeneratedRecipeDraft | null;
|
||||
drafts: GeneratedRecipeDraft[];
|
||||
savedRecipeId?: number | null;
|
||||
generationVersion: number;
|
||||
}
|
||||
|
||||
export interface CommitRecipeResult {
|
||||
sessionId: number;
|
||||
recipeId: number;
|
||||
recipeName: string;
|
||||
draftId: string;
|
||||
}
|
||||
|
||||
export interface CommitRecipesResult {
|
||||
sessionId: number;
|
||||
saved: CommitRecipeResult[];
|
||||
}
|
||||
|
||||
export const DEFAULT_DIET_PROFILE: DietUserProfile = {
|
||||
heightCm: 175,
|
||||
weightKg: 75,
|
||||
age: 30,
|
||||
sex: 'male',
|
||||
activityLevel: 'moderate',
|
||||
goal: 'maintain',
|
||||
mealsPerDayMask: 15,
|
||||
allergiesNotes: '',
|
||||
dislikedFoods: '',
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface IngredientCategory {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export interface IngredientNutritionItem {
|
||||
id: number;
|
||||
categoryId: number;
|
||||
categoryCode: string;
|
||||
categoryName: string;
|
||||
name: string;
|
||||
namePl?: string | null;
|
||||
caloriesPer100G: number;
|
||||
proteinGPer100G: number;
|
||||
fatGPer100G: number;
|
||||
carbsGPer100G: number;
|
||||
fiberGPer100G: number | null;
|
||||
}
|
||||
|
||||
export interface UpsertIngredientNutritionItemInput {
|
||||
categoryId: number;
|
||||
name: string;
|
||||
namePl?: string | null;
|
||||
caloriesPer100G: number;
|
||||
proteinGPer100G: number;
|
||||
fatGPer100G: number;
|
||||
carbsGPer100G: number;
|
||||
fiberGPer100G?: number | null;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface MealPlanEntry {
|
||||
id: number;
|
||||
planDate: string;
|
||||
mealCategory: number;
|
||||
recipeId: number;
|
||||
recipeName: string;
|
||||
portions: number;
|
||||
calories: number | null;
|
||||
}
|
||||
|
||||
export interface UpsertMealPlanEntryInput {
|
||||
planDate: string;
|
||||
mealCategory: number;
|
||||
recipeId: number;
|
||||
portions?: number;
|
||||
}
|
||||
|
||||
export interface ShoppingListItem {
|
||||
name: string;
|
||||
unit: string | null;
|
||||
totalGrams: number | null;
|
||||
totalAmount: number | null;
|
||||
breakdown: string[];
|
||||
}
|
||||
100
meal-plan-frontend-angular/src/app/core/models/recipe.models.ts
Normal file
100
meal-plan-frontend-angular/src/app/core/models/recipe.models.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export enum MealCategory {
|
||||
Breakfast = 0,
|
||||
SecondBreakfast = 1,
|
||||
Lunch = 2,
|
||||
Dinner = 3,
|
||||
}
|
||||
|
||||
export const MEAL_CATEGORY_ROUTES: Record<MealCategory, string> = {
|
||||
[MealCategory.Breakfast]: '/breakfast',
|
||||
[MealCategory.SecondBreakfast]: '/second-breakfast',
|
||||
[MealCategory.Lunch]: '/lunch',
|
||||
[MealCategory.Dinner]: '/dinner',
|
||||
};
|
||||
|
||||
export const MEAL_CATEGORY_I18N_KEYS: Record<MealCategory, string> = {
|
||||
[MealCategory.Breakfast]: 'categories.breakfast',
|
||||
[MealCategory.SecondBreakfast]: 'categories.secondBreakfast',
|
||||
[MealCategory.Lunch]: 'categories.lunch',
|
||||
[MealCategory.Dinner]: 'categories.dinner',
|
||||
};
|
||||
|
||||
export interface RecipeListItem {
|
||||
id: number;
|
||||
name: string;
|
||||
mealCategory: MealCategory;
|
||||
calories: number | null;
|
||||
prepTimeMinutes: number | null;
|
||||
}
|
||||
|
||||
export interface Ingredient {
|
||||
id: number;
|
||||
name: string;
|
||||
amountGrams: number | null;
|
||||
unit: string | null;
|
||||
sortOrder: number;
|
||||
catalogItemId?: number | null;
|
||||
}
|
||||
|
||||
export interface RecipeStep {
|
||||
id: number;
|
||||
stepNumber: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface RecipeDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
mealCategory: MealCategory;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
carbs: number | null;
|
||||
prepTimeMinutes: number | null;
|
||||
ingredients: Ingredient[];
|
||||
steps: RecipeStep[];
|
||||
}
|
||||
|
||||
export interface CategoryCount {
|
||||
category: MealCategory;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CreateIngredientInput {
|
||||
name: string;
|
||||
amountGrams: number | null;
|
||||
unit: string;
|
||||
sortOrder: number;
|
||||
catalogItemId?: number | null;
|
||||
}
|
||||
|
||||
export interface CreateStepInput {
|
||||
stepNumber: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CreateRecipeInput {
|
||||
name: string;
|
||||
mealCategory: MealCategory;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
carbs: number | null;
|
||||
prepTimeMinutes: number | null;
|
||||
ingredients: CreateIngredientInput[];
|
||||
steps: CreateStepInput[];
|
||||
}
|
||||
|
||||
export interface RecipeImportResult {
|
||||
createdCount: number;
|
||||
skippedCount: number;
|
||||
errors: string[];
|
||||
createdRecipeIds: number[];
|
||||
}
|
||||
|
||||
export interface RecipeMacroRecalcResult {
|
||||
recipesProcessed: number;
|
||||
recipesUpdated: number;
|
||||
unlinkedIngredientRows: number;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
AppearanceId,
|
||||
IconStyleId,
|
||||
THEME_PROPOSALS,
|
||||
ThemeProposal,
|
||||
} from '../theme/appearance.config';
|
||||
|
||||
const STORAGE_KEY = 'dailymeals.appearance';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppearanceService {
|
||||
readonly proposal = signal<ThemeProposal>(this.load());
|
||||
|
||||
readonly iconStyle = signal<IconStyleId>(this.proposal().iconStyle);
|
||||
|
||||
init(): void {
|
||||
this.apply(this.proposal());
|
||||
}
|
||||
|
||||
setProposal(id: AppearanceId): void {
|
||||
const next = THEME_PROPOSALS.find((p) => p.id === id) ?? THEME_PROPOSALS[0];
|
||||
this.proposal.set(next);
|
||||
this.iconStyle.set(next.iconStyle);
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
this.apply(next);
|
||||
}
|
||||
|
||||
proposals(): ThemeProposal[] {
|
||||
return THEME_PROPOSALS;
|
||||
}
|
||||
|
||||
strokeWidth(): string {
|
||||
return this.iconStyle() === 'bold' ? '2.5' : '2';
|
||||
}
|
||||
|
||||
useEmojiIcons(): boolean {
|
||||
return this.iconStyle() === 'emoji';
|
||||
}
|
||||
|
||||
private load(): ThemeProposal {
|
||||
const stored = localStorage.getItem(STORAGE_KEY) as AppearanceId | null;
|
||||
const found = THEME_PROPOSALS.find((p) => p.id === stored);
|
||||
const proposal = found ?? THEME_PROPOSALS[0];
|
||||
this.apply(proposal);
|
||||
return proposal;
|
||||
}
|
||||
|
||||
private apply(proposal: ThemeProposal): void {
|
||||
const root = document.documentElement;
|
||||
root.dataset['colorTheme'] = proposal.colorTheme;
|
||||
root.dataset['iconStyle'] = proposal.iconStyle;
|
||||
}
|
||||
}
|
||||
111
meal-plan-frontend-angular/src/app/core/services/auth.service.ts
Normal file
111
meal-plan-frontend-angular/src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
TokenResponse,
|
||||
UserInfo,
|
||||
} from '../models/auth.models';
|
||||
|
||||
const REFRESH_TOKEN_KEY = 'dm_refresh_token';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly accessToken = signal<string | null>(null);
|
||||
private readonly refreshToken = signal<string | null>(null);
|
||||
readonly user = signal<UserInfo | null>(null);
|
||||
|
||||
constructor(private readonly http: HttpClient) {
|
||||
const stored = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (stored) {
|
||||
this.refreshToken.set(stored);
|
||||
}
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return Boolean(this.accessToken());
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return this.accessToken();
|
||||
}
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
return this.refreshToken();
|
||||
}
|
||||
|
||||
setTokens(tokens: TokenResponse): void {
|
||||
this.accessToken.set(tokens.accessToken);
|
||||
this.refreshToken.set(tokens.refreshToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
|
||||
}
|
||||
|
||||
clearAuth(): void {
|
||||
this.accessToken.set(null);
|
||||
this.refreshToken.set(null);
|
||||
this.user.set(null);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
async login(payload: LoginRequest): Promise<UserInfo> {
|
||||
const tokens = await firstValueFrom(this.http.post<TokenResponse>('/api/auth/login', payload));
|
||||
this.setTokens(tokens);
|
||||
return this.loadCurrentUser();
|
||||
}
|
||||
|
||||
async register(payload: RegisterRequest): Promise<UserInfo> {
|
||||
const tokens = await firstValueFrom(this.http.post<TokenResponse>('/api/auth/register', payload));
|
||||
this.setTokens(tokens);
|
||||
return this.loadCurrentUser();
|
||||
}
|
||||
|
||||
async loadCurrentUser(): Promise<UserInfo> {
|
||||
const me = await firstValueFrom(this.http.get<UserInfo>('/api/auth/me'));
|
||||
this.user.set(me);
|
||||
return me;
|
||||
}
|
||||
|
||||
async restoreSession(): Promise<boolean> {
|
||||
const refresh = this.refreshToken();
|
||||
if (!refresh) return false;
|
||||
const token = await this.refreshAccessToken();
|
||||
if (!token) {
|
||||
this.clearAuth();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this.loadCurrentUser();
|
||||
return true;
|
||||
} catch {
|
||||
this.clearAuth();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
const refresh = this.refreshToken();
|
||||
try {
|
||||
if (refresh) {
|
||||
await firstValueFrom(this.http.post('/api/auth/logout', { refreshToken: refresh }));
|
||||
}
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
this.clearAuth();
|
||||
}
|
||||
|
||||
async refreshAccessToken(): Promise<string | null> {
|
||||
const refresh = this.refreshToken();
|
||||
if (!refresh) return null;
|
||||
try {
|
||||
const tokens = await firstValueFrom(
|
||||
this.http.post<TokenResponse>('/api/auth/refresh', { refreshToken: refresh }),
|
||||
);
|
||||
this.setTokens(tokens);
|
||||
return tokens.accessToken;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import {
|
||||
CommitDietPlanResult,
|
||||
DietGeneratorSession,
|
||||
DietUserProfile,
|
||||
MacroProposal,
|
||||
} from '../models/generator.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DietGeneratorService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
fetchProfile(): Observable<DietUserProfile | null> {
|
||||
return this.http.get<DietUserProfile>('/api/diet-generator/profile').pipe(
|
||||
map((data) => (data.heightCm ? data : null)),
|
||||
);
|
||||
}
|
||||
|
||||
saveProfile(profile: DietUserProfile): Observable<DietUserProfile> {
|
||||
return this.http.put<DietUserProfile>('/api/diet-generator/profile', profile);
|
||||
}
|
||||
|
||||
createSession(input: {
|
||||
planStartDate?: string;
|
||||
planDayCount?: number;
|
||||
calorieToleranceKcal?: number;
|
||||
}): Observable<DietGeneratorSession> {
|
||||
return this.http.post<DietGeneratorSession>('/api/diet-generator/sessions', input);
|
||||
}
|
||||
|
||||
fetchSession(id: number): Observable<DietGeneratorSession> {
|
||||
return this.http.get<DietGeneratorSession>(`/api/diet-generator/sessions/${id}`);
|
||||
}
|
||||
|
||||
proposeMacros(sessionId: number): Observable<DietGeneratorSession> {
|
||||
return this.http.post<DietGeneratorSession>(
|
||||
`/api/diet-generator/sessions/${sessionId}/propose-macros`,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
acceptMacros(sessionId: number, macros?: Partial<MacroProposal>): Observable<DietGeneratorSession> {
|
||||
return this.http.post<DietGeneratorSession>(
|
||||
`/api/diet-generator/sessions/${sessionId}/accept-macros`,
|
||||
{
|
||||
calories: macros?.calories,
|
||||
proteinG: macros?.proteinG,
|
||||
fatG: macros?.fatG,
|
||||
carbsG: macros?.carbsG,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
generatePlan(sessionId: number): Observable<DietGeneratorSession> {
|
||||
return this.http.post<DietGeneratorSession>(
|
||||
`/api/diet-generator/sessions/${sessionId}/generate-plan`,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
regenerateMeal(
|
||||
sessionId: number,
|
||||
planDate: string,
|
||||
mealCategory: number,
|
||||
): Observable<DietGeneratorSession> {
|
||||
return this.http.post<DietGeneratorSession>(
|
||||
`/api/diet-generator/sessions/${sessionId}/regenerate-meal`,
|
||||
{ planDate, mealCategory },
|
||||
);
|
||||
}
|
||||
|
||||
toggleMealLock(sessionId: number, mealId: number, isLocked: boolean): Observable<DietGeneratorSession> {
|
||||
return this.http.patch<DietGeneratorSession>(
|
||||
`/api/diet-generator/sessions/${sessionId}/meals/${mealId}`,
|
||||
{ isLocked },
|
||||
);
|
||||
}
|
||||
|
||||
commitPlan(sessionId: number): Observable<CommitDietPlanResult> {
|
||||
return this.http.post<CommitDietPlanResult>(
|
||||
`/api/diet-generator/sessions/${sessionId}/commit`,
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
129
meal-plan-frontend-angular/src/app/core/services/diet.service.ts
Normal file
129
meal-plan-frontend-angular/src/app/core/services/diet.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import {
|
||||
CopyMealsResult,
|
||||
CreateDietReminderInput,
|
||||
DailySummary,
|
||||
DietReminder,
|
||||
DietSuggestion,
|
||||
DrinkCatalogItem,
|
||||
DueReminder,
|
||||
FavoriteCatalogItem,
|
||||
FavoriteRecipe,
|
||||
HistoryDay,
|
||||
LogDrinkInput,
|
||||
LogMealInput,
|
||||
MealConsumption,
|
||||
MealPreview,
|
||||
RecentMeal,
|
||||
UpdateDietReminderInput,
|
||||
UserDailyGoals,
|
||||
todayIso,
|
||||
} from '../models/diet.models';
|
||||
import { normalizeDailySummary } from '../utils/diet-normalize.util';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DietService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
getSummary(date = todayIso()): Observable<DailySummary> {
|
||||
return this.http
|
||||
.get<unknown>('/api/diet/summary', { params: { date } })
|
||||
.pipe(map(normalizeDailySummary));
|
||||
}
|
||||
|
||||
getGoals(): Observable<UserDailyGoals> {
|
||||
return this.http.get<UserDailyGoals>('/api/diet/goals');
|
||||
}
|
||||
|
||||
saveGoals(goals: UserDailyGoals): Observable<UserDailyGoals> {
|
||||
return this.http.put<UserDailyGoals>('/api/diet/goals', goals);
|
||||
}
|
||||
|
||||
logMeal(input: LogMealInput): Observable<MealConsumption> {
|
||||
return this.http.post<MealConsumption>('/api/diet/meals', input);
|
||||
}
|
||||
|
||||
previewMeal(input: LogMealInput, date = todayIso()): Observable<MealPreview> {
|
||||
return this.http.post<MealPreview>('/api/diet/meals/preview', input, { params: { date } });
|
||||
}
|
||||
|
||||
getSuggestions(date = todayIso(), limit = 5): Observable<DietSuggestion[]> {
|
||||
return this.http.get<DietSuggestion[]>('/api/diet/suggestions', { params: { date, limit } });
|
||||
}
|
||||
|
||||
deleteMeal(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/diet/meals/${id}`);
|
||||
}
|
||||
|
||||
getDrinkCatalog(): Observable<DrinkCatalogItem[]> {
|
||||
return this.http.get<DrinkCatalogItem[]>('/api/diet/drinks/catalog');
|
||||
}
|
||||
|
||||
logDrink(input: LogDrinkInput): Observable<void> {
|
||||
return this.http.post<void>('/api/diet/drinks', input);
|
||||
}
|
||||
|
||||
deleteDrink(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/diet/drinks/${id}`);
|
||||
}
|
||||
|
||||
getReminders(): Observable<DietReminder[]> {
|
||||
return this.http.get<DietReminder[]>('/api/diet/reminders');
|
||||
}
|
||||
|
||||
getDueReminders(date = todayIso(), time?: string): Observable<DueReminder[]> {
|
||||
const params: Record<string, string> = { date };
|
||||
if (time) params['time'] = time;
|
||||
return this.http.get<DueReminder[]>('/api/diet/reminders/due', { params });
|
||||
}
|
||||
|
||||
createReminder(input: CreateDietReminderInput): Observable<DietReminder> {
|
||||
return this.http.post<DietReminder>('/api/diet/reminders', input);
|
||||
}
|
||||
|
||||
updateReminder(id: number, input: UpdateDietReminderInput): Observable<DietReminder> {
|
||||
return this.http.put<DietReminder>(`/api/diet/reminders/${id}`, input);
|
||||
}
|
||||
|
||||
deleteReminder(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/diet/reminders/${id}`);
|
||||
}
|
||||
|
||||
getHistory(from: string, to: string): Observable<HistoryDay[]> {
|
||||
return this.http.get<HistoryDay[]>('/api/diet/history', { params: { from, to } });
|
||||
}
|
||||
|
||||
getRecentMeals(limit = 10): Observable<RecentMeal[]> {
|
||||
return this.http.get<RecentMeal[]>('/api/diet/meals/recent', { params: { limit } });
|
||||
}
|
||||
|
||||
copyYesterdayMeals(date = todayIso()): Observable<CopyMealsResult> {
|
||||
return this.http.post<CopyMealsResult>('/api/diet/meals/copy-yesterday', null, { params: { date } });
|
||||
}
|
||||
|
||||
getFavoriteRecipes(): Observable<FavoriteRecipe[]> {
|
||||
return this.http.get<FavoriteRecipe[]>('/api/diet/favorites/recipes');
|
||||
}
|
||||
|
||||
addFavoriteRecipe(recipeId: number): Observable<FavoriteRecipe> {
|
||||
return this.http.post<FavoriteRecipe>(`/api/diet/favorites/recipes/${recipeId}`, null);
|
||||
}
|
||||
|
||||
removeFavoriteRecipe(recipeId: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/diet/favorites/recipes/${recipeId}`);
|
||||
}
|
||||
|
||||
getFavoriteCatalogItems(): Observable<FavoriteCatalogItem[]> {
|
||||
return this.http.get<FavoriteCatalogItem[]>('/api/diet/favorites/catalog');
|
||||
}
|
||||
|
||||
addFavoriteCatalogItem(catalogItemId: number): Observable<FavoriteCatalogItem> {
|
||||
return this.http.post<FavoriteCatalogItem>(`/api/diet/favorites/catalog/${catalogItemId}`, null);
|
||||
}
|
||||
|
||||
removeFavoriteCatalogItem(catalogItemId: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/diet/favorites/catalog/${catalogItemId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
IngredientCategory,
|
||||
IngredientNutritionItem,
|
||||
UpsertIngredientNutritionItemInput,
|
||||
} from '../models/ingredient-catalog.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class IngredientCatalogService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
getCategories(): Observable<IngredientCategory[]> {
|
||||
return this.http.get<IngredientCategory[]>('/api/ingredient-catalog/categories');
|
||||
}
|
||||
|
||||
getItems(categoryCode?: string, search?: string): Observable<IngredientNutritionItem[]> {
|
||||
let params = new HttpParams();
|
||||
if (categoryCode) params = params.set('category', categoryCode);
|
||||
if (search) params = params.set('search', search);
|
||||
return this.http.get<IngredientNutritionItem[]>('/api/ingredient-catalog', { params });
|
||||
}
|
||||
|
||||
getItem(id: number): Observable<IngredientNutritionItem> {
|
||||
return this.http.get<IngredientNutritionItem>(`/api/ingredient-catalog/${id}`);
|
||||
}
|
||||
|
||||
createItem(input: UpsertIngredientNutritionItemInput): Observable<IngredientNutritionItem> {
|
||||
return this.http.post<IngredientNutritionItem>('/api/ingredient-catalog', input);
|
||||
}
|
||||
|
||||
updateItem(id: number, input: UpsertIngredientNutritionItemInput): Observable<IngredientNutritionItem> {
|
||||
return this.http.put<IngredientNutritionItem>(`/api/ingredient-catalog/${id}`, input);
|
||||
}
|
||||
|
||||
deleteItem(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/ingredient-catalog/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
const STORAGE_KEY = 'dailymeals.lang';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LanguageService {
|
||||
constructor(private readonly translate: TranslateService) {}
|
||||
|
||||
init(): void {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const lang =
|
||||
stored === 'en' || stored === 'pl'
|
||||
? stored
|
||||
: navigator.language.toLowerCase().startsWith('pl')
|
||||
? 'pl'
|
||||
: 'en';
|
||||
this.translate.addLangs(['en', 'pl']);
|
||||
this.translate.setFallbackLang('en');
|
||||
this.setLanguage(lang);
|
||||
}
|
||||
|
||||
setLanguage(lang: 'en' | 'pl'): void {
|
||||
this.translate.use(lang);
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
|
||||
currentLanguage(): 'en' | 'pl' {
|
||||
const current = this.translate.getCurrentLang();
|
||||
return current?.startsWith('pl') ? 'pl' : 'en';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type LayoutId = 'sidebar' | 'topnav' | 'compact' | 'wide';
|
||||
|
||||
const STORAGE_KEY = 'dailymeals.layout';
|
||||
const VALID: LayoutId[] = ['sidebar', 'topnav', 'compact', 'wide'];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LayoutService {
|
||||
readonly layout = signal<LayoutId>(this.load());
|
||||
|
||||
setLayout(id: LayoutId): void {
|
||||
this.layout.set(id);
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
document.documentElement.dataset['layout'] = id;
|
||||
}
|
||||
|
||||
private load(): LayoutId {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const id = VALID.includes(stored as LayoutId) ? (stored as LayoutId) : 'sidebar';
|
||||
document.documentElement.dataset['layout'] = id;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
MealPlanEntry,
|
||||
ShoppingListItem,
|
||||
UpsertMealPlanEntryInput,
|
||||
} from '../models/meal-plan.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MealPlanService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
getEntries(from: string, to: string): Observable<MealPlanEntry[]> {
|
||||
return this.http.get<MealPlanEntry[]>('/api/meal-plan', { params: { from, to } });
|
||||
}
|
||||
|
||||
upsertEntry(input: UpsertMealPlanEntryInput): Observable<MealPlanEntry> {
|
||||
return this.http.put<MealPlanEntry>('/api/meal-plan', input);
|
||||
}
|
||||
|
||||
deleteEntry(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/meal-plan/${id}`);
|
||||
}
|
||||
|
||||
getShoppingList(from: string, to: string): Observable<ShoppingListItem[]> {
|
||||
return this.http.get<ShoppingListItem[]>('/api/meal-plan/shopping-list', { params: { from, to } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
CommitRecipesResult,
|
||||
GeneratedRecipeDraft,
|
||||
RecipeGeneratorConstraints,
|
||||
RecipeGeneratorSession,
|
||||
} from '../models/generator.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RecipeGeneratorService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
createSession(constraints: RecipeGeneratorConstraints): Observable<RecipeGeneratorSession> {
|
||||
return this.http.post<RecipeGeneratorSession>('/api/recipe-generator/sessions', constraints);
|
||||
}
|
||||
|
||||
fetchSession(id: number): Observable<RecipeGeneratorSession> {
|
||||
return this.http.get<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${id}`);
|
||||
}
|
||||
|
||||
generateDrafts(sessionId: number, count = 3): Observable<RecipeGeneratorSession> {
|
||||
return this.http.post<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${sessionId}/generate`, {
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
regeneratePart(
|
||||
sessionId: number,
|
||||
draftId: string,
|
||||
mode: 'ingredients' | 'steps' | 'full',
|
||||
instruction?: string,
|
||||
): Observable<RecipeGeneratorSession> {
|
||||
return this.http.post<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${sessionId}/regenerate`, {
|
||||
draftId,
|
||||
mode,
|
||||
instruction,
|
||||
});
|
||||
}
|
||||
|
||||
removeDraft(sessionId: number, draftId: string): Observable<RecipeGeneratorSession> {
|
||||
return this.http.delete<RecipeGeneratorSession>(
|
||||
`/api/recipe-generator/sessions/${sessionId}/drafts/${encodeURIComponent(draftId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
updateDraft(sessionId: number, draft: GeneratedRecipeDraft): Observable<RecipeGeneratorSession> {
|
||||
return this.http.put<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${sessionId}/draft`, draft);
|
||||
}
|
||||
|
||||
commitRecipes(sessionId: number, draftIds?: string[]): Observable<CommitRecipesResult> {
|
||||
return this.http.post<CommitRecipesResult>(`/api/recipe-generator/sessions/${sessionId}/commit`, {
|
||||
draftIds: draftIds?.length ? draftIds : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
CreateRecipeInput,
|
||||
RecipeDetail,
|
||||
RecipeImportResult,
|
||||
RecipeMacroRecalcResult,
|
||||
} from '../models/recipe.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RecipeManagementService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
createRecipe(input: CreateRecipeInput): Observable<RecipeDetail> {
|
||||
return this.http.post<RecipeDetail>('/api/recipes', this.toPayload(input));
|
||||
}
|
||||
|
||||
updateRecipe(id: number, input: CreateRecipeInput): Observable<RecipeDetail> {
|
||||
return this.http.put<RecipeDetail>(`/api/recipes/${id}`, this.toPayload(input));
|
||||
}
|
||||
|
||||
downloadImportTemplate(): Observable<Blob> {
|
||||
return this.http.get('/api/recipes/import/template', { responseType: 'blob' });
|
||||
}
|
||||
|
||||
importFromExcel(file: File): Observable<RecipeImportResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.http.post<RecipeImportResult>('/api/recipes/import/excel', formData);
|
||||
}
|
||||
|
||||
recalculateAllMacros(): Observable<RecipeMacroRecalcResult> {
|
||||
return this.http.post<RecipeMacroRecalcResult>('/api/recipes/recalculate-macros', {});
|
||||
}
|
||||
|
||||
private toPayload(input: CreateRecipeInput) {
|
||||
return {
|
||||
name: input.name,
|
||||
mealCategory: input.mealCategory,
|
||||
calories: input.calories,
|
||||
protein: input.protein,
|
||||
fat: input.fat,
|
||||
carbs: input.carbs,
|
||||
prepTimeMinutes: input.prepTimeMinutes,
|
||||
ingredients: input.ingredients.map((i, index) => ({
|
||||
name: i.name,
|
||||
amountGrams: i.amountGrams,
|
||||
unit: i.unit.trim() || null,
|
||||
sortOrder: i.sortOrder || index,
|
||||
catalogItemId: i.catalogItemId ?? null,
|
||||
})),
|
||||
steps: input.steps.map((s) => ({
|
||||
stepNumber: s.stepNumber,
|
||||
description: s.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
CategoryCount,
|
||||
MealCategory,
|
||||
RecipeDetail,
|
||||
RecipeListItem,
|
||||
} from '../models/recipe.models';
|
||||
|
||||
export interface RecipeQuery {
|
||||
category?: MealCategory;
|
||||
search?: string;
|
||||
ingredient?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RecipeService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
getRecipes(query: RecipeQuery = {}): Observable<RecipeListItem[]> {
|
||||
let params = new HttpParams();
|
||||
if (query.category != null) params = params.set('category', query.category);
|
||||
if (query.search) params = params.set('search', query.search);
|
||||
if (query.ingredient) params = params.set('ingredient', query.ingredient);
|
||||
return this.http.get<RecipeListItem[]>('/api/recipes', { params });
|
||||
}
|
||||
|
||||
getRecipeById(id: number): Observable<RecipeDetail> {
|
||||
return this.http.get<RecipeDetail>(`/api/recipes/${id}`);
|
||||
}
|
||||
|
||||
getCategories(): Observable<CategoryCount[]> {
|
||||
return this.http.get<CategoryCount[]>('/api/recipes/categories');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable, OnDestroy, inject } from '@angular/core';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { DietService } from './diet.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { DueReminder } from '../models/diet.models';
|
||||
import { todayIso } from '../models/diet.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ReminderNotificationService implements OnDestroy {
|
||||
private readonly diet = inject(DietService);
|
||||
private readonly auth = inject(AuthService);
|
||||
private sub: Subscription | null = null;
|
||||
private readonly notifiedIds = new Set<number>();
|
||||
|
||||
start(): void {
|
||||
if (this.sub || !this.auth.isAuthenticated()) return;
|
||||
this.requestPermission();
|
||||
this.poll();
|
||||
this.sub = interval(60_000).subscribe(() => this.poll());
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.sub?.unsubscribe();
|
||||
this.sub = null;
|
||||
this.notifiedIds.clear();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
private requestPermission(): void {
|
||||
if (typeof Notification === 'undefined') return;
|
||||
if (Notification.permission === 'default') {
|
||||
void Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
private poll(): void {
|
||||
if (!this.auth.isAuthenticated()) return;
|
||||
const now = new Date();
|
||||
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
this.diet.getDueReminders(todayIso(), time).subscribe({
|
||||
next: (due) => this.showNotifications(due),
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
private showNotifications(due: DueReminder[]): void {
|
||||
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
|
||||
for (const r of due) {
|
||||
if (this.notifiedIds.has(r.id)) continue;
|
||||
this.notifiedIds.add(r.id);
|
||||
const body = r.message ?? undefined;
|
||||
new Notification(r.title, { body, tag: `reminder-${r.id}` });
|
||||
}
|
||||
if (this.notifiedIds.size > 100) {
|
||||
this.notifiedIds.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemeService {
|
||||
readonly theme = signal<Theme>(this.detectInitial());
|
||||
|
||||
constructor() {
|
||||
this.apply(this.theme());
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
const next = this.theme() === 'dark' ? 'light' : 'dark';
|
||||
this.theme.set(next);
|
||||
this.apply(next);
|
||||
}
|
||||
|
||||
private detectInitial(): Theme {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
private apply(theme: Theme): void {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type ColorThemeId = 'forest' | 'ocean' | 'sunset' | 'berry';
|
||||
export type IconStyleId = 'outline' | 'emoji' | 'bold';
|
||||
export type AppearanceId = ColorThemeId;
|
||||
|
||||
export interface ThemeProposal {
|
||||
id: AppearanceId;
|
||||
labelKey: string;
|
||||
descriptionKey: string;
|
||||
colorTheme: ColorThemeId;
|
||||
iconStyle: IconStyleId;
|
||||
/** Preview swatch for UI */
|
||||
swatch: string;
|
||||
}
|
||||
|
||||
export const THEME_PROPOSALS: ThemeProposal[] = [
|
||||
{
|
||||
id: 'forest',
|
||||
labelKey: 'appearance.forest',
|
||||
descriptionKey: 'appearance.forestDesc',
|
||||
colorTheme: 'forest',
|
||||
iconStyle: 'outline',
|
||||
swatch: '#16774c',
|
||||
},
|
||||
{
|
||||
id: 'ocean',
|
||||
labelKey: 'appearance.ocean',
|
||||
descriptionKey: 'appearance.oceanDesc',
|
||||
colorTheme: 'ocean',
|
||||
iconStyle: 'outline',
|
||||
swatch: '#2563eb',
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
labelKey: 'appearance.sunset',
|
||||
descriptionKey: 'appearance.sunsetDesc',
|
||||
colorTheme: 'sunset',
|
||||
iconStyle: 'emoji',
|
||||
swatch: '#d97706',
|
||||
},
|
||||
{
|
||||
id: 'berry',
|
||||
labelKey: 'appearance.berry',
|
||||
descriptionKey: 'appearance.berryDesc',
|
||||
colorTheme: 'berry',
|
||||
iconStyle: 'bold',
|
||||
swatch: '#7c3aed',
|
||||
},
|
||||
];
|
||||
|
||||
export const NAV_ICON_EMOJI: Record<string, string> = {
|
||||
dashboard: '📊',
|
||||
coffee: '☕',
|
||||
croissant: '🥐',
|
||||
soup: '🍲',
|
||||
moon: '🌙',
|
||||
list: '📋',
|
||||
book: '✏️',
|
||||
leaf: '🥬',
|
||||
diet: '🥗',
|
||||
calendar: '📅',
|
||||
cart: '🛒',
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
|
||||
const GENERIC_API_TITLES = new Set([
|
||||
'An unexpected error occurred.',
|
||||
'Internal Server Error',
|
||||
]);
|
||||
|
||||
export function extractApiError(error: unknown, fallback = 'Something went wrong.'): string {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
const data = error.error as { error?: string; title?: string } | undefined;
|
||||
if (data?.error) return data.error;
|
||||
if (data?.title && !GENERIC_API_TITLES.has(data.title)) return data.title;
|
||||
return fallback;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { MacroRemaining, DecimalMacroRemaining, DailySummary } from '../models/diet.models';
|
||||
|
||||
const EMPTY_INT_MACRO: MacroRemaining = {
|
||||
consumed: 0,
|
||||
goal: null,
|
||||
remaining: null,
|
||||
progressPercent: null,
|
||||
};
|
||||
|
||||
const EMPTY_DECIMAL_MACRO: DecimalMacroRemaining = {
|
||||
consumed: 0,
|
||||
goal: null,
|
||||
remaining: null,
|
||||
progressPercent: null,
|
||||
};
|
||||
|
||||
function pick<T>(raw: Record<string, unknown>, camel: string, pascal: string): T | undefined {
|
||||
return (raw[camel] ?? raw[pascal]) as T | undefined;
|
||||
}
|
||||
|
||||
function normalizeIntMacro(raw: unknown): MacroRemaining {
|
||||
if (!raw || typeof raw !== 'object') return { ...EMPTY_INT_MACRO };
|
||||
const obj = raw as Record<string, unknown>;
|
||||
return {
|
||||
goal: (pick<number | null>(obj, 'goal', 'Goal') ?? null) as number | null,
|
||||
consumed: Number(pick<number>(obj, 'consumed', 'Consumed') ?? 0),
|
||||
remaining: (pick<number | null>(obj, 'remaining', 'Remaining') ?? null) as number | null,
|
||||
progressPercent: (pick<number | null>(obj, 'progressPercent', 'ProgressPercent') ?? null) as
|
||||
| number
|
||||
| null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDecimalMacro(raw: unknown): DecimalMacroRemaining {
|
||||
if (!raw || typeof raw !== 'object') return { ...EMPTY_DECIMAL_MACRO };
|
||||
const obj = raw as Record<string, unknown>;
|
||||
return {
|
||||
goal: (pick<number | null>(obj, 'goal', 'Goal') ?? null) as number | null,
|
||||
consumed: Number(pick<number>(obj, 'consumed', 'Consumed') ?? 0),
|
||||
remaining: (pick<number | null>(obj, 'remaining', 'Remaining') ?? null) as number | null,
|
||||
progressPercent: (pick<number | null>(obj, 'progressPercent', 'ProgressPercent') ?? null) as
|
||||
| number
|
||||
| null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalizes API payloads so templates never crash on missing nested fields. */
|
||||
export function normalizeDailySummary(raw: unknown): DailySummary {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {
|
||||
date: '',
|
||||
goals: null,
|
||||
calories: { ...EMPTY_INT_MACRO },
|
||||
proteinG: { ...EMPTY_DECIMAL_MACRO },
|
||||
fatG: { ...EMPTY_DECIMAL_MACRO },
|
||||
carbsG: { ...EMPTY_DECIMAL_MACRO },
|
||||
waterMl: { ...EMPTY_INT_MACRO },
|
||||
meals: [],
|
||||
drinks: [],
|
||||
};
|
||||
}
|
||||
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const meals = pick<unknown[]>(obj, 'meals', 'Meals');
|
||||
const drinks = pick<unknown[]>(obj, 'drinks', 'Drinks');
|
||||
|
||||
return {
|
||||
date: String(pick<string>(obj, 'date', 'Date') ?? ''),
|
||||
goals: (pick(obj, 'goals', 'Goals') as DailySummary['goals']) ?? null,
|
||||
calories: normalizeIntMacro(pick(obj, 'calories', 'Calories')),
|
||||
proteinG: normalizeDecimalMacro(pick(obj, 'proteinG', 'ProteinG')),
|
||||
fatG: normalizeDecimalMacro(pick(obj, 'fatG', 'FatG')),
|
||||
carbsG: normalizeDecimalMacro(pick(obj, 'carbsG', 'CarbsG')),
|
||||
waterMl: normalizeIntMacro(pick(obj, 'waterMl', 'WaterMl')),
|
||||
meals: Array.isArray(meals) ? (meals as DailySummary['meals']) : [],
|
||||
drinks: Array.isArray(drinks) ? (drinks as DailySummary['drinks']) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatReminderTime(value: string | null | undefined): string {
|
||||
if (!value) return '';
|
||||
return value.length >= 5 ? value.slice(0, 5) : value;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
const COUNT_AMOUNT_THRESHOLD = 10;
|
||||
|
||||
const NON_QUANTIFIED_UNITS = new Set([
|
||||
'to taste',
|
||||
'for serving',
|
||||
'optional',
|
||||
'do smaku',
|
||||
'do podania',
|
||||
'opcjonalnie',
|
||||
'garnish',
|
||||
'szczypta',
|
||||
]);
|
||||
|
||||
function normalizeUnit(unit: string): string {
|
||||
return unit.trim().toLowerCase().replace(/\.$/, '');
|
||||
}
|
||||
|
||||
export function isNonQuantifiedUnit(unit: string | null | undefined): boolean {
|
||||
if (!unit?.trim()) return false;
|
||||
return NON_QUANTIFIED_UNITS.has(normalizeUnit(unit));
|
||||
}
|
||||
|
||||
function isCountUnit(unit: string | null | undefined): boolean {
|
||||
if (!unit?.trim()) return false;
|
||||
const u = normalizeUnit(unit);
|
||||
return (
|
||||
u === 'szt' ||
|
||||
u === 'sztuka' ||
|
||||
u === 'sztuki' ||
|
||||
u === 'ząbek' ||
|
||||
u === 'zabek' ||
|
||||
u === 'zabki' ||
|
||||
u === 'clove' ||
|
||||
u === 'cloves' ||
|
||||
u === 'łyżka' ||
|
||||
u === 'lyzka' ||
|
||||
u === 'łyżeczka' ||
|
||||
u === 'lyzeczka' ||
|
||||
u === 'jajko' ||
|
||||
u === 'jajka' ||
|
||||
u === 'egg' ||
|
||||
u === 'eggs'
|
||||
);
|
||||
}
|
||||
|
||||
function gramsPerCountUnit(unit: string): number {
|
||||
const u = normalizeUnit(unit);
|
||||
switch (u) {
|
||||
case 'ząbek':
|
||||
case 'zabek':
|
||||
case 'zabki':
|
||||
case 'clove':
|
||||
case 'cloves':
|
||||
return 4;
|
||||
case 'łyżka':
|
||||
case 'lyzka':
|
||||
return 15;
|
||||
case 'łyżeczka':
|
||||
case 'lyzeczka':
|
||||
return 5;
|
||||
default:
|
||||
return 60;
|
||||
}
|
||||
}
|
||||
|
||||
function roundAmount(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function normalizeIngredientQuantity(
|
||||
amountGrams: number | null | undefined,
|
||||
unit: string | null | undefined,
|
||||
): { amountGrams: number | null; unit: string | null } {
|
||||
const unitRaw = unit?.trim() || null;
|
||||
|
||||
if (isNonQuantifiedUnit(unitRaw)) {
|
||||
return { amountGrams: amountGrams ?? null, unit: unitRaw };
|
||||
}
|
||||
|
||||
if (amountGrams == null || amountGrams <= 0) {
|
||||
return { amountGrams: null, unit: unitRaw };
|
||||
}
|
||||
|
||||
if (isCountUnit(unitRaw) && unitRaw) {
|
||||
if (amountGrams <= COUNT_AMOUNT_THRESHOLD) {
|
||||
return {
|
||||
amountGrams: roundAmount(amountGrams * gramsPerCountUnit(unitRaw)),
|
||||
unit: null,
|
||||
};
|
||||
}
|
||||
return { amountGrams: roundAmount(amountGrams), unit: null };
|
||||
}
|
||||
|
||||
return { amountGrams: roundAmount(amountGrams), unit: null };
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return Number.isInteger(value) ? value.toString() : value.toFixed(1).replace(/\.0$/, '');
|
||||
}
|
||||
|
||||
export function formatIngredientAmount(amountGrams: number | null, unit: string | null): string {
|
||||
if (isNonQuantifiedUnit(unit)) {
|
||||
return unit?.trim() ?? '';
|
||||
}
|
||||
|
||||
const normalized = normalizeIngredientQuantity(amountGrams, unit);
|
||||
if (normalized.amountGrams == null || normalized.amountGrams <= 0) {
|
||||
return normalized.unit ?? '';
|
||||
}
|
||||
|
||||
return `${formatNumber(normalized.amountGrams)} g`;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { MealPlanEntry } from '../models/meal-plan.models';
|
||||
import { MealCategory, RecipeListItem } from '../models/recipe.models';
|
||||
|
||||
export const PLANNER_MEAL_CATEGORIES = [
|
||||
MealCategory.Breakfast,
|
||||
MealCategory.SecondBreakfast,
|
||||
MealCategory.Lunch,
|
||||
MealCategory.Dinner,
|
||||
] as const;
|
||||
|
||||
export interface DayMealPlanning {
|
||||
dailyTarget: number;
|
||||
logged: number;
|
||||
remaining: number;
|
||||
slotTarget: number;
|
||||
openSlots: number;
|
||||
pendingCalories: number;
|
||||
}
|
||||
|
||||
export interface PendingMealSelection {
|
||||
category: MealCategory;
|
||||
calories: number;
|
||||
}
|
||||
|
||||
export function entryCaloriesTotal(entry: MealPlanEntry): number {
|
||||
if (entry.calories == null) return 0;
|
||||
return Math.round(entry.calories * entry.portions);
|
||||
}
|
||||
|
||||
export function computeDayMealPlanning(
|
||||
date: string,
|
||||
category: MealCategory,
|
||||
entries: MealPlanEntry[],
|
||||
dailyTarget: number,
|
||||
pending?: PendingMealSelection | null,
|
||||
): DayMealPlanning {
|
||||
const dayEntries = entries.filter((e) => e.planDate === date);
|
||||
const categoriesWithSaved = new Set(dayEntries.map((e) => e.mealCategory));
|
||||
const existingInSlot = dayEntries.find((e) => e.mealCategory === category);
|
||||
|
||||
let logged = dayEntries.reduce((sum, e) => sum + entryCaloriesTotal(e), 0);
|
||||
|
||||
const pendingCalories =
|
||||
pending && pending.category === category && pending.calories > 0 ? pending.calories : 0;
|
||||
|
||||
if (pendingCalories > 0) {
|
||||
if (existingInSlot) {
|
||||
logged = logged - entryCaloriesTotal(existingInSlot) + pendingCalories;
|
||||
} else {
|
||||
logged += pendingCalories;
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = dailyTarget - logged;
|
||||
|
||||
const emptyCategories = PLANNER_MEAL_CATEGORIES.filter((cat) => !categoriesWithSaved.has(cat));
|
||||
const currentSlotSaved = categoriesWithSaved.has(category);
|
||||
|
||||
let unfilled = [...emptyCategories];
|
||||
if (pendingCalories > 0 && !currentSlotSaved) {
|
||||
unfilled = unfilled.filter((cat) => cat !== category);
|
||||
}
|
||||
|
||||
let openSlots: number;
|
||||
if (currentSlotSaved) {
|
||||
openSlots = Math.max(1, unfilled.length + 1);
|
||||
} else if (pendingCalories > 0) {
|
||||
openSlots = Math.max(1, unfilled.length);
|
||||
} else if (unfilled.includes(category)) {
|
||||
openSlots = Math.max(1, unfilled.length);
|
||||
} else {
|
||||
openSlots = Math.max(1, unfilled.length + 1);
|
||||
}
|
||||
|
||||
const slotTarget = dailyTarget > 0 ? Math.round(Math.max(0, remaining) / openSlots) : 0;
|
||||
|
||||
return { dailyTarget, logged, remaining, slotTarget, openSlots, pendingCalories };
|
||||
}
|
||||
|
||||
export function rankRecipeSuggestions(
|
||||
recipes: RecipeListItem[],
|
||||
category: MealCategory,
|
||||
slotTarget: number,
|
||||
limit = 6,
|
||||
): { recipe: RecipeListItem; diff: number }[] {
|
||||
if (slotTarget <= 0) return [];
|
||||
|
||||
return recipes
|
||||
.filter((r) => r.mealCategory === category && r.calories != null && r.calories > 0)
|
||||
.map((recipe) => ({ recipe, diff: Math.abs(recipe.calories! - slotTarget) }))
|
||||
.sort((a, b) => a.diff - b.diff || a.recipe.name.localeCompare(b.recipe.name))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export function estimateEntryCalories(recipe: RecipeListItem | null, portions: number): number | null {
|
||||
if (!recipe?.calories) return null;
|
||||
return Math.round(recipe.calories * portions);
|
||||
}
|
||||
159
meal-plan-frontend-angular/src/app/core/utils/pdf-export.util.ts
Normal file
159
meal-plan-frontend-angular/src/app/core/utils/pdf-export.util.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import html2canvas from 'html2canvas';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { RecipeDetail } from '../models/recipe.models';
|
||||
import { isNonQuantifiedUnit, normalizeIngredientQuantity } from './ingredient-quantity.util';
|
||||
|
||||
export interface ShoppingItem {
|
||||
name: string;
|
||||
totalGrams: number | null;
|
||||
totalAmount: number | null;
|
||||
unit: string | null;
|
||||
quantified: boolean;
|
||||
sources: string[];
|
||||
contributions: Array<{ recipe: string; amount: number | null }>;
|
||||
}
|
||||
|
||||
function roundMacro(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function scaleRecipeDetail(recipe: RecipeDetail, portions: number): RecipeDetail {
|
||||
return {
|
||||
...recipe,
|
||||
calories: recipe.calories != null ? Math.round(recipe.calories * portions) : null,
|
||||
protein: recipe.protein != null ? roundMacro(recipe.protein * portions) : null,
|
||||
fat: recipe.fat != null ? roundMacro(recipe.fat * portions) : null,
|
||||
carbs: recipe.carbs != null ? roundMacro(recipe.carbs * portions) : null,
|
||||
ingredients: recipe.ingredients.map((ing) => ({
|
||||
...ing,
|
||||
amountGrams: ing.amountGrams != null ? roundMacro(ing.amountGrams * portions) : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildShoppingList(recipes: RecipeDetail[]): ShoppingItem[] {
|
||||
const buckets = new Map<string, ShoppingItem>();
|
||||
|
||||
for (const recipe of recipes) {
|
||||
for (const ing of recipe.ingredients) {
|
||||
const displayName = ing.name.trim();
|
||||
const normName = displayName.toLowerCase();
|
||||
const normalized = normalizeIngredientQuantity(ing.amountGrams, ing.unit);
|
||||
const unitRaw = normalized.unit;
|
||||
const nonQuantified =
|
||||
isNonQuantifiedUnit(unitRaw) || (unitRaw === null && normalized.amountGrams === null);
|
||||
|
||||
const key = nonQuantified ? `nq:${normName}` : `g:${normName}`;
|
||||
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
name: displayName,
|
||||
totalGrams: null,
|
||||
totalAmount: null,
|
||||
unit: nonQuantified ? unitRaw : null,
|
||||
quantified: !nonQuantified,
|
||||
sources: [],
|
||||
contributions: [],
|
||||
};
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
|
||||
if (!bucket.sources.includes(recipe.name)) {
|
||||
bucket.sources.push(recipe.name);
|
||||
}
|
||||
|
||||
if (nonQuantified) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const amount = normalized.amountGrams;
|
||||
if (amount == null || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bucket.contributions.push({ recipe: recipe.name, amount });
|
||||
bucket.totalGrams = (bucket.totalGrams ?? 0) + amount;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(buckets.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }),
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return Number.isInteger(value) ? value.toString() : value.toFixed(2).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
export function formatQuantity(item: ShoppingItem, toTasteLabel: string): string {
|
||||
if (!item.quantified) {
|
||||
return item.unit ? item.unit : toTasteLabel;
|
||||
}
|
||||
return `${formatNumber(item.totalGrams ?? 0)} g`;
|
||||
}
|
||||
|
||||
export function formatBreakdown(item: ShoppingItem): string | null {
|
||||
if (!item.quantified || item.sources.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const amounts = item.contributions.map((c) => c.amount).filter((a): a is number => a !== null);
|
||||
if (amounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unitLabel = 'g';
|
||||
const allEqual = amounts.every((a) => a === amounts[0]);
|
||||
|
||||
if (allEqual && amounts.length > 1) {
|
||||
return `${amounts.length} × ${formatNumber(amounts[0])} ${unitLabel}`;
|
||||
}
|
||||
|
||||
return amounts.map((a) => `${formatNumber(a)} ${unitLabel}`).join(' + ');
|
||||
}
|
||||
|
||||
export async function generatePdf(
|
||||
translate: TranslateService,
|
||||
fileName = 'meal-plan.pdf',
|
||||
containerId = 'pdf-render-area',
|
||||
): Promise<void> {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
throw new Error(translate.instant('errors.pdfRenderAreaMissing'));
|
||||
}
|
||||
|
||||
const pages = Array.from(container.querySelectorAll<HTMLElement>('.pdf-page'));
|
||||
if (pages.length === 0) {
|
||||
throw new Error(translate.instant('errors.pdfNothingToExport'));
|
||||
}
|
||||
|
||||
const pdf = new jsPDF({ unit: 'pt', format: 'a4', orientation: 'portrait' });
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
|
||||
for (let i = 0; i < pages.length; i += 1) {
|
||||
const canvas = await html2canvas(pages[i], {
|
||||
scale: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL('image/jpeg', 0.92);
|
||||
const imgWidth = pageWidth;
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||||
|
||||
if (i > 0) {
|
||||
pdf.addPage();
|
||||
}
|
||||
|
||||
const finalHeight = Math.min(imgHeight, pageHeight);
|
||||
const finalWidth =
|
||||
finalHeight === imgHeight ? imgWidth : (canvas.width * finalHeight) / canvas.height;
|
||||
pdf.addImage(imgData, 'JPEG', (pageWidth - finalWidth) / 2, 0, finalWidth, finalHeight);
|
||||
}
|
||||
|
||||
pdf.save(fileName);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
function polishPluralSuffix(count: number): string {
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (count === 1) return 'one';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'few';
|
||||
return 'many';
|
||||
}
|
||||
|
||||
export function translatePlural(
|
||||
translate: TranslateService,
|
||||
key: string,
|
||||
count: number,
|
||||
): string {
|
||||
const lang = translate.getCurrentLang() ?? translate.getFallbackLang() ?? 'en';
|
||||
let suffix: string;
|
||||
if (lang.startsWith('pl')) {
|
||||
suffix = polishPluralSuffix(count);
|
||||
} else {
|
||||
suffix = count === 1 ? 'one' : 'other';
|
||||
}
|
||||
const fullKey = `${key}_${suffix}`;
|
||||
const translated = translate.instant(fullKey, { count });
|
||||
if (translated === fullKey) {
|
||||
return translate.instant(`${key}_other`, { count });
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { IngredientNutritionItem } from '../models/ingredient-catalog.models';
|
||||
|
||||
export interface MacroTotals {
|
||||
calories: number;
|
||||
protein: number;
|
||||
fat: number;
|
||||
carbs: number;
|
||||
}
|
||||
|
||||
export interface MacroIngredientInput {
|
||||
catalogItemId?: number | null;
|
||||
amountGrams?: number | null;
|
||||
unit?: string | null;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function hasNonMacroUnit(unit?: string | null): boolean {
|
||||
if (!unit?.trim()) return false;
|
||||
const u = unit.trim().toLowerCase();
|
||||
return u === 'do smaku' || u === 'optional' || u === 'opcjonalnie' || u === 'szczypta';
|
||||
}
|
||||
|
||||
export function shouldIncludeIngredientInMacroSum(ing: MacroIngredientInput): boolean {
|
||||
return Boolean(
|
||||
ing.catalogItemId && ing.amountGrams != null && ing.amountGrams > 0 && !hasNonMacroUnit(ing.unit),
|
||||
);
|
||||
}
|
||||
|
||||
export function isUnlinkedMacroIngredient(ing: MacroIngredientInput): boolean {
|
||||
if (!ing.name?.trim()) return false;
|
||||
if (hasNonMacroUnit(ing.unit)) return false;
|
||||
return Boolean(ing.amountGrams != null && ing.amountGrams > 0 && !ing.catalogItemId);
|
||||
}
|
||||
|
||||
export function countUnlinkedMacroIngredients(ingredients: MacroIngredientInput[]): number {
|
||||
return ingredients.filter(isUnlinkedMacroIngredient).length;
|
||||
}
|
||||
|
||||
export function estimateMacrosFromCatalog(
|
||||
ingredients: MacroIngredientInput[],
|
||||
catalogItems: IngredientNutritionItem[],
|
||||
): MacroTotals | null {
|
||||
let calories = 0;
|
||||
let protein = 0;
|
||||
let fat = 0;
|
||||
let carbs = 0;
|
||||
let hasAny = false;
|
||||
|
||||
for (const ing of ingredients) {
|
||||
if (!shouldIncludeIngredientInMacroSum(ing)) continue;
|
||||
const cat = catalogItems.find((c) => c.id === ing.catalogItemId);
|
||||
if (!cat) continue;
|
||||
const scale = Number(ing.amountGrams) / 100;
|
||||
calories += Math.round(cat.caloriesPer100G * scale);
|
||||
protein += cat.proteinGPer100G * scale;
|
||||
fat += cat.fatGPer100G * scale;
|
||||
carbs += cat.carbsGPer100G * scale;
|
||||
hasAny = true;
|
||||
}
|
||||
|
||||
return hasAny ? { calories, protein, fat, carbs } : null;
|
||||
}
|
||||
53
meal-plan-frontend-angular/src/app/core/utils/sort.util.ts
Normal file
53
meal-plan-frontend-angular/src/app/core/utils/sort.util.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export function nextSort<T extends string>(
|
||||
current: { column: T; direction: SortDirection } | null,
|
||||
column: T,
|
||||
): { column: T; direction: SortDirection } {
|
||||
if (current?.column === column) {
|
||||
return { column, direction: current.direction === 'asc' ? 'desc' : 'asc' };
|
||||
}
|
||||
return { column, direction: 'asc' };
|
||||
}
|
||||
|
||||
export function sortIndicator(
|
||||
current: { column: string; direction: SortDirection } | null,
|
||||
column: string,
|
||||
): string {
|
||||
if (current?.column !== column) return '';
|
||||
return current.direction === 'asc' ? ' ↑' : ' ↓';
|
||||
}
|
||||
|
||||
export function compareText(a: string, b: string, direction: SortDirection): number {
|
||||
const result = a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
|
||||
return direction === 'asc' ? result : -result;
|
||||
}
|
||||
|
||||
export function compareNumber(
|
||||
a: number | null | undefined,
|
||||
b: number | null | undefined,
|
||||
direction: SortDirection,
|
||||
): number {
|
||||
const aMissing = a == null || Number.isNaN(a);
|
||||
const bMissing = b == null || Number.isNaN(b);
|
||||
if (aMissing && bMissing) return 0;
|
||||
if (aMissing) return 1;
|
||||
if (bMissing) return -1;
|
||||
const result = a - b;
|
||||
return direction === 'asc' ? result : -result;
|
||||
}
|
||||
|
||||
export function sortBy<T>(
|
||||
items: readonly T[],
|
||||
direction: SortDirection,
|
||||
getValue: (item: T) => string | number | null | undefined,
|
||||
): T[] {
|
||||
return [...items].sort((left, right) => {
|
||||
const a = getValue(left);
|
||||
const b = getValue(right);
|
||||
if (typeof a === 'number' || typeof b === 'number') {
|
||||
return compareNumber(typeof a === 'number' ? a : null, typeof b === 'number' ? b : null, direction);
|
||||
}
|
||||
return compareText(String(a ?? ''), String(b ?? ''), direction);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { Subject, debounceTime, distinctUntilChanged, takeUntil } from 'rxjs';
|
||||
import { RecipeService } from '../../core/services/recipe.service';
|
||||
import { RecipeListItem } from '../../core/models/recipe.models';
|
||||
import { RecipeCardComponent } from '../recipes/recipe-card.component';
|
||||
import { CategoryFilter, RecipeFiltersComponent } from '../recipes/recipe-filters.component';
|
||||
import { PdfGeneratorComponent } from '../pdf/pdf-generator.component';
|
||||
import { ErrorStateComponent } from '../../shared/components/error-state.component';
|
||||
import { EmptyStateComponent } from '../../shared/components/empty-state.component';
|
||||
import { GridSkeletonComponent } from '../../shared/components/skeleton.component';
|
||||
import { translatePlural } from '../../core/utils/plural-translate.util';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
import { nextSort, sortBy, sortIndicator, type SortDirection } from '../../core/utils/sort.util';
|
||||
|
||||
type RecipeSortColumn = 'name' | 'calories' | 'prepTime';
|
||||
|
||||
@Component({
|
||||
selector: 'app-all-meals',
|
||||
standalone: true,
|
||||
imports: [
|
||||
TranslatePipe,
|
||||
RecipeCardComponent,
|
||||
RecipeFiltersComponent,
|
||||
PdfGeneratorComponent,
|
||||
ErrorStateComponent,
|
||||
EmptyStateComponent,
|
||||
GridSkeletonComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<header class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight sm:text-3xl">{{ 'recipes.allMealsTitle' | translate }}</h1>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">{{ 'recipes.allMealsSubtitle' | translate }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full bg-brand-100 px-3.5 py-1.5 text-sm font-semibold text-brand-800 dark:bg-brand-900/40 dark:text-brand-300"
|
||||
>
|
||||
{{ selectedCountLabel() }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<app-recipe-filters
|
||||
[active]="filter()"
|
||||
(onChange)="filter.set($event)"
|
||||
[search]="search()"
|
||||
(onSearchChange)="onSearchChange($event)"
|
||||
[searchByIngredient]="searchByIngredient()"
|
||||
(onSearchByIngredientChange)="onIngredientModeChange($event)"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button type="button" (click)="selectAllVisible()" [disabled]="visible().length === 0" class="btn-secondary">
|
||||
☑ {{ 'recipes.selectAllVisible' | translate }}
|
||||
</button>
|
||||
<button type="button" (click)="deselectAll()" [disabled]="selected().size === 0" class="btn-secondary">
|
||||
☐ {{ 'recipes.deselectAll' | translate }}
|
||||
</button>
|
||||
<div class="ml-auto">
|
||||
<app-pdf-generator [selectedIds]="selectedIds()" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<app-grid-skeleton [count]="9" />
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<app-error-state [message]="error()!" [showRetry]="true" (retry)="load()" />
|
||||
}
|
||||
|
||||
@if (!loading() && !error() && visible().length === 0) {
|
||||
<app-empty-state
|
||||
[title]="'recipes.noMatchingTitle' | translate"
|
||||
[description]="
|
||||
searchByIngredient()
|
||||
? ('recipes.noMatchingIngredient' | translate)
|
||||
: ('recipes.noMatchingFilter' | translate)
|
||||
"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (!loading() && !error() && visible().length > 0) {
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
@for (col of recipeSortColumns; track col.id) {
|
||||
<button type="button" class="rounded-lg border border-slate-200 px-3 py-1.5 font-medium hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800" (click)="toggleRecipeSort(col.id)">
|
||||
{{ col.labelKey | translate }}{{ recipeSortIndicator(col.id) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@for (recipe of sortedVisible(); track recipe.id) {
|
||||
<app-recipe-card
|
||||
[recipe]="recipe"
|
||||
[selectable]="true"
|
||||
[showCategory]="true"
|
||||
[selected]="selected().has(recipe.id)"
|
||||
(toggleSelected)="toggleSelected($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AllMealsComponent implements OnInit, OnDestroy {
|
||||
private readonly recipeService = inject(RecipeService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
private readonly searchDebounced$ = new Subject<string>();
|
||||
|
||||
readonly filter = signal<CategoryFilter>('all');
|
||||
readonly search = signal('');
|
||||
readonly debouncedSearch = signal('');
|
||||
readonly searchByIngredient = signal(false);
|
||||
readonly selected = signal(new Set<number>());
|
||||
readonly recipes = signal<RecipeListItem[] | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly recipeSort = signal<{ column: RecipeSortColumn; direction: SortDirection }>({
|
||||
column: 'name',
|
||||
direction: 'asc',
|
||||
});
|
||||
readonly recipeSortColumns: { id: RecipeSortColumn; labelKey: string }[] = [
|
||||
{ id: 'name', labelKey: 'manage.recipeName' },
|
||||
{ id: 'calories', labelKey: 'recipes.calories' },
|
||||
{ id: 'prepTime', labelKey: 'manage.prepTimeMin' },
|
||||
];
|
||||
|
||||
readonly visible = computed(() => {
|
||||
const list = this.recipes();
|
||||
if (!list) return [];
|
||||
const term = this.search().trim().toLowerCase();
|
||||
const activeFilter = this.filter();
|
||||
return list.filter((r) => {
|
||||
const matchesCategory = activeFilter === 'all' || r.mealCategory === activeFilter;
|
||||
const matchesSearch =
|
||||
this.searchByIngredient() || term === '' || r.name.toLowerCase().includes(term);
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
});
|
||||
|
||||
readonly sortedVisible = computed(() => {
|
||||
const { column, direction } = this.recipeSort();
|
||||
return sortBy(this.visible(), direction, (recipe) => {
|
||||
switch (column) {
|
||||
case 'name':
|
||||
return recipe.name;
|
||||
case 'calories':
|
||||
return recipe.calories;
|
||||
case 'prepTime':
|
||||
return recipe.prepTimeMinutes;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
readonly selectedIds = computed(() => Array.from(this.selected()));
|
||||
|
||||
ngOnInit(): void {
|
||||
this.searchDebounced$
|
||||
.pipe(debounceTime(350), distinctUntilChanged(), takeUntil(this.destroy$))
|
||||
.subscribe((value) => {
|
||||
this.debouncedSearch.set(value);
|
||||
this.load();
|
||||
});
|
||||
this.load();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
onSearchChange(value: string): void {
|
||||
this.search.set(value);
|
||||
this.searchDebounced$.next(value.trim());
|
||||
}
|
||||
|
||||
onIngredientModeChange(value: boolean): void {
|
||||
this.searchByIngredient.set(value);
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
const ingredient =
|
||||
this.searchByIngredient() && this.debouncedSearch() ? this.debouncedSearch() : undefined;
|
||||
this.recipeService.getRecipes({ ingredient }).subscribe({
|
||||
next: (data) => {
|
||||
this.recipes.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.loadRecipesFailed')));
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleSelected(id: number): void {
|
||||
this.selected.update((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
selectAllVisible(): void {
|
||||
this.selected.update((prev) => {
|
||||
const next = new Set(prev);
|
||||
this.visible().forEach((r) => next.add(r.id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
deselectAll(): void {
|
||||
this.selected.set(new Set());
|
||||
}
|
||||
|
||||
selectedCountLabel(): string {
|
||||
return translatePlural(this.translate, 'common.selectedCount', this.selected().size);
|
||||
}
|
||||
|
||||
toggleRecipeSort(column: RecipeSortColumn): void {
|
||||
this.recipeSort.update((current) => nextSort(current, column));
|
||||
}
|
||||
|
||||
recipeSortIndicator(column: RecipeSortColumn): string {
|
||||
return sortIndicator(this.recipeSort(), column);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ThemeService } from '../../core/services/theme.service';
|
||||
import { LanguageSwitcherComponent } from '../../shared/components/language-switcher.component';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, RouterLink, TranslatePipe, LanguageSwitcherComponent],
|
||||
template: `
|
||||
<div
|
||||
class="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-brand-50 to-slate-100 px-4 dark:from-slate-950 dark:to-slate-900"
|
||||
>
|
||||
<div class="absolute right-4 top-4 flex items-center gap-2">
|
||||
<app-language-switcher />
|
||||
<button
|
||||
type="button"
|
||||
(click)="theme.toggle()"
|
||||
class="btn-secondary !px-2.5 !py-2"
|
||||
[attr.aria-label]="'nav.toggleTheme' | translate"
|
||||
>
|
||||
@if (theme.theme() === 'dark') {
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<div class="mb-8 flex flex-col items-center text-center">
|
||||
<span class="grid h-14 w-14 place-items-center rounded-2xl bg-brand-600 text-white shadow-lg">
|
||||
<svg class="h-7 w-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2" />
|
||||
<path d="M7 2v20" />
|
||||
<path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7" />
|
||||
</svg>
|
||||
</span>
|
||||
<h1 class="mt-4 text-2xl font-extrabold tracking-tight">{{ 'app.name' | translate }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ 'auth.signInSubtitle' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="card space-y-5 p-6 sm:p-8" novalidate>
|
||||
@if (error()) {
|
||||
<div
|
||||
role="alert"
|
||||
class="flex items-start gap-2 rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
|
||||
>
|
||||
<span aria-hidden="true">⚠</span>
|
||||
<span>{{ error() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div>
|
||||
<label for="email" class="mb-1.5 block text-sm font-medium">{{ 'auth.email' | translate }}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
formControlName="email"
|
||||
autocomplete="email"
|
||||
class="input"
|
||||
[placeholder]="'auth.emailPlaceholder' | translate"
|
||||
/>
|
||||
@if (form.controls.email.touched && form.controls.email.errors?.['required']) {
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ 'validation.emailRequired' | translate }}</p>
|
||||
}
|
||||
@if (form.controls.email.touched && form.controls.email.errors?.['email']) {
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ 'validation.emailInvalid' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="mb-1.5 block text-sm font-medium">{{ 'auth.password' | translate }}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
formControlName="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
@if (form.controls.password.touched && form.controls.password.errors?.['required']) {
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ 'validation.passwordRequired' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="pending()" class="btn-primary w-full">
|
||||
@if (pending()) {
|
||||
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
}
|
||||
{{ pending() ? ('auth.signingIn' | translate) : ('auth.signIn' | translate) }}
|
||||
</button>
|
||||
|
||||
<p class="text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ 'auth.noAccount' | translate }}
|
||||
<a routerLink="/register" class="font-semibold text-brand-600 hover:text-brand-700">
|
||||
{{ 'auth.createOne' | translate }}
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly translate = inject(TranslateService);
|
||||
readonly theme = inject(ThemeService);
|
||||
|
||||
readonly pending = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', Validators.required],
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.auth.isAuthenticated()) {
|
||||
void this.router.navigateByUrl('/');
|
||||
}
|
||||
}
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
this.form.markAllAsTouched();
|
||||
if (this.form.invalid) return;
|
||||
|
||||
this.pending.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
await this.auth.login(this.form.getRawValue());
|
||||
void this.router.navigateByUrl('/');
|
||||
} catch (err) {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.signInFailed')));
|
||||
} finally {
|
||||
this.pending.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ThemeService } from '../../core/services/theme.service';
|
||||
import { LanguageSwitcherComponent } from '../../shared/components/language-switcher.component';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
|
||||
function passwordStrength(control: AbstractControl): ValidationErrors | null {
|
||||
const value = control.value as string;
|
||||
if (!value) return null;
|
||||
const errors: ValidationErrors = {};
|
||||
if (value.length < 8) errors['minLength'] = true;
|
||||
if (!/[A-Z]/.test(value)) errors['uppercase'] = true;
|
||||
if (!/[a-z]/.test(value)) errors['lowercase'] = true;
|
||||
if (!/[0-9]/.test(value)) errors['digit'] = true;
|
||||
return Object.keys(errors).length ? errors : null;
|
||||
}
|
||||
|
||||
function passwordsMatch(group: AbstractControl): ValidationErrors | null {
|
||||
const password = group.get('password')?.value;
|
||||
const confirm = group.get('confirmPassword')?.value;
|
||||
return password === confirm ? null : { mismatch: true };
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, RouterLink, TranslatePipe, LanguageSwitcherComponent],
|
||||
template: `
|
||||
<div
|
||||
class="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-brand-50 to-slate-100 px-4 dark:from-slate-950 dark:to-slate-900"
|
||||
>
|
||||
<div class="absolute right-4 top-4 flex items-center gap-2">
|
||||
<app-language-switcher />
|
||||
<button
|
||||
type="button"
|
||||
(click)="theme.toggle()"
|
||||
class="btn-secondary !px-2.5 !py-2"
|
||||
[attr.aria-label]="'nav.toggleTheme' | translate"
|
||||
>
|
||||
@if (theme.theme() === 'dark') {
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<div class="mb-8 flex flex-col items-center text-center">
|
||||
<span class="grid h-14 w-14 place-items-center rounded-2xl bg-brand-600 text-white shadow-lg">
|
||||
<svg class="h-7 w-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2" />
|
||||
<path d="M7 2v20" />
|
||||
<path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7" />
|
||||
</svg>
|
||||
</span>
|
||||
<h1 class="mt-4 text-2xl font-extrabold tracking-tight">{{ 'auth.createAccountTitle' | translate }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ 'auth.createAccountSubtitle' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="card space-y-5 p-6 sm:p-8" novalidate>
|
||||
@if (error()) {
|
||||
<div role="alert" class="flex items-start gap-2 rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
|
||||
<span aria-hidden="true">⚠</span>
|
||||
<span>{{ error() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div>
|
||||
<label for="email" class="mb-1.5 block text-sm font-medium">{{ 'auth.email' | translate }}</label>
|
||||
<input id="email" type="email" formControlName="email" autocomplete="email" class="input" [placeholder]="'auth.emailPlaceholder' | translate" />
|
||||
@if (form.controls.email.touched && form.controls.email.errors?.['required']) {
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ 'validation.emailRequired' | translate }}</p>
|
||||
}
|
||||
@if (form.controls.email.touched && form.controls.email.errors?.['email']) {
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ 'validation.emailInvalid' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="userName" class="mb-1.5 block text-sm font-medium">
|
||||
{{ 'auth.displayName' | translate }}
|
||||
<span class="text-slate-400">({{ 'common.optional' | translate }})</span>
|
||||
</label>
|
||||
<input id="userName" type="text" formControlName="userName" autocomplete="name" class="input" [placeholder]="'auth.displayNamePlaceholder' | translate" />
|
||||
@if (form.controls.userName.touched && form.controls.userName.errors?.['maxlength']) {
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ 'validation.displayNameTooLong' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="mb-1.5 block text-sm font-medium">{{ 'auth.password' | translate }}</label>
|
||||
<input id="password" type="password" formControlName="password" autocomplete="new-password" class="input" placeholder="••••••••" />
|
||||
@if (form.controls.password.touched && form.controls.password.errors?.['minLength']) {
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ 'validation.passwordMinLength' | translate }}</p>
|
||||
}
|
||||
@if (form.controls.password.touched && form.controls.password.errors?.['uppercase']) {
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ 'validation.passwordUppercase' | translate }}</p>
|
||||
}
|
||||
@if (form.controls.password.touched && form.controls.password.errors?.['lowercase']) {
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ 'validation.passwordLowercase' | translate }}</p>
|
||||
}
|
||||
@if (form.controls.password.touched && form.controls.password.errors?.['digit']) {
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ 'validation.passwordDigit' | translate }}</p>
|
||||
}
|
||||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">{{ 'auth.passwordHint' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirmPassword" class="mb-1.5 block text-sm font-medium">{{ 'auth.confirmPassword' | translate }}</label>
|
||||
<input id="confirmPassword" type="password" formControlName="confirmPassword" autocomplete="new-password" class="input" placeholder="••••••••" />
|
||||
@if (form.controls.confirmPassword.touched && form.controls.confirmPassword.errors?.['required']) {
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ 'validation.confirmPasswordRequired' | translate }}</p>
|
||||
}
|
||||
@if (form.touched && form.errors?.['mismatch']) {
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ 'validation.passwordsMismatch' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="pending()" class="btn-primary w-full">
|
||||
@if (pending()) {
|
||||
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
}
|
||||
{{ pending() ? ('auth.creatingAccount' | translate) : ('auth.createAccount' | translate) }}
|
||||
</button>
|
||||
|
||||
<p class="text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ 'auth.hasAccount' | translate }}
|
||||
<a routerLink="/login" class="font-semibold text-brand-600 hover:text-brand-700">
|
||||
{{ 'auth.signIn' | translate }}
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RegisterComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly translate = inject(TranslateService);
|
||||
readonly theme = inject(ThemeService);
|
||||
|
||||
readonly pending = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
readonly form = this.fb.nonNullable.group(
|
||||
{
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
userName: ['', Validators.maxLength(256)],
|
||||
password: ['', passwordStrength],
|
||||
confirmPassword: ['', Validators.required],
|
||||
},
|
||||
{ validators: passwordsMatch },
|
||||
);
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.auth.isAuthenticated()) {
|
||||
void this.router.navigateByUrl('/');
|
||||
}
|
||||
}
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
this.form.markAllAsTouched();
|
||||
if (this.form.invalid) return;
|
||||
|
||||
this.pending.set(true);
|
||||
this.error.set(null);
|
||||
const { email, password, userName } = this.form.getRawValue();
|
||||
try {
|
||||
await this.auth.register({
|
||||
email,
|
||||
password,
|
||||
userName: userName.trim() || undefined,
|
||||
});
|
||||
void this.router.navigateByUrl('/');
|
||||
} catch (err) {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.registerFailed')));
|
||||
} finally {
|
||||
this.pending.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { MealCategory } from '../../core/models/recipe.models';
|
||||
import { CategoryPageComponent } from '../category/category-page.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-breakfast',
|
||||
standalone: true,
|
||||
imports: [CategoryPageComponent],
|
||||
template: `<app-category-page [category]="category" />`,
|
||||
})
|
||||
export class BreakfastComponent {
|
||||
readonly category = MealCategory.Breakfast;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Component, Input, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { RecipeService } from '../../core/services/recipe.service';
|
||||
import {
|
||||
MealCategory,
|
||||
MEAL_CATEGORY_I18N_KEYS,
|
||||
RecipeListItem,
|
||||
} from '../../core/models/recipe.models';
|
||||
import { ErrorStateComponent } from '../../shared/components/error-state.component';
|
||||
import { EmptyStateComponent } from '../../shared/components/empty-state.component';
|
||||
import { GridSkeletonComponent } from '../../shared/components/skeleton.component';
|
||||
import { RecipeCardComponent } from '../recipes/recipe-card.component';
|
||||
import { translatePlural } from '../../core/utils/plural-translate.util';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
import { nextSort, sortBy, sortIndicator, type SortDirection } from '../../core/utils/sort.util';
|
||||
|
||||
type RecipeSortColumn = 'name' | 'calories' | 'prepTime';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
TranslatePipe,
|
||||
ErrorStateComponent,
|
||||
EmptyStateComponent,
|
||||
GridSkeletonComponent,
|
||||
RecipeCardComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight sm:text-3xl">{{ categoryLabel | translate }}</h1>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">
|
||||
@if (recipes()) {
|
||||
{{ recipeCountLabel(recipes()!.length) }}
|
||||
} @else {
|
||||
{{ 'common.loading' | translate }}
|
||||
}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<app-grid-skeleton />
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<app-error-state [message]="error()!" [showRetry]="true" (retry)="load()" />
|
||||
}
|
||||
|
||||
@if (!loading() && !error() && recipes()?.length === 0) {
|
||||
<app-empty-state
|
||||
[title]="emptyTitle"
|
||||
[description]="'recipes.emptyCategoryDescription' | translate"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (!loading() && !error() && recipes() && recipes()!.length > 0) {
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
@for (col of recipeSortColumns; track col.id) {
|
||||
<button type="button" class="rounded-lg border border-slate-200 px-3 py-1.5 font-medium hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800" (click)="toggleRecipeSort(col.id)">
|
||||
{{ col.labelKey | translate }}{{ recipeSortIndicator(col.id) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@for (recipe of sortedRecipes(); track recipe.id) {
|
||||
<app-recipe-card [recipe]="recipe" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class CategoryPageComponent implements OnInit {
|
||||
private readonly recipeService = inject(RecipeService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
@Input() category?: MealCategory;
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly recipes = signal<RecipeListItem[] | null>(null);
|
||||
readonly recipeSort = signal<{ column: RecipeSortColumn; direction: SortDirection }>({
|
||||
column: 'name',
|
||||
direction: 'asc',
|
||||
});
|
||||
readonly recipeSortColumns: { id: RecipeSortColumn; labelKey: string }[] = [
|
||||
{ id: 'name', labelKey: 'manage.recipeName' },
|
||||
{ id: 'calories', labelKey: 'recipes.calories' },
|
||||
{ id: 'prepTime', labelKey: 'manage.prepTimeMin' },
|
||||
];
|
||||
|
||||
readonly sortedRecipes = computed(() => {
|
||||
const list = this.recipes();
|
||||
if (!list) return [];
|
||||
const { column, direction } = this.recipeSort();
|
||||
return sortBy(list, direction, (recipe) => {
|
||||
switch (column) {
|
||||
case 'name':
|
||||
return recipe.name;
|
||||
case 'calories':
|
||||
return recipe.calories;
|
||||
case 'prepTime':
|
||||
return recipe.prepTimeMinutes;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
get categoryLabel(): string {
|
||||
const cat = this.resolvedCategory;
|
||||
return cat != null ? MEAL_CATEGORY_I18N_KEYS[cat] : 'categories.unknown';
|
||||
}
|
||||
|
||||
get emptyTitle(): string {
|
||||
return this.translate.instant('recipes.emptyCategoryTitle', {
|
||||
category: this.translate.instant(this.categoryLabel),
|
||||
});
|
||||
}
|
||||
|
||||
private get resolvedCategory(): MealCategory | undefined {
|
||||
return this.category ?? this.route.snapshot.data['category'];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
const category = this.resolvedCategory;
|
||||
if (category == null) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.recipeService.getRecipes({ category }).subscribe({
|
||||
next: (data) => {
|
||||
this.recipes.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.loadRecipesFailed')));
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
recipeCountLabel(count: number): string {
|
||||
return translatePlural(this.translate, 'common.recipeCount', count);
|
||||
}
|
||||
|
||||
toggleRecipeSort(column: RecipeSortColumn): void {
|
||||
this.recipeSort.update((current) => nextSort(current, column));
|
||||
}
|
||||
|
||||
recipeSortIndicator(column: RecipeSortColumn): string {
|
||||
return sortIndicator(this.recipeSort(), column);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { MealCategory } from '../../core/models/recipe.models';
|
||||
import { CategoryPageComponent } from '../category/category-page.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dinner',
|
||||
standalone: true,
|
||||
imports: [CategoryPageComponent],
|
||||
template: `<app-category-page [category]="category" />`,
|
||||
})
|
||||
export class DinnerComponent {
|
||||
readonly category = MealCategory.Dinner;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { MealCategory } from '../../core/models/recipe.models';
|
||||
import { CategoryPageComponent } from '../category/category-page.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lunch',
|
||||
standalone: true,
|
||||
imports: [CategoryPageComponent],
|
||||
template: `<app-category-page [category]="category" />`,
|
||||
})
|
||||
export class LunchComponent {
|
||||
readonly category = MealCategory.Lunch;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { MealCategory } from '../../core/models/recipe.models';
|
||||
import { CategoryPageComponent } from '../category/category-page.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-second-breakfast',
|
||||
standalone: true,
|
||||
imports: [CategoryPageComponent],
|
||||
template: `<app-category-page [category]="category" />`,
|
||||
})
|
||||
export class SecondBreakfastComponent {
|
||||
readonly category = MealCategory.SecondBreakfast;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { RecipeService } from '../../core/services/recipe.service';
|
||||
import {
|
||||
CategoryCount,
|
||||
MealCategory,
|
||||
MEAL_CATEGORY_I18N_KEYS,
|
||||
MEAL_CATEGORY_ROUTES,
|
||||
} from '../../core/models/recipe.models';
|
||||
import { ErrorStateComponent } from '../../shared/components/error-state.component';
|
||||
import { SkeletonComponent } from '../../shared/components/skeleton.component';
|
||||
import { translatePlural } from '../../core/utils/plural-translate.util';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
|
||||
const CARD_META: Record<
|
||||
MealCategory,
|
||||
{ emoji: string; gradient: string }
|
||||
> = {
|
||||
[MealCategory.Breakfast]: { emoji: '☕', gradient: 'from-amber-400 to-orange-500' },
|
||||
[MealCategory.SecondBreakfast]: { emoji: '🥐', gradient: 'from-sky-400 to-blue-500' },
|
||||
[MealCategory.Lunch]: { emoji: '🍲', gradient: 'from-brand-400 to-brand-600' },
|
||||
[MealCategory.Dinner]: { emoji: '🌙', gradient: 'from-violet-400 to-purple-600' },
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [RouterLink, TranslatePipe, ErrorStateComponent, SkeletonComponent],
|
||||
template: `
|
||||
<div class="space-y-8">
|
||||
<header>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight sm:text-3xl">
|
||||
@if (auth.user()?.userName) {
|
||||
{{ 'dashboard.welcomeNamed' | translate: { name: auth.user()!.userName } }}
|
||||
} @else {
|
||||
{{ 'dashboard.welcome' | translate }}
|
||||
}
|
||||
</h1>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">{{ 'dashboard.subtitle' | translate }}</p>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<app-error-state [message]="error()!" [showRetry]="true" (retry)="load()" />
|
||||
} @else {
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
@if (loading()) {
|
||||
@for (i of [0, 1, 2, 3]; track i) {
|
||||
<app-skeleton className="h-40" />
|
||||
}
|
||||
} @else {
|
||||
@for (cat of categories(); track cat.category) {
|
||||
<a
|
||||
[routerLink]="routeFor(cat.category)"
|
||||
class="card group relative overflow-hidden p-6 hover:shadow-lg"
|
||||
>
|
||||
<div
|
||||
class="absolute -right-6 -top-6 grid h-24 w-24 place-items-center rounded-full bg-gradient-to-br text-3xl opacity-90"
|
||||
[class]="metaFor(cat.category).gradient"
|
||||
>
|
||||
{{ metaFor(cat.category).emoji }}
|
||||
</div>
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">
|
||||
{{ 'common.category' | translate }}
|
||||
</p>
|
||||
<h2 class="mt-1 text-xl font-bold">{{ categoryLabel(cat.category) | translate }}</h2>
|
||||
<p class="mt-6 text-3xl font-extrabold text-brand-600">{{ cat.count }}</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ recipeCountLabel(cat.count) }}
|
||||
</p>
|
||||
<span class="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-brand-600 group-hover:gap-2">
|
||||
{{ 'dashboard.viewRecipes' | translate }} →
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
private readonly recipeService = inject(RecipeService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
readonly auth = inject(AuthService);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly categories = signal<CategoryCount[]>([]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.recipeService.getCategories().subscribe({
|
||||
next: (data) => {
|
||||
this.categories.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.loadCategoriesFailed')));
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
routeFor(category: MealCategory): string {
|
||||
return MEAL_CATEGORY_ROUTES[category];
|
||||
}
|
||||
|
||||
categoryLabel(category: MealCategory): string {
|
||||
return MEAL_CATEGORY_I18N_KEYS[category];
|
||||
}
|
||||
|
||||
metaFor(category: MealCategory) {
|
||||
return CARD_META[category];
|
||||
}
|
||||
|
||||
recipeCountLabel(count: number): string {
|
||||
return translatePlural(this.translate, 'common.recipeCount', count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { switchMap } from 'rxjs';
|
||||
import { DietGeneratorService } from '../../core/services/diet-generator.service';
|
||||
import {
|
||||
DEFAULT_DIET_PROFILE,
|
||||
DietGeneratorDraftMeal,
|
||||
DietGeneratorSession,
|
||||
DietUserProfile,
|
||||
} from '../../core/models/generator.models';
|
||||
import { MEAL_CATEGORY_I18N_KEYS, MealCategory, RecipeDetail } from '../../core/models/recipe.models';
|
||||
import { RecipeDetailViewComponent } from '../recipes/recipe-detail-view.component';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
|
||||
type Step = 'profile' | 'macros' | 'plan' | 'done';
|
||||
|
||||
@Component({
|
||||
selector: 'app-diet-generator',
|
||||
standalone: true,
|
||||
imports: [FormsModule, RouterLink, TranslatePipe, RecipeDetailViewComponent],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight sm:text-3xl">{{ 'generators.diet.title' | translate }}</h1>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">{{ 'generators.diet.subtitle' | translate }}</p>
|
||||
</header>
|
||||
|
||||
@if (info()) {
|
||||
<div role="status" class="rounded-xl bg-sky-50 p-3 text-sm text-sky-800 dark:bg-sky-950/40 dark:text-sky-200">
|
||||
{{ info() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div role="alert" class="rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
|
||||
{{ error() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (step() === 'profile') {
|
||||
<section class="card space-y-4 p-6">
|
||||
<h2 class="text-lg font-bold">{{ 'generators.diet.profileTitle' | translate }}</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="block text-sm">
|
||||
{{ 'generators.diet.heightCm' | translate }}
|
||||
<input type="number" class="input mt-1 w-full" [(ngModel)]="profile.heightCm" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
{{ 'generators.diet.weightKg' | translate }}
|
||||
<input type="number" class="input mt-1 w-full" [(ngModel)]="profile.weightKg" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
{{ 'generators.diet.age' | translate }}
|
||||
<input type="number" class="input mt-1 w-full" [(ngModel)]="profile.age" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
{{ 'generators.diet.sex' | translate }}
|
||||
<select class="select mt-1 w-full" [(ngModel)]="profile.sex">
|
||||
<option value="male">{{ 'generators.diet.male' | translate }}</option>
|
||||
<option value="female">{{ 'generators.diet.female' | translate }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block text-sm sm:col-span-2">
|
||||
{{ 'generators.diet.activity' | translate }}
|
||||
<select class="select mt-1 w-full" [(ngModel)]="profile.activityLevel">
|
||||
<option value="sedentary">{{ 'generators.diet.sedentary' | translate }}</option>
|
||||
<option value="light">{{ 'generators.diet.light' | translate }}</option>
|
||||
<option value="moderate">{{ 'generators.diet.moderate' | translate }}</option>
|
||||
<option value="active">{{ 'generators.diet.active' | translate }}</option>
|
||||
<option value="very_active">{{ 'generators.diet.veryActive' | translate }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block text-sm sm:col-span-2">
|
||||
{{ 'generators.diet.goal' | translate }}
|
||||
<select class="select mt-1 w-full" [(ngModel)]="profile.goal">
|
||||
<option value="lose_weight">{{ 'generators.diet.loseWeight' | translate }}</option>
|
||||
<option value="maintain">{{ 'generators.diet.maintain' | translate }}</option>
|
||||
<option value="gain_muscle">{{ 'generators.diet.gainMuscle' | translate }}</option>
|
||||
<option value="recomp">{{ 'generators.diet.recomp' | translate }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block text-sm sm:col-span-2">
|
||||
{{ 'generators.diet.allergies' | translate }}
|
||||
<textarea class="input mt-1 w-full" rows="2" [(ngModel)]="profile.allergiesNotes"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" [disabled]="loading()" (click)="startSession()">
|
||||
{{ 'generators.diet.continue' | translate }}
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (step() === 'macros') {
|
||||
<section class="card space-y-4 p-6">
|
||||
<h2 class="text-lg font-bold">{{ 'generators.diet.macrosTitle' | translate }}</h2>
|
||||
@if (macros.rationale) {
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{{ macros.rationale }}</p>
|
||||
}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="block text-sm">
|
||||
{{ 'manage.caloriesKcal' | translate }}
|
||||
<input type="number" class="input mt-1 w-full" [(ngModel)]="macros.calories" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
{{ 'manage.proteinG' | translate }}
|
||||
<input type="number" class="input mt-1 w-full" [(ngModel)]="macros.proteinG" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
{{ 'manage.fatG' | translate }}
|
||||
<input type="number" class="input mt-1 w-full" [(ngModel)]="macros.fatG" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
{{ 'manage.carbsG' | translate }}
|
||||
<input type="number" class="input mt-1 w-full" [(ngModel)]="macros.carbsG" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn-secondary" (click)="step.set('profile')">
|
||||
{{ 'common.back' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-primary" [disabled]="loading()" (click)="acceptAndGenerate()">
|
||||
{{ 'generators.diet.acceptAndGenerate' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (step() === 'plan' && session()) {
|
||||
<section class="space-y-4">
|
||||
<div class="card p-4">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{{ 'generators.diet.planHint' | translate }}</p>
|
||||
</div>
|
||||
@for (entry of mealsByDate(); track entry.date) {
|
||||
<div class="card p-4">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="font-bold">{{ entry.date }}</h3>
|
||||
<span class="text-sm" [class.text-brand-700]="entry.summary?.withinTolerance" [class.text-amber-700]="!entry.summary?.withinTolerance">
|
||||
{{ entry.summary?.totalCalories ?? 0 }} / {{ entry.summary?.targetCalories ?? 0 }} kcal
|
||||
@if (entry.summary?.withinTolerance) { ✓ }
|
||||
</span>
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
@for (meal of entry.meals; track meal.id) {
|
||||
<li class="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-slate-50 p-3 dark:bg-slate-900/40">
|
||||
<div>
|
||||
<p class="text-xs uppercase text-slate-400">{{ categoryLabel(meal.mealCategory) | translate }}</p>
|
||||
<p class="font-medium">{{ meal.recipeName }}</p>
|
||||
<p class="text-xs text-slate-500">{{ mealMacroLine(meal) }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn-secondary !px-2 !py-1 text-xs" (click)="openRecipePreview(meal)">
|
||||
{{ 'generators.diet.viewRecipe' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary !px-2 !py-1 text-xs" (click)="toggleLock(meal.id, !meal.isLocked)">
|
||||
{{ (meal.isLocked ? 'generators.diet.unlock' : 'generators.diet.lock') | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary !px-2 !py-1 text-xs" [disabled]="meal.isLocked" (click)="regenerateMeal(meal.planDate, meal.mealCategory)">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn-primary" [disabled]="loading()" (click)="commitPlan()">
|
||||
{{ 'generators.diet.commit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (step() === 'done') {
|
||||
<div class="card space-y-3 p-6 text-brand-800 dark:text-brand-200">
|
||||
<p class="font-semibold">{{ 'generators.diet.done' | translate }}</p>
|
||||
@if (commitResult()) {
|
||||
<p class="text-sm">{{ commitResult() }}</p>
|
||||
}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a routerLink="/meal-planner" class="btn-primary">{{ 'nav.mealPlanner' | translate }}</a>
|
||||
<button type="button" class="btn-secondary" (click)="reset()">
|
||||
{{ 'generators.diet.newPlan' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading() && step() !== 'done') {
|
||||
<p class="text-sm text-slate-400">{{ 'common.loading' | translate }}</p>
|
||||
}
|
||||
|
||||
@if (previewMeal()) {
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-4 sm:items-center"
|
||||
(click)="closeRecipePreview()"
|
||||
>
|
||||
<div
|
||||
class="card max-h-[90vh] w-full max-w-3xl overflow-y-auto p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<button type="button" class="btn-secondary !px-2 !py-2" (click)="closeRecipePreview()">✕</button>
|
||||
</div>
|
||||
<app-recipe-detail-view [recipe]="mealToRecipeDetail(previewMeal()!)" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DietGeneratorComponent implements OnInit {
|
||||
private readonly dietGenerator = inject(DietGeneratorService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly step = signal<Step>('profile');
|
||||
readonly sessionId = signal<number | null>(null);
|
||||
readonly session = signal<DietGeneratorSession | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly info = signal<string | null>(null);
|
||||
readonly commitResult = signal<string | null>(null);
|
||||
readonly previewMeal = signal<DietGeneratorDraftMeal | null>(null);
|
||||
|
||||
profile: DietUserProfile = { ...DEFAULT_DIET_PROFILE };
|
||||
macros = { calories: 2000, proteinG: 150, fatG: 65, carbsG: 200, rationale: '' };
|
||||
|
||||
readonly mealsByDate = computed(() => {
|
||||
const s = this.session();
|
||||
if (!s) return [];
|
||||
const map = new Map<string, typeof s.draftMeals>();
|
||||
for (const meal of s.draftMeals) {
|
||||
const list = map.get(meal.planDate) ?? [];
|
||||
list.push(meal);
|
||||
map.set(meal.planDate, list);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, meals]) => ({
|
||||
date,
|
||||
meals,
|
||||
summary: s.daySummaries.find((d) => d.planDate === date),
|
||||
}));
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.dietGenerator.fetchProfile().subscribe({
|
||||
next: (p) => {
|
||||
if (p) this.profile = { ...DEFAULT_DIET_PROFILE, ...p };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
categoryLabel(category: number): string {
|
||||
return MEAL_CATEGORY_I18N_KEYS[category as MealCategory] ?? 'categories.unknown';
|
||||
}
|
||||
|
||||
mealMacroLine(meal: DietGeneratorDraftMeal): string {
|
||||
const parts = [`${meal.calories ?? '?'} kcal`];
|
||||
if (meal.proteinG != null) parts.push(`P ${meal.proteinG}g`);
|
||||
if (meal.fatG != null) parts.push(`F ${meal.fatG}g`);
|
||||
if (meal.carbsG != null) parts.push(`C ${meal.carbsG}g`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
startSession(): void {
|
||||
this.error.set(null);
|
||||
this.loading.set(true);
|
||||
this.dietGenerator
|
||||
.saveProfile(this.profile)
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
this.dietGenerator.createSession({ planDayCount: 7, calorieToleranceKcal: 10 }),
|
||||
),
|
||||
switchMap((session) => {
|
||||
this.sessionId.set(session.id);
|
||||
this.applyMacros(session);
|
||||
return this.dietGenerator.proposeMacros(session.id);
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: (refined) => {
|
||||
this.applyMacros(refined);
|
||||
this.step.set('macros');
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (e) => {
|
||||
this.error.set(extractApiError(e, this.translate.instant('generators.diet.error')));
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
acceptAndGenerate(): void {
|
||||
const id = this.sessionId();
|
||||
if (!id) return;
|
||||
this.error.set(null);
|
||||
this.loading.set(true);
|
||||
this.dietGenerator
|
||||
.acceptMacros(id, this.macros)
|
||||
.pipe(switchMap(() => this.dietGenerator.generatePlan(id)))
|
||||
.subscribe({
|
||||
next: (plan) => {
|
||||
this.session.set(plan);
|
||||
this.step.set('plan');
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (e) => {
|
||||
this.error.set(extractApiError(e, this.translate.instant('generators.diet.error')));
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleLock(mealId: number, isLocked: boolean): void {
|
||||
const id = this.sessionId();
|
||||
if (!id) return;
|
||||
this.dietGenerator.toggleMealLock(id, mealId, isLocked).subscribe({
|
||||
next: (s) => this.session.set(s),
|
||||
error: (e) => this.error.set(extractApiError(e, this.translate.instant('generators.diet.error'))),
|
||||
});
|
||||
}
|
||||
|
||||
regenerateMeal(planDate: string, mealCategory: number): void {
|
||||
const id = this.sessionId();
|
||||
if (!id) return;
|
||||
this.dietGenerator.regenerateMeal(id, planDate, mealCategory).subscribe({
|
||||
next: (s) => {
|
||||
this.session.set(s);
|
||||
this.error.set(null);
|
||||
this.info.set(null);
|
||||
},
|
||||
error: (e) => {
|
||||
if (typeof e === 'object' && e !== null && 'status' in e && (e as { status: number }).status === 400) {
|
||||
this.info.set(this.translate.instant('generators.diet.noAlternativeMeals'));
|
||||
this.error.set(null);
|
||||
} else {
|
||||
this.error.set(extractApiError(e, this.translate.instant('generators.diet.error')));
|
||||
this.info.set(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openRecipePreview(meal: DietGeneratorDraftMeal): void {
|
||||
this.previewMeal.set(meal);
|
||||
}
|
||||
|
||||
closeRecipePreview(): void {
|
||||
this.previewMeal.set(null);
|
||||
}
|
||||
|
||||
mealToRecipeDetail(meal: DietGeneratorDraftMeal): RecipeDetail {
|
||||
return {
|
||||
id: meal.recipeId,
|
||||
name: meal.recipeName,
|
||||
mealCategory: meal.mealCategory,
|
||||
calories: meal.calories ?? null,
|
||||
protein: meal.proteinG ?? null,
|
||||
fat: meal.fatG ?? null,
|
||||
carbs: meal.carbsG ?? null,
|
||||
prepTimeMinutes: meal.prepTimeMinutes ?? null,
|
||||
ingredients: (meal.ingredients ?? []).map((ingredient) => ({
|
||||
id: ingredient.id,
|
||||
name: ingredient.name,
|
||||
amountGrams: ingredient.amountGrams ?? null,
|
||||
unit: ingredient.unit ?? null,
|
||||
sortOrder: ingredient.sortOrder,
|
||||
catalogItemId: null,
|
||||
})),
|
||||
steps: (meal.steps ?? []).map((step) => ({
|
||||
id: step.id,
|
||||
stepNumber: step.stepNumber,
|
||||
description: step.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
commitPlan(): void {
|
||||
const id = this.sessionId();
|
||||
if (!id) return;
|
||||
this.loading.set(true);
|
||||
this.dietGenerator.commitPlan(id).subscribe({
|
||||
next: (r) => {
|
||||
this.commitResult.set(`${r.planStartDate} → ${r.planEndDate}`);
|
||||
this.step.set('done');
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (e) => {
|
||||
this.error.set(extractApiError(e, this.translate.instant('generators.diet.error')));
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.step.set('profile');
|
||||
this.sessionId.set(null);
|
||||
this.session.set(null);
|
||||
this.commitResult.set(null);
|
||||
this.error.set(null);
|
||||
void this.router.navigateByUrl('/diet-generator');
|
||||
}
|
||||
|
||||
private applyMacros(session: DietGeneratorSession): void {
|
||||
if (!session.proposedMacros) return;
|
||||
this.macros = {
|
||||
calories: session.proposedMacros.calories,
|
||||
proteinG: session.proposedMacros.proteinG,
|
||||
fatG: session.proposedMacros.fatG,
|
||||
carbsG: session.proposedMacros.carbsG,
|
||||
rationale: session.proposedMacros.rationale ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,904 @@
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { Subject, debounceTime, distinctUntilChanged, of, switchMap, takeUntil } from 'rxjs';
|
||||
import { DietService } from '../../core/services/diet.service';
|
||||
import { IngredientCatalogService } from '../../core/services/ingredient-catalog.service';
|
||||
import { LanguageService } from '../../core/services/language.service';
|
||||
import { RecipeService } from '../../core/services/recipe.service';
|
||||
import {
|
||||
CONSUMPTION_CATEGORIES,
|
||||
DailySummary,
|
||||
DietReminder,
|
||||
DietReminderType,
|
||||
DietSuggestion,
|
||||
FavoriteCatalogItem,
|
||||
FavoriteRecipe,
|
||||
HistoryDay,
|
||||
LogMealInput,
|
||||
MealPreview,
|
||||
RecentMeal,
|
||||
REMINDER_DAYS_MASK_ALL,
|
||||
UserDailyGoals,
|
||||
addDaysIso,
|
||||
todayIso,
|
||||
} from '../../core/models/diet.models';
|
||||
import { IngredientNutritionItem } from '../../core/models/ingredient-catalog.models';
|
||||
import { RecipeListItem } from '../../core/models/recipe.models';
|
||||
import { ErrorStateComponent } from '../../shared/components/error-state.component';
|
||||
import { SkeletonComponent } from '../../shared/components/skeleton.component';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
import { formatReminderTime } from '../../core/utils/diet-normalize.util';
|
||||
import { nextSort, sortBy, sortIndicator, type SortDirection } from '../../core/utils/sort.util';
|
||||
|
||||
type MealLogSortColumn = 'name' | 'calories';
|
||||
type DrinkLogSortColumn = 'name' | 'volume';
|
||||
type ReminderSortColumn = 'title' | 'time';
|
||||
type Tab = 'today' | 'goals' | 'reminders' | 'history';
|
||||
type HistoryRange = 7 | 30;
|
||||
|
||||
@Component({
|
||||
selector: 'app-diet-tracker',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe, FormsModule, TranslatePipe, ErrorStateComponent, SkeletonComponent],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight sm:text-3xl">{{ 'diet.title' | translate }}</h1>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">{{ 'diet.subtitle' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (t of tabs; track t.id) {
|
||||
<button type="button" (click)="setTab(t.id)" class="rounded-xl px-4 py-2 text-sm font-semibold transition" [class.bg-brand-600]="tab() === t.id" [class.text-white]="tab() === t.id" [class.bg-slate-100]="tab() !== t.id" [class.text-slate-600]="tab() !== t.id" [class.dark:bg-slate-800]="tab() !== t.id">
|
||||
{{ t.labelKey | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (tab() === 'today') {
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button type="button" class="btn-secondary !py-1.5 !text-sm" (click)="shiftDate(-1)">←</button>
|
||||
<button type="button" class="btn-secondary !py-1.5 !text-sm" (click)="goToday()">{{ 'diet.goToday' | translate }}</button>
|
||||
<button type="button" class="btn-secondary !py-1.5 !text-sm" (click)="shiftDate(1)">→</button>
|
||||
<span class="text-sm font-semibold" [class.text-brand-600]="selectedDate() === todayIso()">{{ selectedDate() }}</span>
|
||||
@if (selectedDate() !== todayIso()) {
|
||||
<span class="text-xs text-slate-400">({{ 'diet.notToday' | translate }})</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<app-skeleton [className]="'h-48 w-full'" />
|
||||
} @else if (error()) {
|
||||
<app-error-state [message]="error()!" [showRetry]="true" (retry)="loadSummary()" />
|
||||
} @else if (summary()) {
|
||||
<section class="card space-y-3 p-5">
|
||||
<h2 class="text-lg font-bold">{{ 'diet.quickLog' | translate }}</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn-secondary !py-1.5 !text-xs" [disabled]="busy()" (click)="copyYesterday()">
|
||||
{{ 'diet.copyYesterday' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (recentMeals().length > 0) {
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-500">{{ 'diet.recentMeals' | translate }}</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@for (m of recentMeals(); track m.name + m.lastLoggedAt) {
|
||||
<button type="button" class="rounded-xl border border-slate-200 px-3 py-1.5 text-xs font-medium hover:border-brand-400 dark:border-slate-700" [disabled]="busy()" (click)="logRecent(m)">
|
||||
{{ m.name }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (favoriteRecipes().length > 0 || favoriteCatalog().length > 0) {
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-500">{{ 'diet.favorites' | translate }}</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@for (f of favoriteRecipes(); track f.recipeId) {
|
||||
<button type="button" class="rounded-xl bg-brand-50 px-3 py-1.5 text-xs font-medium text-brand-800 dark:bg-brand-950/30" [disabled]="busy()" (click)="logFavoriteRecipe(f)">
|
||||
★ {{ f.name }}
|
||||
</button>
|
||||
}
|
||||
@for (f of favoriteCatalog(); track f.catalogItemId) {
|
||||
<button type="button" class="rounded-xl bg-slate-50 px-3 py-1.5 text-xs font-medium dark:bg-slate-800/50" [disabled]="busy()" (click)="logFavoriteCatalog(f)">
|
||||
★ {{ favoriteCatalogName(f) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
@for (card of progressCards(); track card.labelKey) {
|
||||
<div class="card p-4">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">{{ card.labelKey | translate }}</p>
|
||||
<p class="mt-1 text-2xl font-bold">
|
||||
{{ card.consumed }}
|
||||
@if (card.goal != null) {
|
||||
<span class="text-base font-normal text-slate-400">/ {{ card.goal }}{{ card.unit }}</span>
|
||||
}
|
||||
</p>
|
||||
@if (card.remaining != null) {
|
||||
<p class="mt-1 text-xs text-slate-500">{{ 'diet.remaining' | translate }}: {{ card.remaining }}{{ card.unit }}</p>
|
||||
}
|
||||
@if (card.goal != null) {
|
||||
<div class="mt-3 h-2 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
|
||||
<div class="h-full rounded-full bg-brand-600" [style.width.%]="card.percent"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (suggestions().length > 0) {
|
||||
<section class="card p-5">
|
||||
<h2 class="text-lg font-bold">{{ 'diet.suggestionsTitle' | translate }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ 'diet.suggestionsHint' | translate }}</p>
|
||||
<ul class="mt-4 space-y-2">
|
||||
@for (s of suggestions(); track s.catalogItemId) {
|
||||
<li class="flex flex-wrap items-center justify-between gap-2 rounded-xl bg-slate-50 px-3 py-2 dark:bg-slate-800/50">
|
||||
<div>
|
||||
<p class="font-medium">{{ suggestionName(s) }}</p>
|
||||
<p class="text-xs text-slate-400">
|
||||
{{ s.suggestedGrams }}g · {{ s.calories ?? '?' }} kcal · P {{ s.proteinG | number: '1.0-1' }}g
|
||||
· {{ ('diet.suggestionReasons.' + s.reasonKey) | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary !py-1.5 !text-xs" [disabled]="busy()" (click)="logSuggestion(s)">
|
||||
+ {{ 'diet.logSuggestion' | translate }}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="card space-y-4 p-5">
|
||||
<h2 class="text-lg font-bold">{{ 'diet.logDrink' | translate }}</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (item of catalog(); track item.id) {
|
||||
<button type="button" [disabled]="busy()" (click)="logDrinkFromCatalog(item.id, item.defaultVolumeMl)" class="inline-flex items-center gap-2 rounded-xl border border-slate-200 px-3 py-2 text-sm font-medium hover:border-brand-400 hover:bg-brand-50 dark:border-slate-700">
|
||||
<span>{{ item.iconEmoji ?? '🥤' }}</span>
|
||||
{{ item.name }}
|
||||
<span class="text-slate-400">+{{ item.defaultVolumeMl }}ml</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card space-y-4 p-5">
|
||||
<h2 class="text-lg font-bold">{{ 'diet.logMeal' | translate }}</h2>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'common.category' | translate }}</span>
|
||||
<select class="select mt-1 w-full" [(ngModel)]="mealCategory">
|
||||
@for (c of categories; track c.value) {
|
||||
<option [ngValue]="c.value">{{ c.labelKey | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'diet.searchRecipe' | translate }}</span>
|
||||
<input class="input mt-1 w-full" [(ngModel)]="recipeSearch" (ngModelChange)="onRecipeSearch($event)" />
|
||||
</label>
|
||||
@if (recipeResults().length > 0) {
|
||||
<ul class="max-h-40 space-y-1 overflow-y-auto rounded-xl border border-slate-200 p-2 dark:border-slate-700">
|
||||
@for (r of recipeResults(); track r.id) {
|
||||
<li>
|
||||
<button type="button" (click)="selectRecipe(r.id)" class="w-full rounded-lg px-3 py-2 text-left text-sm" [class.bg-brand-600]="selectedRecipeId === r.id" [class.text-white]="selectedRecipeId === r.id">
|
||||
{{ r.name }}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'diet.portions' | translate }}</span>
|
||||
<input type="number" min="0.25" step="0.25" class="input mt-1 w-full" [(ngModel)]="portions" (ngModelChange)="refreshPreview()" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'diet.fromCatalog' | translate }}</span>
|
||||
<select class="select mt-1 w-full" [(ngModel)]="catalogItemId" (ngModelChange)="onCatalogItemChange($event)">
|
||||
<option [ngValue]="null">{{ 'diet.noCatalogItem' | translate }}</option>
|
||||
@for (item of ingredientItems(); track item.id) {
|
||||
<option [ngValue]="item.id">{{ displayIngredientName(item) }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@if (catalogItemId) {
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'diet.grams' | translate }}</span>
|
||||
<input type="number" min="1" class="input mt-1 w-full" [(ngModel)]="grams" (ngModelChange)="refreshPreview()" />
|
||||
</label>
|
||||
}
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'diet.orCustomMeal' | translate }}</span>
|
||||
<input class="input mt-1 w-full" [(ngModel)]="customMealName" (ngModelChange)="onCustomMealChange($event)" />
|
||||
</label>
|
||||
|
||||
@if (mealPreview()) {
|
||||
<div class="rounded-xl border border-brand-200 bg-brand-50/60 p-4 dark:border-brand-900 dark:bg-brand-950/20">
|
||||
<p class="font-semibold">{{ 'diet.mealPreview' | translate }}: {{ mealPreview()!.meal.name }}</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{{ mealPreview()!.meal.calories ?? '?' }} kcal ·
|
||||
P {{ (mealPreview()!.meal.proteinG ?? 0) | number: '1.0-1' }}g ·
|
||||
F {{ (mealPreview()!.meal.fatG ?? 0) | number: '1.0-1' }}g ·
|
||||
C {{ (mealPreview()!.meal.carbsG ?? 0) | number: '1.0-1' }}g
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
{{ 'diet.remainingAfter' | translate }}:
|
||||
{{ mealPreview()!.remainingAfter.calories ?? '?' }} kcal,
|
||||
P {{ mealPreview()!.remainingAfter.proteinG ?? '?' }}g,
|
||||
F {{ mealPreview()!.remainingAfter.fatG ?? '?' }}g,
|
||||
C {{ mealPreview()!.remainingAfter.carbsG ?? '?' }}g
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="button" class="btn-secondary" [disabled]="busy() || !canLogMeal()" (click)="logMeal()">
|
||||
+ {{ 'diet.markEaten' | translate }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="card p-5">
|
||||
<h3 class="font-bold">{{ mealsHeading() }}</h3>
|
||||
@if ((summary()?.meals?.length ?? 0) > 0) {
|
||||
<div class="mt-3 grid grid-cols-[1fr_auto_auto] gap-2 border-b border-slate-200 px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:border-slate-700">
|
||||
<button type="button" class="text-left hover:text-slate-700" (click)="toggleMealSort('name')">
|
||||
{{ 'ingredientCatalog.columns.name' | translate }}{{ mealSortIndicator('name') }}
|
||||
</button>
|
||||
<button type="button" class="text-right hover:text-slate-700" (click)="toggleMealSort('calories')">
|
||||
{{ 'recipes.calories' | translate }}{{ mealSortIndicator('calories') }}
|
||||
</button>
|
||||
<span></span>
|
||||
</div>
|
||||
}
|
||||
<ul class="mt-3 space-y-2">
|
||||
@if ((summary()?.meals?.length ?? 0) === 0) {
|
||||
<li class="text-sm text-slate-400">{{ 'diet.nothingLogged' | translate }}</li>
|
||||
} @else {
|
||||
@for (m of sortedMeals(); track m.id) {
|
||||
<li class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 dark:bg-slate-800/50">
|
||||
<div>
|
||||
<p class="font-medium">{{ m.name }}</p>
|
||||
<p class="text-xs text-slate-400">{{ mealMacroLine(m) }}</p>
|
||||
</div>
|
||||
<button type="button" class="text-slate-400 hover:text-red-500" (click)="removeMeal(m.id)">✕</button>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<h3 class="font-bold">{{ drinksHeading() }}</h3>
|
||||
@if ((summary()?.drinks?.length ?? 0) > 0) {
|
||||
<div class="mt-3 grid grid-cols-[1fr_auto_auto] gap-2 border-b border-slate-200 px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:border-slate-700">
|
||||
<button type="button" class="text-left hover:text-slate-700" (click)="toggleDrinkSort('name')">
|
||||
{{ 'ingredientCatalog.columns.name' | translate }}{{ drinkSortIndicator('name') }}
|
||||
</button>
|
||||
<button type="button" class="text-right hover:text-slate-700" (click)="toggleDrinkSort('volume')">
|
||||
{{ 'diet.water' | translate }}{{ drinkSortIndicator('volume') }}
|
||||
</button>
|
||||
<span></span>
|
||||
</div>
|
||||
}
|
||||
<ul class="mt-3 space-y-2">
|
||||
@if ((summary()?.drinks?.length ?? 0) === 0) {
|
||||
<li class="text-sm text-slate-400">{{ 'diet.nothingLogged' | translate }}</li>
|
||||
} @else {
|
||||
@for (d of sortedDrinks(); track d.id) {
|
||||
<li class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 dark:bg-slate-800/50">
|
||||
<div>
|
||||
<p class="font-medium">{{ d.name }}</p>
|
||||
<p class="text-xs text-slate-400">{{ d.volumeMl }} ml</p>
|
||||
</div>
|
||||
<button type="button" class="text-slate-400 hover:text-red-500" (click)="removeDrink(d.id)">✕</button>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (tab() === 'history') {
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="rounded-xl px-4 py-2 text-sm font-semibold" [class.bg-brand-600]="historyRange() === 7" [class.text-white]="historyRange() === 7" [class.bg-slate-100]="historyRange() !== 7" (click)="setHistoryRange(7)">7 {{ 'diet.days' | translate }}</button>
|
||||
<button type="button" class="rounded-xl px-4 py-2 text-sm font-semibold" [class.bg-brand-600]="historyRange() === 30" [class.text-white]="historyRange() === 30" [class.bg-slate-100]="historyRange() !== 30" (click)="setHistoryRange(30)">30 {{ 'diet.days' | translate }}</button>
|
||||
</div>
|
||||
@if (historyLoading()) {
|
||||
<app-skeleton [className]="'h-48 w-full'" />
|
||||
} @else if (history().length === 0) {
|
||||
<p class="text-sm text-slate-400">{{ 'diet.noHistory' | translate }}</p>
|
||||
} @else {
|
||||
<div class="card p-5">
|
||||
<h2 class="text-lg font-bold">{{ 'diet.historyCalories' | translate }}</h2>
|
||||
<div class="mt-6 flex items-end gap-1 overflow-x-auto pb-2" [style.height.px]="180">
|
||||
@for (day of history(); track day.date) {
|
||||
<div class="flex min-w-[24px] flex-1 flex-col items-center justify-end gap-1">
|
||||
<div
|
||||
class="w-full max-w-[32px] rounded-t bg-brand-600 transition-all"
|
||||
[style.height.%]="barHeight(day.calories)"
|
||||
[title]="day.date + ': ' + day.calories + ' kcal'"
|
||||
></div>
|
||||
<span class="text-[10px] text-slate-400">{{ dayLabel(day.date) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-400">{{ 'diet.historyMax' | translate: { max: historyMaxCalories() } }}</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (tab() === 'goals') {
|
||||
<div class="card max-w-lg space-y-4 p-5">
|
||||
<h2 class="text-lg font-bold">{{ 'diet.dailyGoals' | translate }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ 'diet.goalsHint' | translate }}</p>
|
||||
@for (field of goalFields; track field.key) {
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ field.labelKey | translate }}</span>
|
||||
<input type="number" min="0" class="input mt-1 w-full" [(ngModel)]="goalsForm[field.key]" />
|
||||
</label>
|
||||
}
|
||||
<button type="button" class="btn-secondary" [disabled]="busy()" (click)="saveGoals()">{{ 'diet.saveGoals' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (tab() === 'reminders') {
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="card space-y-4 p-5">
|
||||
<h2 class="text-lg font-bold">{{ editingReminderId() ? ('diet.editReminder' | translate) : ('diet.addReminder' | translate) }}</h2>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'diet.reminderType' | translate }}</span>
|
||||
<select class="select mt-1 w-full" [(ngModel)]="reminderType">
|
||||
<option [ngValue]="reminderTypes.Water">{{ 'diet.reminderWater' | translate }}</option>
|
||||
<option [ngValue]="reminderTypes.Meal">{{ 'diet.reminderMeal' | translate }}</option>
|
||||
<option [ngValue]="reminderTypes.Custom">{{ 'diet.reminderCustom' | translate }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'diet.reminderTitle' | translate }}</span>
|
||||
<input class="input mt-1 w-full" [(ngModel)]="reminderTitle" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'diet.reminderTime' | translate }}</span>
|
||||
<input type="time" class="input mt-1 w-full" [(ngModel)]="reminderTime" />
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn-secondary" [disabled]="busy() || !reminderTitle.trim()" (click)="saveReminder()">
|
||||
{{ editingReminderId() ? ('common.save' | translate) : ('diet.saveReminder' | translate) }}
|
||||
</button>
|
||||
@if (editingReminderId()) {
|
||||
<button type="button" class="btn-secondary" (click)="cancelEditReminder()">{{ 'common.cancel' | translate }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<h2 class="text-lg font-bold">{{ 'diet.yourReminders' | translate }}</h2>
|
||||
@if (reminders().length > 0) {
|
||||
<div class="mt-3 grid grid-cols-[1fr_auto_auto_auto] gap-2 border-b border-slate-200 px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:border-slate-700">
|
||||
<button type="button" class="text-left hover:text-slate-700" (click)="toggleReminderSort('title')">
|
||||
{{ 'diet.reminderTitle' | translate }}{{ reminderSortIndicator('title') }}
|
||||
</button>
|
||||
<button type="button" class="text-right hover:text-slate-700" (click)="toggleReminderSort('time')">
|
||||
{{ 'diet.reminderTime' | translate }}{{ reminderSortIndicator('time') }}
|
||||
</button>
|
||||
<span></span><span></span>
|
||||
</div>
|
||||
}
|
||||
<ul class="mt-3 space-y-2">
|
||||
@if (reminders().length === 0) {
|
||||
<li class="text-sm text-slate-400">{{ 'diet.noReminders' | translate }}</li>
|
||||
} @else {
|
||||
@for (r of sortedReminders(); track r.id) {
|
||||
<li class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 dark:bg-slate-800/50">
|
||||
<div>
|
||||
<p class="font-medium">{{ r.title }}</p>
|
||||
<p class="text-xs text-slate-400">{{ formatReminderTime(r.timeOfDay) }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="text-slate-400 hover:text-brand-600" (click)="startEditReminder(r)">{{ 'common.edit' | translate }}</button>
|
||||
<button type="button" class="text-slate-400 hover:text-red-500" (click)="removeReminder(r.id)">✕</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DietTrackerComponent implements OnInit, OnDestroy {
|
||||
private readonly diet = inject(DietService);
|
||||
private readonly recipes = inject(RecipeService);
|
||||
private readonly ingredients = inject(IngredientCatalogService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly language = inject(LanguageService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
private readonly recipeSearch$ = new Subject<string>();
|
||||
private readonly preview$ = new Subject<void>();
|
||||
|
||||
readonly categories = CONSUMPTION_CATEGORIES;
|
||||
readonly reminderTypes = DietReminderType;
|
||||
readonly todayIso = todayIso;
|
||||
readonly tabs: { id: Tab; labelKey: string }[] = [
|
||||
{ id: 'today', labelKey: 'diet.tabToday' },
|
||||
{ id: 'history', labelKey: 'diet.tabHistory' },
|
||||
{ id: 'goals', labelKey: 'diet.tabGoals' },
|
||||
{ id: 'reminders', labelKey: 'diet.tabReminders' },
|
||||
];
|
||||
readonly goalFields: { key: keyof UserDailyGoals; labelKey: string }[] = [
|
||||
{ key: 'calorieGoal', labelKey: 'diet.calorieGoal' },
|
||||
{ key: 'proteinGoalG', labelKey: 'manage.proteinG' },
|
||||
{ key: 'fatGoalG', labelKey: 'manage.fatG' },
|
||||
{ key: 'carbsGoalG', labelKey: 'manage.carbsG' },
|
||||
{ key: 'waterGoalMl', labelKey: 'diet.waterGoal' },
|
||||
];
|
||||
|
||||
tab = signal<Tab>('today');
|
||||
selectedDate = signal(todayIso());
|
||||
loading = signal(false);
|
||||
busy = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
summary = signal<DailySummary | null>(null);
|
||||
catalog = signal<{ id: number; name: string; defaultVolumeMl: number; iconEmoji?: string | null }[]>([]);
|
||||
recipeResults = signal<RecipeListItem[]>([]);
|
||||
reminders = signal<DietReminder[]>([]);
|
||||
suggestions = signal<DietSuggestion[]>([]);
|
||||
ingredientItems = signal<IngredientNutritionItem[]>([]);
|
||||
mealPreview = signal<MealPreview | null>(null);
|
||||
recentMeals = signal<RecentMeal[]>([]);
|
||||
favoriteRecipes = signal<FavoriteRecipe[]>([]);
|
||||
favoriteCatalog = signal<FavoriteCatalogItem[]>([]);
|
||||
history = signal<HistoryDay[]>([]);
|
||||
historyLoading = signal(false);
|
||||
historyRange = signal<HistoryRange>(7);
|
||||
editingReminderId = signal<number | null>(null);
|
||||
|
||||
mealCategory = 2;
|
||||
recipeSearch = '';
|
||||
selectedRecipeId: number | null = null;
|
||||
customMealName = '';
|
||||
portions = 1;
|
||||
catalogItemId: number | null = null;
|
||||
grams = 150;
|
||||
goalsForm: UserDailyGoals = {};
|
||||
reminderType = DietReminderType.Water;
|
||||
reminderTitle = '';
|
||||
reminderTime = '09:00';
|
||||
|
||||
readonly mealSort = signal<{ column: MealLogSortColumn; direction: SortDirection }>({
|
||||
column: 'name',
|
||||
direction: 'asc',
|
||||
});
|
||||
readonly drinkSort = signal<{ column: DrinkLogSortColumn; direction: SortDirection }>({
|
||||
column: 'name',
|
||||
direction: 'asc',
|
||||
});
|
||||
readonly reminderSort = signal<{ column: ReminderSortColumn; direction: SortDirection }>({
|
||||
column: 'time',
|
||||
direction: 'asc',
|
||||
});
|
||||
|
||||
readonly sortedMeals = computed(() => {
|
||||
const meals = this.summary()?.meals ?? [];
|
||||
const { column, direction } = this.mealSort();
|
||||
return sortBy(meals, direction, (meal) => (column === 'name' ? meal.name : meal.calories));
|
||||
});
|
||||
|
||||
readonly sortedDrinks = computed(() => {
|
||||
const drinks = this.summary()?.drinks ?? [];
|
||||
const { column, direction } = this.drinkSort();
|
||||
return sortBy(drinks, direction, (drink) => (column === 'name' ? drink.name : drink.volumeMl));
|
||||
});
|
||||
|
||||
readonly sortedReminders = computed(() => {
|
||||
const { column, direction } = this.reminderSort();
|
||||
return sortBy(this.reminders(), direction, (reminder) =>
|
||||
column === 'title' ? reminder.title : reminder.timeOfDay,
|
||||
);
|
||||
});
|
||||
|
||||
readonly historyMaxCalories = computed(() => {
|
||||
const days = this.history();
|
||||
if (days.length === 0) return 0;
|
||||
return Math.max(...days.map((d) => d.calories), 1);
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSummary();
|
||||
this.loadQuickLogData();
|
||||
this.diet.getDrinkCatalog().subscribe({ next: (items) => this.catalog.set(items), error: () => this.catalog.set([]) });
|
||||
this.ingredients.getItems().subscribe({ next: (items) => this.ingredientItems.set(items), error: () => this.ingredientItems.set([]) });
|
||||
|
||||
this.recipeSearch$.pipe(debounceTime(300), distinctUntilChanged(), switchMap((q) => (q.trim().length >= 2 ? this.recipes.getRecipes({ search: q }) : of([]))), takeUntil(this.destroy$)).subscribe((items) => this.recipeResults.set(items));
|
||||
|
||||
this.preview$.pipe(debounceTime(250), takeUntil(this.destroy$)).subscribe(() => this.loadPreview());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
readonly progressCards = computed(() => {
|
||||
const s = this.summary();
|
||||
if (!s) return [];
|
||||
const pct = (consumed: number, goal?: number | null) => (goal && goal > 0 ? Math.min((consumed / goal) * 100, 100) : 0);
|
||||
return [
|
||||
{ labelKey: 'diet.calories', consumed: s.calories.consumed, goal: s.calories.goal, remaining: s.calories.remaining, unit: ' kcal', percent: pct(s.calories.consumed, s.calories.goal) },
|
||||
{ labelKey: 'diet.water', consumed: s.waterMl.consumed, goal: s.waterMl.goal, remaining: s.waterMl.remaining, unit: 'ml', percent: pct(s.waterMl.consumed, s.waterMl.goal) },
|
||||
{ labelKey: 'recipes.protein', consumed: Number(s.proteinG.consumed), goal: s.proteinG.goal != null ? Number(s.proteinG.goal) : null, remaining: s.proteinG.remaining != null ? Number(s.proteinG.remaining) : null, unit: 'g', percent: pct(Number(s.proteinG.consumed), s.proteinG.goal != null ? Number(s.proteinG.goal) : null) },
|
||||
{ labelKey: 'recipes.fat', consumed: Number(s.fatG.consumed), goal: s.fatG.goal != null ? Number(s.fatG.goal) : null, remaining: s.fatG.remaining != null ? Number(s.fatG.remaining) : null, unit: 'g', percent: pct(Number(s.fatG.consumed), s.fatG.goal != null ? Number(s.fatG.goal) : null) },
|
||||
{ labelKey: 'recipes.carbs', consumed: Number(s.carbsG.consumed), goal: s.carbsG.goal != null ? Number(s.carbsG.goal) : null, remaining: s.carbsG.remaining != null ? Number(s.carbsG.remaining) : null, unit: 'g', percent: pct(Number(s.carbsG.consumed), s.carbsG.goal != null ? Number(s.carbsG.goal) : null) },
|
||||
];
|
||||
});
|
||||
|
||||
formatReminderTime = formatReminderTime;
|
||||
|
||||
mealsHeading(): string {
|
||||
return this.selectedDate() === todayIso()
|
||||
? this.translate.instant('diet.mealsToday')
|
||||
: this.translate.instant('diet.mealsOnDate', { date: this.selectedDate() });
|
||||
}
|
||||
|
||||
drinksHeading(): string {
|
||||
return this.selectedDate() === todayIso()
|
||||
? this.translate.instant('diet.drinksToday')
|
||||
: this.translate.instant('diet.drinksOnDate', { date: this.selectedDate() });
|
||||
}
|
||||
|
||||
shiftDate(days: number): void {
|
||||
this.selectedDate.update((d) => addDaysIso(d, days));
|
||||
this.loadSummary();
|
||||
}
|
||||
|
||||
goToday(): void {
|
||||
this.selectedDate.set(todayIso());
|
||||
this.loadSummary();
|
||||
}
|
||||
|
||||
setTab(id: Tab): void {
|
||||
this.tab.set(id);
|
||||
if (id === 'today') {
|
||||
this.loadSummary();
|
||||
this.loadQuickLogData();
|
||||
}
|
||||
if (id === 'goals') this.loadGoals();
|
||||
if (id === 'reminders') this.loadReminders();
|
||||
if (id === 'history') this.loadHistory();
|
||||
}
|
||||
|
||||
setHistoryRange(range: HistoryRange): void {
|
||||
this.historyRange.set(range);
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
loadSummary(): void {
|
||||
const date = this.selectedDate();
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.diet.getSummary(date).subscribe({
|
||||
next: (data) => {
|
||||
this.summary.set(data);
|
||||
this.loading.set(false);
|
||||
this.loadSuggestions();
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.loadDietFailed')));
|
||||
this.summary.set(null);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
loadQuickLogData(): void {
|
||||
this.diet.getRecentMeals(8).subscribe({ next: (m) => this.recentMeals.set(m), error: () => this.recentMeals.set([]) });
|
||||
this.diet.getFavoriteRecipes().subscribe({ next: (f) => this.favoriteRecipes.set(f), error: () => this.favoriteRecipes.set([]) });
|
||||
this.diet.getFavoriteCatalogItems().subscribe({ next: (f) => this.favoriteCatalog.set(f), error: () => this.favoriteCatalog.set([]) });
|
||||
}
|
||||
|
||||
loadHistory(): void {
|
||||
const to = todayIso();
|
||||
const from = addDaysIso(to, -(this.historyRange() - 1));
|
||||
this.historyLoading.set(true);
|
||||
this.diet.getHistory(from, to).subscribe({
|
||||
next: (days) => {
|
||||
this.history.set(days);
|
||||
this.historyLoading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.history.set([]);
|
||||
this.historyLoading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
barHeight(calories: number): number {
|
||||
const max = this.historyMaxCalories();
|
||||
if (max <= 0) return 0;
|
||||
return Math.max((calories / max) * 100, 2);
|
||||
}
|
||||
|
||||
dayLabel(iso: string): string {
|
||||
const d = new Date(`${iso}T12:00:00`);
|
||||
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
loadSuggestions(): void {
|
||||
this.diet.getSuggestions(this.selectedDate()).subscribe({
|
||||
next: (items) => this.suggestions.set(items),
|
||||
error: () => this.suggestions.set([]),
|
||||
});
|
||||
}
|
||||
|
||||
loadGoals(): void {
|
||||
this.diet.getGoals().subscribe((g) => (this.goalsForm = { ...g }));
|
||||
}
|
||||
|
||||
loadReminders(): void {
|
||||
this.diet.getReminders().subscribe((items) => this.reminders.set(items));
|
||||
}
|
||||
|
||||
onRecipeSearch(value: string): void {
|
||||
this.recipeSearch = value;
|
||||
this.selectedRecipeId = null;
|
||||
this.catalogItemId = null;
|
||||
this.mealPreview.set(null);
|
||||
this.recipeSearch$.next(value);
|
||||
}
|
||||
|
||||
selectRecipe(id: number): void {
|
||||
this.selectedRecipeId = id;
|
||||
this.customMealName = '';
|
||||
this.catalogItemId = null;
|
||||
this.refreshPreview();
|
||||
}
|
||||
|
||||
onCustomMealChange(value: string): void {
|
||||
this.customMealName = value;
|
||||
if (value.trim()) {
|
||||
this.selectedRecipeId = null;
|
||||
this.catalogItemId = null;
|
||||
}
|
||||
this.refreshPreview();
|
||||
}
|
||||
|
||||
onCatalogItemChange(id: number | null): void {
|
||||
this.catalogItemId = id;
|
||||
if (id) {
|
||||
this.selectedRecipeId = null;
|
||||
this.customMealName = '';
|
||||
}
|
||||
this.refreshPreview();
|
||||
}
|
||||
|
||||
refreshPreview(): void {
|
||||
this.preview$.next();
|
||||
}
|
||||
|
||||
buildMealPayload(): LogMealInput | null {
|
||||
const date = this.selectedDate();
|
||||
if (this.selectedRecipeId) {
|
||||
return { recipeId: this.selectedRecipeId, mealCategory: this.mealCategory, logDate: date, portions: this.portions };
|
||||
}
|
||||
if (this.catalogItemId && this.grams > 0) {
|
||||
return { catalogItemId: this.catalogItemId, grams: this.grams, mealCategory: this.mealCategory, logDate: date };
|
||||
}
|
||||
if (this.customMealName.trim()) {
|
||||
return { name: this.customMealName.trim(), mealCategory: this.mealCategory, logDate: date };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
loadPreview(): void {
|
||||
const payload = this.buildMealPayload();
|
||||
if (!payload) {
|
||||
this.mealPreview.set(null);
|
||||
return;
|
||||
}
|
||||
this.diet.previewMeal(payload, this.selectedDate()).subscribe({
|
||||
next: (preview) => this.mealPreview.set(preview),
|
||||
error: () => this.mealPreview.set(null),
|
||||
});
|
||||
}
|
||||
|
||||
canLogMeal(): boolean {
|
||||
return this.buildMealPayload() != null;
|
||||
}
|
||||
|
||||
logMeal(): void {
|
||||
const payload = this.buildMealPayload();
|
||||
if (!payload) return;
|
||||
this.busy.set(true);
|
||||
this.diet.logMeal(payload).subscribe({
|
||||
next: () => {
|
||||
this.resetMealForm();
|
||||
this.busy.set(false);
|
||||
this.loadSummary();
|
||||
this.loadQuickLogData();
|
||||
},
|
||||
error: () => this.busy.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
logRecent(m: RecentMeal): void {
|
||||
this.busy.set(true);
|
||||
const payload: LogMealInput = {
|
||||
mealCategory: m.mealCategory,
|
||||
logDate: this.selectedDate(),
|
||||
portions: m.portions,
|
||||
};
|
||||
if (m.recipeId) payload.recipeId = m.recipeId;
|
||||
else if (m.catalogItemId && m.grams) {
|
||||
payload.catalogItemId = m.catalogItemId;
|
||||
payload.grams = m.grams;
|
||||
} else payload.name = m.name;
|
||||
this.diet.logMeal(payload).subscribe({
|
||||
next: () => { this.busy.set(false); this.loadSummary(); },
|
||||
error: () => this.busy.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
logFavoriteRecipe(f: FavoriteRecipe): void {
|
||||
this.busy.set(true);
|
||||
this.diet.logMeal({ recipeId: f.recipeId, mealCategory: f.mealCategory, logDate: this.selectedDate(), portions: 1 }).subscribe({
|
||||
next: () => { this.busy.set(false); this.loadSummary(); },
|
||||
error: () => this.busy.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
logFavoriteCatalog(f: FavoriteCatalogItem): void {
|
||||
this.busy.set(true);
|
||||
this.diet.logMeal({ catalogItemId: f.catalogItemId, grams: 150, mealCategory: this.mealCategory, logDate: this.selectedDate() }).subscribe({
|
||||
next: () => { this.busy.set(false); this.loadSummary(); },
|
||||
error: () => this.busy.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
copyYesterday(): void {
|
||||
this.busy.set(true);
|
||||
this.diet.copyYesterdayMeals(this.selectedDate()).subscribe({
|
||||
next: () => { this.busy.set(false); this.loadSummary(); },
|
||||
error: () => this.busy.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
logSuggestion(s: DietSuggestion): void {
|
||||
this.busy.set(true);
|
||||
this.diet.logMeal({ catalogItemId: s.catalogItemId, grams: s.suggestedGrams, mealCategory: this.mealCategory, logDate: this.selectedDate() }).subscribe({
|
||||
next: () => {
|
||||
this.busy.set(false);
|
||||
this.loadSummary();
|
||||
},
|
||||
error: () => this.busy.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
resetMealForm(): void {
|
||||
this.recipeSearch = '';
|
||||
this.customMealName = '';
|
||||
this.selectedRecipeId = null;
|
||||
this.catalogItemId = null;
|
||||
this.portions = 1;
|
||||
this.grams = 150;
|
||||
this.recipeResults.set([]);
|
||||
this.mealPreview.set(null);
|
||||
}
|
||||
|
||||
logDrinkFromCatalog(drinkCatalogId: number, volumeMl: number): void {
|
||||
this.busy.set(true);
|
||||
this.diet.logDrink({ drinkCatalogId, volumeMl, logDate: this.selectedDate() }).subscribe({
|
||||
next: () => { this.busy.set(false); this.loadSummary(); },
|
||||
error: () => this.busy.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
removeMeal(id: number): void {
|
||||
this.diet.deleteMeal(id).subscribe(() => this.loadSummary());
|
||||
}
|
||||
|
||||
removeDrink(id: number): void {
|
||||
this.diet.deleteDrink(id).subscribe(() => this.loadSummary());
|
||||
}
|
||||
|
||||
saveGoals(): void {
|
||||
this.busy.set(true);
|
||||
this.diet.saveGoals(this.goalsForm).subscribe({
|
||||
next: () => { this.busy.set(false); this.loadSummary(); },
|
||||
error: () => this.busy.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
startEditReminder(r: DietReminder): void {
|
||||
this.editingReminderId.set(r.id);
|
||||
this.reminderType = r.reminderType;
|
||||
this.reminderTitle = r.title;
|
||||
this.reminderTime = r.timeOfDay.slice(0, 5);
|
||||
}
|
||||
|
||||
cancelEditReminder(): void {
|
||||
this.editingReminderId.set(null);
|
||||
this.reminderTitle = '';
|
||||
this.reminderTime = '09:00';
|
||||
this.reminderType = DietReminderType.Water;
|
||||
}
|
||||
|
||||
saveReminder(): void {
|
||||
this.busy.set(true);
|
||||
const input = {
|
||||
reminderType: this.reminderType,
|
||||
title: this.reminderTitle.trim(),
|
||||
timeOfDay: this.reminderTime,
|
||||
daysOfWeekMask: REMINDER_DAYS_MASK_ALL,
|
||||
};
|
||||
const editId = this.editingReminderId();
|
||||
const req = editId
|
||||
? this.diet.updateReminder(editId, input)
|
||||
: this.diet.createReminder(input);
|
||||
req.subscribe({
|
||||
next: () => {
|
||||
this.cancelEditReminder();
|
||||
this.busy.set(false);
|
||||
this.loadReminders();
|
||||
},
|
||||
error: () => this.busy.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
removeReminder(id: number): void {
|
||||
this.diet.deleteReminder(id).subscribe(() => this.loadReminders());
|
||||
}
|
||||
|
||||
mealMacroLine(m: { calories?: number | null; proteinG?: number | null; fatG?: number | null; carbsG?: number | null }): string {
|
||||
if (m.calories == null) return this.translate.instant('diet.noMacros');
|
||||
return `${m.calories} kcal · P ${Number(m.proteinG ?? 0).toFixed(1)}g · F ${Number(m.fatG ?? 0).toFixed(1)}g · C ${Number(m.carbsG ?? 0).toFixed(1)}g`;
|
||||
}
|
||||
|
||||
suggestionName(s: DietSuggestion): string {
|
||||
if (this.language.currentLanguage() === 'pl' && s.namePl) return s.namePl;
|
||||
return s.name;
|
||||
}
|
||||
|
||||
displayIngredientName(item: IngredientNutritionItem): string {
|
||||
if (this.language.currentLanguage() === 'pl' && item.namePl) return item.namePl;
|
||||
return item.name;
|
||||
}
|
||||
|
||||
favoriteCatalogName(f: FavoriteCatalogItem): string {
|
||||
if (this.language.currentLanguage() === 'pl' && f.namePl) return f.namePl;
|
||||
return f.name;
|
||||
}
|
||||
|
||||
toggleMealSort(column: MealLogSortColumn): void {
|
||||
this.mealSort.update((current) => nextSort(current, column));
|
||||
}
|
||||
|
||||
mealSortIndicator(column: MealLogSortColumn): string {
|
||||
return sortIndicator(this.mealSort(), column);
|
||||
}
|
||||
|
||||
toggleDrinkSort(column: DrinkLogSortColumn): void {
|
||||
this.drinkSort.update((current) => nextSort(current, column));
|
||||
}
|
||||
|
||||
drinkSortIndicator(column: DrinkLogSortColumn): string {
|
||||
return sortIndicator(this.drinkSort(), column);
|
||||
}
|
||||
|
||||
toggleReminderSort(column: ReminderSortColumn): void {
|
||||
this.reminderSort.update((current) => nextSort(current, column));
|
||||
}
|
||||
|
||||
reminderSortIndicator(column: ReminderSortColumn): string {
|
||||
return sortIndicator(this.reminderSort(), column);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { Subject, debounceTime, takeUntil } from 'rxjs';
|
||||
import { IngredientCatalogService } from '../../core/services/ingredient-catalog.service';
|
||||
import { LanguageService } from '../../core/services/language.service';
|
||||
import {
|
||||
IngredientCategory,
|
||||
IngredientNutritionItem,
|
||||
UpsertIngredientNutritionItemInput,
|
||||
} from '../../core/models/ingredient-catalog.models';
|
||||
import { ErrorStateComponent } from '../../shared/components/error-state.component';
|
||||
import { EmptyStateComponent } from '../../shared/components/empty-state.component';
|
||||
import { SkeletonComponent } from '../../shared/components/skeleton.component';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
import { nextSort, sortBy, sortIndicator, type SortDirection } from '../../core/utils/sort.util';
|
||||
|
||||
type IngredientSortColumn = 'name' | 'category' | 'calories' | 'protein' | 'fat' | 'carbs' | 'fiber';
|
||||
|
||||
const EMPTY_FORM: UpsertIngredientNutritionItemInput = {
|
||||
categoryId: 0,
|
||||
name: '',
|
||||
namePl: '',
|
||||
caloriesPer100G: 0,
|
||||
proteinGPer100G: 0,
|
||||
fatGPer100G: 0,
|
||||
carbsGPer100G: 0,
|
||||
fiberGPer100G: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-ingredient-catalog',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe, FormsModule, TranslatePipe, ErrorStateComponent, EmptyStateComponent, SkeletonComponent],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<header class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight sm:text-3xl">
|
||||
{{ 'ingredientCatalog.title' | translate }}
|
||||
</h1>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">{{ 'ingredientCatalog.subtitle' | translate }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="openCreateForm()">
|
||||
+ {{ 'ingredientCatalog.addIngredient' | translate }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (showForm()) {
|
||||
<section class="card space-y-4 p-5">
|
||||
<h2 class="text-lg font-bold">
|
||||
{{ editingId() ? ('ingredientCatalog.editIngredient' | translate) : ('ingredientCatalog.addIngredient' | translate) }}
|
||||
</h2>
|
||||
@if (formError()) {
|
||||
<p class="text-sm text-red-600">{{ formError() }}</p>
|
||||
}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="block text-sm sm:col-span-2">
|
||||
<span class="font-medium">{{ 'ingredientCatalog.columns.category' | translate }}</span>
|
||||
<select class="select mt-1 w-full" [(ngModel)]="form.categoryId">
|
||||
<option [ngValue]="0" disabled>{{ 'ingredientCatalog.selectCategory' | translate }}</option>
|
||||
@for (cat of categories(); track cat.id) {
|
||||
<option [ngValue]="cat.id">{{ ('ingredientCatalog.categories.' + cat.code) | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'ingredientCatalog.nameEn' | translate }}</span>
|
||||
<input class="input mt-1 w-full" [(ngModel)]="form.name" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'ingredientCatalog.namePlLabel' | translate }}</span>
|
||||
<input class="input mt-1 w-full" [(ngModel)]="form.namePl" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'ingredientCatalog.columns.calories' | translate }}</span>
|
||||
<input type="number" min="0" class="input mt-1 w-full" [(ngModel)]="form.caloriesPer100G" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'ingredientCatalog.columns.protein' | translate }}</span>
|
||||
<input type="number" min="0" step="0.1" class="input mt-1 w-full" [(ngModel)]="form.proteinGPer100G" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'ingredientCatalog.columns.fat' | translate }}</span>
|
||||
<input type="number" min="0" step="0.1" class="input mt-1 w-full" [(ngModel)]="form.fatGPer100G" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'ingredientCatalog.columns.carbs' | translate }}</span>
|
||||
<input type="number" min="0" step="0.1" class="input mt-1 w-full" [(ngModel)]="form.carbsGPer100G" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'ingredientCatalog.columns.fiber' | translate }}</span>
|
||||
<input type="number" min="0" step="0.1" class="input mt-1 w-full" [(ngModel)]="form.fiberGPer100G" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn-primary" [disabled]="saving()" (click)="saveForm()">
|
||||
{{ 'common.save' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="closeForm()">{{ 'common.cancel' | translate }}</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" (click)="setCategory('')" class="rounded-xl px-4 py-2 text-sm font-semibold transition" [class.bg-brand-600]="category() === ''" [class.text-white]="category() === ''" [class.bg-slate-100]="category() !== ''" [class.text-slate-600]="category() !== ''" [class.dark:bg-slate-800]="category() !== ''">
|
||||
{{ 'ingredientCatalog.allCategories' | translate }} ({{ totalItems() }})
|
||||
</button>
|
||||
@for (cat of categories(); track cat.code) {
|
||||
<button type="button" (click)="setCategory(cat.code)" class="rounded-xl px-4 py-2 text-sm font-semibold transition" [class.bg-brand-600]="category() === cat.code" [class.text-white]="category() === cat.code" [class.bg-slate-100]="category() !== cat.code" [class.text-slate-600]="category() !== cat.code" [class.dark:bg-slate-800]="category() !== cat.code">
|
||||
{{ ('ingredientCatalog.categories.' + cat.code) | translate }} ({{ cat.itemCount }})
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<label class="block max-w-md text-sm">
|
||||
<span class="font-medium">{{ 'common.search' | translate }}</span>
|
||||
<input class="input mt-1 w-full" [(ngModel)]="search" (ngModelChange)="onSearch($event)" [placeholder]="'ingredientCatalog.searchPlaceholder' | translate" />
|
||||
</label>
|
||||
|
||||
@if (loading()) {
|
||||
<app-skeleton [className]="'h-64 w-full'" />
|
||||
} @else if (error()) {
|
||||
<app-error-state [message]="error()!" [showRetry]="true" (retry)="fetchItems()" />
|
||||
} @else if (items().length === 0) {
|
||||
<app-empty-state [title]="'ingredientCatalog.noResultsTitle' | translate" [description]="'ingredientCatalog.noResultsDescription' | translate" />
|
||||
} @else {
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3">
|
||||
<button type="button" class="inline-flex items-center gap-1 font-inherit uppercase hover:text-slate-700 dark:hover:text-slate-200" (click)="toggleSort('name')">
|
||||
{{ 'ingredientCatalog.columns.name' | translate }}{{ indicator('name') }}
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-3">
|
||||
<button type="button" class="inline-flex items-center gap-1 font-inherit uppercase hover:text-slate-700 dark:hover:text-slate-200" (click)="toggleSort('category')">
|
||||
{{ 'ingredientCatalog.columns.category' | translate }}{{ indicator('category') }}
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-3">
|
||||
<button type="button" class="inline-flex items-center gap-1 font-inherit uppercase hover:text-slate-700 dark:hover:text-slate-200" (click)="toggleSort('calories')">
|
||||
{{ 'ingredientCatalog.columns.calories' | translate }}{{ indicator('calories') }}
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-3">
|
||||
<button type="button" class="inline-flex items-center gap-1 font-inherit uppercase hover:text-slate-700 dark:hover:text-slate-200" (click)="toggleSort('protein')">
|
||||
{{ 'ingredientCatalog.columns.protein' | translate }}{{ indicator('protein') }}
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-3">
|
||||
<button type="button" class="inline-flex items-center gap-1 font-inherit uppercase hover:text-slate-700 dark:hover:text-slate-200" (click)="toggleSort('fat')">
|
||||
{{ 'ingredientCatalog.columns.fat' | translate }}{{ indicator('fat') }}
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-3">
|
||||
<button type="button" class="inline-flex items-center gap-1 font-inherit uppercase hover:text-slate-700 dark:hover:text-slate-200" (click)="toggleSort('carbs')">
|
||||
{{ 'ingredientCatalog.columns.carbs' | translate }}{{ indicator('carbs') }}
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-3">
|
||||
<button type="button" class="inline-flex items-center gap-1 font-inherit uppercase hover:text-slate-700 dark:hover:text-slate-200" (click)="toggleSort('fiber')">
|
||||
{{ 'ingredientCatalog.columns.fiber' | translate }}{{ indicator('fiber') }}
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
@for (item of sortedItems(); track item.id) {
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||||
<td class="px-4 py-3 font-medium">{{ displayName(item) }}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ ('ingredientCatalog.categories.' + item.categoryCode) | translate }}</td>
|
||||
<td class="px-4 py-3">{{ item.caloriesPer100G }}</td>
|
||||
<td class="px-4 py-3">{{ item.proteinGPer100G | number: '1.1-1' }}</td>
|
||||
<td class="px-4 py-3">{{ item.fatGPer100G | number: '1.1-1' }}</td>
|
||||
<td class="px-4 py-3">{{ item.carbsGPer100G | number: '1.1-1' }}</td>
|
||||
<td class="px-4 py-3">{{ item.fiberGPer100G != null ? (item.fiberGPer100G | number: '1.1-1') : '—' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button type="button" class="mr-2 text-brand-600" (click)="openEditForm(item)">{{ 'common.edit' | translate }}</button>
|
||||
<button type="button" class="text-red-500" (click)="deleteItem(item.id)">{{ 'common.delete' | translate }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="border-t border-slate-200 px-4 py-3 text-xs text-slate-400 dark:border-slate-700">{{ 'ingredientCatalog.per100gNote' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class IngredientCatalogComponent implements OnInit, OnDestroy {
|
||||
private readonly catalog = inject(IngredientCatalogService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly language = inject(LanguageService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
private readonly search$ = new Subject<string>();
|
||||
|
||||
categories = signal<IngredientCategory[]>([]);
|
||||
items = signal<IngredientNutritionItem[]>([]);
|
||||
category = signal('');
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
showForm = signal(false);
|
||||
editingId = signal<number | null>(null);
|
||||
saving = signal(false);
|
||||
formError = signal<string | null>(null);
|
||||
search = '';
|
||||
form: UpsertIngredientNutritionItemInput = { ...EMPTY_FORM };
|
||||
sort = signal<{ column: IngredientSortColumn; direction: SortDirection }>({ column: 'name', direction: 'asc' });
|
||||
|
||||
readonly sortedItems = computed(() => {
|
||||
const rows = this.items();
|
||||
const { column, direction } = this.sort();
|
||||
const lang = this.language.currentLanguage();
|
||||
return sortBy(rows, direction, (item) => {
|
||||
switch (column) {
|
||||
case 'name':
|
||||
return lang === 'pl' && item.namePl ? item.namePl : item.name;
|
||||
case 'category':
|
||||
return item.categoryCode;
|
||||
case 'calories':
|
||||
return item.caloriesPer100G;
|
||||
case 'protein':
|
||||
return Number(item.proteinGPer100G);
|
||||
case 'fat':
|
||||
return Number(item.fatGPer100G);
|
||||
case 'carbs':
|
||||
return Number(item.carbsGPer100G);
|
||||
case 'fiber':
|
||||
return item.fiberGPer100G != null ? Number(item.fiberGPer100G) : null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadCategories();
|
||||
this.search$.pipe(debounceTime(300), takeUntil(this.destroy$)).subscribe(() => this.fetchItems());
|
||||
this.fetchItems();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
loadCategories(): void {
|
||||
this.catalog.getCategories().subscribe({
|
||||
next: (cats) => this.categories.set(cats),
|
||||
error: () => this.categories.set([]),
|
||||
});
|
||||
}
|
||||
|
||||
totalItems(): number {
|
||||
return this.categories().reduce((sum, c) => sum + c.itemCount, 0);
|
||||
}
|
||||
|
||||
setCategory(code: string): void {
|
||||
this.category.set(code);
|
||||
this.fetchItems();
|
||||
}
|
||||
|
||||
onSearch(value: string): void {
|
||||
this.search = value;
|
||||
this.search$.next(value);
|
||||
}
|
||||
|
||||
fetchItems(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.catalog.getItems(this.category() || undefined, this.search.trim() || undefined).pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (rows) => {
|
||||
this.items.set(rows);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.loadIngredientCatalogFailed')));
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openCreateForm(): void {
|
||||
this.editingId.set(null);
|
||||
this.form = { ...EMPTY_FORM, categoryId: this.categories()[0]?.id ?? 0 };
|
||||
this.formError.set(null);
|
||||
this.showForm.set(true);
|
||||
}
|
||||
|
||||
openEditForm(item: IngredientNutritionItem): void {
|
||||
this.editingId.set(item.id);
|
||||
this.form = {
|
||||
categoryId: item.categoryId,
|
||||
name: item.name,
|
||||
namePl: item.namePl ?? '',
|
||||
caloriesPer100G: item.caloriesPer100G,
|
||||
proteinGPer100G: item.proteinGPer100G,
|
||||
fatGPer100G: item.fatGPer100G,
|
||||
carbsGPer100G: item.carbsGPer100G,
|
||||
fiberGPer100G: item.fiberGPer100G,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
};
|
||||
this.formError.set(null);
|
||||
this.showForm.set(true);
|
||||
}
|
||||
|
||||
closeForm(): void {
|
||||
this.showForm.set(false);
|
||||
this.editingId.set(null);
|
||||
this.formError.set(null);
|
||||
}
|
||||
|
||||
saveForm(): void {
|
||||
if (!this.form.categoryId || !this.form.name.trim()) {
|
||||
this.formError.set(this.translate.instant('ingredientCatalog.formInvalid'));
|
||||
return;
|
||||
}
|
||||
this.saving.set(true);
|
||||
this.formError.set(null);
|
||||
const payload = {
|
||||
...this.form,
|
||||
name: this.form.name.trim(),
|
||||
namePl: this.form.namePl?.trim() || null,
|
||||
fiberGPer100G: this.form.fiberGPer100G ?? null,
|
||||
};
|
||||
const req = this.editingId()
|
||||
? this.catalog.updateItem(this.editingId()!, payload)
|
||||
: this.catalog.createItem(payload);
|
||||
req.pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.closeForm();
|
||||
this.loadCategories();
|
||||
this.fetchItems();
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving.set(false);
|
||||
this.formError.set(extractApiError(err, this.translate.instant('errors.saveIngredientFailed')));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteItem(id: number): void {
|
||||
if (!confirm(this.translate.instant('ingredientCatalog.deleteConfirm'))) return;
|
||||
this.catalog.deleteItem(id).pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: () => {
|
||||
this.loadCategories();
|
||||
this.fetchItems();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
displayName(item: IngredientNutritionItem): string {
|
||||
if (this.language.currentLanguage() === 'pl' && item.namePl) return item.namePl;
|
||||
return item.name;
|
||||
}
|
||||
|
||||
toggleSort(column: IngredientSortColumn): void {
|
||||
this.sort.update((current) => nextSort(current, column));
|
||||
}
|
||||
|
||||
indicator(column: IngredientSortColumn): string {
|
||||
return sortIndicator(this.sort(), column);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Component, ElementRef, inject, signal, viewChild } from '@angular/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { RecipeManagementService } from '../../core/services/recipe-management.service';
|
||||
import { RecipeImportResult } from '../../core/models/recipe.models';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-excel-import-panel',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<section class="card p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300">
|
||||
📊
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-lg font-bold">{{ 'manage.importTitle' | translate }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ 'manage.importDescription' | translate }}</p>
|
||||
<button type="button" (click)="downloadTemplate()" [disabled]="downloading()" class="btn-secondary mt-4">
|
||||
@if (downloading()) {
|
||||
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
||||
} @else {
|
||||
↓
|
||||
}
|
||||
{{ 'manage.downloadTemplate' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card space-y-4 p-6">
|
||||
<label class="block text-sm font-medium">{{ 'manage.uploadLabel' | translate }}</label>
|
||||
<input
|
||||
#fileInput
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
class="block w-full text-sm text-slate-500 file:mr-4 file:rounded-lg file:border-0 file:bg-brand-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-brand-700 hover:file:bg-brand-100 dark:file:bg-brand-900/40 dark:file:text-brand-300"
|
||||
(change)="onFileSelected($event)"
|
||||
/>
|
||||
|
||||
@if (importError()) {
|
||||
<div role="alert" class="rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
|
||||
{{ importError() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (importResult()) {
|
||||
<div class="rounded-xl border border-brand-200 bg-brand-50 p-4 dark:border-brand-800 dark:bg-brand-950/30">
|
||||
<div class="flex items-center gap-2 font-semibold text-brand-800 dark:text-brand-200">
|
||||
✓ {{ 'manage.importComplete' | translate }}
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
{{ 'manage.createdCount' | translate: { count: importResult()!.createdCount } }} ·
|
||||
{{ 'manage.skippedCount' | translate: { count: importResult()!.skippedCount } }}
|
||||
</p>
|
||||
@if (importResult()!.errors.length > 0) {
|
||||
<ul class="mt-3 max-h-40 space-y-1 overflow-y-auto text-sm text-amber-700 dark:text-amber-300">
|
||||
@for (msg of importResult()!.errors; track msg) {
|
||||
<li>• {{ msg }}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="!selectedFile() || importPending()"
|
||||
(click)="handleImport()"
|
||||
class="btn-primary"
|
||||
>
|
||||
@if (importPending()) {
|
||||
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
} @else {
|
||||
↑
|
||||
}
|
||||
{{ importPending() ? ('manage.importing' | translate) : ('manage.importRecipes' | translate) }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-dashed border-slate-300 p-4 text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
<p class="font-medium text-slate-700 dark:text-slate-200">{{ 'manage.excelFormatTitle' | translate }}</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1">
|
||||
<li>{{ 'manage.excelRecipesSheet' | translate }}</li>
|
||||
<li>{{ 'manage.excelIngredientsSheet' | translate }}</li>
|
||||
<li>{{ 'manage.excelStepsSheet' | translate }}</li>
|
||||
</ul>
|
||||
<p class="mt-2">{{ 'manage.duplicateSkipped' | translate }}</p>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ExcelImportPanelComponent {
|
||||
private readonly management = inject(RecipeManagementService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly fileInput = viewChild<ElementRef<HTMLInputElement>>('fileInput');
|
||||
|
||||
readonly selectedFile = signal<File | null>(null);
|
||||
readonly downloading = signal(false);
|
||||
readonly importPending = signal(false);
|
||||
readonly importError = signal<string | null>(null);
|
||||
readonly importResult = signal<RecipeImportResult | null>(null);
|
||||
|
||||
onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.selectedFile.set(input.files?.[0] ?? null);
|
||||
}
|
||||
|
||||
async downloadTemplate(): Promise<void> {
|
||||
this.downloading.set(true);
|
||||
try {
|
||||
const blob = await firstValueFrom(this.management.downloadImportTemplate());
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'DailyMeals-recipe-import-template.xlsx';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
this.downloading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async handleImport(): Promise<void> {
|
||||
const file = this.selectedFile();
|
||||
if (!file) return;
|
||||
|
||||
this.importError.set(null);
|
||||
this.importResult.set(null);
|
||||
this.importPending.set(true);
|
||||
try {
|
||||
const result = await firstValueFrom(this.management.importFromExcel(file));
|
||||
this.importResult.set(result);
|
||||
this.selectedFile.set(null);
|
||||
const input = this.fileInput()?.nativeElement;
|
||||
if (input) input.value = '';
|
||||
} catch (err) {
|
||||
this.importError.set(extractApiError(err, this.translate.instant('errors.importFailed')));
|
||||
} finally {
|
||||
this.importPending.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { Component, inject, signal, viewChild } from '@angular/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CreateRecipeInput, RecipeListItem } from '../../core/models/recipe.models';
|
||||
import { RecipeManagementService } from '../../core/services/recipe-management.service';
|
||||
import { RecipeService } from '../../core/services/recipe.service';
|
||||
import { RecipeFormComponent } from './recipe-form.component';
|
||||
import { ExcelImportPanelComponent } from './excel-import-panel.component';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
|
||||
type Tab = 'manual' | 'edit' | 'import';
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-meals',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe, RecipeFormComponent, ExcelImportPanelComponent],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight sm:text-3xl">{{ 'manage.title' | translate }}</h1>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">{{ 'manage.subtitle' | translate }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary shrink-0"
|
||||
[disabled]="recalculatePending()"
|
||||
(click)="handleRecalculate()"
|
||||
>
|
||||
@if (recalculatePending()) {
|
||||
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
||||
{{ 'manage.recalculating' | translate }}
|
||||
} @else {
|
||||
↻ {{ 'manage.recalculateMacros' | translate }}
|
||||
}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (recalculateError()) {
|
||||
<div role="alert" class="rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
|
||||
{{ recalculateError() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex gap-2 border-b border-slate-200 dark:border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
(click)="setTab('manual')"
|
||||
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-semibold transition"
|
||||
[class]="
|
||||
tab() === 'manual'
|
||||
? 'border-brand-600 text-brand-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400'
|
||||
"
|
||||
>
|
||||
✎ {{ 'manage.tabManual' | translate }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="setTab('edit')"
|
||||
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-semibold transition"
|
||||
[class]="
|
||||
tab() === 'edit'
|
||||
? 'border-brand-600 text-brand-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400'
|
||||
"
|
||||
>
|
||||
✏ {{ 'manage.tabEdit' | translate }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="setTab('import')"
|
||||
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-semibold transition"
|
||||
[class]="
|
||||
tab() === 'import'
|
||||
? 'border-brand-600 text-brand-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400'
|
||||
"
|
||||
>
|
||||
↑ {{ 'manage.tabImport' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (successMessage() && (tab() === 'manual' || tab() === 'edit')) {
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-xl bg-brand-50 p-3 text-sm font-medium text-brand-800 dark:bg-brand-950/40 dark:text-brand-200"
|
||||
>
|
||||
✓ {{ successMessage() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (tab() === 'manual') {
|
||||
<app-recipe-form
|
||||
#recipeForm
|
||||
[pending]="pending()"
|
||||
[error]="formError()"
|
||||
(submitRecipe)="handleCreate($event)"
|
||||
/>
|
||||
} @else if (tab() === 'edit') {
|
||||
<div class="grid gap-6 lg:grid-cols-[280px_1fr]">
|
||||
<div class="card max-h-[70vh] overflow-y-auto p-4">
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'common.search' | translate }}</span>
|
||||
<input class="input mt-1 w-full" [value]="searchQuery()" (input)="onSearch($event)" />
|
||||
</label>
|
||||
<ul class="mt-3 space-y-1">
|
||||
@if (loadingList()) {
|
||||
<li class="text-sm text-slate-400">{{ 'common.loading' | translate }}</li>
|
||||
} @else {
|
||||
@for (r of filteredRecipes(); track r.id) {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg px-3 py-2 text-left text-sm transition"
|
||||
[class.bg-brand-600]="editingId() === r.id"
|
||||
[class.text-white]="editingId() === r.id"
|
||||
(click)="startEdit(r.id)"
|
||||
>
|
||||
{{ r.name }}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@if (editingId()) {
|
||||
<app-recipe-form
|
||||
#editForm
|
||||
[pending]="pending()"
|
||||
[error]="formError()"
|
||||
[editMode]="true"
|
||||
[recipeId]="editingId()"
|
||||
(submitRecipe)="handleUpdate($event)"
|
||||
/>
|
||||
} @else {
|
||||
<p class="text-sm text-slate-400">{{ 'manage.selectRecipeToEdit' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<app-excel-import-panel />
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ManageMealsComponent {
|
||||
private readonly management = inject(RecipeManagementService);
|
||||
private readonly recipes = inject(RecipeService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly recipeForm = viewChild('recipeForm', { read: RecipeFormComponent });
|
||||
private readonly editForm = viewChild('editForm', { read: RecipeFormComponent });
|
||||
|
||||
readonly tab = signal<Tab>('manual');
|
||||
readonly pending = signal(false);
|
||||
readonly formError = signal<string | null>(null);
|
||||
readonly successMessage = signal<string | null>(null);
|
||||
readonly recipeList = signal<RecipeListItem[]>([]);
|
||||
readonly searchQuery = signal('');
|
||||
readonly loadingList = signal(false);
|
||||
readonly editingId = signal<number | null>(null);
|
||||
readonly recalculatePending = signal(false);
|
||||
readonly recalculateError = signal<string | null>(null);
|
||||
|
||||
readonly filteredRecipes = () => {
|
||||
const q = this.searchQuery().trim().toLowerCase();
|
||||
const list = this.recipeList();
|
||||
if (!q) return list;
|
||||
return list.filter((r) => r.name.toLowerCase().includes(q));
|
||||
};
|
||||
|
||||
setTab(id: Tab): void {
|
||||
this.tab.set(id);
|
||||
this.formError.set(null);
|
||||
this.successMessage.set(null);
|
||||
if (id === 'edit') this.loadRecipeList();
|
||||
}
|
||||
|
||||
onSearch(event: Event): void {
|
||||
this.searchQuery.set((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
loadRecipeList(): void {
|
||||
this.loadingList.set(true);
|
||||
this.recipes.getRecipes().subscribe({
|
||||
next: (items) => {
|
||||
this.recipeList.set(items);
|
||||
this.loadingList.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.recipeList.set([]);
|
||||
this.loadingList.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async startEdit(id: number): Promise<void> {
|
||||
this.editingId.set(id);
|
||||
this.formError.set(null);
|
||||
this.successMessage.set(null);
|
||||
try {
|
||||
const recipe = await firstValueFrom(this.recipes.getRecipeById(id));
|
||||
setTimeout(() => this.editForm()?.loadRecipe(recipe));
|
||||
} catch (err) {
|
||||
this.formError.set(extractApiError(err, this.translate.instant('errors.loadRecipeFailed')));
|
||||
}
|
||||
}
|
||||
|
||||
async handleCreate(input: CreateRecipeInput): Promise<void> {
|
||||
this.formError.set(null);
|
||||
this.successMessage.set(null);
|
||||
this.pending.set(true);
|
||||
try {
|
||||
const recipe = await firstValueFrom(this.management.createRecipe(input));
|
||||
this.successMessage.set(this.translate.instant('manage.savedSuccess', { name: recipe.name }));
|
||||
this.recipeForm()?.reset();
|
||||
} catch (err) {
|
||||
this.formError.set(extractApiError(err, this.translate.instant('errors.saveRecipeFailed')));
|
||||
} finally {
|
||||
this.pending.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async handleUpdate(input: CreateRecipeInput): Promise<void> {
|
||||
const id = this.editingId();
|
||||
if (!id) return;
|
||||
this.formError.set(null);
|
||||
this.successMessage.set(null);
|
||||
this.pending.set(true);
|
||||
try {
|
||||
const recipe = await firstValueFrom(this.management.updateRecipe(id, input));
|
||||
this.successMessage.set(this.translate.instant('manage.updatedSuccess', { name: recipe.name }));
|
||||
this.loadRecipeList();
|
||||
} catch (err) {
|
||||
this.formError.set(extractApiError(err, this.translate.instant('errors.saveRecipeFailed')));
|
||||
} finally {
|
||||
this.pending.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async handleRecalculate(): Promise<void> {
|
||||
this.recalculateError.set(null);
|
||||
this.successMessage.set(null);
|
||||
this.recalculatePending.set(true);
|
||||
try {
|
||||
const result = await firstValueFrom(this.management.recalculateAllMacros());
|
||||
this.successMessage.set(
|
||||
this.translate.instant('manage.recalculateSuccess', {
|
||||
updated: result.recipesUpdated,
|
||||
processed: result.recipesProcessed,
|
||||
unlinked: result.unlinkedIngredientRows,
|
||||
}),
|
||||
);
|
||||
if (this.tab() === 'edit') {
|
||||
this.loadRecipeList();
|
||||
const id = this.editingId();
|
||||
if (id) await this.startEdit(id);
|
||||
}
|
||||
} catch (err) {
|
||||
this.recalculateError.set(
|
||||
extractApiError(err, this.translate.instant('errors.recalculateMacrosFailed')),
|
||||
);
|
||||
} finally {
|
||||
this.recalculatePending.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { Component, EventEmitter, Input, Output, inject, OnInit } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormArray,
|
||||
FormBuilder,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
CreateRecipeInput,
|
||||
MealCategory,
|
||||
MEAL_CATEGORY_I18N_KEYS,
|
||||
RecipeDetail,
|
||||
} from '../../core/models/recipe.models';
|
||||
import { IngredientCatalogService } from '../../core/services/ingredient-catalog.service';
|
||||
import { IngredientNutritionItem } from '../../core/models/ingredient-catalog.models';
|
||||
import { LanguageService } from '../../core/services/language.service';
|
||||
import {
|
||||
countUnlinkedMacroIngredients,
|
||||
estimateMacrosFromCatalog,
|
||||
hasNonMacroUnit,
|
||||
isUnlinkedMacroIngredient,
|
||||
} from '../../core/utils/recipe-macros.util';
|
||||
|
||||
const MEAL_CATEGORIES = [
|
||||
MealCategory.Breakfast,
|
||||
MealCategory.SecondBreakfast,
|
||||
MealCategory.Lunch,
|
||||
MealCategory.Dinner,
|
||||
] as const;
|
||||
|
||||
function minStepsArray(min: number) {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const arr = control as FormArray;
|
||||
return arr.length >= min ? null : { minSteps: true };
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-form',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, TranslatePipe],
|
||||
template: `
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="space-y-8" novalidate>
|
||||
@if (error) {
|
||||
<div role="alert" class="rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
|
||||
{{ error }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="card space-y-4 p-6">
|
||||
<h2 class="text-lg font-bold">{{ 'manage.basicInfo' | translate }}</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="mb-1.5 block text-sm font-medium">{{ 'manage.recipeName' | translate }}</label>
|
||||
<input class="input" formControlName="name" [placeholder]="'manage.recipeNamePlaceholder' | translate" />
|
||||
@if (form.controls.name.touched && form.controls.name.errors?.['required']) {
|
||||
<p class="mt-1 text-sm text-red-600">{{ 'validation.recipeNameRequired' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium">{{ 'common.category' | translate }}</label>
|
||||
<select class="select" formControlName="mealCategory">
|
||||
@for (cat of mealCategories; track cat) {
|
||||
<option [ngValue]="cat">{{ categoryLabel(cat) | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium">{{ 'manage.prepTimeMin' | translate }}</label>
|
||||
<input type="number" class="input" formControlName="prepTimeMinutes" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium">{{ 'manage.caloriesKcal' | translate }}</label>
|
||||
<input type="number" class="input" formControlName="calories" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium">{{ 'manage.proteinG' | translate }}</label>
|
||||
<input type="number" step="0.1" class="input" formControlName="protein" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium">{{ 'manage.fatG' | translate }}</label>
|
||||
<input type="number" step="0.1" class="input" formControlName="fat" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium">{{ 'manage.carbsG' | translate }}</label>
|
||||
<input type="number" step="0.1" class="input" formControlName="carbs" />
|
||||
</div>
|
||||
</div>
|
||||
@if (estimatedTotalMacros(); as macros) {
|
||||
<p class="text-sm text-slate-500">
|
||||
{{ 'manage.estimatedFromCatalog' | translate }}:
|
||||
{{ macros.calories }} kcal · P {{ macros.protein.toFixed(1) }}g · F {{ macros.fat.toFixed(1) }}g · C
|
||||
{{ macros.carbs.toFixed(1) }}g
|
||||
</p>
|
||||
}
|
||||
@if (unlinkedIngredientCount() > 0) {
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||
{{ 'manage.unlinkedIngredientsWarning' | translate: { count: unlinkedIngredientCount() } }}
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="card space-y-4 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold">{{ 'recipes.ingredients' | translate }}</h2>
|
||||
<button type="button" class="btn-secondary" (click)="addIngredient()">
|
||||
+ {{ 'common.add' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-3" formArrayName="ingredients">
|
||||
@for (ctrl of ingredients.controls; track $index; let i = $index) {
|
||||
<div
|
||||
class="space-y-2 rounded-xl border p-3"
|
||||
[class.border-slate-200]="!isUnlinkedIngredient(i)"
|
||||
[class.dark:border-slate-700]="!isUnlinkedIngredient(i)"
|
||||
[class.border-amber-400]="isUnlinkedIngredient(i)"
|
||||
[class.bg-amber-50]="isUnlinkedIngredient(i)"
|
||||
[class.dark:bg-amber-950]="isUnlinkedIngredient(i)"
|
||||
[formGroupName]="i"
|
||||
>
|
||||
<div class="grid gap-2 sm:grid-cols-[1fr_100px_100px_auto]">
|
||||
<input class="input" formControlName="name" [placeholder]="'manage.ingredientName' | translate" />
|
||||
<input type="number" step="0.1" class="input" formControlName="amountGrams" [placeholder]="'manage.ingredientGrams' | translate" (input)="onAmountChange(i)" />
|
||||
<input class="input" formControlName="unit" [placeholder]="'manage.ingredientUnit' | translate" />
|
||||
<button type="button" class="btn-secondary !px-2.5" (click)="removeIngredient(i)" [attr.aria-label]="'manage.removeIngredient' | translate">
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
<label class="block text-xs">
|
||||
<span class="font-medium text-slate-500">{{ 'manage.catalogLink' | translate }}</span>
|
||||
<select class="select mt-1 w-full" formControlName="catalogItemId" (change)="onCatalogChange(i)">
|
||||
<option [ngValue]="null">{{ 'diet.noCatalogItem' | translate }}</option>
|
||||
@for (item of catalogItems; track item.id) {
|
||||
<option [ngValue]="item.id">{{ displayCatalogName(item) }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (macroHint(i); as hint) {
|
||||
<p class="text-xs text-brand-700 dark:text-brand-300">{{ 'manage.macroHint' | translate: hint }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card space-y-4 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold">{{ 'manage.preparationSteps' | translate }}</h2>
|
||||
<button type="button" class="btn-secondary" (click)="addStep()">
|
||||
+ {{ 'manage.addStep' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (steps.errors?.['minSteps']) {
|
||||
<p class="text-sm text-red-600">{{ 'validation.stepsMin' | translate }}</p>
|
||||
}
|
||||
<div class="space-y-3" formArrayName="steps">
|
||||
@for (ctrl of steps.controls; track $index; let i = $index) {
|
||||
<div class="flex gap-2" [formGroupName]="i">
|
||||
<input type="number" class="input w-20 shrink-0" formControlName="stepNumber" />
|
||||
<input class="input flex-1" formControlName="description" [placeholder]="'manage.stepPlaceholder' | translate" />
|
||||
<button type="button" class="btn-secondary !px-2.5" (click)="removeStep(i)" [attr.aria-label]="'manage.removeStep' | translate">
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="submit" [disabled]="pending" class="btn-primary">
|
||||
@if (pending) {
|
||||
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
}
|
||||
{{ pending ? ('manage.saving' | translate) : (editMode ? ('manage.updateRecipe' | translate) : ('manage.saveRecipe' | translate)) }}
|
||||
</button>
|
||||
</form>
|
||||
`,
|
||||
})
|
||||
export class RecipeFormComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly catalog = inject(IngredientCatalogService);
|
||||
private readonly language = inject(LanguageService);
|
||||
|
||||
@Input() pending = false;
|
||||
@Input() error: string | null = null;
|
||||
@Input() editMode = false;
|
||||
@Input() recipeId: number | null = null;
|
||||
@Output() submitRecipe = new EventEmitter<CreateRecipeInput>();
|
||||
|
||||
readonly mealCategories = MEAL_CATEGORIES;
|
||||
catalogItems: IngredientNutritionItem[] = [];
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
name: ['', [Validators.required, Validators.maxLength(255)]],
|
||||
mealCategory: [MealCategory.Breakfast],
|
||||
calories: this.fb.control<number | null>(null),
|
||||
protein: this.fb.control<number | null>(null),
|
||||
fat: this.fb.control<number | null>(null),
|
||||
carbs: this.fb.control<number | null>(null),
|
||||
prepTimeMinutes: this.fb.control<number | null>(null),
|
||||
ingredients: this.fb.array([this.createIngredientGroup()]),
|
||||
steps: this.fb.array([this.createStepGroup(1)], { validators: minStepsArray(1) }),
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.catalog.getItems().subscribe({
|
||||
next: (items) => (this.catalogItems = items),
|
||||
error: () => (this.catalogItems = []),
|
||||
});
|
||||
}
|
||||
|
||||
get ingredients(): FormArray {
|
||||
return this.form.controls.ingredients;
|
||||
}
|
||||
|
||||
get steps(): FormArray {
|
||||
return this.form.controls.steps;
|
||||
}
|
||||
|
||||
categoryLabel(cat: MealCategory): string {
|
||||
return MEAL_CATEGORY_I18N_KEYS[cat];
|
||||
}
|
||||
|
||||
displayCatalogName(item: IngredientNutritionItem): string {
|
||||
if (this.language.currentLanguage() === 'pl' && item.namePl) return item.namePl;
|
||||
return item.name;
|
||||
}
|
||||
|
||||
createIngredientGroup(catalogItemId: number | null = null) {
|
||||
return this.fb.nonNullable.group({
|
||||
name: ['', Validators.required],
|
||||
amountGrams: this.fb.control<number | null>(null),
|
||||
unit: [''],
|
||||
sortOrder: [0],
|
||||
catalogItemId: this.fb.control<number | null>(catalogItemId),
|
||||
});
|
||||
}
|
||||
|
||||
createStepGroup(stepNumber: number) {
|
||||
return this.fb.nonNullable.group({
|
||||
stepNumber: [stepNumber, [Validators.required, Validators.min(1)]],
|
||||
description: ['', Validators.required],
|
||||
});
|
||||
}
|
||||
|
||||
addIngredient(): void {
|
||||
this.ingredients.push(
|
||||
this.fb.nonNullable.group({
|
||||
name: ['', Validators.required],
|
||||
amountGrams: this.fb.control<number | null>(null),
|
||||
unit: [''],
|
||||
sortOrder: [this.ingredients.length],
|
||||
catalogItemId: this.fb.control<number | null>(null),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
removeIngredient(index: number): void {
|
||||
this.ingredients.removeAt(index);
|
||||
}
|
||||
|
||||
addStep(): void {
|
||||
this.steps.push(this.createStepGroup(this.steps.length + 1));
|
||||
}
|
||||
|
||||
removeStep(index: number): void {
|
||||
this.steps.removeAt(index);
|
||||
}
|
||||
|
||||
onCatalogChange(index: number): void {
|
||||
const group = this.ingredients.at(index);
|
||||
const catalogItemId = group.get('catalogItemId')?.value as number | null;
|
||||
if (!catalogItemId) return;
|
||||
const item = this.catalogItems.find((c) => c.id === catalogItemId);
|
||||
if (item && !group.get('name')?.value) {
|
||||
group.patchValue({ name: this.displayCatalogName(item) });
|
||||
}
|
||||
}
|
||||
|
||||
onAmountChange(_index: number): void {
|
||||
// triggers macroHint recomputation in template
|
||||
}
|
||||
|
||||
estimatedTotalMacros() {
|
||||
const values = this.ingredients.getRawValue();
|
||||
return estimateMacrosFromCatalog(values, this.catalogItems);
|
||||
}
|
||||
|
||||
unlinkedIngredientCount(): number {
|
||||
return countUnlinkedMacroIngredients(this.ingredients.getRawValue());
|
||||
}
|
||||
|
||||
isUnlinkedIngredient(index: number): boolean {
|
||||
const values = this.ingredients.at(index).getRawValue();
|
||||
return isUnlinkedMacroIngredient(values);
|
||||
}
|
||||
|
||||
macroHint(index: number): { calories: string; protein: string; fat: string; carbs: string } | null {
|
||||
const group = this.ingredients.at(index);
|
||||
const catalogItemId = group.get('catalogItemId')?.value as number | null;
|
||||
const grams = group.get('amountGrams')?.value as number | null;
|
||||
const unit = group.get('unit')?.value as string;
|
||||
if (!catalogItemId || !grams || grams <= 0 || hasNonMacroUnit(unit)) return null;
|
||||
const item = this.catalogItems.find((c) => c.id === catalogItemId);
|
||||
if (!item) return null;
|
||||
const factor = grams / 100;
|
||||
return {
|
||||
calories: (item.caloriesPer100G * factor).toFixed(0),
|
||||
protein: (item.proteinGPer100G * factor).toFixed(1),
|
||||
fat: (item.fatGPer100G * factor).toFixed(1),
|
||||
carbs: (item.carbsGPer100G * factor).toFixed(1),
|
||||
};
|
||||
}
|
||||
|
||||
loadRecipe(recipe: RecipeDetail): void {
|
||||
this.editMode = true;
|
||||
this.recipeId = recipe.id;
|
||||
this.form.patchValue({
|
||||
name: recipe.name,
|
||||
mealCategory: recipe.mealCategory,
|
||||
calories: recipe.calories,
|
||||
protein: recipe.protein,
|
||||
fat: recipe.fat,
|
||||
carbs: recipe.carbs,
|
||||
prepTimeMinutes: recipe.prepTimeMinutes,
|
||||
});
|
||||
this.ingredients.clear();
|
||||
for (const ing of recipe.ingredients) {
|
||||
this.ingredients.push(
|
||||
this.fb.nonNullable.group({
|
||||
name: [ing.name, Validators.required],
|
||||
amountGrams: this.fb.control<number | null>(ing.amountGrams),
|
||||
unit: [ing.unit ?? ''],
|
||||
sortOrder: [ing.sortOrder],
|
||||
catalogItemId: this.fb.control<number | null>(ing.catalogItemId ?? null),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (this.ingredients.length === 0) {
|
||||
this.ingredients.push(this.createIngredientGroup());
|
||||
}
|
||||
this.steps.clear();
|
||||
for (const step of recipe.steps) {
|
||||
this.steps.push(this.createStepGroup(step.stepNumber));
|
||||
this.steps.at(this.steps.length - 1).patchValue({
|
||||
stepNumber: step.stepNumber,
|
||||
description: step.description,
|
||||
});
|
||||
}
|
||||
if (this.steps.length === 0) {
|
||||
this.steps.push(this.createStepGroup(1));
|
||||
}
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
this.form.markAllAsTouched();
|
||||
if (this.form.invalid) return;
|
||||
|
||||
const values = this.form.getRawValue();
|
||||
this.submitRecipe.emit({
|
||||
name: values.name,
|
||||
mealCategory: values.mealCategory,
|
||||
calories: values.calories ?? null,
|
||||
protein: values.protein ?? null,
|
||||
fat: values.fat ?? null,
|
||||
carbs: values.carbs ?? null,
|
||||
prepTimeMinutes: values.prepTimeMinutes ?? null,
|
||||
ingredients: values.ingredients.map((i, index) => ({
|
||||
name: i.name,
|
||||
amountGrams: i.amountGrams ?? null,
|
||||
unit: i.unit ?? '',
|
||||
sortOrder: i.sortOrder ?? index,
|
||||
catalogItemId: i.catalogItemId ?? null,
|
||||
})),
|
||||
steps: values.steps,
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.editMode = false;
|
||||
this.recipeId = null;
|
||||
this.form.reset({
|
||||
name: '',
|
||||
mealCategory: MealCategory.Breakfast,
|
||||
calories: null,
|
||||
protein: null,
|
||||
fat: null,
|
||||
carbs: null,
|
||||
prepTimeMinutes: null,
|
||||
});
|
||||
this.ingredients.clear();
|
||||
this.ingredients.push(this.createIngredientGroup());
|
||||
this.steps.clear();
|
||||
this.steps.push(this.createStepGroup(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Component, Input, inject, signal } from '@angular/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { RecipeService } from '../../core/services/recipe.service';
|
||||
import {
|
||||
MealCategory,
|
||||
MEAL_CATEGORY_I18N_KEYS,
|
||||
RecipeDetail,
|
||||
} from '../../core/models/recipe.models';
|
||||
import { MealPlanEntry } from '../../core/models/meal-plan.models';
|
||||
import {
|
||||
buildShoppingList,
|
||||
formatBreakdown,
|
||||
formatQuantity,
|
||||
generatePdf,
|
||||
scaleRecipeDetail,
|
||||
ShoppingItem,
|
||||
} from '../../core/utils/pdf-export.util';
|
||||
import { formatIngredientAmount } from '../../core/utils/ingredient-quantity.util';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
|
||||
const PLANNER_CATEGORIES = [
|
||||
MealCategory.Breakfast,
|
||||
MealCategory.SecondBreakfast,
|
||||
MealCategory.Lunch,
|
||||
MealCategory.Dinner,
|
||||
] as const;
|
||||
|
||||
type Phase = 'idle' | 'loading' | 'rendering';
|
||||
|
||||
interface ExportRecipe {
|
||||
recipe: RecipeDetail;
|
||||
slotLabel: string;
|
||||
portions: number;
|
||||
}
|
||||
|
||||
const PAGE_STYLE: Record<string, string> = {
|
||||
width: '794px',
|
||||
minHeight: '1123px',
|
||||
boxSizing: 'border-box',
|
||||
padding: '48px',
|
||||
fontFamily: 'Inter, Arial, sans-serif',
|
||||
color: '#0f172a',
|
||||
background: '#ffffff',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-meal-plan-day-pdf-export',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-brand-600 hover:underline disabled:opacity-50"
|
||||
[disabled]="phase() !== 'idle'"
|
||||
[title]="'mealPlanner.exportDay' | translate"
|
||||
(click)="handleGenerate()"
|
||||
>
|
||||
@if (phase() !== 'idle') {
|
||||
…
|
||||
} @else {
|
||||
PDF
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (error()) {
|
||||
<p class="mt-1 text-xs text-red-600">{{ error() }}</p>
|
||||
}
|
||||
|
||||
@if (phase() === 'rendering') {
|
||||
<div [id]="renderAreaId">
|
||||
<div class="pdf-page" [style]="pageStyle">
|
||||
<div>
|
||||
<div style="border-bottom: 3px solid #16774c; padding-bottom: 16px; margin-bottom: 24px">
|
||||
<h1 style="font-size: 28px; font-weight: 800; margin: 0">
|
||||
{{ 'pdf.dayPlanTitle' | translate: { date: dateLabel() } }}
|
||||
</h1>
|
||||
</div>
|
||||
<ul style="margin: 0; padding: 0; list-style: none">
|
||||
@for (slot of overviewSlots(); track slot.label) {
|
||||
<li style="display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px">
|
||||
<span style="font-weight: 700; color: #475569">{{ slot.label }}</span>
|
||||
<span>
|
||||
@if (slot.recipeName) {
|
||||
{{ slot.recipeName }} ({{ slot.portions }}×)
|
||||
} @else {
|
||||
{{ 'pdf.emptySlot' | translate }}
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@for (item of exportRecipes(); track item.recipe.id + item.slotLabel) {
|
||||
<div class="pdf-page" [style]="pageStyle">
|
||||
<div>
|
||||
<div style="border-bottom: 3px solid #16774c; padding-bottom: 16px; margin-bottom: 24px">
|
||||
<span style="display: inline-block; background: #d6f1e0; color: #125f3f; border-radius: 999px; padding: 4px 12px; font-size: 12px; font-weight: 700">
|
||||
{{ categoryLabel(item.recipe.mealCategory) }}
|
||||
</span>
|
||||
<h1 style="font-size: 28px; font-weight: 800; margin: 12px 0 0">{{ item.recipe.name }}</h1>
|
||||
<p style="color: #64748b; margin: 6px 0 0; font-size: 14px">
|
||||
{{ 'pdf.plannedMealMeta' | translate: { slot: item.slotLabel, portions: item.portions } }}
|
||||
</p>
|
||||
</div>
|
||||
<h2 style="font-size: 18px; font-weight: 700; margin: 0 0 12px">{{ 'recipes.ingredients' | translate }}</h2>
|
||||
<ul style="margin: 0 0 28px; padding: 0; list-style: none">
|
||||
@for (ing of item.recipe.ingredients; track ing.id) {
|
||||
<li style="display: flex; justify-content: space-between; padding: 7px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px">
|
||||
<span>{{ ing.name }}</span>
|
||||
<span style="color: #64748b">{{ formatIngredientAmount(ing.amountGrams, ing.unit) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<h2 style="font-size: 18px; font-weight: 700; margin: 0 0 12px">{{ 'recipes.preparation' | translate }}</h2>
|
||||
<ol style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.6">
|
||||
@for (step of item.recipe.steps; track step.id) {
|
||||
<li style="margin-bottom: 8px">{{ step.description }}</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pdf-page" [style]="pageStyle">
|
||||
<div style="display: flex; flex-direction: column; min-height: 1027px">
|
||||
<div style="border-bottom: 3px solid #16774c; padding-bottom: 16px; margin-bottom: 24px">
|
||||
<h1 style="font-size: 28px; font-weight: 800; margin: 0">{{ 'pdf.shoppingList' | translate }}</h1>
|
||||
<p style="color: #64748b; margin: 6px 0 0; font-size: 14px">
|
||||
{{ 'pdf.dayShoppingSubtitle' | translate: { date: dateLabel(), count: exportRecipes().length } }}
|
||||
</p>
|
||||
</div>
|
||||
@if (shoppingList().length === 0) {
|
||||
<p style="color: #94a3b8; font-size: 14px">{{ 'pdf.emptyShoppingList' | translate }}</p>
|
||||
} @else {
|
||||
<ul style="flex: 1; margin: 0; padding: 0; list-style: none">
|
||||
@for (item of shoppingList(); track item.name + (item.unit ?? 'g')) {
|
||||
<li style="display: flex; align-items: baseline; padding: 8px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px">
|
||||
<span style="flex: 0 0 240px; font-weight: 600">{{ item.name }}</span>
|
||||
<span style="flex: 0 0 110px">— {{ formatQty(item) }}</span>
|
||||
@if (formatBreak(item); as breakdown) {
|
||||
<span style="color: #94a3b8; font-size: 12px">({{ breakdown }})</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MealPlanDayPdfExportComponent {
|
||||
private readonly recipes = inject(RecipeService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
@Input({ required: true }) date!: string;
|
||||
@Input({ required: true }) entries: MealPlanEntry[] = [];
|
||||
|
||||
readonly pageStyle = PAGE_STYLE;
|
||||
readonly phase = signal<Phase>('idle');
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly exportRecipes = signal<ExportRecipe[]>([]);
|
||||
readonly shoppingList = signal<ShoppingItem[]>([]);
|
||||
readonly dateLabel = signal('');
|
||||
readonly overviewSlots = signal<Array<{ label: string; recipeName: string | null; portions: number | null }>>([]);
|
||||
|
||||
get renderAreaId(): string {
|
||||
return `pdf-render-area-${this.date}`;
|
||||
}
|
||||
|
||||
categoryLabel(cat: MealCategory): string {
|
||||
return this.translate.instant(MEAL_CATEGORY_I18N_KEYS[cat]);
|
||||
}
|
||||
|
||||
formatQty(item: ShoppingItem): string {
|
||||
return formatQuantity(item, this.translate.instant('pdf.toTaste'));
|
||||
}
|
||||
|
||||
formatBreak(item: ShoppingItem): string | null {
|
||||
return formatBreakdown(item);
|
||||
}
|
||||
|
||||
readonly formatIngredientAmount = formatIngredientAmount;
|
||||
|
||||
handleGenerate(): void {
|
||||
if (this.phase() !== 'idle') return;
|
||||
this.error.set(null);
|
||||
this.phase.set('loading');
|
||||
|
||||
const dayEntries = this.entries
|
||||
.filter((e) => e.planDate === this.date)
|
||||
.sort((a, b) => a.mealCategory - b.mealCategory || a.id - b.id);
|
||||
|
||||
this.dateLabel.set(
|
||||
new Date(`${this.date}T12:00:00`).toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
);
|
||||
|
||||
this.overviewSlots.set(
|
||||
PLANNER_CATEGORIES.map((cat) => {
|
||||
const first = dayEntries.find((e) => e.mealCategory === cat);
|
||||
return {
|
||||
label: this.categoryLabel(cat),
|
||||
recipeName: first?.recipeName ?? null,
|
||||
portions: first?.portions ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (dayEntries.length === 0) {
|
||||
this.exportRecipes.set([]);
|
||||
this.shoppingList.set([]);
|
||||
this.phase.set('rendering');
|
||||
void this.runPdf();
|
||||
return;
|
||||
}
|
||||
|
||||
forkJoin(dayEntries.map((entry) => this.recipes.getRecipeById(entry.recipeId))).subscribe({
|
||||
next: (details) => {
|
||||
const scaled = details.map((recipe, index) => {
|
||||
const entry = dayEntries[index];
|
||||
return {
|
||||
slotLabel: this.categoryLabel(entry.mealCategory as MealCategory),
|
||||
portions: entry.portions,
|
||||
recipe: scaleRecipeDetail(recipe, entry.portions),
|
||||
};
|
||||
});
|
||||
this.exportRecipes.set(scaled);
|
||||
this.shoppingList.set(buildShoppingList(scaled.map((s) => s.recipe)));
|
||||
this.phase.set('rendering');
|
||||
void this.runPdf();
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.pdfLoadFailed')));
|
||||
this.phase.set('idle');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async runPdf(): Promise<void> {
|
||||
await document.fonts?.ready?.catch(() => undefined);
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
try {
|
||||
const safeDate = this.date.replace(/[^\d-]/g, '');
|
||||
await generatePdf(this.translate, `dailymeals-${safeDate}.pdf`, this.renderAreaId);
|
||||
} catch (err) {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.pdfGenerateFailed')));
|
||||
} finally {
|
||||
this.phase.set('idle');
|
||||
this.exportRecipes.set([]);
|
||||
this.shoppingList.set([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { Subject, debounceTime, distinctUntilChanged, of, switchMap, takeUntil } from 'rxjs';
|
||||
import { MealPlanService } from '../../core/services/meal-plan.service';
|
||||
import { RecipeService } from '../../core/services/recipe.service';
|
||||
import { DietService } from '../../core/services/diet.service';
|
||||
import { MealPlanEntry } from '../../core/models/meal-plan.models';
|
||||
import { MealCategory, MEAL_CATEGORY_I18N_KEYS, RecipeListItem } from '../../core/models/recipe.models';
|
||||
import {
|
||||
computeDayMealPlanning,
|
||||
estimateEntryCalories,
|
||||
rankRecipeSuggestions,
|
||||
} from '../../core/utils/meal-planner-suggestions.util';
|
||||
import {
|
||||
addDaysIso,
|
||||
endOfWeekIso,
|
||||
startOfWeekIso,
|
||||
todayIso,
|
||||
} from '../../core/models/diet.models';
|
||||
import { ErrorStateComponent } from '../../shared/components/error-state.component';
|
||||
import { SkeletonComponent } from '../../shared/components/skeleton.component';
|
||||
import { MealPlanDayPdfExportComponent } from './meal-plan-day-pdf-export.component';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
|
||||
const PLANNER_CATEGORIES = [
|
||||
MealCategory.Breakfast,
|
||||
MealCategory.SecondBreakfast,
|
||||
MealCategory.Lunch,
|
||||
MealCategory.Dinner,
|
||||
] as const;
|
||||
|
||||
@Component({
|
||||
selector: 'app-meal-planner',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe, FormsModule, TranslatePipe, ErrorStateComponent, SkeletonComponent, MealPlanDayPdfExportComponent],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight sm:text-3xl">{{ 'mealPlanner.title' | translate }}</h1>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">{{ 'mealPlanner.subtitle' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button type="button" class="btn-secondary !py-1.5 !text-sm" (click)="prevWeek()">←</button>
|
||||
<button type="button" class="btn-secondary !py-1.5 !text-sm" (click)="goToday()">{{ 'mealPlanner.today' | translate }}</button>
|
||||
<button type="button" class="btn-secondary !py-1.5 !text-sm" (click)="nextWeek()">→</button>
|
||||
<span class="text-sm font-medium text-slate-600 dark:text-slate-300">{{ weekLabel() }}</span>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<app-skeleton [className]="'h-64 w-full'" />
|
||||
} @else if (error()) {
|
||||
<app-error-state [message]="error()!" [showRetry]="true" (retry)="loadEntries()" />
|
||||
} @else {
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[640px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="p-2 text-left font-semibold text-slate-500">{{ 'common.category' | translate }}</th>
|
||||
@for (day of weekDays(); track day.iso) {
|
||||
<th class="p-2 text-left font-semibold" [class.text-brand-600]="day.iso === todayIso()">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span>{{ day.label }}</span>
|
||||
<app-meal-plan-day-pdf-export [date]="day.iso" [entries]="entries()" />
|
||||
</div>
|
||||
</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (cat of categories; track cat) {
|
||||
<tr class="border-t border-slate-200 dark:border-slate-800">
|
||||
<td class="p-2 font-medium">{{ categoryLabel(cat) | translate }}</td>
|
||||
@for (day of weekDays(); track day.iso) {
|
||||
<td class="p-2 align-top">
|
||||
@for (entry of entriesFor(day.iso, cat); track entry.id) {
|
||||
<div class="mb-1 flex items-start justify-between gap-1 rounded-lg bg-slate-50 px-2 py-1 dark:bg-slate-800/50">
|
||||
<div>
|
||||
<p class="font-medium">{{ entry.recipeName }}</p>
|
||||
<p class="text-xs text-slate-400">{{ entry.portions | number: '1.0-1' }}× · {{ entry.calories ?? '?' }} kcal</p>
|
||||
</div>
|
||||
<button type="button" class="text-slate-400 hover:text-red-500" (click)="removeEntry(entry.id)">✕</button>
|
||||
</div>
|
||||
}
|
||||
<button type="button" class="mt-1 text-xs font-semibold text-brand-600 hover:underline" (click)="openAdd(day.iso, cat)">
|
||||
+ {{ 'mealPlanner.addRecipe' | translate }}
|
||||
</button>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (adding()) {
|
||||
<div class="card space-y-4 p-5">
|
||||
<h2 class="text-lg font-bold">{{ 'mealPlanner.addRecipe' | translate }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ addingLabel() }}</p>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'mealPlanner.dailyTarget' | translate }}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="50"
|
||||
class="input mt-1 w-full"
|
||||
[ngModel]="dailyTarget()"
|
||||
(ngModelChange)="setDailyTarget($event)"
|
||||
/>
|
||||
</label>
|
||||
@if (dayPlanning() && dailyTarget() > 0) {
|
||||
<div class="rounded-xl bg-slate-50 px-3 py-2 text-sm text-slate-600 dark:bg-slate-800/50 dark:text-slate-300">
|
||||
<p>{{ 'mealPlanner.daySummary' | translate: { logged: dayPlanning()!.logged, target: dayPlanning()!.dailyTarget } }}</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ 'mealPlanner.remaining' | translate: { count: remainingCalories() } }}
|
||||
@if (dayPlanning()!.slotTarget > 0) {
|
||||
· {{ 'mealPlanner.slotHint' | translate: { count: dayPlanning()!.slotTarget } }}
|
||||
}
|
||||
@if (dayPlanning()!.pendingCalories > 0) {
|
||||
· {{ 'mealPlanner.includingSelection' | translate: { count: dayPlanning()!.pendingCalories } }}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="text-xs text-slate-500">{{ 'mealPlanner.noSuggestions' | translate }}</p>
|
||||
}
|
||||
@if (suggestions().length > 0) {
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium">{{ 'mealPlanner.suggestionsTitle' | translate }}</p>
|
||||
<ul class="max-h-48 space-y-1 overflow-y-auto rounded-xl border border-brand-200 bg-brand-50/50 p-2 dark:border-brand-900 dark:bg-brand-950/30">
|
||||
@for (item of suggestions(); track item.recipe.id) {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectFromSuggestion(item.recipe)"
|
||||
class="w-full rounded-lg px-3 py-2 text-left text-sm"
|
||||
[class.ring-2]="selectedRecipe()?.id === item.recipe.id"
|
||||
[class.ring-brand-600]="selectedRecipe()?.id === item.recipe.id"
|
||||
[class.ring-inset]="selectedRecipe()?.id === item.recipe.id"
|
||||
>
|
||||
<span class="font-medium">{{ item.recipe.name }}</span>
|
||||
<span class="ml-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ item.recipe.calories }} kcal
|
||||
@if (item.diff > 0) {
|
||||
· ±{{ item.diff }}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'diet.searchRecipe' | translate }}</span>
|
||||
<input class="input mt-1 w-full" [(ngModel)]="recipeSearch" (ngModelChange)="onRecipeSearch($event)" />
|
||||
</label>
|
||||
@if (recipeSearch.trim().length >= 2 && !selectedRecipe() && recipeResults().length > 0) {
|
||||
<ul class="max-h-40 space-y-1 overflow-y-auto rounded-xl border border-slate-200 p-2 dark:border-slate-700">
|
||||
@for (r of recipeResults(); track r.id) {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectFromSearch(r)"
|
||||
class="w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
{{ r.name }}
|
||||
@if (r.calories != null) {
|
||||
<span class="ml-2 text-xs text-slate-500">{{ r.calories }} kcal</span>
|
||||
}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (recipeSearch.trim().length >= 2 && !selectedRecipe() && recipeResults().length === 0) {
|
||||
<p class="text-xs text-slate-500">{{ 'recipes.noMatchingTitle' | translate }}</p>
|
||||
}
|
||||
@if (selectedRecipe()) {
|
||||
<div class="flex items-center justify-between gap-2 rounded-xl border border-brand-300 bg-brand-50 px-3 py-2 text-sm dark:border-brand-800 dark:bg-brand-950/40">
|
||||
<span class="font-medium text-brand-900 dark:text-brand-100">
|
||||
{{ 'mealPlanner.selectedRecipe' | translate: { name: selectedRecipe()!.name } }}
|
||||
@if (estimatedMealCalories() != null) {
|
||||
· {{ estimatedMealCalories() }} kcal
|
||||
} @else if (selectedRecipe()!.calories != null) {
|
||||
· {{ selectedRecipe()!.calories }} kcal
|
||||
}
|
||||
</span>
|
||||
<button type="button" class="text-slate-400 hover:text-red-500" (click)="clearSelection()" [attr.aria-label]="'mealPlanner.clearSelection' | translate">✕</button>
|
||||
</div>
|
||||
}
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'diet.portions' | translate }}</span>
|
||||
<input type="number" min="0.25" step="0.25" class="input mt-1 w-full" [(ngModel)]="portions" />
|
||||
</label>
|
||||
@if (estimatedMealCalories() != null) {
|
||||
<p class="text-sm text-slate-500">{{ 'mealPlanner.estimatedMeal' | translate: { count: estimatedMealCalories() } }}</p>
|
||||
}
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn-secondary" [disabled]="!selectedRecipe() || busy()" (click)="saveEntry()">{{ 'common.save' | translate }}</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancelAdd()">{{ 'common.cancel' | translate }}</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class MealPlannerComponent implements OnInit {
|
||||
private readonly mealPlan = inject(MealPlanService);
|
||||
private readonly recipes = inject(RecipeService);
|
||||
private readonly diet = inject(DietService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
private readonly recipeSearch$ = new Subject<string>();
|
||||
|
||||
readonly categories = PLANNER_CATEGORIES;
|
||||
readonly todayIso = todayIso;
|
||||
|
||||
weekStart = signal(startOfWeekIso(todayIso()));
|
||||
entries = signal<MealPlanEntry[]>([]);
|
||||
loading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
busy = signal(false);
|
||||
adding = signal<{ date: string; category: MealCategory } | null>(null);
|
||||
recipeSearch = '';
|
||||
selectedRecipe = signal<RecipeListItem | null>(null);
|
||||
recipeResults = signal<RecipeListItem[]>([]);
|
||||
categoryRecipes = signal<RecipeListItem[]>([]);
|
||||
calorieGoal = signal(0);
|
||||
dailyTargets = signal<Record<string, number>>({});
|
||||
portions = 1;
|
||||
|
||||
readonly dailyTarget = computed(() => {
|
||||
const ctx = this.adding();
|
||||
if (!ctx) return 0;
|
||||
return this.dailyTargets()[ctx.date] ?? this.calorieGoal();
|
||||
});
|
||||
|
||||
readonly estimatedMealCalories = computed(() =>
|
||||
estimateEntryCalories(this.selectedRecipe(), this.portions),
|
||||
);
|
||||
|
||||
readonly dayPlanning = computed(() => {
|
||||
const ctx = this.adding();
|
||||
if (!ctx) return null;
|
||||
const pendingCalories = this.estimatedMealCalories();
|
||||
const pending =
|
||||
pendingCalories != null && pendingCalories > 0
|
||||
? { category: ctx.category, calories: pendingCalories }
|
||||
: null;
|
||||
return computeDayMealPlanning(ctx.date, ctx.category, this.entries(), this.dailyTarget(), pending);
|
||||
});
|
||||
|
||||
readonly remainingCalories = computed(() => {
|
||||
const planning = this.dayPlanning();
|
||||
return planning ? Math.max(0, planning.remaining) : 0;
|
||||
});
|
||||
|
||||
readonly suggestions = computed(() => {
|
||||
const ctx = this.adding();
|
||||
const planning = this.dayPlanning();
|
||||
if (!ctx || !planning) return [];
|
||||
return rankRecipeSuggestions(this.categoryRecipes(), ctx.category, planning.slotTarget);
|
||||
});
|
||||
|
||||
readonly weekDays = computed(() => {
|
||||
const start = this.weekStart();
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const iso = addDaysIso(start, i);
|
||||
const d = new Date(`${iso}T12:00:00`);
|
||||
return {
|
||||
iso,
|
||||
label: d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
readonly weekLabel = computed(() => {
|
||||
const start = this.weekStart();
|
||||
const end = endOfWeekIso(start);
|
||||
return `${start} — ${end}`;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadEntries();
|
||||
this.diet.getGoals().subscribe((g) => this.calorieGoal.set(g.calorieGoal ?? 0));
|
||||
this.recipeSearch$
|
||||
.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
switchMap((q) => {
|
||||
if (q.trim().length >= 2) {
|
||||
return this.recipes.getRecipes({ search: q });
|
||||
}
|
||||
return of([]);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe((items) => this.recipeResults.set(items));
|
||||
}
|
||||
|
||||
categoryLabel(cat: MealCategory): string {
|
||||
return MEAL_CATEGORY_I18N_KEYS[cat];
|
||||
}
|
||||
|
||||
entriesFor(date: string, category: MealCategory): MealPlanEntry[] {
|
||||
return this.entries().filter((e) => e.planDate === date && e.mealCategory === category);
|
||||
}
|
||||
|
||||
loadEntries(): void {
|
||||
const from = this.weekStart();
|
||||
const to = endOfWeekIso(from);
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.mealPlan.getEntries(from, to).subscribe({
|
||||
next: (items) => {
|
||||
this.entries.set(items);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.loadMealPlanFailed')));
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
prevWeek(): void {
|
||||
this.weekStart.update((s) => addDaysIso(s, -7));
|
||||
this.loadEntries();
|
||||
}
|
||||
|
||||
nextWeek(): void {
|
||||
this.weekStart.update((s) => addDaysIso(s, 7));
|
||||
this.loadEntries();
|
||||
}
|
||||
|
||||
goToday(): void {
|
||||
this.weekStart.set(startOfWeekIso(todayIso()));
|
||||
this.loadEntries();
|
||||
}
|
||||
|
||||
openAdd(date: string, category: MealCategory): void {
|
||||
this.adding.set({ date, category });
|
||||
this.recipeSearch = '';
|
||||
this.selectedRecipe.set(null);
|
||||
this.recipeResults.set([]);
|
||||
this.portions = 1;
|
||||
this.recipes.getRecipes({ category }).subscribe((items) => this.categoryRecipes.set(items));
|
||||
}
|
||||
|
||||
setDailyTarget(value: number): void {
|
||||
const ctx = this.adding();
|
||||
if (!ctx) return;
|
||||
const parsed = Number.isFinite(value) ? value : 0;
|
||||
this.dailyTargets.update((prev) => ({ ...prev, [ctx.date]: parsed }));
|
||||
}
|
||||
|
||||
cancelAdd(): void {
|
||||
this.adding.set(null);
|
||||
}
|
||||
|
||||
addingLabel(): string {
|
||||
const ctx = this.adding();
|
||||
if (!ctx) return '';
|
||||
return `${ctx.date} · ${this.translate.instant(this.categoryLabel(ctx.category))}`;
|
||||
}
|
||||
|
||||
onRecipeSearch(value: string): void {
|
||||
this.recipeSearch = value;
|
||||
this.recipeSearch$.next(value);
|
||||
}
|
||||
|
||||
selectFromSuggestion(r: RecipeListItem): void {
|
||||
this.selectedRecipe.set(r);
|
||||
}
|
||||
|
||||
selectFromSearch(r: RecipeListItem): void {
|
||||
this.selectedRecipe.set(r);
|
||||
this.recipeSearch = '';
|
||||
this.recipeResults.set([]);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedRecipe.set(null);
|
||||
}
|
||||
|
||||
saveEntry(): void {
|
||||
const ctx = this.adding();
|
||||
const recipe = this.selectedRecipe();
|
||||
if (!ctx || !recipe) return;
|
||||
this.busy.set(true);
|
||||
this.mealPlan
|
||||
.upsertEntry({
|
||||
planDate: ctx.date,
|
||||
mealCategory: ctx.category,
|
||||
recipeId: recipe.id,
|
||||
portions: this.portions,
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.busy.set(false);
|
||||
this.adding.set(null);
|
||||
this.loadEntries();
|
||||
},
|
||||
error: () => this.busy.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
removeEntry(id: number): void {
|
||||
this.mealPlan.deleteEntry(id).subscribe(() => this.loadEntries());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-not-found',
|
||||
standalone: true,
|
||||
imports: [RouterLink, TranslatePipe],
|
||||
template: `
|
||||
<div class="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||
<p class="text-6xl font-black text-brand-600">404</p>
|
||||
<h1 class="mt-4 text-xl font-bold">{{ 'notFound.title' | translate }}</h1>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">{{ 'notFound.description' | translate }}</p>
|
||||
<a routerLink="/" class="btn-primary mt-6">{{ 'notFound.backToDashboard' | translate }}</a>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class NotFoundComponent {}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { Component, Input, OnDestroy, effect, inject, signal } from '@angular/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { RecipeService } from '../../core/services/recipe.service';
|
||||
import {
|
||||
MealCategory,
|
||||
MEAL_CATEGORY_I18N_KEYS,
|
||||
RecipeDetail,
|
||||
} from '../../core/models/recipe.models';
|
||||
import {
|
||||
buildShoppingList,
|
||||
formatBreakdown,
|
||||
formatQuantity,
|
||||
generatePdf,
|
||||
ShoppingItem,
|
||||
} from '../../core/utils/pdf-export.util';
|
||||
import { formatIngredientAmount } from '../../core/utils/ingredient-quantity.util';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
|
||||
type Phase = 'idle' | 'loading' | 'rendering';
|
||||
|
||||
const PAGE_STYLE: Record<string, string> = {
|
||||
width: '794px',
|
||||
minHeight: '1123px',
|
||||
boxSizing: 'border-box',
|
||||
padding: '48px',
|
||||
fontFamily: 'Inter, Arial, sans-serif',
|
||||
color: '#0f172a',
|
||||
background: '#ffffff',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-pdf-generator',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
(click)="handleGenerate()"
|
||||
[disabled]="disabled || selectedIds.length === 0 || phase() !== 'idle'"
|
||||
class="btn-primary"
|
||||
>
|
||||
@if (phase() !== 'idle') {
|
||||
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
} @else {
|
||||
↓
|
||||
}
|
||||
{{
|
||||
phase() === 'loading'
|
||||
? ('pdf.preparing' | translate)
|
||||
: phase() === 'rendering'
|
||||
? ('pdf.generating' | translate)
|
||||
: ('pdf.generate' | translate)
|
||||
}}
|
||||
</button>
|
||||
|
||||
@if (error()) {
|
||||
<p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ error() }}</p>
|
||||
}
|
||||
|
||||
@if (phase() === 'rendering') {
|
||||
<div id="pdf-render-area">
|
||||
@for (recipe of recipes(); track recipe.id) {
|
||||
<div class="pdf-page" [style]="pageStyle">
|
||||
<div>
|
||||
<div style="border-bottom: 3px solid #16774c; padding-bottom: 16px; margin-bottom: 24px">
|
||||
<span
|
||||
style="
|
||||
display: inline-block;
|
||||
background: #d6f1e0;
|
||||
color: #125f3f;
|
||||
border-radius: 999px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
"
|
||||
>
|
||||
{{ categoryLabel(recipe.mealCategory) }}
|
||||
</span>
|
||||
<h1 style="font-size: 28px; font-weight: 800; margin: 12px 0 0">{{ recipe.name }}</h1>
|
||||
@if (recipe.prepTimeMinutes != null) {
|
||||
<p style="color: #64748b; margin: 6px 0 0; font-size: 14px">
|
||||
{{ 'pdf.prepTime' | translate: { count: recipe.prepTimeMinutes } }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 28px">
|
||||
@for (macro of macros(recipe); track macro.label) {
|
||||
<div
|
||||
style="
|
||||
flex: 1;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<div style="font-size: 20px; font-weight: 800">
|
||||
{{ macro.value != null ? macro.value + ' ' + macro.suffix : '—' }}
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
"
|
||||
>
|
||||
{{ macro.label }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h2 style="font-size: 18px; font-weight: 700; margin: 0 0 12px">{{ 'recipes.ingredients' | translate }}</h2>
|
||||
<ul style="margin: 0 0 28px; padding: 0; list-style: none">
|
||||
@for (ing of recipe.ingredients; track ing.id) {
|
||||
<li
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 14px;
|
||||
"
|
||||
>
|
||||
<span>{{ ing.name }}</span>
|
||||
<span style="color: #64748b">{{ formatIngredientAmount(ing.amountGrams, ing.unit) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<h2 style="font-size: 18px; font-weight: 700; margin: 0 0 12px">{{ 'recipes.preparation' | translate }}</h2>
|
||||
<ol style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.6">
|
||||
@for (step of recipe.steps; track step.id) {
|
||||
<li style="margin-bottom: 8px">{{ step.description }}</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pdf-page" [style]="pageStyle">
|
||||
<div style="display: flex; flex-direction: column; min-height: 1027px">
|
||||
<div style="border-bottom: 3px solid #16774c; padding-bottom: 16px; margin-bottom: 24px">
|
||||
<h1 style="font-size: 28px; font-weight: 800; margin: 0">{{ 'pdf.shoppingList' | translate }}</h1>
|
||||
<p style="color: #64748b; margin: 6px 0 0; font-size: 14px">
|
||||
{{ generatedOnLabel() }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul style="flex: 1; margin: 0; padding: 0; list-style: none">
|
||||
@for (item of shoppingList(); track itemKey(item)) {
|
||||
<li
|
||||
style="
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 14px;
|
||||
"
|
||||
>
|
||||
<span style="flex: 0 0 240px; font-weight: 600">{{ item.name }}</span>
|
||||
<span style="flex: 0 0 110px; color: #0f172a">— {{ quantityLabel(item) }}</span>
|
||||
@if (breakdownLabel(item); as breakdown) {
|
||||
<span style="color: #94a3b8; font-size: 12px">({{ breakdown }})</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div style="margin-top: 32px; border-top: 1px solid #e2e8f0; padding-top: 16px">
|
||||
<h3
|
||||
style="
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
margin: 0 0 10px;
|
||||
"
|
||||
>
|
||||
{{ 'pdf.recipesInExport' | translate }}
|
||||
</h3>
|
||||
<ol style="margin: 0; padding-left: 20px; font-size: 12px; line-height: 1.7; color: #475569">
|
||||
@for (r of recipes(); track r.id) {
|
||||
<li>
|
||||
{{ r.name }} — {{ categoryLabel(r.mealCategory) }}
|
||||
@if (r.calories != null) {
|
||||
· {{ r.calories }} kcal
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class PdfGeneratorComponent implements OnDestroy {
|
||||
private readonly recipeService = inject(RecipeService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private renderCancelled = false;
|
||||
|
||||
@Input() selectedIds: number[] = [];
|
||||
@Input() disabled = false;
|
||||
|
||||
readonly phase = signal<Phase>('idle');
|
||||
readonly recipes = signal<RecipeDetail[]>([]);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly pageStyle = PAGE_STYLE;
|
||||
readonly formatIngredientAmount = formatIngredientAmount;
|
||||
|
||||
readonly shoppingList = signal<ShoppingItem[]>([]);
|
||||
private generatedOn = '';
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.phase() === 'rendering' && this.recipes().length > 0) {
|
||||
void this.runPdfGeneration();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.renderCancelled = true;
|
||||
}
|
||||
|
||||
categoryLabel(category: MealCategory): string {
|
||||
return this.translate.instant(MEAL_CATEGORY_I18N_KEYS[category]);
|
||||
}
|
||||
|
||||
macros(recipe: RecipeDetail) {
|
||||
return [
|
||||
{ label: this.translate.instant('recipes.calories'), value: recipe.calories, suffix: 'kcal' },
|
||||
{ label: this.translate.instant('recipes.protein'), value: recipe.protein, suffix: 'g' },
|
||||
{ label: this.translate.instant('recipes.fat'), value: recipe.fat, suffix: 'g' },
|
||||
{ label: this.translate.instant('recipes.carbs'), value: recipe.carbs, suffix: 'g' },
|
||||
];
|
||||
}
|
||||
|
||||
quantityLabel(item: ShoppingItem): string {
|
||||
return formatQuantity(item, this.translate.instant('pdf.toTaste'));
|
||||
}
|
||||
|
||||
breakdownLabel(item: ShoppingItem): string | null {
|
||||
return formatBreakdown(item);
|
||||
}
|
||||
|
||||
itemKey(item: ShoppingItem): string {
|
||||
return `${item.name}-${item.unit ?? 'g'}-${item.quantified}`;
|
||||
}
|
||||
|
||||
generatedOnLabel(): string {
|
||||
const count = this.recipes().length;
|
||||
const lang = this.translate.getCurrentLang() ?? this.translate.getFallbackLang() ?? 'en';
|
||||
let suffix: string;
|
||||
if (lang.startsWith('pl')) {
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (count === 1) suffix = 'one';
|
||||
else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) suffix = 'few';
|
||||
else suffix = 'many';
|
||||
} else {
|
||||
suffix = count === 1 ? 'one' : 'other';
|
||||
}
|
||||
return this.translate.instant(`pdf.generatedOn_${suffix}`, {
|
||||
date: this.generatedOn,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
handleGenerate(): void {
|
||||
if (this.selectedIds.length === 0 || this.phase() !== 'idle') return;
|
||||
this.error.set(null);
|
||||
this.phase.set('loading');
|
||||
|
||||
forkJoin(this.selectedIds.map((id) => this.recipeService.getRecipeById(id))).subscribe({
|
||||
next: (details) => {
|
||||
this.generatedOn = new Date().toLocaleDateString(
|
||||
this.translate.getCurrentLang() ?? 'en', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
this.shoppingList.set(buildShoppingList(details));
|
||||
this.recipes.set(details);
|
||||
this.phase.set('rendering');
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.pdfLoadFailed')));
|
||||
this.phase.set('idle');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async runPdfGeneration(): Promise<void> {
|
||||
this.renderCancelled = false;
|
||||
await document.fonts?.ready?.catch(() => undefined);
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
if (this.renderCancelled) return;
|
||||
|
||||
try {
|
||||
await generatePdf(this.translate, 'dailymeals-plan.pdf');
|
||||
} catch (err) {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.pdfGenerateFailed')));
|
||||
} finally {
|
||||
if (!this.renderCancelled) {
|
||||
this.phase.set('idle');
|
||||
this.recipes.set([]);
|
||||
this.shoppingList.set([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Location } from '@angular/common';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { RecipeService } from '../../core/services/recipe.service';
|
||||
import { RecipeDetail } from '../../core/models/recipe.models';
|
||||
import { RecipeDetailViewComponent } from '../recipes/recipe-detail-view.component';
|
||||
import { ErrorStateComponent } from '../../shared/components/error-state.component';
|
||||
import { SkeletonComponent } from '../../shared/components/skeleton.component';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-detail-page',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe, RecipeDetailViewComponent, ErrorStateComponent, SkeletonComponent],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<button type="button" (click)="goBack()" class="btn-secondary">
|
||||
← {{ 'common.back' | translate }}
|
||||
</button>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="space-y-6">
|
||||
<app-skeleton className="h-6 w-24" />
|
||||
<app-skeleton className="h-9 w-2/3" />
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
@for (i of [0, 1, 2, 3]; track i) {
|
||||
<app-skeleton className="h-20" />
|
||||
}
|
||||
</div>
|
||||
<app-skeleton className="h-64" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<app-error-state [message]="error()!" [showRetry]="true" (retry)="load()" />
|
||||
}
|
||||
|
||||
@if (!loading() && !error() && recipe()) {
|
||||
<app-recipe-detail-view [recipe]="recipe()!" />
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RecipeDetailPageComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly location = inject(Location);
|
||||
private readonly recipeService = inject(RecipeService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly recipe = signal<RecipeDetail | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
const id = Number(this.route.snapshot.paramMap.get('id'));
|
||||
if (!Number.isFinite(id)) {
|
||||
this.error.set(this.translate.instant('errors.loadRecipeFailed'));
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.recipeService.getRecipeById(id).subscribe({
|
||||
next: (data) => {
|
||||
this.recipe.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.loadRecipeFailed')));
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.location.back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { switchMap } from 'rxjs';
|
||||
import { RecipeGeneratorService } from '../../core/services/recipe-generator.service';
|
||||
import {
|
||||
CommitRecipeResult,
|
||||
GeneratedRecipeDraft,
|
||||
RecipeGeneratorConstraints,
|
||||
} from '../../core/models/generator.models';
|
||||
import { MEAL_CATEGORY_I18N_KEYS, MealCategory } from '../../core/models/recipe.models';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
|
||||
type Step = 'constraints' | 'review' | 'done';
|
||||
|
||||
const MEAL_CATEGORIES = [
|
||||
MealCategory.Breakfast,
|
||||
MealCategory.SecondBreakfast,
|
||||
MealCategory.Lunch,
|
||||
MealCategory.Dinner,
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-generator',
|
||||
standalone: true,
|
||||
imports: [FormsModule, RouterLink, TranslatePipe],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight sm:text-3xl">{{ 'generators.recipe.title' | translate }}</h1>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">{{ 'generators.recipe.subtitle' | translate }}</p>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div role="alert" class="rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
|
||||
{{ error() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (step() === 'constraints') {
|
||||
<section class="card space-y-4 p-6">
|
||||
<h2 class="text-lg font-bold">{{ 'generators.recipe.constraintsTitle' | translate }}</h2>
|
||||
<label class="block text-sm">
|
||||
{{ 'generators.recipe.prompt' | translate }}
|
||||
<textarea
|
||||
class="input mt-1 w-full"
|
||||
rows="3"
|
||||
[(ngModel)]="constraints.prompt"
|
||||
[placeholder]="'generators.recipe.promptPlaceholder' | translate"
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
{{ 'generators.recipe.targetCalories' | translate }}
|
||||
<input
|
||||
type="number"
|
||||
min="50"
|
||||
max="3000"
|
||||
class="input mt-1 w-full"
|
||||
[(ngModel)]="constraints.targetCalories"
|
||||
/>
|
||||
<span class="mt-1 block text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ 'generators.recipe.targetCaloriesHint' | translate }}
|
||||
</span>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
{{ 'generators.recipe.recipeCount' | translate }}
|
||||
<input type="number" min="1" max="8" class="input mt-1 w-full" [(ngModel)]="recipeCount" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
{{ 'common.category' | translate }}
|
||||
<select class="select mt-1 w-full" [(ngModel)]="constraints.mealCategory">
|
||||
@for (cat of mealCategories; track cat) {
|
||||
<option [ngValue]="cat">{{ categoryLabel(cat) | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
{{ 'generators.recipe.maxPrep' | translate }}
|
||||
<input type="number" class="input mt-1 w-full" [(ngModel)]="constraints.maxPrepTimeMinutes" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
{{ 'generators.recipe.exclusions' | translate }}
|
||||
<input class="input mt-1 w-full" [(ngModel)]="constraints.exclusions" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
[disabled]="pending() || !constraints.prompt.trim()"
|
||||
(click)="runGenerate()"
|
||||
>
|
||||
{{ 'generators.recipe.generate' | translate }}
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (step() === 'review' && drafts().length > 0) {
|
||||
<section class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ 'generators.recipe.reviewHint' | translate: { count: drafts().length } }}
|
||||
</p>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" [checked]="allSelected()" (change)="toggleAll()" />
|
||||
{{ 'generators.recipe.selectAll' | translate }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@for (draft of drafts(); track draft.draftId) {
|
||||
<div class="card space-y-3 p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<label class="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1"
|
||||
[checked]="selectedIds().has(draft.draftId)"
|
||||
(change)="toggleSelected(draft.draftId)"
|
||||
/>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{{ draft.name }}</h2>
|
||||
@if (draft.calories != null) {
|
||||
<p
|
||||
class="text-sm"
|
||||
[class.text-amber-700]="constraints.targetCalories && !isWithinCalorieTarget(draft.calories)"
|
||||
[class.text-slate-500]="!constraints.targetCalories || isWithinCalorieTarget(draft.calories)"
|
||||
[class.dark:text-slate-400]="!constraints.targetCalories || isWithinCalorieTarget(draft.calories)"
|
||||
>
|
||||
@if (constraints.targetCalories) {
|
||||
{{
|
||||
'generators.recipe.caloriesWithTarget'
|
||||
| translate: { actual: draft.calories, target: constraints.targetCalories }
|
||||
}}
|
||||
} @else {
|
||||
{{ draft.calories }} kcal
|
||||
}
|
||||
· P {{ draft.protein ?? 0 }}g · F {{ draft.fat ?? 0 }}g · C {{ draft.carbs ?? 0 }}g
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
<button type="button" class="btn-secondary !px-2 !py-1 text-xs" [disabled]="pending()" (click)="removeDraft(draft.draftId)">
|
||||
{{ 'generators.recipe.remove' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (draft.unlinkedIngredientCount > 0) {
|
||||
<p class="text-sm text-amber-700">
|
||||
{{ 'manage.unlinkedIngredientsWarning' | translate: { count: draft.unlinkedIngredientCount } }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3 class="font-semibold">{{ 'recipes.ingredients' | translate }}</h3>
|
||||
<ul class="space-y-1 text-sm">
|
||||
@for (ing of draft.ingredients; track $index) {
|
||||
<li [class.text-amber-700]="!ing.isLinked && ing.amountGrams">
|
||||
{{ ing.name }} — {{ ing.amountGrams ?? '?' }}g {{ ing.unit ?? '' }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<h3 class="font-semibold">{{ 'manage.preparationSteps' | translate }}</h3>
|
||||
<ol class="list-decimal space-y-1 pl-5 text-sm">
|
||||
@for (s of draft.steps; track s.stepNumber) {
|
||||
<li>{{ s.description }}</li>
|
||||
}
|
||||
</ol>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn-secondary !px-2 !py-1 text-xs" [disabled]="pending()" (click)="regen(draft.draftId, 'ingredients')">
|
||||
{{ 'generators.recipe.regenIngredients' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary !px-2 !py-1 text-xs" [disabled]="pending()" (click)="regen(draft.draftId, 'steps')">
|
||||
{{ 'generators.recipe.regenSteps' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary !px-2 !py-1 text-xs" [disabled]="pending()" (click)="regen(draft.draftId, 'full')">
|
||||
{{ 'generators.recipe.regenFull' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn-secondary" (click)="step.set('constraints')">
|
||||
{{ 'common.back' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-primary" [disabled]="pending() || selectedIds().size === 0" (click)="saveSelected()">
|
||||
{{ 'generators.recipe.saveSelected' | translate: { count: selectedIds().size } }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (step() === 'done' && savedRecipes().length > 0) {
|
||||
<div class="card space-y-3 p-6">
|
||||
<p class="font-semibold">{{ 'generators.recipe.doneMultiple' | translate: { count: savedRecipes().length } }}</p>
|
||||
<ul class="space-y-2">
|
||||
@for (r of savedRecipes(); track r.recipeId) {
|
||||
<li>
|
||||
<a [routerLink]="['/recipes', r.recipeId]" class="text-brand-600 hover:underline">{{ r.recipeName }}</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<button type="button" class="btn-secondary" (click)="reset()">
|
||||
{{ 'generators.recipe.generateMore' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RecipeGeneratorComponent {
|
||||
private readonly recipeGenerator = inject(RecipeGeneratorService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly mealCategories = MEAL_CATEGORIES;
|
||||
readonly step = signal<Step>('constraints');
|
||||
readonly sessionId = signal<number | null>(null);
|
||||
readonly drafts = signal<GeneratedRecipeDraft[]>([]);
|
||||
readonly selectedIds = signal<Set<string>>(new Set());
|
||||
readonly savedRecipes = signal<CommitRecipeResult[]>([]);
|
||||
readonly pending = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
recipeCount = 3;
|
||||
constraints: RecipeGeneratorConstraints = {
|
||||
prompt: '',
|
||||
mealCategory: MealCategory.Breakfast,
|
||||
targetCalories: null,
|
||||
calorieToleranceKcal: 10,
|
||||
maxPrepTimeMinutes: 30,
|
||||
cuisineStyle: '',
|
||||
dietStyle: '',
|
||||
exclusions: '',
|
||||
};
|
||||
|
||||
isWithinCalorieTarget(calories?: number | null): boolean {
|
||||
if (!this.constraints.targetCalories || calories == null) return true;
|
||||
const tolerance = this.constraints.calorieToleranceKcal ?? 10;
|
||||
return Math.abs(calories - this.constraints.targetCalories) <= tolerance;
|
||||
}
|
||||
|
||||
readonly allSelected = computed(() => {
|
||||
const list = this.drafts();
|
||||
const selected = this.selectedIds();
|
||||
return list.length > 0 && list.every((d) => selected.has(d.draftId));
|
||||
});
|
||||
|
||||
categoryLabel(cat: MealCategory): string {
|
||||
return MEAL_CATEGORY_I18N_KEYS[cat];
|
||||
}
|
||||
|
||||
private syncDrafts(next: GeneratedRecipeDraft[]): void {
|
||||
this.drafts.set(next);
|
||||
this.selectedIds.set(new Set(next.map((d) => d.draftId)));
|
||||
}
|
||||
|
||||
runGenerate(): void {
|
||||
this.error.set(null);
|
||||
this.pending.set(true);
|
||||
const count = Math.min(8, Math.max(1, this.recipeCount || 3));
|
||||
const existingId = this.sessionId();
|
||||
const request$ = existingId
|
||||
? this.recipeGenerator.generateDrafts(existingId, count)
|
||||
: this.recipeGenerator
|
||||
.createSession(this.constraints)
|
||||
.pipe(
|
||||
switchMap((created) => {
|
||||
this.sessionId.set(created.id);
|
||||
return this.recipeGenerator.generateDrafts(created.id, count);
|
||||
}),
|
||||
);
|
||||
|
||||
request$.subscribe({
|
||||
next: (session) => {
|
||||
if (!this.sessionId()) this.sessionId.set(session.id);
|
||||
this.syncDrafts(session.drafts ?? []);
|
||||
this.step.set('review');
|
||||
this.pending.set(false);
|
||||
},
|
||||
error: (e) => {
|
||||
this.error.set(extractApiError(e, this.translate.instant('generators.recipe.error')));
|
||||
this.pending.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
regen(draftId: string, mode: 'ingredients' | 'steps' | 'full'): void {
|
||||
const id = this.sessionId();
|
||||
if (!id) return;
|
||||
this.pending.set(true);
|
||||
this.error.set(null);
|
||||
this.recipeGenerator.regeneratePart(id, draftId, mode, this.constraints.prompt).subscribe({
|
||||
next: (session) => {
|
||||
this.syncDrafts(session.drafts ?? []);
|
||||
this.pending.set(false);
|
||||
},
|
||||
error: (e) => {
|
||||
this.error.set(extractApiError(e, this.translate.instant('generators.recipe.error')));
|
||||
this.pending.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
removeDraft(draftId: string): void {
|
||||
const id = this.sessionId();
|
||||
if (!id) return;
|
||||
this.pending.set(true);
|
||||
this.error.set(null);
|
||||
this.recipeGenerator.removeDraft(id, draftId).subscribe({
|
||||
next: (session) => {
|
||||
const next = session.drafts ?? [];
|
||||
this.syncDrafts(next);
|
||||
if (next.length === 0) this.step.set('constraints');
|
||||
this.pending.set(false);
|
||||
},
|
||||
error: (e) => {
|
||||
this.error.set(extractApiError(e, this.translate.instant('generators.recipe.error')));
|
||||
this.pending.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
saveSelected(): void {
|
||||
const id = this.sessionId();
|
||||
const ids = [...this.selectedIds()];
|
||||
if (!id || ids.length === 0) return;
|
||||
this.pending.set(true);
|
||||
this.error.set(null);
|
||||
this.recipeGenerator.commitRecipes(id, ids).subscribe({
|
||||
next: (result) => {
|
||||
this.savedRecipes.set(result.saved);
|
||||
const remaining = this.drafts().filter((d) => !this.selectedIds().has(d.draftId));
|
||||
if (remaining.length === 0) {
|
||||
this.step.set('done');
|
||||
} else {
|
||||
this.syncDrafts(remaining);
|
||||
}
|
||||
this.pending.set(false);
|
||||
},
|
||||
error: (e) => {
|
||||
this.error.set(extractApiError(e, this.translate.instant('generators.recipe.error')));
|
||||
this.pending.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleSelected(draftId: string): void {
|
||||
this.selectedIds.update((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(draftId)) next.delete(draftId);
|
||||
else next.add(draftId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
toggleAll(): void {
|
||||
if (this.allSelected()) {
|
||||
this.selectedIds.set(new Set());
|
||||
} else {
|
||||
this.selectedIds.set(new Set(this.drafts().map((d) => d.draftId)));
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.step.set('constraints');
|
||||
this.sessionId.set(null);
|
||||
this.drafts.set([]);
|
||||
this.selectedIds.set(new Set());
|
||||
this.savedRecipes.set([]);
|
||||
this.error.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { RecipeListItem } from '../../core/models/recipe.models';
|
||||
import { CategoryBadgeComponent } from '../../shared/components/category-badge.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-card',
|
||||
standalone: true,
|
||||
imports: [RouterLink, TranslatePipe, CategoryBadgeComponent],
|
||||
template: `
|
||||
<div class="card group relative flex flex-col p-5 hover:shadow-md">
|
||||
@if (selectable) {
|
||||
<label class="absolute right-4 top-4 z-10 flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="selected"
|
||||
(change)="onToggle()"
|
||||
class="h-5 w-5 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
|
||||
[attr.aria-label]="'recipes.selectRecipe' | translate: { name: recipe.name }"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
|
||||
<a [routerLink]="['/recipes', recipe.id]" class="flex flex-1 flex-col">
|
||||
@if (showCategory) {
|
||||
<div class="mb-2 pr-8">
|
||||
<app-category-badge [category]="recipe.mealCategory" />
|
||||
</div>
|
||||
}
|
||||
<h3 class="pr-8 text-base font-semibold leading-snug group-hover:text-brand-600">
|
||||
{{ recipe.name }}
|
||||
</h3>
|
||||
|
||||
<div class="mt-auto flex flex-wrap items-center gap-2 pt-5 text-sm text-slate-500 dark:text-slate-400">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-lg bg-slate-100 px-2.5 py-1 dark:bg-slate-800">
|
||||
<span class="text-orange-500" aria-hidden="true">🔥</span>
|
||||
@if (recipe.calories != null) {
|
||||
{{ 'recipes.kcal' | translate: { count: recipe.calories } }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-lg bg-slate-100 px-2.5 py-1 dark:bg-slate-800">
|
||||
<span class="text-sky-500" aria-hidden="true">⏱</span>
|
||||
@if (recipe.prepTimeMinutes != null) {
|
||||
{{ 'recipes.minutes' | translate: { count: recipe.prepTimeMinutes } }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RecipeCardComponent {
|
||||
@Input({ required: true }) recipe!: RecipeListItem;
|
||||
@Input() selectable = false;
|
||||
@Input() selected = false;
|
||||
@Input() showCategory = false;
|
||||
@Output() toggleSelected = new EventEmitter<number>();
|
||||
|
||||
onToggle(): void {
|
||||
this.toggleSelected.emit(this.recipe.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Component, Input, computed, signal } from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { RecipeDetail } from '../../core/models/recipe.models';
|
||||
import { CategoryBadgeComponent } from '../../shared/components/category-badge.component';
|
||||
import { nextSort, sortBy, sortIndicator, type SortDirection } from '../../core/utils/sort.util';
|
||||
|
||||
type IngredientSortColumn = 'name' | 'amount';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-detail-view',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe, CategoryBadgeComponent],
|
||||
template: `
|
||||
<article class="space-y-8">
|
||||
<header class="space-y-3">
|
||||
<app-category-badge [category]="recipe.mealCategory" />
|
||||
<h1 class="text-3xl font-extrabold tracking-tight">{{ recipe.name }}</h1>
|
||||
@if (recipe.prepTimeMinutes != null) {
|
||||
<p class="inline-flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
<span aria-hidden="true">⏱</span>
|
||||
{{ 'recipes.prepTime' | translate: { count: recipe.prepTimeMinutes } }}
|
||||
</p>
|
||||
}
|
||||
</header>
|
||||
|
||||
<section class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
|
||||
<p class="text-2xl font-extrabold text-orange-500">
|
||||
{{ recipe.calories ?? '—' }}
|
||||
@if (recipe.calories != null) {
|
||||
<span class="text-sm font-semibold"> kcal</span>
|
||||
}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
{{ 'recipes.calories' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
|
||||
<p class="text-2xl font-extrabold text-brand-600">
|
||||
{{ recipe.protein ?? '—' }}
|
||||
@if (recipe.protein != null) {
|
||||
<span class="text-sm font-semibold"> g</span>
|
||||
}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
{{ 'recipes.protein' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
|
||||
<p class="text-2xl font-extrabold text-amber-500">
|
||||
{{ recipe.fat ?? '—' }}
|
||||
@if (recipe.fat != null) {
|
||||
<span class="text-sm font-semibold"> g</span>
|
||||
}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
{{ 'recipes.fat' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
|
||||
<p class="text-2xl font-extrabold text-sky-500">
|
||||
{{ recipe.carbs ?? '—' }}
|
||||
@if (recipe.carbs != null) {
|
||||
<span class="text-sm font-semibold"> g</span>
|
||||
}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
{{ 'recipes.carbs' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-8 lg:grid-cols-[1fr_1.4fr]">
|
||||
<section>
|
||||
<h2 class="text-lg font-bold">{{ 'recipes.ingredients' | translate }}</h2>
|
||||
@if (recipe.ingredients.length === 0) {
|
||||
<p class="mt-3 text-sm text-slate-500">{{ 'recipes.noIngredients' | translate }}</p>
|
||||
} @else {
|
||||
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-800">
|
||||
<div class="grid grid-cols-[1fr_auto] border-b border-slate-200 bg-slate-50 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:bg-slate-800/50">
|
||||
<button type="button" class="text-left hover:text-slate-700 dark:hover:text-slate-200" (click)="toggleIngredientSort('name')">
|
||||
{{ 'ingredientCatalog.columns.name' | translate }}{{ ingredientIndicator('name') }}
|
||||
</button>
|
||||
<button type="button" class="text-right hover:text-slate-700 dark:hover:text-slate-200" (click)="toggleIngredientSort('amount')">
|
||||
{{ 'diet.grams' | translate }}{{ ingredientIndicator('amount') }}
|
||||
</button>
|
||||
</div>
|
||||
<ul class="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
@for (ing of sortedIngredients(); track ing.id) {
|
||||
<li class="grid grid-cols-[1fr_auto] items-center px-4 py-3 text-sm">
|
||||
<span>{{ ing.name }}</span>
|
||||
<span class="font-medium text-slate-500 dark:text-slate-400">
|
||||
{{ formatAmount(ing.amountGrams, ing.unit) }}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-lg font-bold">{{ 'recipes.preparation' | translate }}</h2>
|
||||
@if (recipe.steps.length === 0) {
|
||||
<p class="mt-3 text-sm text-slate-500">{{ 'recipes.noSteps' | translate }}</p>
|
||||
} @else {
|
||||
<ol class="mt-4 space-y-4">
|
||||
@for (step of recipe.steps; track step.id) {
|
||||
<li class="flex gap-4">
|
||||
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-brand-600 text-sm font-bold text-white">
|
||||
{{ step.stepNumber }}
|
||||
</span>
|
||||
<p class="pt-1 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
`,
|
||||
})
|
||||
export class RecipeDetailViewComponent {
|
||||
@Input({ required: true }) recipe!: RecipeDetail;
|
||||
|
||||
readonly ingredientSort = signal<{ column: IngredientSortColumn; direction: SortDirection }>({
|
||||
column: 'name',
|
||||
direction: 'asc',
|
||||
});
|
||||
|
||||
readonly sortedIngredients = computed(() => {
|
||||
const { column, direction } = this.ingredientSort();
|
||||
return sortBy(this.recipe.ingredients, direction, (ing) =>
|
||||
column === 'name' ? ing.name : ing.amountGrams,
|
||||
);
|
||||
});
|
||||
|
||||
formatAmount(amount: number | null, unit: string | null): string {
|
||||
if (amount == null && !unit) return '';
|
||||
if (amount == null) return unit ?? '';
|
||||
const display = Number.isInteger(amount) ? amount.toString() : amount.toString();
|
||||
return unit ? `${display} ${unit}` : `${display} g`;
|
||||
}
|
||||
|
||||
toggleIngredientSort(column: IngredientSortColumn): void {
|
||||
this.ingredientSort.update((current) => nextSort(current, column));
|
||||
}
|
||||
|
||||
ingredientIndicator(column: IngredientSortColumn): string {
|
||||
return sortIndicator(this.ingredientSort(), column);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { MealCategory, MEAL_CATEGORY_I18N_KEYS } from '../../core/models/recipe.models';
|
||||
|
||||
export type CategoryFilter = MealCategory | 'all';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-filters',
|
||||
standalone: true,
|
||||
imports: [FormsModule, TranslatePipe],
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (pill of pills; track pill.value) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="onChange.emit(pill.value)"
|
||||
class="rounded-full px-4 py-1.5 text-sm font-medium transition"
|
||||
[class]="
|
||||
pill.value === active
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800'
|
||||
"
|
||||
>
|
||||
{{ pill.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="relative md:w-72">
|
||||
@if (searchByIngredient) {
|
||||
<span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-brand-500" aria-hidden="true">🥕</span>
|
||||
} @else {
|
||||
<svg
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
}
|
||||
<input
|
||||
type="search"
|
||||
[ngModel]="search"
|
||||
(ngModelChange)="onSearchChange.emit($event)"
|
||||
class="input pl-9"
|
||||
[placeholder]="
|
||||
searchByIngredient
|
||||
? ('recipes.searchByIngredient' | translate)
|
||||
: ('recipes.searchRecipes' | translate)
|
||||
"
|
||||
[attr.aria-label]="
|
||||
searchByIngredient
|
||||
? ('recipes.searchByIngredientAria' | translate)
|
||||
: ('recipes.searchByNameAria' | translate)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="inline-flex w-fit cursor-pointer select-none items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
[ngModel]="searchByIngredient"
|
||||
(ngModelChange)="onSearchByIngredientChange.emit($event)"
|
||||
class="h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
{{ 'recipes.searchByIngredientLabel' | translate }}
|
||||
</label>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RecipeFiltersComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
@Input({ required: true }) active!: CategoryFilter;
|
||||
@Output() onChange = new EventEmitter<CategoryFilter>();
|
||||
@Input({ required: true }) search!: string;
|
||||
@Output() onSearchChange = new EventEmitter<string>();
|
||||
@Input() searchByIngredient = false;
|
||||
@Output() onSearchByIngredientChange = new EventEmitter<boolean>();
|
||||
|
||||
get pills(): Array<{ value: CategoryFilter; label: string }> {
|
||||
return [
|
||||
{ value: 'all', label: this.translate.instant('common.all') },
|
||||
...[
|
||||
MealCategory.Breakfast,
|
||||
MealCategory.SecondBreakfast,
|
||||
MealCategory.Lunch,
|
||||
MealCategory.Dinner,
|
||||
].map((cat) => ({
|
||||
value: cat as CategoryFilter,
|
||||
label: this.translate.instant(MEAL_CATEGORY_I18N_KEYS[cat]),
|
||||
})),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { MealPlanService } from '../../core/services/meal-plan.service';
|
||||
import { ShoppingListItem } from '../../core/models/meal-plan.models';
|
||||
import {
|
||||
addDaysIso,
|
||||
endOfWeekIso,
|
||||
startOfWeekIso,
|
||||
todayIso,
|
||||
} from '../../core/models/diet.models';
|
||||
import { ErrorStateComponent } from '../../shared/components/error-state.component';
|
||||
import { SkeletonComponent } from '../../shared/components/skeleton.component';
|
||||
import { extractApiError } from '../../core/utils/api-error.util';
|
||||
|
||||
const CHECKLIST_KEY = 'dm_shopping_checklist';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shopping-list',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe, FormsModule, TranslatePipe, ErrorStateComponent, SkeletonComponent],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight sm:text-3xl">{{ 'shoppingList.title' | translate }}</h1>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">{{ 'shoppingList.subtitle' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="card flex flex-wrap items-end gap-4 p-5">
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'shoppingList.from' | translate }}</span>
|
||||
<input type="date" class="input mt-1" [(ngModel)]="fromDate" />
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="font-medium">{{ 'shoppingList.to' | translate }}</span>
|
||||
<input type="date" class="input mt-1" [(ngModel)]="toDate" />
|
||||
</label>
|
||||
<button type="button" class="btn-secondary" (click)="loadList()">{{ 'shoppingList.load' | translate }}</button>
|
||||
<button type="button" class="btn-secondary !text-xs" (click)="clearChecks()">{{ 'shoppingList.clearChecks' | translate }}</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<app-skeleton [className]="'h-48 w-full'" />
|
||||
} @else if (error()) {
|
||||
<app-error-state [message]="error()!" [showRetry]="true" (retry)="loadList()" />
|
||||
} @else if (items().length === 0 && loaded()) {
|
||||
<p class="text-sm text-slate-400">{{ 'shoppingList.empty' | translate }}</p>
|
||||
} @else {
|
||||
<ul class="card divide-y divide-slate-200 dark:divide-slate-800">
|
||||
@for (item of items(); track itemKey(item)) {
|
||||
<li class="flex items-start gap-3 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-slate-300"
|
||||
[checked]="isChecked(item)"
|
||||
(change)="toggleCheck(item)"
|
||||
/>
|
||||
<div [class.opacity-50]="isChecked(item)" [class.line-through]="isChecked(item)">
|
||||
<p class="font-medium">{{ item.name }}</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
@if (item.totalGrams != null) {
|
||||
{{ item.totalGrams | number: '1.0-1' }}g
|
||||
} @else if (item.totalAmount != null) {
|
||||
{{ item.totalAmount | number: '1.0-1' }} {{ item.unit ?? '' }}
|
||||
}
|
||||
</p>
|
||||
@if (item.breakdown.length > 0) {
|
||||
<ul class="mt-1 text-xs text-slate-400">
|
||||
@for (line of item.breakdown; track line) {
|
||||
<li>{{ line }}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p class="text-xs text-slate-400">{{ 'shoppingList.checklistHint' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ShoppingListComponent implements OnInit {
|
||||
private readonly mealPlan = inject(MealPlanService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
fromDate = startOfWeekIso(todayIso());
|
||||
toDate = endOfWeekIso(todayIso());
|
||||
items = signal<ShoppingListItem[]>([]);
|
||||
checkedKeys = signal<Set<string>>(new Set());
|
||||
loading = signal(false);
|
||||
loaded = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
readonly rangeKey = computed(() => `${this.fromDate}_${this.toDate}`);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.restoreChecks();
|
||||
this.loadList();
|
||||
}
|
||||
|
||||
itemKey(item: ShoppingListItem): string {
|
||||
return `${item.name}|${item.unit ?? ''}`;
|
||||
}
|
||||
|
||||
isChecked(item: ShoppingListItem): boolean {
|
||||
return this.checkedKeys().has(this.itemKey(item));
|
||||
}
|
||||
|
||||
toggleCheck(item: ShoppingListItem): void {
|
||||
const key = this.itemKey(item);
|
||||
this.checkedKeys.update((set) => {
|
||||
const next = new Set(set);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
this.persistChecks();
|
||||
}
|
||||
|
||||
clearChecks(): void {
|
||||
this.checkedKeys.set(new Set());
|
||||
this.persistChecks();
|
||||
}
|
||||
|
||||
loadList(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.restoreChecks();
|
||||
this.mealPlan.getShoppingList(this.fromDate, this.toDate).subscribe({
|
||||
next: (list) => {
|
||||
this.items.set(list);
|
||||
this.loaded.set(true);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(extractApiError(err, this.translate.instant('errors.loadShoppingListFailed')));
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private storageKey(): string {
|
||||
return `${CHECKLIST_KEY}_${this.rangeKey()}`;
|
||||
}
|
||||
|
||||
private restoreChecks(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.storageKey());
|
||||
if (!raw) {
|
||||
this.checkedKeys.set(new Set());
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as string[];
|
||||
this.checkedKeys.set(new Set(parsed));
|
||||
} catch {
|
||||
this.checkedKeys.set(new Set());
|
||||
}
|
||||
}
|
||||
|
||||
private persistChecks(): void {
|
||||
localStorage.setItem(this.storageKey(), JSON.stringify([...this.checkedKeys()]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { LayoutService } from '../core/services/layout.service';
|
||||
import { ReminderNotificationService } from '../core/services/reminder-notification.service';
|
||||
import { HorizontalNavComponent } from './horizontal-nav.component';
|
||||
import { NavbarComponent } from './navbar.component';
|
||||
import { SidebarComponent, SidebarVariant } from './sidebar.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-layout',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, NavbarComponent, SidebarComponent, HorizontalNavComponent],
|
||||
template: `
|
||||
@if (layoutService.layout() === 'topnav') {
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<app-navbar [showMenuToggle]="false" />
|
||||
<app-horizontal-nav />
|
||||
<main [class]="mainClass()">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="min-h-screen lg:flex">
|
||||
<app-sidebar
|
||||
[open]="sidebarOpen()"
|
||||
[variant]="sidebarVariant()"
|
||||
(navigate)="closeSidebar()"
|
||||
/>
|
||||
<div class="flex min-h-screen flex-1 flex-col">
|
||||
<app-navbar (toggleSidebar)="toggleSidebar()" />
|
||||
<main [class]="mainClass()">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AppLayoutComponent implements OnInit {
|
||||
readonly layoutService = inject(LayoutService);
|
||||
private readonly reminders = inject(ReminderNotificationService);
|
||||
readonly sidebarOpen = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.reminders.start();
|
||||
}
|
||||
|
||||
readonly sidebarVariant = computed((): SidebarVariant => {
|
||||
const id = this.layoutService.layout();
|
||||
if (id === 'compact') return 'compact';
|
||||
if (id === 'wide') return 'wide';
|
||||
return 'default';
|
||||
});
|
||||
|
||||
readonly mainClass = computed(() => {
|
||||
const base = 'mx-auto w-full flex-1 px-4 py-6 sm:px-6 sm:py-8';
|
||||
switch (this.layoutService.layout()) {
|
||||
case 'topnav':
|
||||
return `${base} max-w-7xl`;
|
||||
case 'wide':
|
||||
return `${base} max-w-6xl`;
|
||||
case 'compact':
|
||||
return `${base} max-w-6xl`;
|
||||
default:
|
||||
return `${base} max-w-6xl`;
|
||||
}
|
||||
});
|
||||
|
||||
toggleSidebar(): void {
|
||||
this.sidebarOpen.update((v) => !v);
|
||||
}
|
||||
|
||||
closeSidebar(): void {
|
||||
this.sidebarOpen.set(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { NAV_ITEMS } from './nav-items';
|
||||
import { NavIconComponent } from './nav-icon.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-horizontal-nav',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive, TranslatePipe, NavIconComponent],
|
||||
template: `
|
||||
<nav
|
||||
class="border-b border-slate-200 bg-white/90 backdrop-blur dark:border-slate-800 dark:bg-slate-900/90"
|
||||
aria-label="Main"
|
||||
>
|
||||
<div class="flex gap-1 overflow-x-auto px-4 py-2 sm:px-6">
|
||||
@for (item of navItems; track item.to) {
|
||||
<a
|
||||
[routerLink]="item.to"
|
||||
routerLinkActive="bg-brand-600 text-white shadow-sm"
|
||||
[routerLinkActiveOptions]="{ exact: item.to === '/' }"
|
||||
(click)="navigate.emit()"
|
||||
class="inline-flex shrink-0 items-center gap-2 rounded-xl px-3.5 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800 [&.bg-brand-600]:text-white"
|
||||
>
|
||||
<app-nav-icon [icon]="item.icon" />
|
||||
<span>{{ item.labelKey | translate }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class HorizontalNavComponent {
|
||||
@Output() navigate = new EventEmitter<void>();
|
||||
readonly navItems = NAV_ITEMS;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Component, Input, inject } from '@angular/core';
|
||||
import type { NavIcon } from './nav-items';
|
||||
import { NAV_ICON_EMOJI } from '../core/theme/appearance.config';
|
||||
import { AppearanceService } from '../core/services/appearance.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-icon',
|
||||
standalone: true,
|
||||
template: `
|
||||
@if (appearance.useEmojiIcons()) {
|
||||
<span class="text-lg leading-none" aria-hidden="true">{{ emoji() }}</span>
|
||||
} @else {
|
||||
@switch (icon) {
|
||||
@case ('dashboard') {
|
||||
<svg [attr.stroke-width]="appearance.strokeWidth()" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
}
|
||||
@case ('coffee') {
|
||||
<svg [attr.stroke-width]="appearance.strokeWidth()" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M18 8h1a4 4 0 0 1 0 8h-1" /><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z" />
|
||||
<line x1="6" y1="1" x2="6" y2="4" /><line x1="10" y1="1" x2="10" y2="4" /><line x1="14" y1="1" x2="14" y2="4" />
|
||||
</svg>
|
||||
}
|
||||
@case ('croissant') {
|
||||
<svg [attr.stroke-width]="appearance.strokeWidth()" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M4 14c0-3 2-6 8-6s8 3 8 6-2 4-8 4-8-1-8-4Z" />
|
||||
</svg>
|
||||
}
|
||||
@case ('soup') {
|
||||
<svg [attr.stroke-width]="appearance.strokeWidth()" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M4 11h16v2a6 6 0 0 1-12 0v-2Z" /><path d="M6 11V8a6 6 0 0 1 12 0v3" />
|
||||
</svg>
|
||||
}
|
||||
@case ('moon') {
|
||||
<svg [attr.stroke-width]="appearance.strokeWidth()" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
}
|
||||
@case ('list') {
|
||||
<svg [attr.stroke-width]="appearance.strokeWidth()" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M11 18H3" /><path d="M11 6H3" /><path d="M16 6h5" /><path d="M16 12h5" /><path d="M16 18h5" />
|
||||
<path d="M7 6v.01" /><path d="M7 12v.01" /><path d="M7 18v.01" />
|
||||
</svg>
|
||||
}
|
||||
@case ('book') {
|
||||
<svg [attr.stroke-width]="appearance.strokeWidth()" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
}
|
||||
@case ('leaf') {
|
||||
<svg [attr.stroke-width]="appearance.strokeWidth()" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.5 18 2c1 2 2 4.5 2 8 0 5.5-4.5 10-9 10Z" />
|
||||
<path d="M2 21c0-3 2-6 6-8" />
|
||||
</svg>
|
||||
}
|
||||
@case ('diet') {
|
||||
<svg [attr.stroke-width]="appearance.strokeWidth()" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M7 21h10" /><path d="M12 3a5 5 0 0 0-5 5v2h10V8a5 5 0 0 0-5-5Z" />
|
||||
<path d="M7 10v11" /><path d="M17 10v11" />
|
||||
</svg>
|
||||
}
|
||||
@case ('calendar') {
|
||||
<svg [attr.stroke-width]="appearance.strokeWidth()" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
}
|
||||
@case ('cart') {
|
||||
<svg [attr.stroke-width]="appearance.strokeWidth()" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<circle cx="9" cy="21" r="1" /><circle cx="20" cy="21" r="1" />
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
|
||||
</svg>
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class NavIconComponent {
|
||||
readonly appearance = inject(AppearanceService);
|
||||
@Input({ required: true }) icon!: NavIcon;
|
||||
|
||||
emoji(): string {
|
||||
return NAV_ICON_EMOJI[this.icon] ?? '•';
|
||||
}
|
||||
}
|
||||
23
meal-plan-frontend-angular/src/app/layout/nav-items.ts
Normal file
23
meal-plan-frontend-angular/src/app/layout/nav-items.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type NavIcon = 'dashboard' | 'coffee' | 'croissant' | 'soup' | 'moon' | 'list' | 'book' | 'leaf' | 'diet' | 'calendar' | 'cart';
|
||||
|
||||
export interface NavItem {
|
||||
to: string;
|
||||
labelKey: string;
|
||||
icon: NavIcon;
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
||||
{ to: '/breakfast', labelKey: 'nav.breakfast', icon: 'coffee' },
|
||||
{ to: '/second-breakfast', labelKey: 'nav.secondBreakfast', icon: 'croissant' },
|
||||
{ to: '/lunch', labelKey: 'nav.lunch', icon: 'soup' },
|
||||
{ to: '/dinner', labelKey: 'nav.dinner', icon: 'moon' },
|
||||
{ to: '/all-meals', labelKey: 'nav.allMeals', icon: 'list' },
|
||||
{ to: '/meal-planner', labelKey: 'nav.mealPlanner', icon: 'calendar' },
|
||||
{ to: '/diet-generator', labelKey: 'nav.dietGenerator', icon: 'diet' },
|
||||
{ to: '/recipe-generator', labelKey: 'nav.recipeGenerator', icon: 'book' },
|
||||
{ to: '/shopping-list', labelKey: 'nav.shoppingList', icon: 'cart' },
|
||||
{ to: '/manage-meals', labelKey: 'nav.manageMeals', icon: 'book' },
|
||||
{ to: '/ingredients', labelKey: 'nav.ingredientCatalog', icon: 'leaf' },
|
||||
{ to: '/diet', labelKey: 'nav.dietTracker', icon: 'diet' },
|
||||
];
|
||||
101
meal-plan-frontend-angular/src/app/layout/navbar.component.ts
Normal file
101
meal-plan-frontend-angular/src/app/layout/navbar.component.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { AuthService } from '../core/services/auth.service';
|
||||
import { ThemeService } from '../core/services/theme.service';
|
||||
import { AppearanceSwitcherComponent } from '../shared/components/appearance-switcher.component';
|
||||
import { LanguageSwitcherComponent } from '../shared/components/language-switcher.component';
|
||||
import { LayoutSwitcherComponent } from '../shared/components/layout-switcher.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-navbar',
|
||||
standalone: true,
|
||||
imports: [RouterLink, TranslatePipe, AppearanceSwitcherComponent, LanguageSwitcherComponent, LayoutSwitcherComponent],
|
||||
template: `
|
||||
<header
|
||||
class="sticky top-0 z-30 border-b border-slate-200 bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-900/80"
|
||||
>
|
||||
<div class="flex h-16 items-center justify-between gap-4 px-4 sm:px-6">
|
||||
<div class="flex items-center gap-3">
|
||||
@if (showMenuToggle) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleSidebar.emit()"
|
||||
class="btn-secondary !px-2.5 !py-2 lg:hidden"
|
||||
[attr.aria-label]="'nav.toggleNav' | translate"
|
||||
>
|
||||
<span
|
||||
class="block h-0.5 w-5 bg-current shadow-[0_6px_0_currentColor,0_-6px_0_currentColor]"
|
||||
></span>
|
||||
</button>
|
||||
}
|
||||
<a routerLink="/" class="flex items-center gap-2 font-extrabold tracking-tight">
|
||||
<span class="grid h-9 w-9 place-items-center rounded-xl bg-brand-600 text-white">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2" />
|
||||
<path d="M7 2v20" />
|
||||
<path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-lg">{{ 'app.name' | translate }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<app-appearance-switcher />
|
||||
<app-layout-switcher />
|
||||
<app-language-switcher />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="theme.toggle()"
|
||||
class="btn-secondary !px-2.5 !py-2"
|
||||
[attr.aria-label]="
|
||||
theme.theme() === 'dark' ? ('nav.lightMode' | translate) : ('nav.darkMode' | translate)
|
||||
"
|
||||
>
|
||||
@if (theme.theme() === 'dark') {
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
|
||||
<div class="hidden text-right sm:block">
|
||||
<p class="text-sm font-semibold leading-tight">
|
||||
{{ auth.user()?.userName ?? ('nav.user' | translate) }}
|
||||
</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">{{ auth.user()?.email }}</p>
|
||||
</div>
|
||||
|
||||
<button type="button" (click)="logout()" class="btn-secondary">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ 'nav.logout' | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`,
|
||||
})
|
||||
export class NavbarComponent {
|
||||
readonly auth = inject(AuthService);
|
||||
readonly theme = inject(ThemeService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
@Input() showMenuToggle = true;
|
||||
@Output() toggleSidebar = new EventEmitter<void>();
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await this.auth.logout();
|
||||
void this.router.navigateByUrl('/login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import type { LayoutId } from '../core/services/layout.service';
|
||||
import { NAV_ITEMS } from './nav-items';
|
||||
import { NavIconComponent } from './nav-icon.component';
|
||||
|
||||
export type SidebarVariant = 'default' | 'compact' | 'wide';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive, TranslatePipe, NavIconComponent],
|
||||
template: `
|
||||
@if (open) {
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-slate-900/40 lg:hidden"
|
||||
(click)="navigate.emit()"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
}
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 transform border-r transition-transform lg:static lg:translate-x-0"
|
||||
[class]="asideClass()"
|
||||
[class.translate-x-0]="open"
|
||||
[class.-translate-x-full]="!open"
|
||||
>
|
||||
@if (variant === 'wide') {
|
||||
<div class="mb-6 hidden rounded-2xl bg-gradient-to-br from-brand-600 to-brand-800 p-4 text-white lg:block">
|
||||
<p class="text-lg font-extrabold tracking-tight">{{ 'app.name' | translate }}</p>
|
||||
<p class="mt-1 text-xs text-brand-100">{{ 'layout.wideTagline' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<nav [class]="navClass()" aria-label="Main">
|
||||
@for (item of navItems; track item.to) {
|
||||
<a
|
||||
[routerLink]="item.to"
|
||||
routerLinkActive="bg-brand-600 text-white"
|
||||
[routerLinkActiveOptions]="{ exact: item.to === '/' }"
|
||||
(click)="navigate.emit()"
|
||||
[title]="variant === 'compact' ? (item.labelKey | translate) : ''"
|
||||
[class]="linkClass()"
|
||||
>
|
||||
<app-nav-icon [icon]="item.icon" />
|
||||
@if (variant !== 'compact') {
|
||||
<span>{{ item.labelKey | translate }}</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
`,
|
||||
})
|
||||
export class SidebarComponent {
|
||||
@Input() open = false;
|
||||
@Input() variant: SidebarVariant = 'default';
|
||||
@Output() navigate = new EventEmitter<void>();
|
||||
|
||||
readonly navItems = NAV_ITEMS;
|
||||
|
||||
asideClass(): string {
|
||||
const base =
|
||||
'border-slate-200 bg-white px-3 py-6 dark:border-slate-800 dark:bg-slate-900';
|
||||
switch (this.variant) {
|
||||
case 'compact':
|
||||
return `w-16 ${base}`;
|
||||
case 'wide':
|
||||
return `w-72 ${base} bg-gradient-to-b from-white to-brand-50/40 dark:from-slate-900 dark:to-brand-950/20`;
|
||||
default:
|
||||
return `w-64 ${base}`;
|
||||
}
|
||||
}
|
||||
|
||||
navClass(): string {
|
||||
return this.variant === 'compact'
|
||||
? 'mt-16 flex flex-col items-center gap-2 lg:mt-0'
|
||||
: 'mt-16 flex flex-col gap-1 lg:mt-0';
|
||||
}
|
||||
|
||||
linkClass(): string {
|
||||
const base =
|
||||
'flex items-center rounded-xl text-sm font-medium text-slate-600 transition hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800 [&.bg-brand-600]:text-white';
|
||||
if (this.variant === 'compact') {
|
||||
return `${base} justify-center p-2.5`;
|
||||
}
|
||||
if (this.variant === 'wide') {
|
||||
return `${base} gap-3 px-4 py-3 text-[0.95rem]`;
|
||||
}
|
||||
return `${base} gap-3 px-3.5 py-2.5`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { AppearanceId } from '../../core/theme/appearance.config';
|
||||
import { AppearanceService } from '../../core/services/appearance.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-appearance-switcher',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
template: `
|
||||
<label class="sr-only" for="appearance-select">{{ 'appearance.label' | translate }}</label>
|
||||
<div class="relative flex items-center gap-1.5">
|
||||
<span
|
||||
class="hidden h-3 w-3 shrink-0 rounded-full sm:inline-block"
|
||||
[style.background-color]="appearance.proposal().swatch"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<select
|
||||
id="appearance-select"
|
||||
class="select !w-auto max-w-[9.5rem] !py-2 !pr-9 !text-sm sm:max-w-none"
|
||||
[value]="appearance.proposal().id"
|
||||
(change)="onChange($event)"
|
||||
[attr.aria-label]="'appearance.label' | translate"
|
||||
>
|
||||
@for (p of appearance.proposals(); track p.id) {
|
||||
<option [value]="p.id">{{ p.labelKey | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AppearanceSwitcherComponent {
|
||||
readonly appearance = inject(AppearanceService);
|
||||
|
||||
onChange(event: Event): void {
|
||||
const value = (event.target as HTMLSelectElement).value as AppearanceId;
|
||||
this.appearance.setProposal(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MealCategory, MEAL_CATEGORY_I18N_KEYS } from '../../core/models/recipe.models';
|
||||
|
||||
const STYLES: Record<MealCategory, string> = {
|
||||
[MealCategory.Breakfast]: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
[MealCategory.SecondBreakfast]: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
|
||||
[MealCategory.Lunch]: 'bg-brand-100 text-brand-800 dark:bg-brand-900/40 dark:text-brand-300',
|
||||
[MealCategory.Dinner]: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-badge',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
template: `
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||
[class]="styles"
|
||||
>
|
||||
{{ labelKey | translate }}
|
||||
</span>
|
||||
`,
|
||||
})
|
||||
export class CategoryBadgeComponent {
|
||||
@Input({ required: true }) category!: MealCategory;
|
||||
|
||||
get labelKey(): string {
|
||||
return MEAL_CATEGORY_I18N_KEYS[this.category] ?? 'categories.unknown';
|
||||
}
|
||||
|
||||
get styles(): string {
|
||||
return STYLES[this.category] ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-empty-state',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-300 px-6 py-16 text-center dark:border-slate-700"
|
||||
>
|
||||
<div class="rounded-full bg-slate-100 p-4 dark:bg-slate-800">
|
||||
<svg class="h-8 w-8 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold">{{ title }}</h3>
|
||||
@if (description) {
|
||||
<p class="mt-1 max-w-sm text-sm text-slate-500 dark:text-slate-400">{{ description }}</p>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class EmptyStateComponent {
|
||||
@Input({ required: true }) title!: string;
|
||||
@Input() description?: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-error-state',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
template: `
|
||||
<div
|
||||
role="alert"
|
||||
class="flex flex-col items-center justify-center rounded-2xl border border-red-200 bg-red-50 px-6 py-12 text-center dark:border-red-900/50 dark:bg-red-950/30"
|
||||
>
|
||||
<svg class="h-8 w-8 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<p class="mt-3 text-sm font-medium text-red-700 dark:text-red-300">{{ message }}</p>
|
||||
@if (showRetry) {
|
||||
<button type="button" (click)="retry.emit()" class="btn-secondary mt-5">
|
||||
{{ 'common.tryAgain' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ErrorStateComponent {
|
||||
@Input({ required: true }) message!: string;
|
||||
@Input() showRetry = false;
|
||||
@Output() retry = new EventEmitter<void>();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { LanguageService } from '../../core/services/language.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-language-switcher',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
template: `
|
||||
<div class="relative" [class]="className">
|
||||
<select
|
||||
class="select !w-auto !py-2 !pr-9 !text-sm"
|
||||
[value]="language.currentLanguage()"
|
||||
(change)="onChange($event)"
|
||||
[attr.aria-label]="'language.label' | translate"
|
||||
>
|
||||
<option value="en">EN</option>
|
||||
<option value="pl">PL</option>
|
||||
</select>
|
||||
<svg
|
||||
class="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LanguageSwitcherComponent {
|
||||
readonly language = inject(LanguageService);
|
||||
className = '';
|
||||
|
||||
onChange(event: Event): void {
|
||||
const value = (event.target as HTMLSelectElement).value as 'en' | 'pl';
|
||||
this.language.setLanguage(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { LayoutId, LayoutService } from '../../core/services/layout.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-layout-switcher',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
template: `
|
||||
<label class="sr-only" for="layout-select">{{ 'layout.label' | translate }}</label>
|
||||
<select
|
||||
id="layout-select"
|
||||
class="select !w-auto !py-2 !pr-9 !text-sm"
|
||||
[value]="layout.layout()"
|
||||
(change)="onChange($event)"
|
||||
[attr.aria-label]="'layout.label' | translate"
|
||||
>
|
||||
@for (option of options; track option.id) {
|
||||
<option [value]="option.id">{{ option.labelKey | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
`,
|
||||
})
|
||||
export class LayoutSwitcherComponent {
|
||||
readonly layout = inject(LayoutService);
|
||||
|
||||
readonly options: Array<{ id: LayoutId; labelKey: string }> = [
|
||||
{ id: 'sidebar', labelKey: 'layout.sidebar' },
|
||||
{ id: 'topnav', labelKey: 'layout.topnav' },
|
||||
{ id: 'compact', labelKey: 'layout.compact' },
|
||||
{ id: 'wide', labelKey: 'layout.wide' },
|
||||
];
|
||||
|
||||
onChange(event: Event): void {
|
||||
const value = (event.target as HTMLSelectElement).value as LayoutId;
|
||||
this.layout.setLayout(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-skeleton',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div
|
||||
class="animate-pulse rounded-lg bg-slate-200 dark:bg-slate-800"
|
||||
[class]="className"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
`,
|
||||
})
|
||||
export class SkeletonComponent {
|
||||
@Input() className = '';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-grid-skeleton',
|
||||
standalone: true,
|
||||
imports: [SkeletonComponent],
|
||||
template: `
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@for (item of items; track item) {
|
||||
<div class="card p-5">
|
||||
<app-skeleton className="h-5 w-3/4" />
|
||||
<app-skeleton className="mt-4 h-4 w-1/2" />
|
||||
<div class="mt-6 flex gap-3">
|
||||
<app-skeleton className="h-8 w-20" />
|
||||
<app-skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class GridSkeletonComponent {
|
||||
@Input() count = 6;
|
||||
|
||||
get items(): number[] {
|
||||
return Array.from({ length: this.count }, (_, i) => i);
|
||||
}
|
||||
}
|
||||
16
meal-plan-frontend-angular/src/index.html
Normal file
16
meal-plan-frontend-angular/src/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>DailyMeals</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
meal-plan-frontend-angular/src/main.ts
Normal file
6
meal-plan-frontend-angular/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
132
meal-plan-frontend-angular/src/styles.css
Normal file
132
meal-plan-frontend-angular/src/styles.css
Normal file
@@ -0,0 +1,132 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root,
|
||||
[data-color-theme='forest'] {
|
||||
--brand-50: #eefaf3;
|
||||
--brand-100: #d6f1e0;
|
||||
--brand-200: #b0e3c6;
|
||||
--brand-300: #7dcda4;
|
||||
--brand-400: #47b07e;
|
||||
--brand-500: #22935f;
|
||||
--brand-600: #16774c;
|
||||
--brand-700: #125f3f;
|
||||
--brand-800: #114c34;
|
||||
--brand-900: #0e3e2c;
|
||||
--surface-bg: #f8fafc;
|
||||
--surface-bg-dark: #020617;
|
||||
}
|
||||
|
||||
[data-color-theme='ocean'] {
|
||||
--brand-50: #eff6ff;
|
||||
--brand-100: #dbeafe;
|
||||
--brand-200: #bfdbfe;
|
||||
--brand-300: #93c5fd;
|
||||
--brand-400: #60a5fa;
|
||||
--brand-500: #3b82f6;
|
||||
--brand-600: #2563eb;
|
||||
--brand-700: #1d4ed8;
|
||||
--brand-800: #1e40af;
|
||||
--brand-900: #1e3a8a;
|
||||
--surface-bg: #f0f9ff;
|
||||
--surface-bg-dark: #0c1222;
|
||||
}
|
||||
|
||||
[data-color-theme='sunset'] {
|
||||
--brand-50: #fffbeb;
|
||||
--brand-100: #fef3c7;
|
||||
--brand-200: #fde68a;
|
||||
--brand-300: #fcd34d;
|
||||
--brand-400: #fbbf24;
|
||||
--brand-500: #f59e0b;
|
||||
--brand-600: #d97706;
|
||||
--brand-700: #b45309;
|
||||
--brand-800: #92400e;
|
||||
--brand-900: #78350f;
|
||||
--surface-bg: #fffbeb;
|
||||
--surface-bg-dark: #1c1410;
|
||||
}
|
||||
|
||||
[data-color-theme='berry'] {
|
||||
--brand-50: #f5f3ff;
|
||||
--brand-100: #ede9fe;
|
||||
--brand-200: #ddd6fe;
|
||||
--brand-300: #c4b5fd;
|
||||
--brand-400: #a78bfa;
|
||||
--brand-500: #8b5cf6;
|
||||
--brand-600: #7c3aed;
|
||||
--brand-700: #6d28d9;
|
||||
--brand-800: #5b21b6;
|
||||
--brand-900: #4c1d95;
|
||||
--surface-bg: #faf5ff;
|
||||
--surface-bg-dark: #140f1f;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--surface-bg);
|
||||
@apply text-slate-900 dark:text-slate-100;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-color: var(--surface-bg-dark);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@apply outline-none ring-2 ring-brand-500 ring-offset-2 ring-offset-white dark:ring-offset-slate-950;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply rounded-2xl border border-slate-200 bg-white shadow-sm transition
|
||||
dark:border-slate-800 dark:bg-slate-900;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold
|
||||
transition focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn border border-slate-300 bg-white text-slate-700 hover:bg-slate-100
|
||||
dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-xl border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900
|
||||
placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-300
|
||||
dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus:ring-brand-700;
|
||||
}
|
||||
|
||||
.select {
|
||||
@apply input appearance-none bg-none pr-10;
|
||||
min-height: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
@apply absolute -m-px h-px w-px overflow-hidden whitespace-nowrap border-0 p-0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#pdf-render-area {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: 0;
|
||||
width: 794px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
27
meal-plan-frontend-angular/tailwind.config.js
Normal file
27
meal-plan-frontend-angular/tailwind.config.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{html,ts}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: 'var(--brand-50)',
|
||||
100: 'var(--brand-100)',
|
||||
200: 'var(--brand-200)',
|
||||
300: 'var(--brand-300)',
|
||||
400: 'var(--brand-400)',
|
||||
500: 'var(--brand-500)',
|
||||
600: 'var(--brand-600)',
|
||||
700: 'var(--brand-700)',
|
||||
800: 'var(--brand-800)',
|
||||
900: 'var(--brand-900)',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
15
meal-plan-frontend-angular/tsconfig.app.json
Normal file
15
meal-plan-frontend-angular/tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
27
meal-plan-frontend-angular/tsconfig.json
Normal file
27
meal-plan-frontend-angular/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
15
meal-plan-frontend-angular/tsconfig.spec.json
Normal file
15
meal-plan-frontend-angular/tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user