Initial commit
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"runtimeExecutable": "bash",
|
||||||
|
"runtimeArgs": ["-c", "cd /Users/jakobfritz/Documents/Programmierprojekt/KalorienTracker/backend && source .venv/bin/activate && uvicorn app.main:app --reload --port 8000"],
|
||||||
|
"port": 8000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"runtimeExecutable": "bash",
|
||||||
|
"runtimeArgs": ["-c", "cd /Users/jakobfritz/Documents/Programmierprojekt/KalorienTracker/frontend && npm run dev -- --port 5173"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
.DS_Store
|
||||||
|
data/
|
||||||
|
# Umgebungsvariablen / Geheime Schlüssel
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Datenbanken
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Python Virtual Environments
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Byte-compiled / Python Cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Betriebssystem-spezifische Dateien
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Kontext und Konventionen für Coding-Agents, die an diesem Repo arbeiten.
|
||||||
|
|
||||||
|
## Projekt
|
||||||
|
|
||||||
|
Selbst gehosteter Kalorien-Tracker (Webapp). Backend: FastAPI + SQLModel + SQLite. Frontend: React (Vite) + TailwindCSS, mobile-first, mit Kamera-Barcode-Scanner. Details und Setup: siehe [README.md](README.md).
|
||||||
|
|
||||||
|
Das Projekt entstand phasenweise (Backend-Fundament → Barcode/Logs-Logik → Frontend → Docker → Mahlzeiten/Ziele/Historie → Log-Edit/Delete + Kochbuch). Es gibt keine Alembic-Migrationen, aber `database.create_db_and_tables()` ruft nach `create_all` zusätzlich `_add_missing_columns()` auf: das inspiziert bestehende Tabellen und ergänzt fehlende Spalten per `ALTER TABLE ... ADD COLUMN` (inkl. `DEFAULT`, aus dem Python-Feld-Default abgeleitet). Deckt additive Schemaänderungen (neue Spalte mit Default) automatisch ab, ohne die DB zu löschen — Umbenennungen, Typänderungen oder das Entfernen von Spalten werden NICHT unterstützt (dafür weiterhin DB löschen). Neue Felder in `models.py` sollten deshalb wenn möglich einen Default bekommen (auch wenn das zugehörige Pydantic-Schema es als Pflichtfeld behandelt — die beiden sind unabhängig, siehe `FoodLog.meal_type = MealType.snack` als Beispiel).
|
||||||
|
|
||||||
|
## Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/
|
||||||
|
├── main.py FastAPI-App, Router-Registrierung, CORS, Lifespan
|
||||||
|
├── database.py SQLModel engine/session
|
||||||
|
├── models.py User, Product, FoodLog, Dish, DishIngredient, DishShare (Tabellen)
|
||||||
|
├── schemas.py Pydantic-Schemas für Requests/Responses
|
||||||
|
├── security.py Passwort-Hashing, JWT
|
||||||
|
├── deps.py get_session, get_current_user
|
||||||
|
├── access.py product_barcode_accessible / user_can_access_dish (Zugriffsprüfung für private Dish-Produkte)
|
||||||
|
├── core/config.py Settings (pydantic-settings, liest .env)
|
||||||
|
└── routers/ auth.py, users.py (inkl. /me/goals, GET / für Sharing-Picker), products.py (inkl. /search), logs.py (inkl. /today, /history, /day/{day}, PUT+DELETE /{log_id}), dishes.py (Kochbuch-CRUD + /shares)
|
||||||
|
|
||||||
|
frontend/src/
|
||||||
|
├── api/client.js axios-Instanz mit Bearer-Interceptor
|
||||||
|
├── context/ AuthContext, ThemeContext (Dark Mode, .dark-Klasse auf <html>, localStorage-persistiert)
|
||||||
|
├── utils/ mealTypes.js (MEAL_TYPES, defaultMealType(), groupByMealType()), numbers.js (parseDecimal)
|
||||||
|
├── components/ BarcodeScanner, MacroProgress, MealTypeSelect, ProductFoundModal, ProductNotFoundForm (mode: 'log' | 'createOnly'), AddMealModal, IngredientPickerModal, ProductSearchInput, EditLogModal, ConfirmDialog, DishShareManager, ProtectedRoute, ThemeToggle
|
||||||
|
└── pages/ LoginPage, RegisterPage, DashboardPage, HistoryPage, SettingsPage, CookbookPage, DishFormPage, DishDetailPage
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dark Mode**: Tailwind v4 nutzt standardmäßig nur `prefers-color-scheme` für `dark:`. Für manuelles Umschalten ist in `index.css` `@custom-variant dark (&:where(.dark, .dark *));` gesetzt — `dark:`-Klassen greifen dadurch, sobald `<html>` die Klasse `dark` trägt (gesteuert von `ThemeContext.jsx`). Neue UI muss beide Varianten mitbringen (z. B. `bg-white dark:bg-gray-800`, `text-gray-900 dark:text-gray-50`) — sonst ist die Komponente im Dark Mode unlesbar hell/dunkel.
|
||||||
|
|
||||||
|
`User` trägt `calorie_goal`/`protein_goal`/`carbs_goal`/`fat_goal` (Default 2000/100/250/70), änderbar über `PUT /api/users/me/goals`. `Product` und `FoodLog` haben zusätzlich zu calories/carbs/protein/fat auch `sugar`, `fiber`, `saturated_fat`, `salt` (Default `0.0`, bei Open-Food-Facts-Import aus `sugars_100g`/`fiber_100g`/`saturated-fat_100g`/`salt_100g`). `GET /api/logs/history?days=N` liefert Tages-Totals für die letzten N Tage (Default 30, für Wochen-/Monatsdurchschnitte schneidet das Frontend das Array selbst statt einen eigenen Averages-Endpoint zu haben). `GET /api/logs/day/{YYYY-MM-DD}` liefert Logs+Totals für einen beliebigen Tag (gleiche Helper-Funktion wie `/today`).
|
||||||
|
|
||||||
|
**Mahlzeiten & Produktsuche** (`models.MealType`: breakfast/lunch/dinner/snack): `FoodLog.meal_type` ist ein Pflichtfeld — jeder Log-Eintrag (Scan-Flow *und* Such-Flow) muss ihn mitschicken, sonst 422. `Product.barcode` ist in der DB weiterhin `str` (unique, required), aber im `ProductCreate`-Schema optional — fehlt er (manuelles Anlegen ohne Scan, z. B. „Döner“ vom Essen gehen), generiert `routers/products.py` serverseitig `f"manual-{uuid4().hex[:12]}"`. `GET /api/products/search?q=` sucht per `ILIKE` über `Product.name` und sortiert Treffer, die der aktuelle User schon mal geloggt hat, nach Aktualität ganz nach oben (inkl. `last_amount_g` aus dem letzten Log dieses Barcodes für Prefill) — das ist die Basis für die „zuletzt verwendet“-Vorschläge, unabhängig davon ob das Produkt ursprünglich gescannt oder manuell angelegt wurde. Reihenfolge der Routen in `products.py` ist wichtig: `/search` muss vor `/{barcode}` registriert sein, sonst matcht FastAPI `"search"` als Barcode-Pfadparameter.
|
||||||
|
|
||||||
|
**Log-Edit**: `PUT /api/logs/{id}` skaliert die gespeicherten Nährwerte über das Verhältnis `neue_menge / alte_menge` direkt auf dem FoodLog-Snapshot — es wird NICHT das referenzierte Product erneut gelesen. Das ist bewusst so (konsistent mit dem "FoodLog ist ein unveränderliches Snapshot"-Prinzip): der Log bleibt korrekt, selbst wenn das zugrunde liegende Produkt/Gericht später gelöscht oder bearbeitet wurde.
|
||||||
|
|
||||||
|
**Kochbuch/Gerichte** (`models.Dish` + `DishIngredient`, `routers/dishes.py`): Ein Gericht ist technisch ein virtuelles Produkt — beim Anlegen/Bearbeiten werden die Zutaten-Nährwerte zu Pro-100g-Werten aggregiert und in einer eigenen `Product`-Zeile mit Barcode `dish-<uuid>` gecacht. Dadurch funktioniert das Loggen eines Gerichts über den ganz normalen `POST /api/logs`-Flow (Barcode + Menge + Mahlzeit), und Gerichte tauchen automatisch in `GET /api/products/search` auf (per `is_dish`-Flag markiert, erkennbar am `dish-`-Präfix). Beim Löschen eines Gerichts wird auch die zugehörige Product-Zeile gelöscht — das ist unproblematisch, weil vergangene FoodLog-Einträge bereits eigene Snapshots ihrer Nährwerte sind und nicht auf die Product-Zeile angewiesen bleiben. `DishIngredientRead` liefert bewusst Pro-100g-Werte (nicht auf `amount_g` skaliert), damit die Zutaten-Objekte strukturell identisch zu `ProductSearchResult` sind — das Frontend (`DishFormPage.jsx`) nutzt dieselbe Form für frisch gesuchte Zutaten und für beim Bearbeiten geladene Zutaten, inkl. client-seitig live berechneter Nährwert-Vorschau.
|
||||||
|
|
||||||
|
**Mehrbenutzer-Absicherung** (`app/access.py`, genutzt von `products.py`, `logs.py`, `dishes.py`): `Product` hat kein `user_id` und ist absichtlich global — alle Nutzer sehen/loggen dieselben Lebensmittel. Die einzige Ausnahme sind Gerichte (`Product.barcode` beginnt mit `dish-`): die sind über `models.DishShare` (dish_id, shared_with_user_id) an ihren Besitzer gebunden. `access.product_barcode_accessible(session, barcode, user_id)` ist die zentrale Prüfung — gibt für normale Barcodes immer `True` zurück, für `dish-*`-Barcodes nur wenn `user_id` Besitzer ist oder ein `DishShare`-Eintrag existiert. Wird an drei Stellen aufgerufen: `GET /api/products/{barcode}` und `GET /api/products/search` (private Gerichte anderer werden wie "nicht gefunden" behandelt, keine Existenz-Leaks), sowie `POST /api/logs` (verhindert Loggen fremder privater Gerichte auch wenn der Barcode bekannt ist). `dishes.py` unterscheidet `_get_owned_dish` (strikt Besitzer, für Bearbeiten/Löschen/Sharing-Verwaltung) von `_get_accessible_dish` (Besitzer ODER Freigabe-Empfänger, für die Detailansicht). `DishRead.is_owner`/`owner_username` sagen dem Frontend, ob Bearbeiten/Löschen/Teilen-UI angezeigt werden darf — das Backend ist die eigentliche Durchsetzung, das Frontend blendet nur aus. `GET /api/users` (nur Username, keine persönlichen Daten) existiert einzig für den Sharing-Picker im Frontend.
|
||||||
|
|
||||||
|
**Kein `window.confirm()`**: Lösch-Bestätigungen laufen über die eigene `ConfirmDialog`-Komponente, nicht über natives `window.confirm()`. Grund: native Dialoge blockieren/verhalten sich inkonsistent in automatisierten Browsern (z. B. Preview-Tooling in dieser Umgebung) und passen optisch nicht zum Rest der App. Neue Lösch-Aktionen sollten `ConfirmDialog` wiederverwenden.
|
||||||
|
|
||||||
|
**`ProductNotFoundForm` hat zwei Modi**: `mode="log"` (Default) erstellt ein Produkt UND loggt es sofort (zeigt Mahlzeit-Auswahl + Mengenfeld, Button "Anlegen & Loggen") — genutzt vom Scan-/Such-Flow. `mode="createOnly"` legt nur das Produkt an und ruft `onCreated(product)` auf, ohne zu loggen (kein Mahlzeit-/Mengenfeld, Button "Anlegen") — genutzt von `IngredientPickerModal.jsx` beim Zutaten-Anlegen im Kochbuch, wo ein frisch angelegtes Produkt nur der Zutatenliste hinzugefügt werden soll, nicht dem Tages-Log. `IngredientPickerModal` bündelt für den Zutaten-Picker dieselben drei Wege wie `AddMealModal` (Suche, Barcode-Scan, manuelles Anlegen) — bei 404 nach einem Scan wird automatisch `ProductNotFoundForm` im `createOnly`-Modus mit dem gescannten Barcode vorausgefüllt geöffnet.
|
||||||
|
|
||||||
|
## Konventionen
|
||||||
|
|
||||||
|
- UI-Texte sind auf Deutsch (Labels, Fehlermeldungen). Neue UI-Texte ebenfalls auf Deutsch verfassen.
|
||||||
|
- Keine Kommentare im Code, außer für nicht-offensichtliche WARUM-Erklärungen (z. B. Workarounds für Library-Bugs).
|
||||||
|
- Backend: neue Endpunkte bekommen ein Pydantic-Schema in `schemas.py` (nicht die SQLModel-Tabellenklasse direkt als Response-Model verwenden, wenn es kein `table=True`-Modell ist — dann `model_config = ConfigDict(from_attributes=True)` nicht vergessen, sonst 500er bei der Serialisierung).
|
||||||
|
- Frontend: Komponenten, die selbst einen API-Call auslösen (z. B. `ProductFoundModal`, `ProductNotFoundForm`), sind für ihren eigenen Loading-/Error-State verantwortlich (kein globaler State-Manager, reicht für diese Projektgröße).
|
||||||
|
- Frontend: Numerische Eingaben für Nährwerte/Mengen/Ziele sind `type="text"` + `inputMode="decimal"` (nicht `type="number"`) und werden über `utils/numbers.js#parseDecimal` geparst, damit auch das deutsche Komma als Dezimaltrennzeichen funktioniert (siehe Fallstricke unten). Neue numerische Eingabefelder sollten diesem Muster folgen.
|
||||||
|
|
||||||
|
## Bekannte Fallstricke
|
||||||
|
|
||||||
|
- **passlib + bcrypt**: `passlib==1.7.4` ist inkompatibel mit `bcrypt>=4.1` (crasht beim Hashen mit `ValueError: password cannot be longer than 72 bytes`). `bcrypt` ist deshalb in `backend/requirements.txt` explizit auf `4.0.1` gepinnt — beim Aktualisieren der Dependencies nicht versehentlich lösen.
|
||||||
|
- **html5-qrcode**: Im Cleanup von `BarcodeScanner.jsx` niemals ungeprüft `scanner.stop()` aufrufen. Wenn `start()` (noch) nicht erfolgreich war (z. B. Kamera-Permission verweigert), wirft `stop()` synchron und crasht ohne Error Boundary den gesamten React-Tree. Immer mit `scanner.isScanning` guarden.
|
||||||
|
- **Foto-Fallback beim Scannen**: `BarcodeScanner.jsx` hat neben dem Live-Video-Scan einen roten Auslöser-Button, fest am unteren Bildschirmrand (absolute positioniert, nicht Teil des normalen Flex-Flows — sonst wird er von der Videovorschau aus dem sichtbaren Bereich geschoben). Der Button macht **keinen** separaten Foto-/Datei-Dialog auf (das würde auf dem Desktop nur den normalen Dateibrowser öffnen, `capture="environment"` auf `<input type="file">` ist ein reines Mobil-Feature) — stattdessen wird ein einzelnes Frame aus dem bereits laufenden `<video>`-Element der Live-Vorschau per Canvas (`drawImage` → `toBlob`) eingefroren und per `scanner.scanFile(file, false)` in Ruhe analysiert. Funktioniert dadurch identisch auf Mobilgerät und Mac/Desktop, bleibt ein einziger durchgehender Screen, und ist zuverlässiger als der Live-Scan bei schlechtem Fokus/Licht, weil ein scharfes Einzelbild statt wackliger Live-Frames ausgewertet wird. Das Bild wird nie hochgeladen oder gespeichert — nur als lokales Canvas/Blob für die Dauer des `scanFile`-Aufrufs. Button ist deaktiviert (`isCameraReady`-State), solange kein Live-Video läuft (z. B. während der Permission-Anfrage oder wenn `start()` fehlgeschlagen ist) — sonst gäbe es nichts zu fotografieren. Schlägt die Erkennung fehl, wird die Live-Kamera automatisch neu gestartet. Der Decode-Pfad selbst (`scanFile` von einem Canvas-generierten Bild) wurde im Browser mit zwei unterschiedlichen, synthetisch per Canvas erzeugten echten EAN-13-Barcodes verifiziert (korrekte Erkennung inkl. Prüfziffer, einmal sogar zufällig ein echter Open-Food-Facts-Treffer). Echter Kamera-Zugriff (`getUserMedia`) bleibt in dieser Umgebung wie immer ungetestet (kein Kamerazugriff im Headless-Browser) — dort ist nur verifizierbar, dass der Button beim fehlgeschlagenen Kamerastart korrekt deaktiviert bleibt und nichts crasht.
|
||||||
|
- **Kamera-Zugriff braucht HTTPS**: `getUserMedia` funktioniert in Browsern nur in einem "secure context" (`https://` oder `localhost`). Beim Testen/Hosten im LAN über `http://<ip>:8080` schlägt der Scanner-Zugriff auf allen Geräten außer `localhost` fehl — das ist kein Bug, siehe README.
|
||||||
|
- **Open Food Facts**: Antworten sind uneinheitlich befüllt (nicht jedes Produkt hat `energy-kcal_100g`). `routers/products.py` fällt bei fehlendem Kcal-Wert auf `energy_100g` (kJ) zurück und rechnet um; fehlende Makro-Felder werden mit `0.0` befüllt statt den Request abzulehnen.
|
||||||
|
- **Docker-Networking**: Das Frontend spricht das Backend nicht direkt an, sondern über den nginx-Proxy (`frontend/nginx.conf`, `location /api/` → `http://backend:8000`). `VITE_API_URL` wird im Docker-Build deshalb bewusst leer gelassen (`""`), damit Axios relative Pfade nutzt. Für die lokale Entwicklung ohne Docker steht `VITE_API_URL=http://localhost:8000` in `frontend/.env`, und CORS ist in `backend/app/main.py` für `localhost:5173` freigeschaltet.
|
||||||
|
- **Preview-/Sandbox-Tools**: In dieser Entwicklungsumgebung konnte das FastAPI-Backend nicht über den `preview_start`-Mechanismus (`.claude/launch.json`) gestartet werden (Sandbox verweigert `.venv`-Zugriff mit `getcwd: Operation not permitted`). Das Frontend funktioniert darüber problemlos. Falls das Backend für einen Preview-Test laufen muss, direkt per Shell starten (`cd backend && source .venv/bin/activate && uvicorn app.main:app --reload`).
|
||||||
|
- **Laufende Docker-Container vor lokalen Tests prüfen**: `docker compose up` published Backend und Frontend auf eigenen Ports (siehe `docker-compose.yml`, aktuell `8300`/`8310`, per `restart: unless-stopped` können sie nach einem Docker-Neustart von selbst wieder hochkommen). Läuft parallel noch ein lokaler `uvicorn`-Prozess (z. B. für manuelles Testen von Codeänderungen), kann der Browser `http://localhost:8000` je nach IPv4/IPv6-Auflösung an den ALTEN Docker-Container statt an den neu gestarteten lokalen Prozess schicken — mit dem Ergebnis, dass die Browser-Session gegen einen veralteten Codestand (und eine andere, bereits befüllte SQLite-Datei) läuft, während `curl 127.0.0.1:8000` (explizit IPv4) den lokalen Prozess trifft und alles korrekt aussieht. Führt zu sehr verwirrenden "funktioniert per curl, crasht im Browser"-Bugs. Vor jedem lokalen Backend-Test daher `docker ps` prüfen und ggf. `docker compose down` ausführen.
|
||||||
|
- **CORS-Origin ist konfigurierbar**: `backend/app/main.py` liest den erlaubten Frontend-Port aus der Env-Var `FRONTEND_PORT` (Default `8310`, passend zum aktuellen `docker-compose.yml`). Für lokale Entwicklung auf Port 5173 (`npm run dev`) muss der lokale uvicorn-Prozess mit `FRONTEND_PORT=5173 uvicorn app.main:app --reload` gestartet werden, sonst schlägt die CORS-Preflight-Anfrage (`OPTIONS` → 400) fehl und Login/Register funktionieren im Browser nicht, obwohl `curl` normal antwortet.
|
||||||
|
- **Dezimal-Eingabe mit Komma**: `<input type="number">` akzeptiert in vielen Locales/Browsern kein Komma als Dezimaltrennzeichen — Eingaben wie „9,8“ werden als ungültig verworfen. Alle Nährwert-/Mengen-/Ziel-Inputs sind deshalb `type="text"` mit `inputMode="decimal"`; das Parsen läuft über `parseDecimal()` (`frontend/src/utils/numbers.js`), das Komma zu Punkt normalisiert.
|
||||||
|
|
||||||
|
## Testen
|
||||||
|
|
||||||
|
Es gibt aktuell keine automatisierten Tests (keine pytest-/vitest-Suite). Änderungen werden manuell verifiziert:
|
||||||
|
- Backend: `curl` gegen die Endpunkte (siehe Beispiele in der Commit-/Session-Historie) oder Swagger-UI unter `/docs`.
|
||||||
|
- Frontend: Dev-Server starten und den Flow im Browser durchklicken (Login → Scan/Fallback → Dashboard). Kamera-Zugriff lässt sich in Headless-/Preview-Browsern nicht testen (Permission wird immer verweigert) — dafür reicht es, zu prüfen, dass der Scanner-Dialog ohne Absturz öffnet und einen Fehlertext zeigt; echtes Scannen muss auf einem Gerät mit Kamera (über `localhost` oder HTTPS) verifiziert werden.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# KalorienTracker
|
||||||
|
|
||||||
|
Ein selbst gehosteter Kalorien-Tracker als Webapp: Barcode scannen, Nährwerte automatisch von [Open Food Facts](https://world.openfoodfacts.org/) ziehen, bei unbekannten Produkten manuell nacherfassen, Tagesübersicht mit Kalorien/Makros.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
KalorienTracker/
|
||||||
|
├── backend/ FastAPI + SQLModel (SQLite), JWT-Auth
|
||||||
|
├── frontend/ React (Vite) + Tailwind, mobile-first
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Backend** (`backend/`): FastAPI-API mit JWT-Login, Produktsuche (lokale DB → Fallback auf Open Food Facts, Ergebnis wird lokal gecacht), Logging von Mahlzeiten inkl. Tagesübersicht, Historie (Tage/Wochen/Monate) und selbst gesetzten Tageszielen. Mehrbenutzerfähig: jeder registrierte Nutzer sieht nur seine eigenen Tagebuch-Einträge, Ziele und privaten Rezepte, während alle Lebensmittel (Barcode-Produkte) in einer gemeinsamen Datenbank für alle Nutzer verfügbar sind. Jeder Endpunkt außer Registrierung/Login verlangt ein gültiges JWT.
|
||||||
|
- **Frontend** (`frontend/`): React-SPA mit Kamera-Barcode-Scanner (`html5-qrcode`), Dashboard mit Fortschrittsbalken für Kalorien/Protein/Carbs/Fett, Formular für manuell angelegte Produkte, Historie-Seite mit Wochen-/Monatsdurchschnitt, Settings-Seite für eigene Tagesziele, umschaltbarer Dark Mode (folgt standardmäßig der Systemeinstellung, per Klick auf das Sonne/Mond-Icon umschaltbar, Wahl bleibt gespeichert).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Variante 1: Docker (empfohlen)
|
||||||
|
|
||||||
|
Voraussetzung: Docker + Docker Compose.
|
||||||
|
|
||||||
|
1. Backend-Konfiguration anlegen:
|
||||||
|
```bash
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
```
|
||||||
|
Trage in `backend/.env` einen eigenen, zufälligen `SECRET_KEY` ein (z. B. `python3 -c "import secrets; print(secrets.token_hex(32))"`).
|
||||||
|
|
||||||
|
2. Bauen und starten:
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Öffnen: [http://localhost:8080](http://localhost:8080)
|
||||||
|
|
||||||
|
Die App läuft komplett hinter einem nginx-Reverse-Proxy: das Frontend wird als statisches Build ausgeliefert, `/api/*`-Requests werden intern an das Backend weitergeleitet. Es ist also nur **ein** Port (`8080`) nach außen nötig — kein CORS-Setup notwendig, auch nicht vom Handy im selben Netzwerk.
|
||||||
|
|
||||||
|
Die SQLite-Datenbank wird persistent unter `./data/kalorientracker.db` auf dem Host gespeichert (Docker-Volume), bleibt also auch nach `docker compose down` erhalten.
|
||||||
|
|
||||||
|
Container stoppen: `docker compose down`.
|
||||||
|
|
||||||
|
#### Wichtiger Hinweis: Kamera-Zugriff & HTTPS
|
||||||
|
|
||||||
|
Browser erlauben Kamera-Zugriff (`getUserMedia`, nötig für den Barcode-Scanner) nur in einem "secure context" — also `https://` oder `localhost`. Ruft man die App über `http://<lan-ip>:8080` vom Handy aus auf, wird der Browser die Kamera **blockieren**. Für produktiven Einsatz übers Heimnetz braucht es daher zusätzlich HTTPS, z. B. über:
|
||||||
|
- einen Reverse Proxy mit automatischem HTTPS (z. B. [Caddy](https://caddyserver.com/)) vor dem `frontend`-Container,
|
||||||
|
- ein selbstsigniertes Zertifikat (z. B. mit [mkcert](https://github.com/FiloSottile/mkcert)) im Heimnetz, oder
|
||||||
|
- einen Tunnel-Dienst (z. B. Tailscale Funnel, Cloudflare Tunnel), der HTTPS terminiert.
|
||||||
|
|
||||||
|
Ohne HTTPS funktioniert die App abgesehen vom Scannen ganz normal (manuelles Anlegen per Formular geht immer).
|
||||||
|
|
||||||
|
### Variante 2: Lokale Entwicklung ohne Docker
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # SECRET_KEY setzen, siehe oben
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
API läuft auf `http://localhost:8000`, Swagger-UI unter `http://localhost:8000/docs`.
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev -- --port 5173
|
||||||
|
```
|
||||||
|
Läuft auf `http://localhost:5173` und spricht per CORS direkt mit dem Backend auf Port 8000 (siehe `frontend/.env`, `VITE_API_URL`).
|
||||||
|
|
||||||
|
## Nutzung
|
||||||
|
|
||||||
|
1. Registrieren / einloggen.
|
||||||
|
2. Auf „Barcode scannen“ tippen, Kamera auf den Barcode halten. Falls die Live-Erkennung hakt (z. B. schlechtes Licht oder Fokus), per „Stattdessen Foto aufnehmen“ ein einzelnes scharfes Foto aufnehmen — das Bild wird einmalig ausgewertet und danach verworfen, nie gespeichert oder hochgeladen.
|
||||||
|
3. Produkt gefunden → Mahlzeit (Frühstück/Mittagessen/Abendessen/Snacks, per Uhrzeit vorausgewählt) + Menge in Gramm eingeben → „Hinzufügen“. Angezeigt werden Kalorien, Carbs, Protein, Fett sowie Zucker, Ballaststoffe, gesättigte Fettsäuren und Salz pro 100g.
|
||||||
|
4. Produkt nicht gefunden (404) → Name + Nährwerte pro 100g manuell eintragen (die vier Zusatz-Nährwerte sind optional) → wird angelegt und direkt geloggt.
|
||||||
|
5. Alternativ „Mahlzeit hinzufügen“ tippen, um ohne Scan nach einem Produkt zu suchen (z. B. „Döner“ vom Essen gehen) oder ein neues anzulegen. Die Suche schlägt zuerst die zuletzt selbst verwendeten Produkte vor (inkl. der zuletzt genutzten Menge) — auch wenn sie ursprünglich per Barcode gescannt wurden.
|
||||||
|
6. Dashboard zeigt die Tagessumme aus Kalorien, Protein, Carbs und Fett als Fortschrittsbalken gegen die eigenen Ziele, plus eine Zeile mit den restlichen Nährwerten (Zucker/Ballaststoffe/ges. Fett/Salz, ohne eigenes Ziel), sowie die Einträge gruppiert nach Frühstück/Mittagessen/Abendessen/Snacks. Jeder Eintrag lässt sich über die Stift-/Papierkorb-Icons nachträglich in Menge/Mahlzeit bearbeiten oder löschen.
|
||||||
|
7. Unter „Ziele“ lassen sich die eigenen Tagesziele für Kalorien, Protein, Carbs und Fett festlegen (Default: 2000 kcal / 100 g Protein / 250 g Carbs / 70 g Fett).
|
||||||
|
8. Unter „Historie“ gibt es den Wochen- und Monatsdurchschnitt (nur über Tage mit Einträgen gemittelt) sowie eine Liste der letzten 30 Tage — ein Tag antippen zeigt die geloggten Einträge dieses Tages, ebenfalls nach Mahlzeit gruppiert.
|
||||||
|
9. Unter „Kochbuch“ lassen sich eigene Gerichte aus bereits bekannten Produkten zusammenstellen (Name, Zubereitung, Zutaten mit Menge, bei Bedarf direkt per Scan oder manuellem Formular neu angelegt) — die Nährwerte pro 100g werden automatisch aus den Zutaten berechnet. Ein Gericht taucht danach wie ein normales Produkt in der Suche auf (mit „Gericht“-Badge) und lässt sich genauso mengenbasiert loggen, bearbeiten oder löschen — praktisch für Rezepte, die man öfter kocht, oder als Inspiration. Rezepte sind privat und nur für den Ersteller sichtbar, lassen sich aber über „Teilen“ auf der Detailseite gezielt mit anderen registrierten Nutzern teilen (diese können das Rezept dann ansehen und loggen, aber nicht bearbeiten oder löschen).
|
||||||
|
|
||||||
|
## Tech-Stack
|
||||||
|
|
||||||
|
- Backend: FastAPI, SQLModel, SQLite, JWT (python-jose), passlib/bcrypt, httpx
|
||||||
|
- Frontend: React, Vite, TailwindCSS, react-router-dom, axios, html5-qrcode
|
||||||
|
- Deployment: Docker, nginx (Reverse Proxy + Static Hosting)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models import Dish, DishShare
|
||||||
|
|
||||||
|
|
||||||
|
def dish_for_barcode(session: Session, barcode: str) -> Dish | None:
|
||||||
|
if not barcode.startswith("dish-"):
|
||||||
|
return None
|
||||||
|
return session.exec(select(Dish).where(Dish.product_barcode == barcode)).first()
|
||||||
|
|
||||||
|
|
||||||
|
def user_can_access_dish(session: Session, dish: Dish, user_id: int) -> bool:
|
||||||
|
if dish.user_id == user_id:
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
session.exec(
|
||||||
|
select(DishShare).where(
|
||||||
|
DishShare.dish_id == dish.id,
|
||||||
|
DishShare.shared_with_user_id == user_id,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def product_barcode_accessible(session: Session, barcode: str, user_id: int) -> bool:
|
||||||
|
dish = dish_for_barcode(session, barcode)
|
||||||
|
if dish is None:
|
||||||
|
return True
|
||||||
|
return user_can_access_dish(session, dish, user_id)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
secret_key: str
|
||||||
|
algorithm: str = "HS256"
|
||||||
|
access_token_expire_minutes: int = 60 * 24
|
||||||
|
database_url: str = "sqlite:///./kalorientracker.db"
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
|
||||||
|
engine = create_engine(settings.database_url, connect_args=connect_args)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_sql(column) -> str:
|
||||||
|
if column.default is None or not getattr(column.default, "is_scalar", False):
|
||||||
|
return ""
|
||||||
|
value = column.default.arg
|
||||||
|
if callable(value):
|
||||||
|
return ""
|
||||||
|
if isinstance(value, Enum):
|
||||||
|
value = value.value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return f" DEFAULT '{value.replace(chr(39), chr(39) * 2)}'"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return f" DEFAULT {int(value)}"
|
||||||
|
return f" DEFAULT {value}"
|
||||||
|
|
||||||
|
|
||||||
|
def _add_missing_columns() -> None:
|
||||||
|
"""Additive, best-effort schema sync for SQLite: adds columns that exist on the
|
||||||
|
SQLModel definitions but not yet in the DB, so new fields don't require wiping
|
||||||
|
the database. Does not handle renames, type changes, or column removal."""
|
||||||
|
inspector = inspect(engine)
|
||||||
|
existing_tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for table in SQLModel.metadata.sorted_tables:
|
||||||
|
if table.name not in existing_tables:
|
||||||
|
continue
|
||||||
|
existing_columns = {col["name"] for col in inspector.get_columns(table.name)}
|
||||||
|
for column in table.columns:
|
||||||
|
if column.name in existing_columns:
|
||||||
|
continue
|
||||||
|
col_type = column.type.compile(dialect=engine.dialect)
|
||||||
|
conn.execute(
|
||||||
|
text(f'ALTER TABLE "{table.name}" ADD COLUMN "{column.name}" {col_type}{_default_sql(column)}')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_db_and_tables() -> None:
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
_add_missing_columns()
|
||||||
|
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas import TokenData
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||||
|
|
||||||
|
SessionDep = Annotated[Session, Depends(get_session)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
session: SessionDep,
|
||||||
|
token: Annotated[str, Depends(oauth2_scheme)],
|
||||||
|
) -> User:
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||||
|
username = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
token_data = TokenData(username=username)
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
user = session.exec(select(User).where(User.username == token_data.username)).first()
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUserDep = Annotated[User, Depends(get_current_user)]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.database import create_db_and_tables
|
||||||
|
from app.routers import auth, dishes, logs, products, users
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
create_db_and_tables()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="KalorienTracker API", lifespan=lifespan)
|
||||||
|
|
||||||
|
_frontend_port = os.getenv("FRONTEND_PORT", "8310")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[
|
||||||
|
f"http://localhost:{_frontend_port}",
|
||||||
|
f"http://127.0.0.1:{_frontend_port}",
|
||||||
|
],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
app.include_router(products.router)
|
||||||
|
app.include_router(logs.router)
|
||||||
|
app.include_router(dishes.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health_check():
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class MealType(str, Enum):
|
||||||
|
breakfast = "breakfast"
|
||||||
|
lunch = "lunch"
|
||||||
|
dinner = "dinner"
|
||||||
|
snack = "snack"
|
||||||
|
|
||||||
|
|
||||||
|
class User(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
username: str = Field(unique=True, index=True)
|
||||||
|
hashed_password: str
|
||||||
|
calorie_goal: float = 2000
|
||||||
|
protein_goal: float = 100
|
||||||
|
carbs_goal: float = 250
|
||||||
|
fat_goal: float = 70
|
||||||
|
|
||||||
|
|
||||||
|
class Product(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
barcode: str = Field(unique=True, index=True)
|
||||||
|
name: str
|
||||||
|
calories: float
|
||||||
|
carbs: float
|
||||||
|
protein: float
|
||||||
|
fat: float
|
||||||
|
sugar: float = 0.0
|
||||||
|
fiber: float = 0.0
|
||||||
|
saturated_fat: float = 0.0
|
||||||
|
salt: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class FoodLog(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id", index=True)
|
||||||
|
barcode: Optional[str] = Field(default=None, index=True)
|
||||||
|
name: str
|
||||||
|
meal_type: MealType = MealType.snack
|
||||||
|
amount_g: float
|
||||||
|
calories: float
|
||||||
|
carbs: float
|
||||||
|
protein: float
|
||||||
|
fat: float
|
||||||
|
sugar: float = 0.0
|
||||||
|
fiber: float = 0.0
|
||||||
|
saturated_fat: float = 0.0
|
||||||
|
salt: float = 0.0
|
||||||
|
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class Dish(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id", index=True)
|
||||||
|
product_barcode: str = Field(foreign_key="product.barcode", unique=True, index=True)
|
||||||
|
name: str
|
||||||
|
instructions: Optional[str] = None
|
||||||
|
total_weight_g: float
|
||||||
|
|
||||||
|
|
||||||
|
class DishIngredient(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
dish_id: int = Field(foreign_key="dish.id", index=True)
|
||||||
|
product_barcode: str = Field(foreign_key="product.barcode")
|
||||||
|
amount_g: float
|
||||||
|
|
||||||
|
|
||||||
|
class DishShare(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
dish_id: int = Field(foreign_key="dish.id", index=True)
|
||||||
|
shared_with_user_id: int = Field(foreign_key="user.id", index=True)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app.deps import SessionDep
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas import Token, UserCreate, UserRead
|
||||||
|
from app.security import create_access_token, get_password_hash, verify_password
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
def register(user_in: UserCreate, session: SessionDep):
|
||||||
|
existing_user = session.exec(select(User).where(User.username == user_in.username)).first()
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered")
|
||||||
|
|
||||||
|
user = User(username=user_in.username, hashed_password=get_password_hash(user_in.password))
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token", response_model=Token)
|
||||||
|
def login(
|
||||||
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
|
session: SessionDep,
|
||||||
|
):
|
||||||
|
user = session.exec(select(User).where(User.username == form_data.username)).first()
|
||||||
|
if not user or not verify_password(form_data.password, user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = create_access_token(data={"sub": user.username})
|
||||||
|
return Token(access_token=access_token)
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.access import product_barcode_accessible, user_can_access_dish
|
||||||
|
from app.deps import CurrentUserDep, SessionDep
|
||||||
|
from app.models import Dish, DishIngredient, DishShare, Product, User
|
||||||
|
from app.schemas import DishCreate, DishIngredientRead, DishRead, DishShareCreate, DishShareRead
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/dishes", tags=["dishes"])
|
||||||
|
|
||||||
|
NUTRIENT_FIELDS = ["calories", "carbs", "protein", "fat", "sugar", "fiber", "saturated_fat", "salt"]
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_nutrition_per_100g(session: Session, ingredients: list, user_id: int) -> tuple[dict, float]:
|
||||||
|
total_weight = sum(ingredient.amount_g for ingredient in ingredients)
|
||||||
|
if total_weight <= 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Zutaten ergeben insgesamt 0g")
|
||||||
|
|
||||||
|
totals = dict.fromkeys(NUTRIENT_FIELDS, 0.0)
|
||||||
|
for ingredient in ingredients:
|
||||||
|
product = session.exec(select(Product).where(Product.barcode == ingredient.barcode)).first()
|
||||||
|
if product is None or not product_barcode_accessible(session, ingredient.barcode, user_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Zutat mit Barcode {ingredient.barcode} nicht gefunden",
|
||||||
|
)
|
||||||
|
factor = ingredient.amount_g / 100.0
|
||||||
|
for field in NUTRIENT_FIELDS:
|
||||||
|
totals[field] += getattr(product, field) * factor
|
||||||
|
|
||||||
|
per_100g = {field: totals[field] / total_weight * 100 for field in NUTRIENT_FIELDS}
|
||||||
|
return per_100g, total_weight
|
||||||
|
|
||||||
|
|
||||||
|
def _get_owned_dish(session: Session, dish_id: int, user_id: int) -> Dish:
|
||||||
|
dish = session.get(Dish, dish_id)
|
||||||
|
if dish is None or dish.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gericht nicht gefunden")
|
||||||
|
return dish
|
||||||
|
|
||||||
|
|
||||||
|
def _get_accessible_dish(session: Session, dish_id: int, user_id: int) -> Dish:
|
||||||
|
dish = session.get(Dish, dish_id)
|
||||||
|
if dish is None or not user_can_access_dish(session, dish, user_id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gericht nicht gefunden")
|
||||||
|
return dish
|
||||||
|
|
||||||
|
|
||||||
|
def _to_dish_read(session: Session, dish: Dish, current_user_id: int) -> DishRead:
|
||||||
|
product = session.exec(select(Product).where(Product.barcode == dish.product_barcode)).first()
|
||||||
|
ingredient_rows = session.exec(select(DishIngredient).where(DishIngredient.dish_id == dish.id)).all()
|
||||||
|
owner = session.get(User, dish.user_id)
|
||||||
|
|
||||||
|
ingredients = []
|
||||||
|
for row in ingredient_rows:
|
||||||
|
ingredient_product = session.exec(select(Product).where(Product.barcode == row.product_barcode)).first()
|
||||||
|
ingredients.append(
|
||||||
|
DishIngredientRead(
|
||||||
|
barcode=row.product_barcode,
|
||||||
|
name=ingredient_product.name if ingredient_product else "Unbekannt",
|
||||||
|
amount_g=row.amount_g,
|
||||||
|
**{
|
||||||
|
field: (getattr(ingredient_product, field) if ingredient_product else 0.0)
|
||||||
|
for field in NUTRIENT_FIELDS
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return DishRead(
|
||||||
|
id=dish.id,
|
||||||
|
name=dish.name,
|
||||||
|
instructions=dish.instructions,
|
||||||
|
barcode=dish.product_barcode,
|
||||||
|
total_weight_g=dish.total_weight_g,
|
||||||
|
ingredients=ingredients,
|
||||||
|
is_owner=dish.user_id == current_user_id,
|
||||||
|
owner_username=owner.username if owner else "?",
|
||||||
|
**{field: getattr(product, field) for field in NUTRIENT_FIELDS},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_ingredients(session: Session, dish: Dish, ingredients_in: list) -> None:
|
||||||
|
existing = session.exec(select(DishIngredient).where(DishIngredient.dish_id == dish.id)).all()
|
||||||
|
for row in existing:
|
||||||
|
session.delete(row)
|
||||||
|
session.flush()
|
||||||
|
for ingredient in ingredients_in:
|
||||||
|
session.add(DishIngredient(dish_id=dish.id, product_barcode=ingredient.barcode, amount_g=ingredient.amount_g))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=DishRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_dish(dish_in: DishCreate, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
per_100g, total_weight = _compute_nutrition_per_100g(session, dish_in.ingredients, current_user.id)
|
||||||
|
|
||||||
|
barcode = f"dish-{uuid4().hex[:12]}"
|
||||||
|
product = Product(barcode=barcode, name=dish_in.name, **per_100g)
|
||||||
|
session.add(product)
|
||||||
|
|
||||||
|
dish = Dish(
|
||||||
|
user_id=current_user.id,
|
||||||
|
product_barcode=barcode,
|
||||||
|
name=dish_in.name,
|
||||||
|
instructions=dish_in.instructions,
|
||||||
|
total_weight_g=total_weight,
|
||||||
|
)
|
||||||
|
session.add(dish)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
_replace_ingredients(session, dish, dish_in.ingredients)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.refresh(dish)
|
||||||
|
return _to_dish_read(session, dish, current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[DishRead])
|
||||||
|
def list_dishes(current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
owned = session.exec(select(Dish).where(Dish.user_id == current_user.id)).all()
|
||||||
|
shared_dish_ids = session.exec(
|
||||||
|
select(DishShare.dish_id).where(DishShare.shared_with_user_id == current_user.id)
|
||||||
|
).all()
|
||||||
|
shared = session.exec(select(Dish).where(Dish.id.in_(shared_dish_ids))).all() if shared_dish_ids else []
|
||||||
|
dishes = sorted(owned + shared, key=lambda dish: dish.name.lower())
|
||||||
|
return [_to_dish_read(session, dish, current_user.id) for dish in dishes]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{dish_id}", response_model=DishRead)
|
||||||
|
def get_dish(dish_id: int, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
dish = _get_accessible_dish(session, dish_id, current_user.id)
|
||||||
|
return _to_dish_read(session, dish, current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{dish_id}", response_model=DishRead)
|
||||||
|
def update_dish(dish_id: int, dish_in: DishCreate, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
dish = _get_owned_dish(session, dish_id, current_user.id)
|
||||||
|
per_100g, total_weight = _compute_nutrition_per_100g(session, dish_in.ingredients, current_user.id)
|
||||||
|
|
||||||
|
product = session.exec(select(Product).where(Product.barcode == dish.product_barcode)).first()
|
||||||
|
product.name = dish_in.name
|
||||||
|
for field in NUTRIENT_FIELDS:
|
||||||
|
setattr(product, field, per_100g[field])
|
||||||
|
session.add(product)
|
||||||
|
|
||||||
|
dish.name = dish_in.name
|
||||||
|
dish.instructions = dish_in.instructions
|
||||||
|
dish.total_weight_g = total_weight
|
||||||
|
session.add(dish)
|
||||||
|
|
||||||
|
_replace_ingredients(session, dish, dish_in.ingredients)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.refresh(dish)
|
||||||
|
return _to_dish_read(session, dish, current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{dish_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_dish(dish_id: int, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
dish = _get_owned_dish(session, dish_id, current_user.id)
|
||||||
|
|
||||||
|
ingredient_rows = session.exec(select(DishIngredient).where(DishIngredient.dish_id == dish.id)).all()
|
||||||
|
for row in ingredient_rows:
|
||||||
|
session.delete(row)
|
||||||
|
|
||||||
|
share_rows = session.exec(select(DishShare).where(DishShare.dish_id == dish.id)).all()
|
||||||
|
for row in share_rows:
|
||||||
|
session.delete(row)
|
||||||
|
|
||||||
|
product = session.exec(select(Product).where(Product.barcode == dish.product_barcode)).first()
|
||||||
|
session.delete(dish)
|
||||||
|
if product:
|
||||||
|
session.delete(product)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{dish_id}/shares", response_model=list[DishShareRead])
|
||||||
|
def list_shares(dish_id: int, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
dish = _get_owned_dish(session, dish_id, current_user.id)
|
||||||
|
shares = session.exec(select(DishShare).where(DishShare.dish_id == dish.id)).all()
|
||||||
|
users = {user.id: user for user in session.exec(select(User)).all()}
|
||||||
|
return [DishShareRead(username=users[s.shared_with_user_id].username) for s in shares if s.shared_with_user_id in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{dish_id}/shares", response_model=list[DishShareRead], status_code=status.HTTP_201_CREATED)
|
||||||
|
def add_share(dish_id: int, share_in: DishShareCreate, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
dish = _get_owned_dish(session, dish_id, current_user.id)
|
||||||
|
|
||||||
|
target = session.exec(select(User).where(User.username == share_in.username)).first()
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nutzer nicht gefunden")
|
||||||
|
if target.id == current_user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Kann nicht mit sich selbst geteilt werden")
|
||||||
|
|
||||||
|
existing = session.exec(
|
||||||
|
select(DishShare).where(DishShare.dish_id == dish.id, DishShare.shared_with_user_id == target.id)
|
||||||
|
).first()
|
||||||
|
if existing is None:
|
||||||
|
session.add(DishShare(dish_id=dish.id, shared_with_user_id=target.id))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return list_shares(dish_id, current_user, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{dish_id}/shares/{username}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def remove_share(dish_id: int, username: str, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
dish = _get_owned_dish(session, dish_id, current_user.id)
|
||||||
|
|
||||||
|
target = session.exec(select(User).where(User.username == username)).first()
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nutzer nicht gefunden")
|
||||||
|
|
||||||
|
row = session.exec(
|
||||||
|
select(DishShare).where(DishShare.dish_id == dish.id, DishShare.shared_with_user_id == target.id)
|
||||||
|
).first()
|
||||||
|
if row:
|
||||||
|
session.delete(row)
|
||||||
|
session.commit()
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
from datetime import date as date_type
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, status
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.access import product_barcode_accessible
|
||||||
|
from app.deps import CurrentUserDep, SessionDep
|
||||||
|
from app.models import FoodLog, Product
|
||||||
|
from app.schemas import (
|
||||||
|
DailyHistoryEntry,
|
||||||
|
DailyLogResponse,
|
||||||
|
DailyTotals,
|
||||||
|
HistoryResponse,
|
||||||
|
LogCreate,
|
||||||
|
LogRead,
|
||||||
|
LogUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||||
|
|
||||||
|
|
||||||
|
def _day_bounds(day: date_type) -> tuple[datetime, datetime]:
|
||||||
|
start = datetime(day.year, day.month, day.day, tzinfo=timezone.utc)
|
||||||
|
return start, start + timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _totals_for_logs(logs: list[FoodLog]) -> DailyTotals:
|
||||||
|
return DailyTotals(
|
||||||
|
calories=sum(log.calories for log in logs),
|
||||||
|
carbs=sum(log.carbs for log in logs),
|
||||||
|
protein=sum(log.protein for log in logs),
|
||||||
|
fat=sum(log.fat for log in logs),
|
||||||
|
sugar=sum(log.sugar for log in logs),
|
||||||
|
fiber=sum(log.fiber for log in logs),
|
||||||
|
saturated_fat=sum(log.saturated_fat for log in logs),
|
||||||
|
salt=sum(log.salt for log in logs),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _day_response(session: Session, user_id: int, day: date_type) -> DailyLogResponse:
|
||||||
|
start, end = _day_bounds(day)
|
||||||
|
logs = session.exec(
|
||||||
|
select(FoodLog)
|
||||||
|
.where(FoodLog.user_id == user_id)
|
||||||
|
.where(FoodLog.timestamp >= start)
|
||||||
|
.where(FoodLog.timestamp < end)
|
||||||
|
.order_by(FoodLog.timestamp)
|
||||||
|
).all()
|
||||||
|
return DailyLogResponse(logs=logs, totals=_totals_for_logs(logs))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=LogRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_log(log_in: LogCreate, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
product = None
|
||||||
|
if log_in.barcode:
|
||||||
|
product = session.exec(select(Product).where(Product.barcode == log_in.barcode)).first()
|
||||||
|
elif log_in.name:
|
||||||
|
product = session.exec(select(Product).where(Product.name == log_in.name)).first()
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="barcode oder name erforderlich")
|
||||||
|
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Produkt nicht gefunden. Zuerst per GET /api/products/{barcode} suchen oder per POST /api/products anlegen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not product_barcode_accessible(session, product.barcode, current_user.id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Produkt nicht gefunden. Zuerst per GET /api/products/{barcode} suchen oder per POST /api/products anlegen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
factor = log_in.amount_g / 100.0
|
||||||
|
log = FoodLog(
|
||||||
|
user_id=current_user.id,
|
||||||
|
barcode=product.barcode,
|
||||||
|
name=product.name,
|
||||||
|
meal_type=log_in.meal_type,
|
||||||
|
amount_g=log_in.amount_g,
|
||||||
|
calories=product.calories * factor,
|
||||||
|
carbs=product.carbs * factor,
|
||||||
|
protein=product.protein * factor,
|
||||||
|
fat=product.fat * factor,
|
||||||
|
sugar=product.sugar * factor,
|
||||||
|
fiber=product.fiber * factor,
|
||||||
|
saturated_fat=product.saturated_fat * factor,
|
||||||
|
salt=product.salt * factor,
|
||||||
|
)
|
||||||
|
session.add(log)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(log)
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
def _get_owned_log(session: Session, log_id: int, user_id: int) -> FoodLog:
|
||||||
|
log = session.get(FoodLog, log_id)
|
||||||
|
if log is None or log.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Eintrag nicht gefunden")
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{log_id}", response_model=LogRead)
|
||||||
|
def update_log(log_id: int, log_in: LogUpdate, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
log = _get_owned_log(session, log_id, current_user.id)
|
||||||
|
|
||||||
|
if log_in.amount_g is not None and log_in.amount_g != log.amount_g:
|
||||||
|
factor = log_in.amount_g / log.amount_g
|
||||||
|
log.calories *= factor
|
||||||
|
log.carbs *= factor
|
||||||
|
log.protein *= factor
|
||||||
|
log.fat *= factor
|
||||||
|
log.sugar *= factor
|
||||||
|
log.fiber *= factor
|
||||||
|
log.saturated_fat *= factor
|
||||||
|
log.salt *= factor
|
||||||
|
log.amount_g = log_in.amount_g
|
||||||
|
|
||||||
|
if log_in.meal_type is not None:
|
||||||
|
log.meal_type = log_in.meal_type
|
||||||
|
|
||||||
|
session.add(log)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(log)
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_log(log_id: int, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
log = _get_owned_log(session, log_id, current_user.id)
|
||||||
|
session.delete(log)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/today", response_model=DailyLogResponse)
|
||||||
|
def get_today_logs(current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
return _day_response(session, current_user.id, today)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history", response_model=HistoryResponse)
|
||||||
|
def get_history(
|
||||||
|
current_user: CurrentUserDep,
|
||||||
|
session: SessionDep,
|
||||||
|
days: int = Query(default=30, ge=1, le=365),
|
||||||
|
):
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
range_start_day = today - timedelta(days=days - 1)
|
||||||
|
range_start, _ = _day_bounds(range_start_day)
|
||||||
|
_, range_end = _day_bounds(today)
|
||||||
|
|
||||||
|
logs = session.exec(
|
||||||
|
select(FoodLog)
|
||||||
|
.where(FoodLog.user_id == current_user.id)
|
||||||
|
.where(FoodLog.timestamp >= range_start)
|
||||||
|
.where(FoodLog.timestamp < range_end)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
logs_by_day: dict[date_type, list[FoodLog]] = {}
|
||||||
|
for log in logs:
|
||||||
|
logs_by_day.setdefault(log.timestamp.date(), []).append(log)
|
||||||
|
|
||||||
|
result_days = [
|
||||||
|
DailyHistoryEntry(
|
||||||
|
date=range_start_day + timedelta(days=offset),
|
||||||
|
totals=_totals_for_logs(logs_by_day.get(range_start_day + timedelta(days=offset), [])),
|
||||||
|
)
|
||||||
|
for offset in range(days)
|
||||||
|
]
|
||||||
|
return HistoryResponse(days=result_days)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/day/{day}", response_model=DailyLogResponse)
|
||||||
|
def get_day_logs(day: date_type, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
return _day_response(session, current_user.id, day)
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, status
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app.access import product_barcode_accessible
|
||||||
|
from app.deps import CurrentUserDep, SessionDep
|
||||||
|
from app.models import FoodLog, Product
|
||||||
|
from app.schemas import ProductCreate, ProductRead, ProductSearchResult
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/products", tags=["products"])
|
||||||
|
|
||||||
|
OFF_API_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_macros(nutriments: dict) -> dict:
|
||||||
|
calories = nutriments.get("energy-kcal_100g")
|
||||||
|
if calories is None:
|
||||||
|
energy_kj = nutriments.get("energy_100g")
|
||||||
|
calories = energy_kj / 4.184 if energy_kj is not None else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"calories": calories,
|
||||||
|
"carbs": nutriments.get("carbohydrates_100g", 0.0) or 0.0,
|
||||||
|
"protein": nutriments.get("proteins_100g", 0.0) or 0.0,
|
||||||
|
"fat": nutriments.get("fat_100g", 0.0) or 0.0,
|
||||||
|
"sugar": nutriments.get("sugars_100g", 0.0) or 0.0,
|
||||||
|
"fiber": nutriments.get("fiber_100g", 0.0) or 0.0,
|
||||||
|
"saturated_fat": nutriments.get("saturated-fat_100g", 0.0) or 0.0,
|
||||||
|
"salt": nutriments.get("salt_100g", 0.0) or 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_from_open_food_facts(barcode: str) -> Product | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.get(OFF_API_URL.format(barcode=barcode))
|
||||||
|
except httpx.RequestError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Open Food Facts ist gerade nicht erreichbar",
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if data.get("status") != 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
product_data = data.get("product", {})
|
||||||
|
macros = _extract_macros(product_data.get("nutriments", {}))
|
||||||
|
name = product_data.get("product_name") or product_data.get("generic_name") or "Unbekanntes Produkt"
|
||||||
|
|
||||||
|
return Product(barcode=barcode, name=name, **macros)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ProductRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_product(product_in: ProductCreate, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
barcode = product_in.barcode or f"manual-{uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
existing = session.exec(select(Product).where(Product.barcode == barcode)).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Barcode existiert bereits")
|
||||||
|
|
||||||
|
product = Product(**{**product_in.model_dump(), "barcode": barcode})
|
||||||
|
session.add(product)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(product)
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=list[ProductSearchResult])
|
||||||
|
def search_products(
|
||||||
|
current_user: CurrentUserDep,
|
||||||
|
session: SessionDep,
|
||||||
|
q: str = Query(default=""),
|
||||||
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
|
):
|
||||||
|
query = select(Product)
|
||||||
|
if q:
|
||||||
|
query = query.where(Product.name.ilike(f"%{q}%"))
|
||||||
|
products = [
|
||||||
|
product
|
||||||
|
for product in session.exec(query).all()
|
||||||
|
if product_barcode_accessible(session, product.barcode, current_user.id)
|
||||||
|
]
|
||||||
|
|
||||||
|
recent_logs = session.exec(
|
||||||
|
select(FoodLog)
|
||||||
|
.where(FoodLog.user_id == current_user.id)
|
||||||
|
.order_by(FoodLog.timestamp.desc())
|
||||||
|
.limit(500)
|
||||||
|
).all()
|
||||||
|
last_used: dict[str, FoodLog] = {}
|
||||||
|
for log in recent_logs:
|
||||||
|
if log.barcode and log.barcode not in last_used:
|
||||||
|
last_used[log.barcode] = log
|
||||||
|
|
||||||
|
def sort_key(product: Product):
|
||||||
|
log = last_used.get(product.barcode)
|
||||||
|
if log:
|
||||||
|
return (0, -log.timestamp.timestamp())
|
||||||
|
return (1, product.name.lower())
|
||||||
|
|
||||||
|
products.sort(key=sort_key)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for product in products[:limit]:
|
||||||
|
log = last_used.get(product.barcode)
|
||||||
|
results.append(
|
||||||
|
ProductSearchResult(
|
||||||
|
**product.model_dump(),
|
||||||
|
last_amount_g=log.amount_g if log else None,
|
||||||
|
is_dish=product.barcode.startswith("dish-"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{barcode}", response_model=ProductRead)
|
||||||
|
async def get_product(barcode: str, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
product = session.exec(select(Product).where(Product.barcode == barcode)).first()
|
||||||
|
if product:
|
||||||
|
if not product_barcode_accessible(session, barcode, current_user.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Produkt nicht gefunden")
|
||||||
|
return product
|
||||||
|
|
||||||
|
product = await _fetch_from_open_food_facts(barcode)
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Produkt nicht gefunden")
|
||||||
|
|
||||||
|
session.add(product)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(product)
|
||||||
|
return product
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app.deps import CurrentUserDep, SessionDep
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas import GoalsUpdate, UserRead, UserSummary
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[UserSummary])
|
||||||
|
def list_users(current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
users = session.exec(select(User).where(User.id != current_user.id).order_by(User.username)).all()
|
||||||
|
return [UserSummary(username=user.username) for user in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserRead)
|
||||||
|
def read_current_user(current_user: CurrentUserDep):
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/goals", response_model=UserRead)
|
||||||
|
def update_goals(goals: GoalsUpdate, current_user: CurrentUserDep, session: SessionDep):
|
||||||
|
current_user.calorie_goal = goals.calorie_goal
|
||||||
|
current_user.protein_goal = goals.protein_goal
|
||||||
|
current_user.carbs_goal = goals.carbs_goal
|
||||||
|
current_user.fat_goal = goals.fat_goal
|
||||||
|
session.add(current_user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(current_user)
|
||||||
|
return current_user
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
from datetime import date as date_type
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models import MealType
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
calorie_goal: float
|
||||||
|
protein_goal: float
|
||||||
|
carbs_goal: float
|
||||||
|
fat_goal: float
|
||||||
|
|
||||||
|
|
||||||
|
class UserSummary(BaseModel):
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class GoalsUpdate(BaseModel):
|
||||||
|
calorie_goal: float
|
||||||
|
protein_goal: float
|
||||||
|
carbs_goal: float
|
||||||
|
fat_goal: float
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductBase(BaseModel):
|
||||||
|
barcode: str
|
||||||
|
name: str
|
||||||
|
calories: float
|
||||||
|
carbs: float
|
||||||
|
protein: float
|
||||||
|
fat: float
|
||||||
|
sugar: float = 0.0
|
||||||
|
fiber: float = 0.0
|
||||||
|
saturated_fat: float = 0.0
|
||||||
|
salt: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCreate(ProductBase):
|
||||||
|
barcode: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductRead(ProductBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSearchResult(ProductRead):
|
||||||
|
last_amount_g: float | None = None
|
||||||
|
is_dish: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class LogCreate(BaseModel):
|
||||||
|
barcode: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
amount_g: float
|
||||||
|
meal_type: MealType
|
||||||
|
|
||||||
|
|
||||||
|
class LogUpdate(BaseModel):
|
||||||
|
amount_g: float | None = None
|
||||||
|
meal_type: MealType | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LogRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
barcode: str | None
|
||||||
|
name: str
|
||||||
|
meal_type: MealType
|
||||||
|
amount_g: float
|
||||||
|
calories: float
|
||||||
|
carbs: float
|
||||||
|
protein: float
|
||||||
|
fat: float
|
||||||
|
sugar: float
|
||||||
|
fiber: float
|
||||||
|
saturated_fat: float
|
||||||
|
salt: float
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DailyTotals(BaseModel):
|
||||||
|
calories: float
|
||||||
|
carbs: float
|
||||||
|
protein: float
|
||||||
|
fat: float
|
||||||
|
sugar: float
|
||||||
|
fiber: float
|
||||||
|
saturated_fat: float
|
||||||
|
salt: float
|
||||||
|
|
||||||
|
|
||||||
|
class DailyLogResponse(BaseModel):
|
||||||
|
logs: list[LogRead]
|
||||||
|
totals: DailyTotals
|
||||||
|
|
||||||
|
|
||||||
|
class DailyHistoryEntry(BaseModel):
|
||||||
|
date: date_type
|
||||||
|
totals: DailyTotals
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryResponse(BaseModel):
|
||||||
|
days: list[DailyHistoryEntry]
|
||||||
|
|
||||||
|
|
||||||
|
class DishIngredientInput(BaseModel):
|
||||||
|
barcode: str
|
||||||
|
amount_g: float
|
||||||
|
|
||||||
|
|
||||||
|
class DishIngredientRead(BaseModel):
|
||||||
|
barcode: str
|
||||||
|
name: str
|
||||||
|
amount_g: float
|
||||||
|
calories: float
|
||||||
|
carbs: float
|
||||||
|
protein: float
|
||||||
|
fat: float
|
||||||
|
sugar: float
|
||||||
|
fiber: float
|
||||||
|
saturated_fat: float
|
||||||
|
salt: float
|
||||||
|
|
||||||
|
|
||||||
|
class DishCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
instructions: str | None = None
|
||||||
|
ingredients: list[DishIngredientInput]
|
||||||
|
|
||||||
|
|
||||||
|
class DishRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
instructions: str | None
|
||||||
|
barcode: str
|
||||||
|
total_weight_g: float
|
||||||
|
calories: float
|
||||||
|
carbs: float
|
||||||
|
protein: float
|
||||||
|
fat: float
|
||||||
|
sugar: float
|
||||||
|
fiber: float
|
||||||
|
saturated_fat: float
|
||||||
|
salt: float
|
||||||
|
ingredients: list[DishIngredientRead]
|
||||||
|
is_owner: bool
|
||||||
|
owner_username: str
|
||||||
|
|
||||||
|
|
||||||
|
class DishShareCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class DishShareRead(BaseModel):
|
||||||
|
username: str
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from jose import jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
sqlmodel
|
||||||
|
python-jose[cryptography]
|
||||||
|
passlib[bcrypt]
|
||||||
|
bcrypt==4.0.1
|
||||||
|
python-multipart
|
||||||
|
pydantic-settings
|
||||||
|
httpx
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: sqlite:////app/data/kalorientracker.db
|
||||||
|
FRONTEND_PORT: "8310"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
ports:
|
||||||
|
- "8300:8000"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
args:
|
||||||
|
VITE_API_URL: ""
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "8310:80"
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG VITE_API_URL=""
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<title>KalorienTracker</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2115
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.18.1",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"react-router-dom": "^7.18.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"tailwindcss": "^4.3.2",
|
||||||
|
"vite": "^8.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from './context/AuthContext'
|
||||||
|
import { ThemeProvider } from './context/ThemeContext'
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
import RegisterPage from './pages/RegisterPage'
|
||||||
|
import DashboardPage from './pages/DashboardPage'
|
||||||
|
import HistoryPage from './pages/HistoryPage'
|
||||||
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
import CookbookPage from './pages/CookbookPage'
|
||||||
|
import DishFormPage from './pages/DishFormPage'
|
||||||
|
import DishDetailPage from './pages/DishDetailPage'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/history"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<HistoryPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SettingsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/cookbook"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<CookbookPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/cookbook/new"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DishFormPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/cookbook/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DishDetailPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/cookbook/:id/edit"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DishFormPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
export default apiClient
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import ProductFoundModal from './ProductFoundModal'
|
||||||
|
import ProductNotFoundForm from './ProductNotFoundForm'
|
||||||
|
import ProductSearchInput from './ProductSearchInput'
|
||||||
|
import MealTypeSelect from './MealTypeSelect'
|
||||||
|
import { defaultMealType } from '../utils/mealTypes'
|
||||||
|
|
||||||
|
export default function AddMealModal({ onLogged, onClose }) {
|
||||||
|
const [mealType, setMealType] = useState(defaultMealType)
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState(null)
|
||||||
|
const [showManualForm, setShowManualForm] = useState(false)
|
||||||
|
|
||||||
|
if (selectedProduct) {
|
||||||
|
return (
|
||||||
|
<ProductFoundModal
|
||||||
|
product={selectedProduct}
|
||||||
|
initialAmountG={selectedProduct.last_amount_g ?? 100}
|
||||||
|
initialMealType={mealType}
|
||||||
|
onLogged={onLogged}
|
||||||
|
onClose={() => setSelectedProduct(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showManualForm) {
|
||||||
|
return (
|
||||||
|
<ProductNotFoundForm
|
||||||
|
initialMealType={mealType}
|
||||||
|
onLogged={onLogged}
|
||||||
|
onClose={() => setShowManualForm(false)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
|
||||||
|
<div className="flex max-h-[85vh] w-full max-w-sm flex-col rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
Mahlzeit hinzufügen
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-2xl leading-none text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<MealTypeSelect value={mealType} onChange={setMealType} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProductSearchInput onSelect={setSelectedProduct} placeholder="Produkt suchen (z. B. Döner)" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowManualForm(true)}
|
||||||
|
className="mt-3 w-full rounded-lg border border-dashed border-gray-300 py-2.5 text-sm font-medium text-emerald-600 dark:border-gray-600 dark:text-emerald-400"
|
||||||
|
>
|
||||||
|
+ Neues Produkt anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
|
||||||
|
|
||||||
|
const SCANNER_ELEMENT_ID = 'barcode-scanner'
|
||||||
|
|
||||||
|
const BARCODE_FORMATS = [
|
||||||
|
Html5QrcodeSupportedFormats.EAN_13,
|
||||||
|
Html5QrcodeSupportedFormats.EAN_8,
|
||||||
|
Html5QrcodeSupportedFormats.UPC_A,
|
||||||
|
Html5QrcodeSupportedFormats.UPC_E,
|
||||||
|
Html5QrcodeSupportedFormats.CODE_128,
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function BarcodeScanner({ onDetected, onClose }) {
|
||||||
|
const onDetectedRef = useRef(onDetected)
|
||||||
|
onDetectedRef.current = onDetected
|
||||||
|
const scannerRef = useRef(null)
|
||||||
|
const hasDetectedRef = useRef(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isCameraReady, setIsCameraReady] = useState(false)
|
||||||
|
const [isProcessingPhoto, setIsProcessingPhoto] = useState(false)
|
||||||
|
|
||||||
|
function handleDetected(decodedText) {
|
||||||
|
if (hasDetectedRef.current) return
|
||||||
|
hasDetectedRef.current = true
|
||||||
|
onDetectedRef.current(decodedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLiveScanning(scanner) {
|
||||||
|
return scanner
|
||||||
|
.start(
|
||||||
|
{ facingMode: 'environment' },
|
||||||
|
{ fps: 10, qrbox: { width: 250, height: 150 } },
|
||||||
|
handleDetected,
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
.then(() => setIsCameraReady(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scanner = new Html5Qrcode(SCANNER_ELEMENT_ID, {
|
||||||
|
formatsToSupport: BARCODE_FORMATS,
|
||||||
|
verbose: false,
|
||||||
|
})
|
||||||
|
scannerRef.current = scanner
|
||||||
|
|
||||||
|
startLiveScanning(scanner).catch((err) => {
|
||||||
|
console.error('Kamera konnte nicht gestartet werden', err)
|
||||||
|
setError('Kamera konnte nicht gestartet werden. Bitte Kamera-Berechtigung erlauben.')
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (scanner.isScanning) {
|
||||||
|
scanner
|
||||||
|
.stop()
|
||||||
|
.then(() => scanner.clear())
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function captureAndAnalyze() {
|
||||||
|
const scanner = scannerRef.current
|
||||||
|
const video = document.querySelector(`#${SCANNER_ELEMENT_ID} video`)
|
||||||
|
if (!scanner || !video || !video.videoWidth) return
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
setIsProcessingPhoto(true)
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = video.videoWidth
|
||||||
|
canvas.height = video.videoHeight
|
||||||
|
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||||
|
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
|
||||||
|
const file = new File([blob], 'frame.png', { type: 'image/png' })
|
||||||
|
|
||||||
|
if (scanner.isScanning) {
|
||||||
|
setIsCameraReady(false)
|
||||||
|
await scanner.stop()
|
||||||
|
}
|
||||||
|
const decodedText = await scanner.scanFile(file, false)
|
||||||
|
handleDetected(decodedText)
|
||||||
|
} catch {
|
||||||
|
setError('Kein Barcode erkannt. Bitte erneut versuchen.')
|
||||||
|
startLiveScanning(scanner).catch(() => setIsCameraReady(false))
|
||||||
|
} finally {
|
||||||
|
setIsProcessingPhoto(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-hidden bg-black">
|
||||||
|
<div id={SCANNER_ELEMENT_ID} className="absolute inset-0 [&_video]:!h-full [&_video]:!w-full [&_video]:object-cover" />
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 top-0 flex items-center justify-between bg-gradient-to-b from-black/70 to-transparent p-4 text-white">
|
||||||
|
<span className="text-base font-medium">Barcode scannen</span>
|
||||||
|
<button onClick={onClose} className="text-2xl leading-none">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-0 flex flex-col items-center gap-3 bg-gradient-to-t from-black/80 to-transparent p-6 pb-8">
|
||||||
|
<p className="text-center text-sm text-white">
|
||||||
|
{error || (isProcessingPhoto ? 'Foto wird analysiert…' : 'Halte den Barcode vor die Kamera')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={captureAndAnalyze}
|
||||||
|
disabled={isProcessingPhoto || !isCameraReady}
|
||||||
|
aria-label="Foto aufnehmen"
|
||||||
|
className="flex h-16 w-16 items-center justify-center rounded-full bg-red-600 shadow-lg active:bg-red-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span className="h-12 w-12 rounded-full border-2 border-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export default function ConfirmDialog({ title, message, confirmLabel = 'Löschen', onConfirm, onCancel }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div className="w-full max-w-sm rounded-2xl bg-white p-6 dark:bg-gray-800">
|
||||||
|
<h2 className="mb-1 text-lg font-semibold text-gray-900 dark:text-gray-50">{title}</h2>
|
||||||
|
{message && <p className="mb-4 text-sm text-gray-500 dark:text-gray-400">{message}</p>}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="flex-1 rounded-lg bg-red-600 py-2.5 text-sm font-medium text-white active:bg-red-700"
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
|
||||||
|
export default function DishShareManager({ dishId }) {
|
||||||
|
const [shares, setShares] = useState([])
|
||||||
|
const [allUsers, setAllUsers] = useState([])
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.get(`/api/dishes/${dishId}/shares`).then((res) => setShares(res.data))
|
||||||
|
apiClient.get('/api/users').then((res) => setAllUsers(res.data))
|
||||||
|
}, [dishId])
|
||||||
|
|
||||||
|
async function handleAdd(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!username.trim()) return
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post(`/api/dishes/${dishId}/shares`, { username: username.trim() })
|
||||||
|
setShares(data)
|
||||||
|
setUsername('')
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
setError('Nutzer nicht gefunden.')
|
||||||
|
} else if (err.response?.status === 400) {
|
||||||
|
setError('Kann nicht mit dir selbst geteilt werden.')
|
||||||
|
} else {
|
||||||
|
setError('Freigabe fehlgeschlagen.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(name) {
|
||||||
|
await apiClient.delete(`/api/dishes/${dishId}/shares/${name}`)
|
||||||
|
setShares((prev) => prev.filter((s) => s.username !== name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">Teilen</h2>
|
||||||
|
{shares.length === 0 ? (
|
||||||
|
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">Noch mit niemandem geteilt.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mb-3 space-y-1.5">
|
||||||
|
{shares.map((share) => (
|
||||||
|
<li key={share.username} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{share.username}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(share.username)}
|
||||||
|
aria-label={`Freigabe für ${share.username} entfernen`}
|
||||||
|
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleAdd} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
list="dish-share-users"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Nutzername"
|
||||||
|
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
<datalist id="dish-share-users">
|
||||||
|
{allUsers.map((user) => (
|
||||||
|
<option key={user.username} value={user.username} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Teilen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import MealTypeSelect from './MealTypeSelect'
|
||||||
|
import { parseDecimal } from '../utils/numbers'
|
||||||
|
|
||||||
|
export default function EditLogModal({ log, onSaved, onClose }) {
|
||||||
|
const [amountG, setAmountG] = useState(String(log.amount_g))
|
||||||
|
const [mealType, setMealType] = useState(log.meal_type)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/api/logs/${log.id}`, {
|
||||||
|
amount_g: parseDecimal(amountG),
|
||||||
|
meal_type: mealType,
|
||||||
|
})
|
||||||
|
onSaved()
|
||||||
|
} catch {
|
||||||
|
setError('Eintrag konnte nicht gespeichert werden.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
|
||||||
|
<div className="w-full max-w-sm rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">{log.name}</h2>
|
||||||
|
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">Eintrag bearbeiten</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<MealTypeSelect value={mealType} onChange={setMealType} />
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Menge (Gramm)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={amountG}
|
||||||
|
onChange={(e) => setAmountG(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-base font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1 rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Speichern…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import BarcodeScanner from './BarcodeScanner'
|
||||||
|
import ProductNotFoundForm from './ProductNotFoundForm'
|
||||||
|
import ProductSearchInput from './ProductSearchInput'
|
||||||
|
|
||||||
|
export default function IngredientPickerModal({ onSelect, onClose }) {
|
||||||
|
const [isScannerOpen, setIsScannerOpen] = useState(false)
|
||||||
|
const [manualBarcode, setManualBarcode] = useState(null)
|
||||||
|
const [showManualForm, setShowManualForm] = useState(false)
|
||||||
|
const [scanError, setScanError] = useState('')
|
||||||
|
|
||||||
|
async function handleDetected(barcode) {
|
||||||
|
setIsScannerOpen(false)
|
||||||
|
setScanError('')
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get(`/api/products/${barcode}`)
|
||||||
|
onSelect(data)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
setManualBarcode(barcode)
|
||||||
|
setShowManualForm(true)
|
||||||
|
} else {
|
||||||
|
setScanError('Produktsuche fehlgeschlagen. Bitte erneut versuchen.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManualForm() {
|
||||||
|
setManualBarcode(null)
|
||||||
|
setShowManualForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showManualForm) {
|
||||||
|
return (
|
||||||
|
<ProductNotFoundForm
|
||||||
|
barcode={manualBarcode}
|
||||||
|
mode="createOnly"
|
||||||
|
onCreated={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
|
||||||
|
<div className="flex max-h-[85vh] w-full max-w-sm flex-col rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Zutat hinzufügen</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-2xl leading-none text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProductSearchInput onSelect={onSelect} placeholder="Zutat suchen" />
|
||||||
|
|
||||||
|
{scanError && (
|
||||||
|
<p className="mt-2 text-center text-sm text-red-600 dark:text-red-400">{scanError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsScannerOpen(true)}
|
||||||
|
className="flex-1 rounded-lg border border-gray-300 py-2 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Barcode scannen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openManualForm}
|
||||||
|
className="flex-1 rounded-lg border border-dashed border-gray-300 py-2 text-sm font-medium text-emerald-600 dark:border-gray-600 dark:text-emerald-400"
|
||||||
|
>
|
||||||
|
+ Neu anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isScannerOpen && (
|
||||||
|
<BarcodeScanner onDetected={handleDetected} onClose={() => setIsScannerOpen(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const BAR_COLORS = {
|
||||||
|
emerald: 'bg-emerald-500',
|
||||||
|
blue: 'bg-blue-500',
|
||||||
|
amber: 'bg-amber-500',
|
||||||
|
rose: 'bg-rose-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MacroProgress({ label, value, target, unit, color }) {
|
||||||
|
const percent = target > 0 ? Math.min(100, Math.round((value / target) * 100)) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-baseline justify-between text-sm">
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">{label}</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
{Math.round(value)} / {target} {unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${BAR_COLORS[color]} transition-all`}
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { MEAL_TYPES } from '../utils/mealTypes'
|
||||||
|
|
||||||
|
export default function MealTypeSelect({ value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Mahlzeit
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
|
{MEAL_TYPES.map((meal) => (
|
||||||
|
<button
|
||||||
|
key={meal.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(meal.value)}
|
||||||
|
className={`rounded-lg py-2 text-xs font-medium transition-colors ${
|
||||||
|
value === meal.value
|
||||||
|
? 'bg-emerald-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{meal.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import MealTypeSelect from './MealTypeSelect'
|
||||||
|
import { defaultMealType } from '../utils/mealTypes'
|
||||||
|
import { parseDecimal } from '../utils/numbers'
|
||||||
|
|
||||||
|
export default function ProductFoundModal({
|
||||||
|
product,
|
||||||
|
initialAmountG = 100,
|
||||||
|
initialMealType,
|
||||||
|
onLogged,
|
||||||
|
onClose,
|
||||||
|
}) {
|
||||||
|
const [amountG, setAmountG] = useState(String(initialAmountG))
|
||||||
|
const [mealType, setMealType] = useState(initialMealType || defaultMealType())
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/logs', {
|
||||||
|
barcode: product.barcode,
|
||||||
|
amount_g: parseDecimal(amountG),
|
||||||
|
meal_type: mealType,
|
||||||
|
})
|
||||||
|
onLogged()
|
||||||
|
} catch {
|
||||||
|
setError('Eintrag konnte nicht gespeichert werden.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
|
||||||
|
<div className="w-full max-w-sm rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">{product.name}</h2>
|
||||||
|
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">Nährwerte pro 100g</p>
|
||||||
|
|
||||||
|
<dl className="mb-4 grid grid-cols-4 gap-2 text-center text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Kcal</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(product.calories)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Carbs</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(product.carbs)}g</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Protein</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(product.protein)}g</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Fett</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(product.fat)}g</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<dl className="mb-4 grid grid-cols-4 gap-2 border-t border-gray-100 pt-3 text-center text-xs dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Zucker</dt>
|
||||||
|
<dd className="font-medium text-gray-800 dark:text-gray-200">{Math.round(product.sugar)}g</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Ballaststoffe</dt>
|
||||||
|
<dd className="font-medium text-gray-800 dark:text-gray-200">{Math.round(product.fiber)}g</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Ges. Fett</dt>
|
||||||
|
<dd className="font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{Math.round(product.saturated_fat)}g
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Salz</dt>
|
||||||
|
<dd className="font-medium text-gray-800 dark:text-gray-200">{product.salt.toFixed(2)}g</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<MealTypeSelect value={mealType} onChange={setMealType} />
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Menge (Gramm)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={amountG}
|
||||||
|
onChange={(e) => setAmountG(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-base font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1 rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Speichern…' : 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import MealTypeSelect from './MealTypeSelect'
|
||||||
|
import { defaultMealType } from '../utils/mealTypes'
|
||||||
|
import { parseDecimal } from '../utils/numbers'
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50'
|
||||||
|
const labelClass = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300'
|
||||||
|
|
||||||
|
export default function ProductNotFoundForm({
|
||||||
|
barcode = null,
|
||||||
|
initialMealType,
|
||||||
|
mode = 'log',
|
||||||
|
onCreated,
|
||||||
|
onLogged,
|
||||||
|
onClose,
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [calories, setCalories] = useState('')
|
||||||
|
const [carbs, setCarbs] = useState('')
|
||||||
|
const [protein, setProtein] = useState('')
|
||||||
|
const [fat, setFat] = useState('')
|
||||||
|
const [sugar, setSugar] = useState('')
|
||||||
|
const [fiber, setFiber] = useState('')
|
||||||
|
const [saturatedFat, setSaturatedFat] = useState('')
|
||||||
|
const [salt, setSalt] = useState('')
|
||||||
|
const [amountG, setAmountG] = useState('100')
|
||||||
|
const [mealType, setMealType] = useState(initialMealType || defaultMealType())
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
function optionalDecimal(value) {
|
||||||
|
return value === '' ? undefined : parseDecimal(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const { data: createdProduct } = await apiClient.post('/api/products', {
|
||||||
|
barcode,
|
||||||
|
name,
|
||||||
|
calories: parseDecimal(calories),
|
||||||
|
carbs: parseDecimal(carbs),
|
||||||
|
protein: parseDecimal(protein),
|
||||||
|
fat: parseDecimal(fat),
|
||||||
|
sugar: optionalDecimal(sugar),
|
||||||
|
fiber: optionalDecimal(fiber),
|
||||||
|
saturated_fat: optionalDecimal(saturatedFat),
|
||||||
|
salt: optionalDecimal(salt),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (mode === 'createOnly') {
|
||||||
|
onCreated(createdProduct)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiClient.post('/api/logs', {
|
||||||
|
barcode: createdProduct.barcode,
|
||||||
|
amount_g: parseDecimal(amountG),
|
||||||
|
meal_type: mealType,
|
||||||
|
})
|
||||||
|
onLogged()
|
||||||
|
} catch {
|
||||||
|
setError('Produkt konnte nicht gespeichert werden.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
|
||||||
|
<div className="w-full max-w-sm rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
{barcode ? 'Produkt nicht gefunden' : 'Neues Produkt anlegen'}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{barcode
|
||||||
|
? `Barcode ${barcode} — bitte Nährwerte pro 100g eintragen`
|
||||||
|
: 'Bitte Nährwerte pro 100g eintragen'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Kcal /100g</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={calories}
|
||||||
|
onChange={(e) => setCalories(e.target.value)}
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Carbs /100g</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={carbs}
|
||||||
|
onChange={(e) => setCarbs(e.target.value)}
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Protein /100g</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={protein}
|
||||||
|
onChange={(e) => setProtein(e.target.value)}
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Fett /100g</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={fat}
|
||||||
|
onChange={(e) => setFat(e.target.value)}
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="pt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Optional</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Zucker /100g</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={sugar}
|
||||||
|
onChange={(e) => setSugar(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Ballaststoffe /100g</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={fiber}
|
||||||
|
onChange={(e) => setFiber(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Ges. Fett /100g</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={saturatedFat}
|
||||||
|
onChange={(e) => setSaturatedFat(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Salz /100g</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={salt}
|
||||||
|
onChange={(e) => setSalt(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'log' && <MealTypeSelect value={mealType} onChange={setMealType} />}
|
||||||
|
|
||||||
|
{mode === 'log' && (
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Menge (Gramm) für heute</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={amountG}
|
||||||
|
onChange={(e) => setAmountG(e.target.value)}
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
<div className="flex gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-base font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1 rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Speichern…' : mode === 'createOnly' ? 'Anlegen' : 'Anlegen & Loggen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
|
||||||
|
export default function ProductSearchInput({ onSelect, placeholder = 'Suchen…', autoFocus = true }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState([])
|
||||||
|
const [isSearching, setIsSearching] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsSearching(true)
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
apiClient
|
||||||
|
.get('/api/products/search', { params: { q: query } })
|
||||||
|
.then((res) => setResults(res.data))
|
||||||
|
.finally(() => setIsSearching(false))
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className="mb-3 w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isSearching ? (
|
||||||
|
<p className="py-4 text-center text-sm text-gray-500 dark:text-gray-400">Suche…</p>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{query ? 'Keine Treffer.' : 'Noch keine Produkte vorhanden.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{results.map((product) => (
|
||||||
|
<li key={product.barcode}>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect(product)}
|
||||||
|
className="flex w-full items-center justify-between py-2.5 text-left text-sm"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-50">{product.name}</span>
|
||||||
|
{product.is_dish && (
|
||||||
|
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||||
|
Gericht
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{product.last_amount_g != null && (
|
||||||
|
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||||
|
zuletzt {Math.round(product.last_amount_g)}g
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-gray-500 dark:text-gray-400">
|
||||||
|
{Math.round(product.calories)} kcal/100g
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
export default function ProtectedRoute({ children }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||||
|
Lädt…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useTheme } from '../context/ThemeContext'
|
||||||
|
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
const isDark = theme === 'dark'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={isDark ? 'Zu hellem Modus wechseln' : 'Zu dunklem Modus wechseln'}
|
||||||
|
className="rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
{isDark ? (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="h-5 w-5"
|
||||||
|
>
|
||||||
|
<path d="M12 3a1 1 0 0 1 1 1v1a1 1 0 1 1-2 0V4a1 1 0 0 1 1-1Zm0 15a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm9-6a1 1 0 0 1-1 1h-1a1 1 0 1 1 0-2h1a1 1 0 0 1 1 1ZM5 12a1 1 0 0 1-1 1H3a1 1 0 1 1 0-2h1a1 1 0 0 1 1 1Zm13.657-6.657a1 1 0 0 1 0 1.414l-.707.707a1 1 0 1 1-1.414-1.414l.707-.707a1 1 0 0 1 1.414 0ZM7.464 17.536a1 1 0 0 1 0 1.414l-.707.707a1 1 0 0 1-1.414-1.414l.707-.707a1 1 0 0 1 1.414 0Zm11.193 1.414a1 1 0 0 1-1.414 0l-.707-.707a1 1 0 1 1 1.414-1.414l.707.707a1 1 0 0 1 0 1.414ZM6.757 6.757a1 1 0 0 1-1.414 0l-.707-.707a1 1 0 0 1 1.414-1.414l.707.707a1 1 0 0 1 0 1.414ZM12 20a1 1 0 0 1 1 1v1a1 1 0 1 1-2 0v-1a1 1 0 0 1 1-1Z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="h-5 w-5"
|
||||||
|
>
|
||||||
|
<path d="M20.742 13.045a8.088 8.088 0 0 1-2.077.267c-4.476 0-8.106-3.63-8.106-8.106 0-.712.093-1.403.267-2.06a1 1 0 0 0-1.281-1.226C5.117 3.077 2 7.15 2 11.945 2 17.5 6.5 22 12.055 22c4.795 0 8.868-3.117 10.023-7.545a1 1 0 0 0-1.336-1.41Z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
|
||||||
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (!token) {
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiClient
|
||||||
|
.get('/api/users/me')
|
||||||
|
.then((res) => setUser(res.data))
|
||||||
|
.catch(() => localStorage.removeItem('access_token'))
|
||||||
|
.finally(() => setIsLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
const form = new URLSearchParams()
|
||||||
|
form.append('username', username)
|
||||||
|
form.append('password', password)
|
||||||
|
|
||||||
|
const { data } = await apiClient.post('/api/auth/token', form, {
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
})
|
||||||
|
localStorage.setItem('access_token', data.access_token)
|
||||||
|
|
||||||
|
const me = await apiClient.get('/api/users/me')
|
||||||
|
setUser(me.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(username, password) {
|
||||||
|
await apiClient.post('/api/auth/register', { username, password })
|
||||||
|
await login(username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{ user, setUser, isAuthenticated: !!user, isLoading, login, register, logout }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within an AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const ThemeContext = createContext(null)
|
||||||
|
|
||||||
|
function getInitialTheme() {
|
||||||
|
const stored = localStorage.getItem('theme')
|
||||||
|
if (stored === 'light' || stored === 'dark') return stored
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }) {
|
||||||
|
const [theme, setTheme] = useState(getInitialTheme)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||||
|
localStorage.setItem('theme', theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const ctx = useContext(ThemeContext)
|
||||||
|
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import ThemeToggle from '../components/ThemeToggle'
|
||||||
|
|
||||||
|
function DishListItem({ dish }) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
to={`/cookbook/${dish.id}`}
|
||||||
|
className="flex items-center justify-between rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-gray-50">{dish.name}</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{Math.round(dish.total_weight_g)}g gesamt · {dish.ingredients.length} Zutaten
|
||||||
|
{!dish.is_owner && ` · geteilt von ${dish.owner_username}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{Math.round(dish.calories)} kcal/100g
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CookbookPage() {
|
||||||
|
const [dishes, setDishes] = useState([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient
|
||||||
|
.get('/api/dishes')
|
||||||
|
.then((res) => setDishes(res.data))
|
||||||
|
.finally(() => setIsLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const ownDishes = dishes.filter((dish) => dish.is_owner)
|
||||||
|
const sharedDishes = dishes.filter((dish) => !dish.is_owner)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 pb-28 dark:bg-gray-900">
|
||||||
|
<header className="flex items-center justify-between bg-white px-4 py-4 shadow-sm dark:bg-gray-800">
|
||||||
|
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-50">Kochbuch</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/dashboard" className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
|
Zurück
|
||||||
|
</Link>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-md space-y-4 p-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-center text-sm text-gray-500 dark:text-gray-400">Lädt…</p>
|
||||||
|
) : dishes.length === 0 ? (
|
||||||
|
<div className="rounded-xl bg-white p-6 text-center shadow-sm dark:bg-gray-800">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Noch keine Gerichte im Kochbuch. Leg dein erstes Gericht an — praktisch für Rezepte, die du
|
||||||
|
öfter kochst, und als Inspiration, wenn du mal nicht weißt, was du kochen sollst.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-gray-500 dark:text-gray-400">Meine Rezepte</h2>
|
||||||
|
{ownDishes.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Noch keine eigenen Rezepte.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{ownDishes.map((dish) => (
|
||||||
|
<DishListItem key={dish.id} dish={dish} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sharedDishes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-gray-500 dark:text-gray-400">Mit mir geteilt</h2>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{sharedDishes.map((dish) => (
|
||||||
|
<DishListItem key={dish.id} dish={dish} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/cookbook/new"
|
||||||
|
className="fixed bottom-6 left-1/2 -translate-x-1/2 rounded-full bg-emerald-600 px-6 py-3 text-base font-medium text-white shadow-lg active:bg-emerald-700"
|
||||||
|
>
|
||||||
|
+ Neues Gericht
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import MacroProgress from '../components/MacroProgress'
|
||||||
|
import BarcodeScanner from '../components/BarcodeScanner'
|
||||||
|
import ProductFoundModal from '../components/ProductFoundModal'
|
||||||
|
import ProductNotFoundForm from '../components/ProductNotFoundForm'
|
||||||
|
import AddMealModal from '../components/AddMealModal'
|
||||||
|
import EditLogModal from '../components/EditLogModal'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
import ThemeToggle from '../components/ThemeToggle'
|
||||||
|
import { groupByMealType } from '../utils/mealTypes'
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const [todayData, setTodayData] = useState(null)
|
||||||
|
const [isScannerOpen, setIsScannerOpen] = useState(false)
|
||||||
|
const [isAddMealOpen, setIsAddMealOpen] = useState(false)
|
||||||
|
const [scanResult, setScanResult] = useState(null)
|
||||||
|
const [scanError, setScanError] = useState('')
|
||||||
|
const [editingLog, setEditingLog] = useState(null)
|
||||||
|
const [deletingLog, setDeletingLog] = useState(null)
|
||||||
|
|
||||||
|
const loadToday = useCallback(async () => {
|
||||||
|
const { data } = await apiClient.get('/api/logs/today')
|
||||||
|
setTodayData(data)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadToday()
|
||||||
|
}, [loadToday])
|
||||||
|
|
||||||
|
async function handleDetected(barcode) {
|
||||||
|
setIsScannerOpen(false)
|
||||||
|
setScanError('')
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get(`/api/products/${barcode}`)
|
||||||
|
setScanResult({ mode: 'found', product: data })
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
setScanResult({ mode: 'notFound', barcode })
|
||||||
|
} else {
|
||||||
|
setScanError('Produktsuche fehlgeschlagen. Bitte erneut versuchen.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogged() {
|
||||||
|
setScanResult(null)
|
||||||
|
setIsAddMealOpen(false)
|
||||||
|
loadToday()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmDelete() {
|
||||||
|
await apiClient.delete(`/api/logs/${deletingLog.id}`)
|
||||||
|
setDeletingLog(null)
|
||||||
|
loadToday()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogSaved() {
|
||||||
|
setEditingLog(null)
|
||||||
|
loadToday()
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = todayData?.totals ?? {
|
||||||
|
calories: 0,
|
||||||
|
protein: 0,
|
||||||
|
carbs: 0,
|
||||||
|
fat: 0,
|
||||||
|
sugar: 0,
|
||||||
|
fiber: 0,
|
||||||
|
saturated_fat: 0,
|
||||||
|
salt: 0,
|
||||||
|
}
|
||||||
|
const mealGroups = groupByMealType(todayData?.logs ?? [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 pb-28 dark:bg-gray-900">
|
||||||
|
<header className="flex items-center justify-between bg-white px-4 py-4 shadow-sm dark:bg-gray-800">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Angemeldet als</p>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-gray-50">{user?.username}</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex items-center gap-4 text-sm font-medium">
|
||||||
|
<Link to="/cookbook" className="text-gray-600 dark:text-gray-300">
|
||||||
|
Kochbuch
|
||||||
|
</Link>
|
||||||
|
<Link to="/history" className="text-gray-600 dark:text-gray-300">
|
||||||
|
Historie
|
||||||
|
</Link>
|
||||||
|
<Link to="/settings" className="text-gray-600 dark:text-gray-300">
|
||||||
|
Ziele
|
||||||
|
</Link>
|
||||||
|
<button onClick={logout} className="text-gray-500 dark:text-gray-400">
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
<ThemeToggle />
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-md space-y-4 p-4">
|
||||||
|
<section className="space-y-4 rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||||
|
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-50">Heute</h1>
|
||||||
|
<MacroProgress
|
||||||
|
label="Kalorien"
|
||||||
|
value={totals.calories}
|
||||||
|
target={user.calorie_goal}
|
||||||
|
unit="kcal"
|
||||||
|
color="emerald"
|
||||||
|
/>
|
||||||
|
<MacroProgress
|
||||||
|
label="Protein"
|
||||||
|
value={totals.protein}
|
||||||
|
target={user.protein_goal}
|
||||||
|
unit="g"
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
<MacroProgress
|
||||||
|
label="Carbs"
|
||||||
|
value={totals.carbs}
|
||||||
|
target={user.carbs_goal}
|
||||||
|
unit="g"
|
||||||
|
color="amber"
|
||||||
|
/>
|
||||||
|
<MacroProgress
|
||||||
|
label="Fett"
|
||||||
|
value={totals.fat}
|
||||||
|
target={user.fat_goal}
|
||||||
|
unit="g"
|
||||||
|
color="rose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-2 border-t border-gray-100 pt-3 text-center text-xs dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Zucker</p>
|
||||||
|
<p className="font-medium text-gray-800 dark:text-gray-200">{Math.round(totals.sugar)}g</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Ballaststoffe</p>
|
||||||
|
<p className="font-medium text-gray-800 dark:text-gray-200">{Math.round(totals.fiber)}g</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Ges. Fett</p>
|
||||||
|
<p className="font-medium text-gray-800 dark:text-gray-200">{Math.round(totals.saturated_fat)}g</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Salz</p>
|
||||||
|
<p className="font-medium text-gray-800 dark:text-gray-200">{totals.salt.toFixed(1)}g</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4 rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-50">Einträge</h2>
|
||||||
|
{mealGroups.map((group) => (
|
||||||
|
<div key={group.type}>
|
||||||
|
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
||||||
|
{group.label}
|
||||||
|
</h3>
|
||||||
|
{group.logs.length ? (
|
||||||
|
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{group.logs.map((log) => (
|
||||||
|
<li key={log.id} className="flex items-center justify-between py-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-gray-50">{log.name}</p>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">{Math.round(log.amount_g)}g</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{Math.round(log.calories)} kcal
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingLog(log)}
|
||||||
|
aria-label="Eintrag bearbeiten"
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4">
|
||||||
|
<path d="M17.414 2.586a2 2 0 0 1 2.828 2.828l-1.31 1.31-2.828-2.828 1.31-1.31ZM16.5 5.207 4 17.707V20.5h2.793L19.293 8l-2.793-2.793Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeletingLog(log)}
|
||||||
|
aria-label="Eintrag löschen"
|
||||||
|
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4">
|
||||||
|
<path d="M9 3a1 1 0 0 0-1 1v1H4.5a1 1 0 1 0 0 2H5v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7h.5a1 1 0 1 0 0-2H16V4a1 1 0 0 0-1-1H9Zm1 5a1 1 0 1 1 2 0v9a1 1 0 1 1-2 0V8Zm4-1a1 1 0 0 0-1 1v9a1 1 0 1 0 2 0V8a1 1 0 0 0-1-1Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500">Keine Einträge.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{scanError && <p className="text-center text-sm text-red-600 dark:text-red-400">{scanError}</p>}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div className="fixed bottom-6 left-1/2 flex -translate-x-1/2 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsScannerOpen(true)}
|
||||||
|
className="rounded-full bg-emerald-600 px-5 py-3 text-base font-medium text-white shadow-lg active:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Barcode scannen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAddMealOpen(true)}
|
||||||
|
className="rounded-full bg-gray-800 px-5 py-3 text-base font-medium text-white shadow-lg active:bg-gray-900 dark:bg-gray-700 dark:active:bg-gray-600"
|
||||||
|
>
|
||||||
|
Mahlzeit hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isScannerOpen && (
|
||||||
|
<BarcodeScanner onDetected={handleDetected} onClose={() => setIsScannerOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAddMealOpen && (
|
||||||
|
<AddMealModal onLogged={handleLogged} onClose={() => setIsAddMealOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scanResult?.mode === 'found' && (
|
||||||
|
<ProductFoundModal
|
||||||
|
product={scanResult.product}
|
||||||
|
onLogged={handleLogged}
|
||||||
|
onClose={() => setScanResult(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scanResult?.mode === 'notFound' && (
|
||||||
|
<ProductNotFoundForm
|
||||||
|
barcode={scanResult.barcode}
|
||||||
|
onLogged={handleLogged}
|
||||||
|
onClose={() => setScanResult(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingLog && (
|
||||||
|
<EditLogModal log={editingLog} onSaved={handleLogSaved} onClose={() => setEditingLog(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deletingLog && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Eintrag löschen?"
|
||||||
|
message={`"${deletingLog.name}" wird aus dem heutigen Log entfernt.`}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
onCancel={() => setDeletingLog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import ProductFoundModal from '../components/ProductFoundModal'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
import DishShareManager from '../components/DishShareManager'
|
||||||
|
import ThemeToggle from '../components/ThemeToggle'
|
||||||
|
|
||||||
|
export default function DishDetailPage() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [dish, setDish] = useState(null)
|
||||||
|
const [isLogging, setIsLogging] = useState(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.get(`/api/dishes/${id}`).then((res) => setDish(res.data))
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
async function handleConfirmDelete() {
|
||||||
|
await apiClient.delete(`/api/dishes/${id}`)
|
||||||
|
navigate('/cookbook')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dish) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||||
|
Lädt…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 pb-28 dark:bg-gray-900">
|
||||||
|
<header className="flex items-center justify-between bg-white px-4 py-4 shadow-sm dark:bg-gray-800">
|
||||||
|
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-50">{dish.name}</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/cookbook" className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
|
Zurück
|
||||||
|
</Link>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-md space-y-4 p-4">
|
||||||
|
<section className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||||
|
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Ergibt insgesamt {Math.round(dish.total_weight_g)}g · Nährwerte pro 100g
|
||||||
|
</p>
|
||||||
|
<dl className="mb-4 grid grid-cols-4 gap-2 text-center text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Kcal</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(dish.calories)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Carbs</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(dish.carbs)}g</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Protein</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(dish.protein)}g</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Fett</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(dish.fat)}g</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">Zutaten</h2>
|
||||||
|
<ul className="mb-4 divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{dish.ingredients.map((ing) => (
|
||||||
|
<li key={ing.barcode} className="flex items-center justify-between py-1.5 text-sm">
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{ing.name}</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">{Math.round(ing.amount_g)}g</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{dish.instructions && (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">Zubereitung</h2>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{dish.instructions}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{dish.is_owner ? (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Link
|
||||||
|
to={`/cookbook/${id}/edit`}
|
||||||
|
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-center text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDeleting(true)}
|
||||||
|
className="flex-1 rounded-lg border border-red-200 py-2.5 text-sm font-medium text-red-600 dark:border-red-900 dark:text-red-400"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DishShareManager dishId={id} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Geteilt von {dish.owner_username}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsLogging(true)}
|
||||||
|
className="fixed bottom-6 left-1/2 -translate-x-1/2 rounded-full bg-emerald-600 px-6 py-3 text-base font-medium text-white shadow-lg active:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Essen loggen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isLogging && (
|
||||||
|
<ProductFoundModal
|
||||||
|
product={dish}
|
||||||
|
onLogged={() => navigate('/dashboard')}
|
||||||
|
onClose={() => setIsLogging(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDeleting && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Gericht löschen?"
|
||||||
|
message={`"${dish.name}" wird dauerhaft aus dem Kochbuch entfernt.`}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
onCancel={() => setIsDeleting(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import IngredientPickerModal from '../components/IngredientPickerModal'
|
||||||
|
import ThemeToggle from '../components/ThemeToggle'
|
||||||
|
import { parseDecimal } from '../utils/numbers'
|
||||||
|
|
||||||
|
const NUTRIENT_FIELDS = ['calories', 'carbs', 'protein', 'fat', 'sugar', 'fiber', 'saturated_fat', 'salt']
|
||||||
|
|
||||||
|
function computeTotals(ingredients) {
|
||||||
|
const totalWeight = ingredients.reduce((sum, ing) => sum + (ing.amount_g || 0), 0)
|
||||||
|
const totals = Object.fromEntries(NUTRIENT_FIELDS.map((f) => [f, 0]))
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
const factor = (ing.amount_g || 0) / 100
|
||||||
|
for (const f of NUTRIENT_FIELDS) totals[f] += (ing[f] || 0) * factor
|
||||||
|
}
|
||||||
|
const per100 =
|
||||||
|
totalWeight > 0
|
||||||
|
? Object.fromEntries(NUTRIENT_FIELDS.map((f) => [f, (totals[f] / totalWeight) * 100]))
|
||||||
|
: Object.fromEntries(NUTRIENT_FIELDS.map((f) => [f, 0]))
|
||||||
|
return { totalWeight, per100 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DishFormPage() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const isEditing = Boolean(id)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [instructions, setInstructions] = useState('')
|
||||||
|
const [ingredients, setIngredients] = useState([])
|
||||||
|
const [isPickerOpen, setIsPickerOpen] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(isEditing)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditing) return
|
||||||
|
apiClient.get(`/api/dishes/${id}`).then((res) => {
|
||||||
|
setName(res.data.name)
|
||||||
|
setInstructions(res.data.instructions ?? '')
|
||||||
|
setIngredients(res.data.ingredients)
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
}, [id, isEditing])
|
||||||
|
|
||||||
|
function addIngredient(product) {
|
||||||
|
setIngredients((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
barcode: product.barcode,
|
||||||
|
name: product.name,
|
||||||
|
amount_g: 100,
|
||||||
|
...Object.fromEntries(NUTRIENT_FIELDS.map((f) => [f, product[f]])),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setIsPickerOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeIngredient(index) {
|
||||||
|
setIngredients((prev) => prev.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIngredientAmount(index, value) {
|
||||||
|
setIngredients((prev) =>
|
||||||
|
prev.map((ing, i) => (i === index ? { ...ing, amount_g: parseDecimal(value) || 0 } : ing)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (ingredients.length === 0) {
|
||||||
|
setError('Mindestens eine Zutat hinzufügen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
instructions: instructions || null,
|
||||||
|
ingredients: ingredients.map((ing) => ({ barcode: ing.barcode, amount_g: ing.amount_g })),
|
||||||
|
}
|
||||||
|
const { data } = isEditing
|
||||||
|
? await apiClient.put(`/api/dishes/${id}`, payload)
|
||||||
|
: await apiClient.post('/api/dishes', payload)
|
||||||
|
navigate(`/cookbook/${data.id}`)
|
||||||
|
} catch {
|
||||||
|
setError('Gericht konnte nicht gespeichert werden.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||||
|
Lädt…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { totalWeight, per100 } = computeTotals(ingredients)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 px-4 py-6 dark:bg-gray-900">
|
||||||
|
<div className="mx-auto max-w-md">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
{isEditing ? 'Gericht bearbeiten' : 'Neues Gericht'}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/cookbook" className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
|
Zurück
|
||||||
|
</Link>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="rounded-xl bg-white p-6 shadow-sm dark:bg-gray-800">
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="mb-4 w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Zubereitung (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={instructions}
|
||||||
|
onChange={(e) => setInstructions(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-white p-6 shadow-sm dark:bg-gray-800">
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">Zutaten</h2>
|
||||||
|
{ingredients.length === 0 ? (
|
||||||
|
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">Noch keine Zutaten.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mb-3 space-y-2">
|
||||||
|
{ingredients.map((ing, index) => (
|
||||||
|
<li key={`${ing.barcode}-${index}`} className="flex items-center gap-2">
|
||||||
|
<span className="flex-1 text-sm text-gray-900 dark:text-gray-50">{ing.name}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={ing.amount_g}
|
||||||
|
onChange={(e) => updateIngredientAmount(index, e.target.value)}
|
||||||
|
className="w-20 rounded-lg border border-gray-300 px-2 py-1 text-right text-sm text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">g</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeIngredient(index)}
|
||||||
|
aria-label="Zutat entfernen"
|
||||||
|
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsPickerOpen(true)}
|
||||||
|
className="w-full rounded-lg border border-dashed border-gray-300 py-2 text-sm font-medium text-emerald-600 dark:border-gray-600 dark:text-emerald-400"
|
||||||
|
>
|
||||||
|
+ Zutat hinzufügen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{ingredients.length > 0 && (
|
||||||
|
<div className="mt-4 border-t border-gray-100 pt-3 dark:border-gray-700">
|
||||||
|
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Ergibt insgesamt {Math.round(totalWeight)}g · Nährwerte pro 100g
|
||||||
|
</p>
|
||||||
|
<dl className="grid grid-cols-4 gap-2 text-center text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Kcal</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">
|
||||||
|
{Math.round(per100.calories)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Carbs</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">
|
||||||
|
{Math.round(per100.carbs)}g
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Protein</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">
|
||||||
|
{Math.round(per100.protein)}g
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Fett</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">
|
||||||
|
{Math.round(per100.fat)}g
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Speichern…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPickerOpen && (
|
||||||
|
<IngredientPickerModal onSelect={addIngredient} onClose={() => setIsPickerOpen(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import ThemeToggle from '../components/ThemeToggle'
|
||||||
|
import { groupByMealType } from '../utils/mealTypes'
|
||||||
|
|
||||||
|
const HISTORY_DAYS = 30
|
||||||
|
|
||||||
|
function average(days) {
|
||||||
|
const daysWithData = days.filter((d) => d.totals.calories > 0)
|
||||||
|
if (daysWithData.length === 0) {
|
||||||
|
return { calories: 0, protein: 0, carbs: 0, fat: 0, count: 0 }
|
||||||
|
}
|
||||||
|
const sum = (key) => daysWithData.reduce((acc, d) => acc + d.totals[key], 0)
|
||||||
|
return {
|
||||||
|
calories: sum('calories') / daysWithData.length,
|
||||||
|
protein: sum('protein') / daysWithData.length,
|
||||||
|
carbs: sum('carbs') / daysWithData.length,
|
||||||
|
fat: sum('fat') / daysWithData.length,
|
||||||
|
count: daysWithData.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const d = new Date(`${dateStr}T00:00:00`)
|
||||||
|
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function AverageCard({ title, avg }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">{title}</h2>
|
||||||
|
{avg.count === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Keine Einträge in diesem Zeitraum.</p>
|
||||||
|
) : (
|
||||||
|
<dl className="grid grid-cols-4 gap-2 text-center text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Kcal</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(avg.calories)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Protein</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(avg.protein)}g</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Carbs</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(avg.carbs)}g</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">Fett</dt>
|
||||||
|
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(avg.fat)}g</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryPage() {
|
||||||
|
const [days, setDays] = useState([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [expandedDate, setExpandedDate] = useState(null)
|
||||||
|
const [dayDetails, setDayDetails] = useState({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient
|
||||||
|
.get(`/api/logs/history?days=${HISTORY_DAYS}`)
|
||||||
|
.then((res) => setDays(res.data.days))
|
||||||
|
.finally(() => setIsLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function toggleDay(dateStr) {
|
||||||
|
if (expandedDate === dateStr) {
|
||||||
|
setExpandedDate(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExpandedDate(dateStr)
|
||||||
|
if (!dayDetails[dateStr]) {
|
||||||
|
const { data } = await apiClient.get(`/api/logs/day/${dateStr}`)
|
||||||
|
setDayDetails((prev) => ({ ...prev, [dateStr]: data.logs }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeklyAvg = average(days.slice(-7))
|
||||||
|
const monthlyAvg = average(days)
|
||||||
|
const reversedDays = [...days].reverse()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 pb-10 dark:bg-gray-900">
|
||||||
|
<header className="flex items-center justify-between bg-white px-4 py-4 shadow-sm dark:bg-gray-800">
|
||||||
|
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-50">Historie</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/dashboard" className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
|
Zurück
|
||||||
|
</Link>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-md space-y-4 p-4">
|
||||||
|
<AverageCard title="Ø letzte 7 Tage" avg={weeklyAvg} />
|
||||||
|
<AverageCard title={`Ø letzte ${HISTORY_DAYS} Tage`} avg={monthlyAvg} />
|
||||||
|
|
||||||
|
<section className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">Tage</h2>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Lädt…</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{reversedDays.map((day) => (
|
||||||
|
<li key={day.date}>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleDay(day.date)}
|
||||||
|
className="flex w-full items-center justify-between py-2.5 text-left text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-50">
|
||||||
|
{formatDate(day.date)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-600 dark:text-gray-300">
|
||||||
|
{day.totals.calories > 0 ? `${Math.round(day.totals.calories)} kcal` : '—'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expandedDate === day.date && (
|
||||||
|
<div className="pb-3">
|
||||||
|
{!dayDetails[day.date] ? (
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500">Lädt…</p>
|
||||||
|
) : dayDetails[day.date].length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Keine Einträge an diesem Tag.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 border-l-2 border-gray-100 pl-3 dark:border-gray-700">
|
||||||
|
{groupByMealType(dayDetails[day.date])
|
||||||
|
.filter((group) => group.logs.length > 0)
|
||||||
|
.map((group) => (
|
||||||
|
<div key={group.type}>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
||||||
|
{group.label}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{group.logs.map((log) => (
|
||||||
|
<li
|
||||||
|
key={log.id}
|
||||||
|
className="flex items-center justify-between text-xs text-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{log.name} ({Math.round(log.amount_g)}g)
|
||||||
|
</span>
|
||||||
|
<span>{Math.round(log.calories)} kcal</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import ThemeToggle from '../components/ThemeToggle'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const { login } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await login(username, password)
|
||||||
|
navigate('/dashboard')
|
||||||
|
} catch {
|
||||||
|
setError('Benutzername oder Passwort falsch.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-900">
|
||||||
|
<div className="absolute right-4 top-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<h1 className="mb-6 text-center text-2xl font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
KalorienTracker
|
||||||
|
</h1>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-4 rounded-xl bg-white p-6 shadow-sm dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Benutzername
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Anmelden…' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Noch kein Konto?{' '}
|
||||||
|
<Link to="/register" className="font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
|
Registrieren
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import ThemeToggle from '../components/ThemeToggle'
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const { register } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await register(username, password)
|
||||||
|
navigate('/dashboard')
|
||||||
|
} catch (err) {
|
||||||
|
const detail = err.response?.data?.detail
|
||||||
|
setError(detail || 'Registrierung fehlgeschlagen.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-900">
|
||||||
|
<div className="absolute right-4 top-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<h1 className="mb-6 text-center text-2xl font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
Konto erstellen
|
||||||
|
</h1>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-4 rounded-xl bg-white p-6 shadow-sm dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Benutzername
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Registrieren…' : 'Registrieren'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Schon ein Konto?{' '}
|
||||||
|
<Link to="/login" className="font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
|
Anmelden
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import ThemeToggle from '../components/ThemeToggle'
|
||||||
|
import { parseDecimal } from '../utils/numbers'
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { user, setUser } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [calorieGoal, setCalorieGoal] = useState(String(user.calorie_goal))
|
||||||
|
const [proteinGoal, setProteinGoal] = useState(String(user.protein_goal))
|
||||||
|
const [carbsGoal, setCarbsGoal] = useState(String(user.carbs_goal))
|
||||||
|
const [fatGoal, setFatGoal] = useState(String(user.fat_goal))
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.put('/api/users/me/goals', {
|
||||||
|
calorie_goal: parseDecimal(calorieGoal),
|
||||||
|
protein_goal: parseDecimal(proteinGoal),
|
||||||
|
carbs_goal: parseDecimal(carbsGoal),
|
||||||
|
fat_goal: parseDecimal(fatGoal),
|
||||||
|
})
|
||||||
|
setUser(data)
|
||||||
|
navigate('/dashboard')
|
||||||
|
} catch {
|
||||||
|
setError('Ziele konnten nicht gespeichert werden.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 px-4 py-6 dark:bg-gray-900">
|
||||||
|
<div className="mx-auto max-w-md">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Tagesziele</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/dashboard" className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
|
Zurück
|
||||||
|
</Link>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-4 rounded-xl bg-white p-6 shadow-sm dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Kalorien (kcal)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={calorieGoal}
|
||||||
|
onChange={(e) => setCalorieGoal(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Protein (g)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={proteinGoal}
|
||||||
|
onChange={(e) => setProteinGoal(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Carbs (g)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={carbsGoal}
|
||||||
|
onChange={(e) => setCarbsGoal(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Fett (g)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={fatGoal}
|
||||||
|
onChange={(e) => setFatGoal(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Speichern…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const MEAL_TYPES = [
|
||||||
|
{ value: 'breakfast', label: 'Frühstück' },
|
||||||
|
{ value: 'lunch', label: 'Mittagessen' },
|
||||||
|
{ value: 'dinner', label: 'Abendessen' },
|
||||||
|
{ value: 'snack', label: 'Snacks' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MEAL_TYPE_ORDER = MEAL_TYPES.map((m) => m.value)
|
||||||
|
|
||||||
|
export function mealTypeLabel(value) {
|
||||||
|
return MEAL_TYPES.find((m) => m.value === value)?.label ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultMealType() {
|
||||||
|
const hour = new Date().getHours()
|
||||||
|
if (hour < 11) return 'breakfast'
|
||||||
|
if (hour < 15) return 'lunch'
|
||||||
|
if (hour < 21) return 'dinner'
|
||||||
|
return 'snack'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupByMealType(logs) {
|
||||||
|
const groups = Object.fromEntries(MEAL_TYPE_ORDER.map((type) => [type, []]))
|
||||||
|
for (const log of logs) {
|
||||||
|
;(groups[log.meal_type] ?? groups.snack).push(log)
|
||||||
|
}
|
||||||
|
return MEAL_TYPE_ORDER.map((type) => ({ type, label: mealTypeLabel(type), logs: groups[type] }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function parseDecimal(value) {
|
||||||
|
if (value === '' || value == null) return NaN
|
||||||
|
return parseFloat(String(value).replace(',', '.'))
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user