Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_URL=""
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>KalorienTracker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+2115
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.18.1",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import { ThemeProvider } from './context/ThemeContext'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import RegisterPage from './pages/RegisterPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import HistoryPage from './pages/HistoryPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import CookbookPage from './pages/CookbookPage'
|
||||
import DishFormPage from './pages/DishFormPage'
|
||||
import DishDetailPage from './pages/DishDetailPage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/history"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HistoryPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cookbook"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CookbookPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cookbook/new"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DishFormPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cookbook/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DishDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cookbook/:id/edit"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DishFormPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
})
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default apiClient
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react'
|
||||
import ProductFoundModal from './ProductFoundModal'
|
||||
import ProductNotFoundForm from './ProductNotFoundForm'
|
||||
import ProductSearchInput from './ProductSearchInput'
|
||||
import MealTypeSelect from './MealTypeSelect'
|
||||
import { defaultMealType } from '../utils/mealTypes'
|
||||
|
||||
export default function AddMealModal({ onLogged, onClose }) {
|
||||
const [mealType, setMealType] = useState(defaultMealType)
|
||||
const [selectedProduct, setSelectedProduct] = useState(null)
|
||||
const [showManualForm, setShowManualForm] = useState(false)
|
||||
|
||||
if (selectedProduct) {
|
||||
return (
|
||||
<ProductFoundModal
|
||||
product={selectedProduct}
|
||||
initialAmountG={selectedProduct.last_amount_g ?? 100}
|
||||
initialMealType={mealType}
|
||||
onLogged={onLogged}
|
||||
onClose={() => setSelectedProduct(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (showManualForm) {
|
||||
return (
|
||||
<ProductNotFoundForm
|
||||
initialMealType={mealType}
|
||||
onLogged={onLogged}
|
||||
onClose={() => setShowManualForm(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
|
||||
<div className="flex max-h-[85vh] w-full max-w-sm flex-col rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||
Mahlzeit hinzufügen
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-2xl leading-none text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<MealTypeSelect value={mealType} onChange={setMealType} />
|
||||
</div>
|
||||
|
||||
<ProductSearchInput onSelect={setSelectedProduct} placeholder="Produkt suchen (z. B. Döner)" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowManualForm(true)}
|
||||
className="mt-3 w-full rounded-lg border border-dashed border-gray-300 py-2.5 text-sm font-medium text-emerald-600 dark:border-gray-600 dark:text-emerald-400"
|
||||
>
|
||||
+ Neues Produkt anlegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
|
||||
|
||||
const SCANNER_ELEMENT_ID = 'barcode-scanner'
|
||||
|
||||
const BARCODE_FORMATS = [
|
||||
Html5QrcodeSupportedFormats.EAN_13,
|
||||
Html5QrcodeSupportedFormats.EAN_8,
|
||||
Html5QrcodeSupportedFormats.UPC_A,
|
||||
Html5QrcodeSupportedFormats.UPC_E,
|
||||
Html5QrcodeSupportedFormats.CODE_128,
|
||||
]
|
||||
|
||||
export default function BarcodeScanner({ onDetected, onClose }) {
|
||||
const onDetectedRef = useRef(onDetected)
|
||||
onDetectedRef.current = onDetected
|
||||
const scannerRef = useRef(null)
|
||||
const hasDetectedRef = useRef(false)
|
||||
const [error, setError] = useState('')
|
||||
const [isCameraReady, setIsCameraReady] = useState(false)
|
||||
const [isProcessingPhoto, setIsProcessingPhoto] = useState(false)
|
||||
|
||||
function handleDetected(decodedText) {
|
||||
if (hasDetectedRef.current) return
|
||||
hasDetectedRef.current = true
|
||||
onDetectedRef.current(decodedText)
|
||||
}
|
||||
|
||||
function startLiveScanning(scanner) {
|
||||
return scanner
|
||||
.start(
|
||||
{ facingMode: 'environment' },
|
||||
{ fps: 10, qrbox: { width: 250, height: 150 } },
|
||||
handleDetected,
|
||||
() => {},
|
||||
)
|
||||
.then(() => setIsCameraReady(true))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const scanner = new Html5Qrcode(SCANNER_ELEMENT_ID, {
|
||||
formatsToSupport: BARCODE_FORMATS,
|
||||
verbose: false,
|
||||
})
|
||||
scannerRef.current = scanner
|
||||
|
||||
startLiveScanning(scanner).catch((err) => {
|
||||
console.error('Kamera konnte nicht gestartet werden', err)
|
||||
setError('Kamera konnte nicht gestartet werden. Bitte Kamera-Berechtigung erlauben.')
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (scanner.isScanning) {
|
||||
scanner
|
||||
.stop()
|
||||
.then(() => scanner.clear())
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function captureAndAnalyze() {
|
||||
const scanner = scannerRef.current
|
||||
const video = document.querySelector(`#${SCANNER_ELEMENT_ID} video`)
|
||||
if (!scanner || !video || !video.videoWidth) return
|
||||
|
||||
setError('')
|
||||
setIsProcessingPhoto(true)
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
|
||||
const file = new File([blob], 'frame.png', { type: 'image/png' })
|
||||
|
||||
if (scanner.isScanning) {
|
||||
setIsCameraReady(false)
|
||||
await scanner.stop()
|
||||
}
|
||||
const decodedText = await scanner.scanFile(file, false)
|
||||
handleDetected(decodedText)
|
||||
} catch {
|
||||
setError('Kein Barcode erkannt. Bitte erneut versuchen.')
|
||||
startLiveScanning(scanner).catch(() => setIsCameraReady(false))
|
||||
} finally {
|
||||
setIsProcessingPhoto(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-hidden bg-black">
|
||||
<div id={SCANNER_ELEMENT_ID} className="absolute inset-0 [&_video]:!h-full [&_video]:!w-full [&_video]:object-cover" />
|
||||
|
||||
<div className="absolute inset-x-0 top-0 flex items-center justify-between bg-gradient-to-b from-black/70 to-transparent p-4 text-white">
|
||||
<span className="text-base font-medium">Barcode scannen</span>
|
||||
<button onClick={onClose} className="text-2xl leading-none">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 flex flex-col items-center gap-3 bg-gradient-to-t from-black/80 to-transparent p-6 pb-8">
|
||||
<p className="text-center text-sm text-white">
|
||||
{error || (isProcessingPhoto ? 'Foto wird analysiert…' : 'Halte den Barcode vor die Kamera')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={captureAndAnalyze}
|
||||
disabled={isProcessingPhoto || !isCameraReady}
|
||||
aria-label="Foto aufnehmen"
|
||||
className="flex h-16 w-16 items-center justify-center rounded-full bg-red-600 shadow-lg active:bg-red-700 disabled:opacity-60"
|
||||
>
|
||||
<span className="h-12 w-12 rounded-full border-2 border-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export default function ConfirmDialog({ title, message, confirmLabel = 'Löschen', onConfirm, onCancel }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 dark:bg-gray-800">
|
||||
<h2 className="mb-1 text-lg font-semibold text-gray-900 dark:text-gray-50">{title}</h2>
|
||||
{message && <p className="mb-4 text-sm text-gray-500 dark:text-gray-400">{message}</p>}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="flex-1 rounded-lg bg-red-600 py-2.5 text-sm font-medium text-white active:bg-red-700"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
export default function DishShareManager({ dishId }) {
|
||||
const [shares, setShares] = useState([])
|
||||
const [allUsers, setAllUsers] = useState([])
|
||||
const [username, setUsername] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.get(`/api/dishes/${dishId}/shares`).then((res) => setShares(res.data))
|
||||
apiClient.get('/api/users').then((res) => setAllUsers(res.data))
|
||||
}, [dishId])
|
||||
|
||||
async function handleAdd(e) {
|
||||
e.preventDefault()
|
||||
if (!username.trim()) return
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const { data } = await apiClient.post(`/api/dishes/${dishId}/shares`, { username: username.trim() })
|
||||
setShares(data)
|
||||
setUsername('')
|
||||
} catch (err) {
|
||||
if (err.response?.status === 404) {
|
||||
setError('Nutzer nicht gefunden.')
|
||||
} else if (err.response?.status === 400) {
|
||||
setError('Kann nicht mit dir selbst geteilt werden.')
|
||||
} else {
|
||||
setError('Freigabe fehlgeschlagen.')
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(name) {
|
||||
await apiClient.delete(`/api/dishes/${dishId}/shares/${name}`)
|
||||
setShares((prev) => prev.filter((s) => s.username !== name))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">Teilen</h2>
|
||||
{shares.length === 0 ? (
|
||||
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">Noch mit niemandem geteilt.</p>
|
||||
) : (
|
||||
<ul className="mb-3 space-y-1.5">
|
||||
{shares.map((share) => (
|
||||
<li key={share.username} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-700 dark:text-gray-300">{share.username}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(share.username)}
|
||||
aria-label={`Freigabe für ${share.username} entfernen`}
|
||||
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAdd} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
list="dish-share-users"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Nutzername"
|
||||
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
<datalist id="dish-share-users">
|
||||
{allUsers.map((user) => (
|
||||
<option key={user.username} value={user.username} />
|
||||
))}
|
||||
</datalist>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
Teilen
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react'
|
||||
import apiClient from '../api/client'
|
||||
import MealTypeSelect from './MealTypeSelect'
|
||||
import { parseDecimal } from '../utils/numbers'
|
||||
|
||||
export default function EditLogModal({ log, onSaved, onClose }) {
|
||||
const [amountG, setAmountG] = useState(String(log.amount_g))
|
||||
const [mealType, setMealType] = useState(log.meal_type)
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await apiClient.put(`/api/logs/${log.id}`, {
|
||||
amount_g: parseDecimal(amountG),
|
||||
meal_type: mealType,
|
||||
})
|
||||
onSaved()
|
||||
} catch {
|
||||
setError('Eintrag konnte nicht gespeichert werden.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
|
||||
<div className="w-full max-w-sm rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">{log.name}</h2>
|
||||
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">Eintrag bearbeiten</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<MealTypeSelect value={mealType} onChange={setMealType} />
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Menge (Gramm)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={amountG}
|
||||
onChange={(e) => setAmountG(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-base font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? 'Speichern…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import apiClient from '../api/client'
|
||||
import BarcodeScanner from './BarcodeScanner'
|
||||
import ProductNotFoundForm from './ProductNotFoundForm'
|
||||
import ProductSearchInput from './ProductSearchInput'
|
||||
|
||||
export default function IngredientPickerModal({ onSelect, onClose }) {
|
||||
const [isScannerOpen, setIsScannerOpen] = useState(false)
|
||||
const [manualBarcode, setManualBarcode] = useState(null)
|
||||
const [showManualForm, setShowManualForm] = useState(false)
|
||||
const [scanError, setScanError] = useState('')
|
||||
|
||||
async function handleDetected(barcode) {
|
||||
setIsScannerOpen(false)
|
||||
setScanError('')
|
||||
try {
|
||||
const { data } = await apiClient.get(`/api/products/${barcode}`)
|
||||
onSelect(data)
|
||||
} catch (err) {
|
||||
if (err.response?.status === 404) {
|
||||
setManualBarcode(barcode)
|
||||
setShowManualForm(true)
|
||||
} else {
|
||||
setScanError('Produktsuche fehlgeschlagen. Bitte erneut versuchen.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openManualForm() {
|
||||
setManualBarcode(null)
|
||||
setShowManualForm(true)
|
||||
}
|
||||
|
||||
if (showManualForm) {
|
||||
return (
|
||||
<ProductNotFoundForm
|
||||
barcode={manualBarcode}
|
||||
mode="createOnly"
|
||||
onCreated={onSelect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
|
||||
<div className="flex max-h-[85vh] w-full max-w-sm flex-col rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Zutat hinzufügen</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-2xl leading-none text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ProductSearchInput onSelect={onSelect} placeholder="Zutat suchen" />
|
||||
|
||||
{scanError && (
|
||||
<p className="mt-2 text-center text-sm text-red-600 dark:text-red-400">{scanError}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsScannerOpen(true)}
|
||||
className="flex-1 rounded-lg border border-gray-300 py-2 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
Barcode scannen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openManualForm}
|
||||
className="flex-1 rounded-lg border border-dashed border-gray-300 py-2 text-sm font-medium text-emerald-600 dark:border-gray-600 dark:text-emerald-400"
|
||||
>
|
||||
+ Neu anlegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isScannerOpen && (
|
||||
<BarcodeScanner onDetected={handleDetected} onClose={() => setIsScannerOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
const BAR_COLORS = {
|
||||
emerald: 'bg-emerald-500',
|
||||
blue: 'bg-blue-500',
|
||||
amber: 'bg-amber-500',
|
||||
rose: 'bg-rose-500',
|
||||
}
|
||||
|
||||
export default function MacroProgress({ label, value, target, unit, color }) {
|
||||
const percent = target > 0 ? Math.min(100, Math.round((value / target) * 100)) : 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-baseline justify-between text-sm">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">{label}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{Math.round(value)} / {target} {unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
className={`h-full rounded-full ${BAR_COLORS[color]} transition-all`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MEAL_TYPES } from '../utils/mealTypes'
|
||||
|
||||
export default function MealTypeSelect({ value, onChange }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Mahlzeit
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{MEAL_TYPES.map((meal) => (
|
||||
<button
|
||||
key={meal.value}
|
||||
type="button"
|
||||
onClick={() => onChange(meal.value)}
|
||||
className={`rounded-lg py-2 text-xs font-medium transition-colors ${
|
||||
value === meal.value
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{meal.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react'
|
||||
import apiClient from '../api/client'
|
||||
import MealTypeSelect from './MealTypeSelect'
|
||||
import { defaultMealType } from '../utils/mealTypes'
|
||||
import { parseDecimal } from '../utils/numbers'
|
||||
|
||||
export default function ProductFoundModal({
|
||||
product,
|
||||
initialAmountG = 100,
|
||||
initialMealType,
|
||||
onLogged,
|
||||
onClose,
|
||||
}) {
|
||||
const [amountG, setAmountG] = useState(String(initialAmountG))
|
||||
const [mealType, setMealType] = useState(initialMealType || defaultMealType())
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await apiClient.post('/api/logs', {
|
||||
barcode: product.barcode,
|
||||
amount_g: parseDecimal(amountG),
|
||||
meal_type: mealType,
|
||||
})
|
||||
onLogged()
|
||||
} catch {
|
||||
setError('Eintrag konnte nicht gespeichert werden.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
|
||||
<div className="w-full max-w-sm rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">{product.name}</h2>
|
||||
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">Nährwerte pro 100g</p>
|
||||
|
||||
<dl className="mb-4 grid grid-cols-4 gap-2 text-center text-sm">
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Kcal</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(product.calories)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Carbs</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(product.carbs)}g</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Protein</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(product.protein)}g</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Fett</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(product.fat)}g</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<dl className="mb-4 grid grid-cols-4 gap-2 border-t border-gray-100 pt-3 text-center text-xs dark:border-gray-700">
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Zucker</dt>
|
||||
<dd className="font-medium text-gray-800 dark:text-gray-200">{Math.round(product.sugar)}g</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Ballaststoffe</dt>
|
||||
<dd className="font-medium text-gray-800 dark:text-gray-200">{Math.round(product.fiber)}g</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Ges. Fett</dt>
|
||||
<dd className="font-medium text-gray-800 dark:text-gray-200">
|
||||
{Math.round(product.saturated_fat)}g
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Salz</dt>
|
||||
<dd className="font-medium text-gray-800 dark:text-gray-200">{product.salt.toFixed(2)}g</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<MealTypeSelect value={mealType} onChange={setMealType} />
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Menge (Gramm)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={amountG}
|
||||
onChange={(e) => setAmountG(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-base font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? 'Speichern…' : 'Hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useState } from 'react'
|
||||
import apiClient from '../api/client'
|
||||
import MealTypeSelect from './MealTypeSelect'
|
||||
import { defaultMealType } from '../utils/mealTypes'
|
||||
import { parseDecimal } from '../utils/numbers'
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50'
|
||||
const labelClass = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300'
|
||||
|
||||
export default function ProductNotFoundForm({
|
||||
barcode = null,
|
||||
initialMealType,
|
||||
mode = 'log',
|
||||
onCreated,
|
||||
onLogged,
|
||||
onClose,
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [calories, setCalories] = useState('')
|
||||
const [carbs, setCarbs] = useState('')
|
||||
const [protein, setProtein] = useState('')
|
||||
const [fat, setFat] = useState('')
|
||||
const [sugar, setSugar] = useState('')
|
||||
const [fiber, setFiber] = useState('')
|
||||
const [saturatedFat, setSaturatedFat] = useState('')
|
||||
const [salt, setSalt] = useState('')
|
||||
const [amountG, setAmountG] = useState('100')
|
||||
const [mealType, setMealType] = useState(initialMealType || defaultMealType())
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
function optionalDecimal(value) {
|
||||
return value === '' ? undefined : parseDecimal(value)
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const { data: createdProduct } = await apiClient.post('/api/products', {
|
||||
barcode,
|
||||
name,
|
||||
calories: parseDecimal(calories),
|
||||
carbs: parseDecimal(carbs),
|
||||
protein: parseDecimal(protein),
|
||||
fat: parseDecimal(fat),
|
||||
sugar: optionalDecimal(sugar),
|
||||
fiber: optionalDecimal(fiber),
|
||||
saturated_fat: optionalDecimal(saturatedFat),
|
||||
salt: optionalDecimal(salt),
|
||||
})
|
||||
|
||||
if (mode === 'createOnly') {
|
||||
onCreated(createdProduct)
|
||||
return
|
||||
}
|
||||
|
||||
await apiClient.post('/api/logs', {
|
||||
barcode: createdProduct.barcode,
|
||||
amount_g: parseDecimal(amountG),
|
||||
meal_type: mealType,
|
||||
})
|
||||
onLogged()
|
||||
} catch {
|
||||
setError('Produkt konnte nicht gespeichert werden.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
|
||||
<div className="w-full max-w-sm rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||
{barcode ? 'Produkt nicht gefunden' : 'Neues Produkt anlegen'}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{barcode
|
||||
? `Barcode ${barcode} — bitte Nährwerte pro 100g eintragen`
|
||||
: 'Bitte Nährwerte pro 100g eintragen'}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Kcal /100g</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={calories}
|
||||
onChange={(e) => setCalories(e.target.value)}
|
||||
required
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Carbs /100g</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={carbs}
|
||||
onChange={(e) => setCarbs(e.target.value)}
|
||||
required
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Protein /100g</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={protein}
|
||||
onChange={(e) => setProtein(e.target.value)}
|
||||
required
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Fett /100g</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={fat}
|
||||
onChange={(e) => setFat(e.target.value)}
|
||||
required
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="pt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Optional</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Zucker /100g</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={sugar}
|
||||
onChange={(e) => setSugar(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Ballaststoffe /100g</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={fiber}
|
||||
onChange={(e) => setFiber(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Ges. Fett /100g</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={saturatedFat}
|
||||
onChange={(e) => setSaturatedFat(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Salz /100g</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={salt}
|
||||
onChange={(e) => setSalt(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'log' && <MealTypeSelect value={mealType} onChange={setMealType} />}
|
||||
|
||||
{mode === 'log' && (
|
||||
<div>
|
||||
<label className={labelClass}>Menge (Gramm) für heute</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={amountG}
|
||||
onChange={(e) => setAmountG(e.target.value)}
|
||||
required
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-base font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? 'Speichern…' : mode === 'createOnly' ? 'Anlegen' : 'Anlegen & Loggen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
export default function ProductSearchInput({ onSelect, placeholder = 'Suchen…', autoFocus = true }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [isSearching, setIsSearching] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setIsSearching(true)
|
||||
const timeout = setTimeout(() => {
|
||||
apiClient
|
||||
.get('/api/products/search', { params: { q: query } })
|
||||
.then((res) => setResults(res.data))
|
||||
.finally(() => setIsSearching(false))
|
||||
}, 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [query])
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
className="mb-3 w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isSearching ? (
|
||||
<p className="py-4 text-center text-sm text-gray-500 dark:text-gray-400">Suche…</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{query ? 'Keine Treffer.' : 'Noch keine Produkte vorhanden.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{results.map((product) => (
|
||||
<li key={product.barcode}>
|
||||
<button
|
||||
onClick={() => onSelect(product)}
|
||||
className="flex w-full items-center justify-between py-2.5 text-left text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-50">{product.name}</span>
|
||||
{product.is_dish && (
|
||||
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
Gericht
|
||||
</span>
|
||||
)}
|
||||
{product.last_amount_g != null && (
|
||||
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
zuletzt {Math.round(product.last_amount_g)}g
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-gray-500 dark:text-gray-400">
|
||||
{Math.round(product.calories)} kcal/100g
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||
Lädt…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useTheme } from '../context/ThemeContext'
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
aria-label={isDark ? 'Zu hellem Modus wechseln' : 'Zu dunklem Modus wechseln'}
|
||||
className="rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
{isDark ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path d="M12 3a1 1 0 0 1 1 1v1a1 1 0 1 1-2 0V4a1 1 0 0 1 1-1Zm0 15a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm9-6a1 1 0 0 1-1 1h-1a1 1 0 1 1 0-2h1a1 1 0 0 1 1 1ZM5 12a1 1 0 0 1-1 1H3a1 1 0 1 1 0-2h1a1 1 0 0 1 1 1Zm13.657-6.657a1 1 0 0 1 0 1.414l-.707.707a1 1 0 1 1-1.414-1.414l.707-.707a1 1 0 0 1 1.414 0ZM7.464 17.536a1 1 0 0 1 0 1.414l-.707.707a1 1 0 0 1-1.414-1.414l.707-.707a1 1 0 0 1 1.414 0Zm11.193 1.414a1 1 0 0 1-1.414 0l-.707-.707a1 1 0 1 1 1.414-1.414l.707.707a1 1 0 0 1 0 1.414ZM6.757 6.757a1 1 0 0 1-1.414 0l-.707-.707a1 1 0 0 1 1.414-1.414l.707.707a1 1 0 0 1 0 1.414ZM12 20a1 1 0 0 1 1 1v1a1 1 0 1 1-2 0v-1a1 1 0 0 1 1-1Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path d="M20.742 13.045a8.088 8.088 0 0 1-2.077.267c-4.476 0-8.106-3.63-8.106-8.106 0-.712.093-1.403.267-2.06a1 1 0 0 0-1.281-1.226C5.117 3.077 2 7.15 2 11.945 2 17.5 6.5 22 12.055 22c4.795 0 8.868-3.117 10.023-7.545a1 1 0 0 0-1.336-1.41Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!token) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
apiClient
|
||||
.get('/api/users/me')
|
||||
.then((res) => setUser(res.data))
|
||||
.catch(() => localStorage.removeItem('access_token'))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
async function login(username, password) {
|
||||
const form = new URLSearchParams()
|
||||
form.append('username', username)
|
||||
form.append('password', password)
|
||||
|
||||
const { data } = await apiClient.post('/api/auth/token', form, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
localStorage.setItem('access_token', data.access_token)
|
||||
|
||||
const me = await apiClient.get('/api/users/me')
|
||||
setUser(me.data)
|
||||
}
|
||||
|
||||
async function register(username, password) {
|
||||
await apiClient.post('/api/auth/register', { username, password })
|
||||
await login(username, password)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('access_token')
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ user, setUser, isAuthenticated: !!user, isLoading, login, register, logout }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within an AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
const ThemeContext = createContext(null)
|
||||
|
||||
function getInitialTheme() {
|
||||
const stored = localStorage.getItem('theme')
|
||||
if (stored === 'light' || stored === 'dark') return stored
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState(getInitialTheme)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme])
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
|
||||
}
|
||||
|
||||
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import apiClient from '../api/client'
|
||||
import ThemeToggle from '../components/ThemeToggle'
|
||||
|
||||
function DishListItem({ dish }) {
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
to={`/cookbook/${dish.id}`}
|
||||
className="flex items-center justify-between rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-50">{dish.name}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{Math.round(dish.total_weight_g)}g gesamt · {dish.ingredients.length} Zutaten
|
||||
{!dish.is_owner && ` · geteilt von ${dish.owner_username}`}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{Math.round(dish.calories)} kcal/100g
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CookbookPage() {
|
||||
const [dishes, setDishes] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get('/api/dishes')
|
||||
.then((res) => setDishes(res.data))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
const ownDishes = dishes.filter((dish) => dish.is_owner)
|
||||
const sharedDishes = dishes.filter((dish) => !dish.is_owner)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pb-28 dark:bg-gray-900">
|
||||
<header className="flex items-center justify-between bg-white px-4 py-4 shadow-sm dark:bg-gray-800">
|
||||
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-50">Kochbuch</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/dashboard" className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
Zurück
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-md space-y-4 p-4">
|
||||
{isLoading ? (
|
||||
<p className="text-center text-sm text-gray-500 dark:text-gray-400">Lädt…</p>
|
||||
) : dishes.length === 0 ? (
|
||||
<div className="rounded-xl bg-white p-6 text-center shadow-sm dark:bg-gray-800">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Noch keine Gerichte im Kochbuch. Leg dein erstes Gericht an — praktisch für Rezepte, die du
|
||||
öfter kochst, und als Inspiration, wenn du mal nicht weißt, was du kochen sollst.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-semibold text-gray-500 dark:text-gray-400">Meine Rezepte</h2>
|
||||
{ownDishes.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Noch keine eigenen Rezepte.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{ownDishes.map((dish) => (
|
||||
<DishListItem key={dish.id} dish={dish} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sharedDishes.length > 0 && (
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-semibold text-gray-500 dark:text-gray-400">Mit mir geteilt</h2>
|
||||
<ul className="space-y-3">
|
||||
{sharedDishes.map((dish) => (
|
||||
<DishListItem key={dish.id} dish={dish} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Link
|
||||
to="/cookbook/new"
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 rounded-full bg-emerald-600 px-6 py-3 text-base font-medium text-white shadow-lg active:bg-emerald-700"
|
||||
>
|
||||
+ Neues Gericht
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import apiClient from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import MacroProgress from '../components/MacroProgress'
|
||||
import BarcodeScanner from '../components/BarcodeScanner'
|
||||
import ProductFoundModal from '../components/ProductFoundModal'
|
||||
import ProductNotFoundForm from '../components/ProductNotFoundForm'
|
||||
import AddMealModal from '../components/AddMealModal'
|
||||
import EditLogModal from '../components/EditLogModal'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import ThemeToggle from '../components/ThemeToggle'
|
||||
import { groupByMealType } from '../utils/mealTypes'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, logout } = useAuth()
|
||||
const [todayData, setTodayData] = useState(null)
|
||||
const [isScannerOpen, setIsScannerOpen] = useState(false)
|
||||
const [isAddMealOpen, setIsAddMealOpen] = useState(false)
|
||||
const [scanResult, setScanResult] = useState(null)
|
||||
const [scanError, setScanError] = useState('')
|
||||
const [editingLog, setEditingLog] = useState(null)
|
||||
const [deletingLog, setDeletingLog] = useState(null)
|
||||
|
||||
const loadToday = useCallback(async () => {
|
||||
const { data } = await apiClient.get('/api/logs/today')
|
||||
setTodayData(data)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadToday()
|
||||
}, [loadToday])
|
||||
|
||||
async function handleDetected(barcode) {
|
||||
setIsScannerOpen(false)
|
||||
setScanError('')
|
||||
try {
|
||||
const { data } = await apiClient.get(`/api/products/${barcode}`)
|
||||
setScanResult({ mode: 'found', product: data })
|
||||
} catch (err) {
|
||||
if (err.response?.status === 404) {
|
||||
setScanResult({ mode: 'notFound', barcode })
|
||||
} else {
|
||||
setScanError('Produktsuche fehlgeschlagen. Bitte erneut versuchen.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogged() {
|
||||
setScanResult(null)
|
||||
setIsAddMealOpen(false)
|
||||
loadToday()
|
||||
}
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
await apiClient.delete(`/api/logs/${deletingLog.id}`)
|
||||
setDeletingLog(null)
|
||||
loadToday()
|
||||
}
|
||||
|
||||
function handleLogSaved() {
|
||||
setEditingLog(null)
|
||||
loadToday()
|
||||
}
|
||||
|
||||
const totals = todayData?.totals ?? {
|
||||
calories: 0,
|
||||
protein: 0,
|
||||
carbs: 0,
|
||||
fat: 0,
|
||||
sugar: 0,
|
||||
fiber: 0,
|
||||
saturated_fat: 0,
|
||||
salt: 0,
|
||||
}
|
||||
const mealGroups = groupByMealType(todayData?.logs ?? [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pb-28 dark:bg-gray-900">
|
||||
<header className="flex items-center justify-between bg-white px-4 py-4 shadow-sm dark:bg-gray-800">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Angemeldet als</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-50">{user?.username}</p>
|
||||
</div>
|
||||
<nav className="flex items-center gap-4 text-sm font-medium">
|
||||
<Link to="/cookbook" className="text-gray-600 dark:text-gray-300">
|
||||
Kochbuch
|
||||
</Link>
|
||||
<Link to="/history" className="text-gray-600 dark:text-gray-300">
|
||||
Historie
|
||||
</Link>
|
||||
<Link to="/settings" className="text-gray-600 dark:text-gray-300">
|
||||
Ziele
|
||||
</Link>
|
||||
<button onClick={logout} className="text-gray-500 dark:text-gray-400">
|
||||
Abmelden
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-md space-y-4 p-4">
|
||||
<section className="space-y-4 rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-50">Heute</h1>
|
||||
<MacroProgress
|
||||
label="Kalorien"
|
||||
value={totals.calories}
|
||||
target={user.calorie_goal}
|
||||
unit="kcal"
|
||||
color="emerald"
|
||||
/>
|
||||
<MacroProgress
|
||||
label="Protein"
|
||||
value={totals.protein}
|
||||
target={user.protein_goal}
|
||||
unit="g"
|
||||
color="blue"
|
||||
/>
|
||||
<MacroProgress
|
||||
label="Carbs"
|
||||
value={totals.carbs}
|
||||
target={user.carbs_goal}
|
||||
unit="g"
|
||||
color="amber"
|
||||
/>
|
||||
<MacroProgress
|
||||
label="Fett"
|
||||
value={totals.fat}
|
||||
target={user.fat_goal}
|
||||
unit="g"
|
||||
color="rose"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 border-t border-gray-100 pt-3 text-center text-xs dark:border-gray-700">
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Zucker</p>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">{Math.round(totals.sugar)}g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Ballaststoffe</p>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">{Math.round(totals.fiber)}g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Ges. Fett</p>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">{Math.round(totals.saturated_fat)}g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Salz</p>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">{totals.salt.toFixed(1)}g</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-50">Einträge</h2>
|
||||
{mealGroups.map((group) => (
|
||||
<div key={group.type}>
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
||||
{group.label}
|
||||
</h3>
|
||||
{group.logs.length ? (
|
||||
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{group.logs.map((log) => (
|
||||
<li key={log.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-50">{log.name}</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">{Math.round(log.amount_g)}g</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{Math.round(log.calories)} kcal
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setEditingLog(log)}
|
||||
aria-label="Eintrag bearbeiten"
|
||||
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4">
|
||||
<path d="M17.414 2.586a2 2 0 0 1 2.828 2.828l-1.31 1.31-2.828-2.828 1.31-1.31ZM16.5 5.207 4 17.707V20.5h2.793L19.293 8l-2.793-2.793Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingLog(log)}
|
||||
aria-label="Eintrag löschen"
|
||||
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4">
|
||||
<path d="M9 3a1 1 0 0 0-1 1v1H4.5a1 1 0 1 0 0 2H5v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7h.5a1 1 0 1 0 0-2H16V4a1 1 0 0 0-1-1H9Zm1 5a1 1 0 1 1 2 0v9a1 1 0 1 1-2 0V8Zm4-1a1 1 0 0 0-1 1v9a1 1 0 1 0 2 0V8a1 1 0 0 0-1-1Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">Keine Einträge.</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{scanError && <p className="text-center text-sm text-red-600 dark:text-red-400">{scanError}</p>}
|
||||
</main>
|
||||
|
||||
<div className="fixed bottom-6 left-1/2 flex -translate-x-1/2 gap-3">
|
||||
<button
|
||||
onClick={() => setIsScannerOpen(true)}
|
||||
className="rounded-full bg-emerald-600 px-5 py-3 text-base font-medium text-white shadow-lg active:bg-emerald-700"
|
||||
>
|
||||
Barcode scannen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsAddMealOpen(true)}
|
||||
className="rounded-full bg-gray-800 px-5 py-3 text-base font-medium text-white shadow-lg active:bg-gray-900 dark:bg-gray-700 dark:active:bg-gray-600"
|
||||
>
|
||||
Mahlzeit hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isScannerOpen && (
|
||||
<BarcodeScanner onDetected={handleDetected} onClose={() => setIsScannerOpen(false)} />
|
||||
)}
|
||||
|
||||
{isAddMealOpen && (
|
||||
<AddMealModal onLogged={handleLogged} onClose={() => setIsAddMealOpen(false)} />
|
||||
)}
|
||||
|
||||
{scanResult?.mode === 'found' && (
|
||||
<ProductFoundModal
|
||||
product={scanResult.product}
|
||||
onLogged={handleLogged}
|
||||
onClose={() => setScanResult(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scanResult?.mode === 'notFound' && (
|
||||
<ProductNotFoundForm
|
||||
barcode={scanResult.barcode}
|
||||
onLogged={handleLogged}
|
||||
onClose={() => setScanResult(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingLog && (
|
||||
<EditLogModal log={editingLog} onSaved={handleLogSaved} onClose={() => setEditingLog(null)} />
|
||||
)}
|
||||
|
||||
{deletingLog && (
|
||||
<ConfirmDialog
|
||||
title="Eintrag löschen?"
|
||||
message={`"${deletingLog.name}" wird aus dem heutigen Log entfernt.`}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => setDeletingLog(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import apiClient from '../api/client'
|
||||
import ProductFoundModal from '../components/ProductFoundModal'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import DishShareManager from '../components/DishShareManager'
|
||||
import ThemeToggle from '../components/ThemeToggle'
|
||||
|
||||
export default function DishDetailPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [dish, setDish] = useState(null)
|
||||
const [isLogging, setIsLogging] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.get(`/api/dishes/${id}`).then((res) => setDish(res.data))
|
||||
}, [id])
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
await apiClient.delete(`/api/dishes/${id}`)
|
||||
navigate('/cookbook')
|
||||
}
|
||||
|
||||
if (!dish) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||
Lädt…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pb-28 dark:bg-gray-900">
|
||||
<header className="flex items-center justify-between bg-white px-4 py-4 shadow-sm dark:bg-gray-800">
|
||||
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-50">{dish.name}</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/cookbook" className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
Zurück
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-md space-y-4 p-4">
|
||||
<section className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
Ergibt insgesamt {Math.round(dish.total_weight_g)}g · Nährwerte pro 100g
|
||||
</p>
|
||||
<dl className="mb-4 grid grid-cols-4 gap-2 text-center text-sm">
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Kcal</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(dish.calories)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Carbs</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(dish.carbs)}g</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Protein</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(dish.protein)}g</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Fett</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(dish.fat)}g</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">Zutaten</h2>
|
||||
<ul className="mb-4 divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{dish.ingredients.map((ing) => (
|
||||
<li key={ing.barcode} className="flex items-center justify-between py-1.5 text-sm">
|
||||
<span className="text-gray-700 dark:text-gray-300">{ing.name}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">{Math.round(ing.amount_g)}g</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{dish.instructions && (
|
||||
<>
|
||||
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">Zubereitung</h2>
|
||||
<p className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">
|
||||
{dish.instructions}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{dish.is_owner ? (
|
||||
<>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
to={`/cookbook/${id}/edit`}
|
||||
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-center text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
Bearbeiten
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setIsDeleting(true)}
|
||||
className="flex-1 rounded-lg border border-red-200 py-2.5 text-sm font-medium text-red-600 dark:border-red-900 dark:text-red-400"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
<DishShareManager dishId={id} />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Geteilt von {dish.owner_username}
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<button
|
||||
onClick={() => setIsLogging(true)}
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 rounded-full bg-emerald-600 px-6 py-3 text-base font-medium text-white shadow-lg active:bg-emerald-700"
|
||||
>
|
||||
Essen loggen
|
||||
</button>
|
||||
|
||||
{isLogging && (
|
||||
<ProductFoundModal
|
||||
product={dish}
|
||||
onLogged={() => navigate('/dashboard')}
|
||||
onClose={() => setIsLogging(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDeleting && (
|
||||
<ConfirmDialog
|
||||
title="Gericht löschen?"
|
||||
message={`"${dish.name}" wird dauerhaft aus dem Kochbuch entfernt.`}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => setIsDeleting(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import apiClient from '../api/client'
|
||||
import IngredientPickerModal from '../components/IngredientPickerModal'
|
||||
import ThemeToggle from '../components/ThemeToggle'
|
||||
import { parseDecimal } from '../utils/numbers'
|
||||
|
||||
const NUTRIENT_FIELDS = ['calories', 'carbs', 'protein', 'fat', 'sugar', 'fiber', 'saturated_fat', 'salt']
|
||||
|
||||
function computeTotals(ingredients) {
|
||||
const totalWeight = ingredients.reduce((sum, ing) => sum + (ing.amount_g || 0), 0)
|
||||
const totals = Object.fromEntries(NUTRIENT_FIELDS.map((f) => [f, 0]))
|
||||
for (const ing of ingredients) {
|
||||
const factor = (ing.amount_g || 0) / 100
|
||||
for (const f of NUTRIENT_FIELDS) totals[f] += (ing[f] || 0) * factor
|
||||
}
|
||||
const per100 =
|
||||
totalWeight > 0
|
||||
? Object.fromEntries(NUTRIENT_FIELDS.map((f) => [f, (totals[f] / totalWeight) * 100]))
|
||||
: Object.fromEntries(NUTRIENT_FIELDS.map((f) => [f, 0]))
|
||||
return { totalWeight, per100 }
|
||||
}
|
||||
|
||||
export default function DishFormPage() {
|
||||
const { id } = useParams()
|
||||
const isEditing = Boolean(id)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [instructions, setInstructions] = useState('')
|
||||
const [ingredients, setIngredients] = useState([])
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(isEditing)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) return
|
||||
apiClient.get(`/api/dishes/${id}`).then((res) => {
|
||||
setName(res.data.name)
|
||||
setInstructions(res.data.instructions ?? '')
|
||||
setIngredients(res.data.ingredients)
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [id, isEditing])
|
||||
|
||||
function addIngredient(product) {
|
||||
setIngredients((prev) => [
|
||||
...prev,
|
||||
{
|
||||
barcode: product.barcode,
|
||||
name: product.name,
|
||||
amount_g: 100,
|
||||
...Object.fromEntries(NUTRIENT_FIELDS.map((f) => [f, product[f]])),
|
||||
},
|
||||
])
|
||||
setIsPickerOpen(false)
|
||||
}
|
||||
|
||||
function removeIngredient(index) {
|
||||
setIngredients((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function updateIngredientAmount(index, value) {
|
||||
setIngredients((prev) =>
|
||||
prev.map((ing, i) => (i === index ? { ...ing, amount_g: parseDecimal(value) || 0 } : ing)),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (ingredients.length === 0) {
|
||||
setError('Mindestens eine Zutat hinzufügen.')
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const payload = {
|
||||
name,
|
||||
instructions: instructions || null,
|
||||
ingredients: ingredients.map((ing) => ({ barcode: ing.barcode, amount_g: ing.amount_g })),
|
||||
}
|
||||
const { data } = isEditing
|
||||
? await apiClient.put(`/api/dishes/${id}`, payload)
|
||||
: await apiClient.post('/api/dishes', payload)
|
||||
navigate(`/cookbook/${data.id}`)
|
||||
} catch {
|
||||
setError('Gericht konnte nicht gespeichert werden.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||
Lädt…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { totalWeight, per100 } = computeTotals(ingredients)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6 dark:bg-gray-900">
|
||||
<div className="mx-auto max-w-md">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||
{isEditing ? 'Gericht bearbeiten' : 'Neues Gericht'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/cookbook" className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
Zurück
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="rounded-xl bg-white p-6 shadow-sm dark:bg-gray-800">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="mb-4 w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Zubereitung (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-white p-6 shadow-sm dark:bg-gray-800">
|
||||
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">Zutaten</h2>
|
||||
{ingredients.length === 0 ? (
|
||||
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">Noch keine Zutaten.</p>
|
||||
) : (
|
||||
<ul className="mb-3 space-y-2">
|
||||
{ingredients.map((ing, index) => (
|
||||
<li key={`${ing.barcode}-${index}`} className="flex items-center gap-2">
|
||||
<span className="flex-1 text-sm text-gray-900 dark:text-gray-50">{ing.name}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={ing.amount_g}
|
||||
onChange={(e) => updateIngredientAmount(index, e.target.value)}
|
||||
className="w-20 rounded-lg border border-gray-300 px-2 py-1 text-right text-sm text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">g</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeIngredient(index)}
|
||||
aria-label="Zutat entfernen"
|
||||
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPickerOpen(true)}
|
||||
className="w-full rounded-lg border border-dashed border-gray-300 py-2 text-sm font-medium text-emerald-600 dark:border-gray-600 dark:text-emerald-400"
|
||||
>
|
||||
+ Zutat hinzufügen
|
||||
</button>
|
||||
|
||||
{ingredients.length > 0 && (
|
||||
<div className="mt-4 border-t border-gray-100 pt-3 dark:border-gray-700">
|
||||
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Ergibt insgesamt {Math.round(totalWeight)}g · Nährwerte pro 100g
|
||||
</p>
|
||||
<dl className="grid grid-cols-4 gap-2 text-center text-sm">
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Kcal</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">
|
||||
{Math.round(per100.calories)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Carbs</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">
|
||||
{Math.round(per100.carbs)}g
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Protein</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">
|
||||
{Math.round(per100.protein)}g
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Fett</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">
|
||||
{Math.round(per100.fat)}g
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? 'Speichern…' : 'Speichern'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{isPickerOpen && (
|
||||
<IngredientPickerModal onSelect={addIngredient} onClose={() => setIsPickerOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import apiClient from '../api/client'
|
||||
import ThemeToggle from '../components/ThemeToggle'
|
||||
import { groupByMealType } from '../utils/mealTypes'
|
||||
|
||||
const HISTORY_DAYS = 30
|
||||
|
||||
function average(days) {
|
||||
const daysWithData = days.filter((d) => d.totals.calories > 0)
|
||||
if (daysWithData.length === 0) {
|
||||
return { calories: 0, protein: 0, carbs: 0, fat: 0, count: 0 }
|
||||
}
|
||||
const sum = (key) => daysWithData.reduce((acc, d) => acc + d.totals[key], 0)
|
||||
return {
|
||||
calories: sum('calories') / daysWithData.length,
|
||||
protein: sum('protein') / daysWithData.length,
|
||||
carbs: sum('carbs') / daysWithData.length,
|
||||
fat: sum('fat') / daysWithData.length,
|
||||
count: daysWithData.length,
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const d = new Date(`${dateStr}T00:00:00`)
|
||||
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })
|
||||
}
|
||||
|
||||
function AverageCard({ title, avg }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">{title}</h2>
|
||||
{avg.count === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Keine Einträge in diesem Zeitraum.</p>
|
||||
) : (
|
||||
<dl className="grid grid-cols-4 gap-2 text-center text-sm">
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Kcal</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(avg.calories)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Protein</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(avg.protein)}g</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Carbs</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(avg.carbs)}g</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Fett</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(avg.fat)}g</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [days, setDays] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [expandedDate, setExpandedDate] = useState(null)
|
||||
const [dayDetails, setDayDetails] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get(`/api/logs/history?days=${HISTORY_DAYS}`)
|
||||
.then((res) => setDays(res.data.days))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
async function toggleDay(dateStr) {
|
||||
if (expandedDate === dateStr) {
|
||||
setExpandedDate(null)
|
||||
return
|
||||
}
|
||||
setExpandedDate(dateStr)
|
||||
if (!dayDetails[dateStr]) {
|
||||
const { data } = await apiClient.get(`/api/logs/day/${dateStr}`)
|
||||
setDayDetails((prev) => ({ ...prev, [dateStr]: data.logs }))
|
||||
}
|
||||
}
|
||||
|
||||
const weeklyAvg = average(days.slice(-7))
|
||||
const monthlyAvg = average(days)
|
||||
const reversedDays = [...days].reverse()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pb-10 dark:bg-gray-900">
|
||||
<header className="flex items-center justify-between bg-white px-4 py-4 shadow-sm dark:bg-gray-800">
|
||||
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-50">Historie</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/dashboard" className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
Zurück
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-md space-y-4 p-4">
|
||||
<AverageCard title="Ø letzte 7 Tage" avg={weeklyAvg} />
|
||||
<AverageCard title={`Ø letzte ${HISTORY_DAYS} Tage`} avg={monthlyAvg} />
|
||||
|
||||
<section className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">Tage</h2>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Lädt…</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{reversedDays.map((day) => (
|
||||
<li key={day.date}>
|
||||
<button
|
||||
onClick={() => toggleDay(day.date)}
|
||||
className="flex w-full items-center justify-between py-2.5 text-left text-sm"
|
||||
>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-50">
|
||||
{formatDate(day.date)}
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
{day.totals.calories > 0 ? `${Math.round(day.totals.calories)} kcal` : '—'}
|
||||
</span>
|
||||
</button>
|
||||
{expandedDate === day.date && (
|
||||
<div className="pb-3">
|
||||
{!dayDetails[day.date] ? (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">Lädt…</p>
|
||||
) : dayDetails[day.date].length === 0 ? (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Keine Einträge an diesem Tag.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 border-l-2 border-gray-100 pl-3 dark:border-gray-700">
|
||||
{groupByMealType(dayDetails[day.date])
|
||||
.filter((group) => group.logs.length > 0)
|
||||
.map((group) => (
|
||||
<div key={group.type}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
||||
{group.label}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{group.logs.map((log) => (
|
||||
<li
|
||||
key={log.id}
|
||||
className="flex items-center justify-between text-xs text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
<span>
|
||||
{log.name} ({Math.round(log.amount_g)}g)
|
||||
</span>
|
||||
<span>{Math.round(log.calories)} kcal</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import ThemeToggle from '../components/ThemeToggle'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/dashboard')
|
||||
} catch {
|
||||
setError('Benutzername oder Passwort falsch.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-900">
|
||||
<div className="absolute right-4 top-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="mb-6 text-center text-2xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
KalorienTracker
|
||||
</h1>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4 rounded-xl bg-white p-6 shadow-sm dark:bg-gray-800"
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Benutzername
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? 'Anmelden…' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Noch kein Konto?{' '}
|
||||
<Link to="/register" className="font-medium text-emerald-600 dark:text-emerald-400">
|
||||
Registrieren
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import ThemeToggle from '../components/ThemeToggle'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const { register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await register(username, password)
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
const detail = err.response?.data?.detail
|
||||
setError(detail || 'Registrierung fehlgeschlagen.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-900">
|
||||
<div className="absolute right-4 top-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="mb-6 text-center text-2xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
Konto erstellen
|
||||
</h1>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4 rounded-xl bg-white p-6 shadow-sm dark:bg-gray-800"
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Benutzername
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? 'Registrieren…' : 'Registrieren'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Schon ein Konto?{' '}
|
||||
<Link to="/login" className="font-medium text-emerald-600 dark:text-emerald-400">
|
||||
Anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import apiClient from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import ThemeToggle from '../components/ThemeToggle'
|
||||
import { parseDecimal } from '../utils/numbers'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, setUser } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [calorieGoal, setCalorieGoal] = useState(String(user.calorie_goal))
|
||||
const [proteinGoal, setProteinGoal] = useState(String(user.protein_goal))
|
||||
const [carbsGoal, setCarbsGoal] = useState(String(user.carbs_goal))
|
||||
const [fatGoal, setFatGoal] = useState(String(user.fat_goal))
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const { data } = await apiClient.put('/api/users/me/goals', {
|
||||
calorie_goal: parseDecimal(calorieGoal),
|
||||
protein_goal: parseDecimal(proteinGoal),
|
||||
carbs_goal: parseDecimal(carbsGoal),
|
||||
fat_goal: parseDecimal(fatGoal),
|
||||
})
|
||||
setUser(data)
|
||||
navigate('/dashboard')
|
||||
} catch {
|
||||
setError('Ziele konnten nicht gespeichert werden.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6 dark:bg-gray-900">
|
||||
<div className="mx-auto max-w-md">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Tagesziele</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/dashboard" className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
Zurück
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4 rounded-xl bg-white p-6 shadow-sm dark:bg-gray-800"
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Kalorien (kcal)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={calorieGoal}
|
||||
onChange={(e) => setCalorieGoal(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Protein (g)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={proteinGoal}
|
||||
onChange={(e) => setProteinGoal(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Carbs (g)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={carbsGoal}
|
||||
onChange={(e) => setCarbsGoal(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Fett (g)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={fatGoal}
|
||||
onChange={(e) => setFatGoal(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? 'Speichern…' : 'Speichern'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export const MEAL_TYPES = [
|
||||
{ value: 'breakfast', label: 'Frühstück' },
|
||||
{ value: 'lunch', label: 'Mittagessen' },
|
||||
{ value: 'dinner', label: 'Abendessen' },
|
||||
{ value: 'snack', label: 'Snacks' },
|
||||
]
|
||||
|
||||
const MEAL_TYPE_ORDER = MEAL_TYPES.map((m) => m.value)
|
||||
|
||||
export function mealTypeLabel(value) {
|
||||
return MEAL_TYPES.find((m) => m.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
export function defaultMealType() {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 11) return 'breakfast'
|
||||
if (hour < 15) return 'lunch'
|
||||
if (hour < 21) return 'dinner'
|
||||
return 'snack'
|
||||
}
|
||||
|
||||
export function groupByMealType(logs) {
|
||||
const groups = Object.fromEntries(MEAL_TYPE_ORDER.map((type) => [type, []]))
|
||||
for (const log of logs) {
|
||||
;(groups[log.meal_type] ?? groups.snack).push(log)
|
||||
}
|
||||
return MEAL_TYPE_ORDER.map((type) => ({ type, label: mealTypeLabel(type), logs: groups[type] }))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function parseDecimal(value) {
|
||||
if (value === '' || value == null) return NaN
|
||||
return parseFloat(String(value).replace(',', '.'))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
Reference in New Issue
Block a user