Initial commit

This commit is contained in:
2026-07-28 15:00:00 +02:00
commit 2f95eeda83
64 changed files with 5990 additions and 0 deletions
+17
View File
@@ -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
View File
@@ -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
+74
View File
@@ -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.
+88
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
*.db
.env
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
.venv/
venv/
*.db
.env
+12
View File
@@ -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"]
View File
+30
View File
@@ -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)
View File
+17
View File
@@ -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()
+57
View File
@@ -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
+43
View File
@@ -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)]
+41
View File
@@ -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"}
+76
View File
@@ -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)
View File
+42
View File
@@ -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)
+218
View File
@@ -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()
+176
View File
@@ -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)
+137
View File
@@ -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
+31
View File
@@ -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
+174
View File
@@ -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
+24
View File
@@ -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)
+9
View File
@@ -0,0 +1,9 @@
fastapi
uvicorn[standard]
sqlmodel
python-jose[cryptography]
passlib[bcrypt]
bcrypt==4.0.1
python-multipart
pydantic-settings
httpx
+24
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.env
+24
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+19
View File
@@ -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
+16
View File
@@ -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.
+12
View File
@@ -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>
+18
View File
@@ -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;
}
}
+2115
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
+84
View File
@@ -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>
)
}
+15
View File
@@ -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
+66
View File
@@ -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"
>
&times;
</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>
)
}
+118
View File
@@ -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">
&times;
</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>
)
}
+24
View File
@@ -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"
>
&times;
</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>
)
}
+72
View File
@@ -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"
>
&times;
</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>
)
}
+27
View File
@@ -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
}
+34
View File
@@ -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>
)
}
+60
View File
@@ -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
}
+30
View File
@@ -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
}
+16
View File
@@ -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;
}
+10
View File
@@ -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>,
)
+101
View File
@@ -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>
)
}
+258
View File
@@ -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>
)
}
+139
View File
@@ -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>
)
}
+232
View File
@@ -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"
>
&times;
</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>
)
}
+167
View File
@@ -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>
)
}
+84
View File
@@ -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>
)
}
+86
View File
@@ -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>
)
}
+119
View File
@@ -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>
)
}
+28
View File
@@ -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] }))
}
+4
View File
@@ -0,0 +1,4 @@
export function parseDecimal(value) {
if (value === '' || value == null) return NaN
return parseFloat(String(value).replace(',', '.'))
}
+8
View File
@@ -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()],
})