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
+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()],
})