Initial commit

This commit is contained in:
2026-07-28 15:00:00 +02:00
commit 2f95eeda83
64 changed files with 5990 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react'
import ProductFoundModal from './ProductFoundModal'
import ProductNotFoundForm from './ProductNotFoundForm'
import ProductSearchInput from './ProductSearchInput'
import MealTypeSelect from './MealTypeSelect'
import { defaultMealType } from '../utils/mealTypes'
export default function AddMealModal({ onLogged, onClose }) {
const [mealType, setMealType] = useState(defaultMealType)
const [selectedProduct, setSelectedProduct] = useState(null)
const [showManualForm, setShowManualForm] = useState(false)
if (selectedProduct) {
return (
<ProductFoundModal
product={selectedProduct}
initialAmountG={selectedProduct.last_amount_g ?? 100}
initialMealType={mealType}
onLogged={onLogged}
onClose={() => setSelectedProduct(null)}
/>
)
}
if (showManualForm) {
return (
<ProductNotFoundForm
initialMealType={mealType}
onLogged={onLogged}
onClose={() => setShowManualForm(false)}
/>
)
}
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
<div className="flex max-h-[85vh] w-full max-w-sm flex-col rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
Mahlzeit hinzufügen
</h2>
<button
onClick={onClose}
className="text-2xl leading-none text-gray-400 dark:text-gray-500"
>
&times;
</button>
</div>
<div className="mb-4">
<MealTypeSelect value={mealType} onChange={setMealType} />
</div>
<ProductSearchInput onSelect={setSelectedProduct} placeholder="Produkt suchen (z. B. Döner)" />
<button
type="button"
onClick={() => setShowManualForm(true)}
className="mt-3 w-full rounded-lg border border-dashed border-gray-300 py-2.5 text-sm font-medium text-emerald-600 dark:border-gray-600 dark:text-emerald-400"
>
+ Neues Produkt anlegen
</button>
</div>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useRef, useState } from 'react'
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
const SCANNER_ELEMENT_ID = 'barcode-scanner'
const BARCODE_FORMATS = [
Html5QrcodeSupportedFormats.EAN_13,
Html5QrcodeSupportedFormats.EAN_8,
Html5QrcodeSupportedFormats.UPC_A,
Html5QrcodeSupportedFormats.UPC_E,
Html5QrcodeSupportedFormats.CODE_128,
]
export default function BarcodeScanner({ onDetected, onClose }) {
const onDetectedRef = useRef(onDetected)
onDetectedRef.current = onDetected
const scannerRef = useRef(null)
const hasDetectedRef = useRef(false)
const [error, setError] = useState('')
const [isCameraReady, setIsCameraReady] = useState(false)
const [isProcessingPhoto, setIsProcessingPhoto] = useState(false)
function handleDetected(decodedText) {
if (hasDetectedRef.current) return
hasDetectedRef.current = true
onDetectedRef.current(decodedText)
}
function startLiveScanning(scanner) {
return scanner
.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 250, height: 150 } },
handleDetected,
() => {},
)
.then(() => setIsCameraReady(true))
}
useEffect(() => {
const scanner = new Html5Qrcode(SCANNER_ELEMENT_ID, {
formatsToSupport: BARCODE_FORMATS,
verbose: false,
})
scannerRef.current = scanner
startLiveScanning(scanner).catch((err) => {
console.error('Kamera konnte nicht gestartet werden', err)
setError('Kamera konnte nicht gestartet werden. Bitte Kamera-Berechtigung erlauben.')
})
return () => {
if (scanner.isScanning) {
scanner
.stop()
.then(() => scanner.clear())
.catch(() => {})
}
}
}, [])
async function captureAndAnalyze() {
const scanner = scannerRef.current
const video = document.querySelector(`#${SCANNER_ELEMENT_ID} video`)
if (!scanner || !video || !video.videoWidth) return
setError('')
setIsProcessingPhoto(true)
try {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height)
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
const file = new File([blob], 'frame.png', { type: 'image/png' })
if (scanner.isScanning) {
setIsCameraReady(false)
await scanner.stop()
}
const decodedText = await scanner.scanFile(file, false)
handleDetected(decodedText)
} catch {
setError('Kein Barcode erkannt. Bitte erneut versuchen.')
startLiveScanning(scanner).catch(() => setIsCameraReady(false))
} finally {
setIsProcessingPhoto(false)
}
}
return (
<div className="fixed inset-0 z-50 overflow-hidden bg-black">
<div id={SCANNER_ELEMENT_ID} className="absolute inset-0 [&_video]:!h-full [&_video]:!w-full [&_video]:object-cover" />
<div className="absolute inset-x-0 top-0 flex items-center justify-between bg-gradient-to-b from-black/70 to-transparent p-4 text-white">
<span className="text-base font-medium">Barcode scannen</span>
<button onClick={onClose} className="text-2xl leading-none">
&times;
</button>
</div>
<div className="absolute inset-x-0 bottom-0 flex flex-col items-center gap-3 bg-gradient-to-t from-black/80 to-transparent p-6 pb-8">
<p className="text-center text-sm text-white">
{error || (isProcessingPhoto ? 'Foto wird analysiert…' : 'Halte den Barcode vor die Kamera')}
</p>
<button
type="button"
onClick={captureAndAnalyze}
disabled={isProcessingPhoto || !isCameraReady}
aria-label="Foto aufnehmen"
className="flex h-16 w-16 items-center justify-center rounded-full bg-red-600 shadow-lg active:bg-red-700 disabled:opacity-60"
>
<span className="h-12 w-12 rounded-full border-2 border-white" />
</button>
</div>
</div>
)
}
+24
View File
@@ -0,0 +1,24 @@
export default function ConfirmDialog({ title, message, confirmLabel = 'Löschen', onConfirm, onCancel }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 dark:bg-gray-800">
<h2 className="mb-1 text-lg font-semibold text-gray-900 dark:text-gray-50">{title}</h2>
{message && <p className="mb-4 text-sm text-gray-500 dark:text-gray-400">{message}</p>}
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
Abbrechen
</button>
<button
onClick={onConfirm}
className="flex-1 rounded-lg bg-red-600 py-2.5 text-sm font-medium text-white active:bg-red-700"
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,91 @@
import { useEffect, useState } from 'react'
import apiClient from '../api/client'
export default function DishShareManager({ dishId }) {
const [shares, setShares] = useState([])
const [allUsers, setAllUsers] = useState([])
const [username, setUsername] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
apiClient.get(`/api/dishes/${dishId}/shares`).then((res) => setShares(res.data))
apiClient.get('/api/users').then((res) => setAllUsers(res.data))
}, [dishId])
async function handleAdd(e) {
e.preventDefault()
if (!username.trim()) return
setError('')
setIsSubmitting(true)
try {
const { data } = await apiClient.post(`/api/dishes/${dishId}/shares`, { username: username.trim() })
setShares(data)
setUsername('')
} catch (err) {
if (err.response?.status === 404) {
setError('Nutzer nicht gefunden.')
} else if (err.response?.status === 400) {
setError('Kann nicht mit dir selbst geteilt werden.')
} else {
setError('Freigabe fehlgeschlagen.')
}
} finally {
setIsSubmitting(false)
}
}
async function handleRemove(name) {
await apiClient.delete(`/api/dishes/${dishId}/shares/${name}`)
setShares((prev) => prev.filter((s) => s.username !== name))
}
return (
<section className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">Teilen</h2>
{shares.length === 0 ? (
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">Noch mit niemandem geteilt.</p>
) : (
<ul className="mb-3 space-y-1.5">
{shares.map((share) => (
<li key={share.username} className="flex items-center justify-between text-sm">
<span className="text-gray-700 dark:text-gray-300">{share.username}</span>
<button
type="button"
onClick={() => handleRemove(share.username)}
aria-label={`Freigabe für ${share.username} entfernen`}
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
>
&times;
</button>
</li>
))}
</ul>
)}
<form onSubmit={handleAdd} className="flex gap-2">
<input
type="text"
list="dish-share-users"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Nutzername"
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
/>
<datalist id="dish-share-users">
{allUsers.map((user) => (
<option key={user.username} value={user.username} />
))}
</datalist>
<button
type="submit"
disabled={isSubmitting}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white active:bg-emerald-700 disabled:opacity-60"
>
Teilen
</button>
</form>
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
</section>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { useState } from 'react'
import apiClient from '../api/client'
import MealTypeSelect from './MealTypeSelect'
import { parseDecimal } from '../utils/numbers'
export default function EditLogModal({ log, onSaved, onClose }) {
const [amountG, setAmountG] = useState(String(log.amount_g))
const [mealType, setMealType] = useState(log.meal_type)
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
setError('')
setIsSubmitting(true)
try {
await apiClient.put(`/api/logs/${log.id}`, {
amount_g: parseDecimal(amountG),
meal_type: mealType,
})
onSaved()
} catch {
setError('Eintrag konnte nicht gespeichert werden.')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
<div className="w-full max-w-sm rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">{log.name}</h2>
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">Eintrag bearbeiten</p>
<form onSubmit={handleSubmit} className="space-y-4">
<MealTypeSelect value={mealType} onChange={setMealType} />
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Menge (Gramm)
</label>
<input
type="text"
inputMode="decimal"
value={amountG}
onChange={(e) => setAmountG(e.target.value)}
required
autoFocus
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
/>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-base font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
Abbrechen
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
>
{isSubmitting ? 'Speichern…' : 'Speichern'}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,87 @@
import { useState } from 'react'
import apiClient from '../api/client'
import BarcodeScanner from './BarcodeScanner'
import ProductNotFoundForm from './ProductNotFoundForm'
import ProductSearchInput from './ProductSearchInput'
export default function IngredientPickerModal({ onSelect, onClose }) {
const [isScannerOpen, setIsScannerOpen] = useState(false)
const [manualBarcode, setManualBarcode] = useState(null)
const [showManualForm, setShowManualForm] = useState(false)
const [scanError, setScanError] = useState('')
async function handleDetected(barcode) {
setIsScannerOpen(false)
setScanError('')
try {
const { data } = await apiClient.get(`/api/products/${barcode}`)
onSelect(data)
} catch (err) {
if (err.response?.status === 404) {
setManualBarcode(barcode)
setShowManualForm(true)
} else {
setScanError('Produktsuche fehlgeschlagen. Bitte erneut versuchen.')
}
}
}
function openManualForm() {
setManualBarcode(null)
setShowManualForm(true)
}
if (showManualForm) {
return (
<ProductNotFoundForm
barcode={manualBarcode}
mode="createOnly"
onCreated={onSelect}
onClose={onClose}
/>
)
}
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
<div className="flex max-h-[85vh] w-full max-w-sm flex-col rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Zutat hinzufügen</h2>
<button
onClick={onClose}
className="text-2xl leading-none text-gray-400 dark:text-gray-500"
>
&times;
</button>
</div>
<ProductSearchInput onSelect={onSelect} placeholder="Zutat suchen" />
{scanError && (
<p className="mt-2 text-center text-sm text-red-600 dark:text-red-400">{scanError}</p>
)}
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => setIsScannerOpen(true)}
className="flex-1 rounded-lg border border-gray-300 py-2 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
Barcode scannen
</button>
<button
type="button"
onClick={openManualForm}
className="flex-1 rounded-lg border border-dashed border-gray-300 py-2 text-sm font-medium text-emerald-600 dark:border-gray-600 dark:text-emerald-400"
>
+ Neu anlegen
</button>
</div>
</div>
{isScannerOpen && (
<BarcodeScanner onDetected={handleDetected} onClose={() => setIsScannerOpen(false)} />
)}
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
const BAR_COLORS = {
emerald: 'bg-emerald-500',
blue: 'bg-blue-500',
amber: 'bg-amber-500',
rose: 'bg-rose-500',
}
export default function MacroProgress({ label, value, target, unit, color }) {
const percent = target > 0 ? Math.min(100, Math.round((value / target) * 100)) : 0
return (
<div>
<div className="mb-1 flex items-baseline justify-between text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">{label}</span>
<span className="text-gray-500 dark:text-gray-400">
{Math.round(value)} / {target} {unit}
</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className={`h-full rounded-full ${BAR_COLORS[color]} transition-all`}
style={{ width: `${percent}%` }}
/>
</div>
</div>
)
}
@@ -0,0 +1,27 @@
import { MEAL_TYPES } from '../utils/mealTypes'
export default function MealTypeSelect({ value, onChange }) {
return (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Mahlzeit
</label>
<div className="grid grid-cols-4 gap-1.5">
{MEAL_TYPES.map((meal) => (
<button
key={meal.value}
type="button"
onClick={() => onChange(meal.value)}
className={`rounded-lg py-2 text-xs font-medium transition-colors ${
value === meal.value
? 'bg-emerald-600 text-white'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{meal.label}
</button>
))}
</div>
</div>
)
}
@@ -0,0 +1,120 @@
import { useState } from 'react'
import apiClient from '../api/client'
import MealTypeSelect from './MealTypeSelect'
import { defaultMealType } from '../utils/mealTypes'
import { parseDecimal } from '../utils/numbers'
export default function ProductFoundModal({
product,
initialAmountG = 100,
initialMealType,
onLogged,
onClose,
}) {
const [amountG, setAmountG] = useState(String(initialAmountG))
const [mealType, setMealType] = useState(initialMealType || defaultMealType())
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
setError('')
setIsSubmitting(true)
try {
await apiClient.post('/api/logs', {
barcode: product.barcode,
amount_g: parseDecimal(amountG),
meal_type: mealType,
})
onLogged()
} catch {
setError('Eintrag konnte nicht gespeichert werden.')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
<div className="w-full max-w-sm rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">{product.name}</h2>
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">Nährwerte pro 100g</p>
<dl className="mb-4 grid grid-cols-4 gap-2 text-center text-sm">
<div>
<dt className="text-gray-500 dark:text-gray-400">Kcal</dt>
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(product.calories)}</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Carbs</dt>
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(product.carbs)}g</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Protein</dt>
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(product.protein)}g</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Fett</dt>
<dd className="font-medium text-gray-900 dark:text-gray-50">{Math.round(product.fat)}g</dd>
</div>
</dl>
<dl className="mb-4 grid grid-cols-4 gap-2 border-t border-gray-100 pt-3 text-center text-xs dark:border-gray-700">
<div>
<dt className="text-gray-500 dark:text-gray-400">Zucker</dt>
<dd className="font-medium text-gray-800 dark:text-gray-200">{Math.round(product.sugar)}g</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Ballaststoffe</dt>
<dd className="font-medium text-gray-800 dark:text-gray-200">{Math.round(product.fiber)}g</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Ges. Fett</dt>
<dd className="font-medium text-gray-800 dark:text-gray-200">
{Math.round(product.saturated_fat)}g
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Salz</dt>
<dd className="font-medium text-gray-800 dark:text-gray-200">{product.salt.toFixed(2)}g</dd>
</div>
</dl>
<form onSubmit={handleSubmit} className="space-y-4">
<MealTypeSelect value={mealType} onChange={setMealType} />
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Menge (Gramm)
</label>
<input
type="text"
inputMode="decimal"
value={amountG}
onChange={(e) => setAmountG(e.target.value)}
required
autoFocus
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
/>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-base font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
Abbrechen
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
>
{isSubmitting ? 'Speichern…' : 'Hinzufügen'}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,224 @@
import { useState } from 'react'
import apiClient from '../api/client'
import MealTypeSelect from './MealTypeSelect'
import { defaultMealType } from '../utils/mealTypes'
import { parseDecimal } from '../utils/numbers'
const inputClass =
'w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50'
const labelClass = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300'
export default function ProductNotFoundForm({
barcode = null,
initialMealType,
mode = 'log',
onCreated,
onLogged,
onClose,
}) {
const [name, setName] = useState('')
const [calories, setCalories] = useState('')
const [carbs, setCarbs] = useState('')
const [protein, setProtein] = useState('')
const [fat, setFat] = useState('')
const [sugar, setSugar] = useState('')
const [fiber, setFiber] = useState('')
const [saturatedFat, setSaturatedFat] = useState('')
const [salt, setSalt] = useState('')
const [amountG, setAmountG] = useState('100')
const [mealType, setMealType] = useState(initialMealType || defaultMealType())
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
function optionalDecimal(value) {
return value === '' ? undefined : parseDecimal(value)
}
async function handleSubmit(e) {
e.preventDefault()
setError('')
setIsSubmitting(true)
try {
const { data: createdProduct } = await apiClient.post('/api/products', {
barcode,
name,
calories: parseDecimal(calories),
carbs: parseDecimal(carbs),
protein: parseDecimal(protein),
fat: parseDecimal(fat),
sugar: optionalDecimal(sugar),
fiber: optionalDecimal(fiber),
saturated_fat: optionalDecimal(saturatedFat),
salt: optionalDecimal(salt),
})
if (mode === 'createOnly') {
onCreated(createdProduct)
return
}
await apiClient.post('/api/logs', {
barcode: createdProduct.barcode,
amount_g: parseDecimal(amountG),
meal_type: mealType,
})
onLogged()
} catch {
setError('Produkt konnte nicht gespeichert werden.')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
<div className="w-full max-w-sm rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
{barcode ? 'Produkt nicht gefunden' : 'Neues Produkt anlegen'}
</h2>
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">
{barcode
? `Barcode ${barcode} — bitte Nährwerte pro 100g eintragen`
: 'Bitte Nährwerte pro 100g eintragen'}
</p>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoFocus
className={inputClass}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>Kcal /100g</label>
<input
type="text"
inputMode="decimal"
value={calories}
onChange={(e) => setCalories(e.target.value)}
required
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Carbs /100g</label>
<input
type="text"
inputMode="decimal"
value={carbs}
onChange={(e) => setCarbs(e.target.value)}
required
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Protein /100g</label>
<input
type="text"
inputMode="decimal"
value={protein}
onChange={(e) => setProtein(e.target.value)}
required
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Fett /100g</label>
<input
type="text"
inputMode="decimal"
value={fat}
onChange={(e) => setFat(e.target.value)}
required
className={inputClass}
/>
</div>
</div>
<p className="pt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Optional</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>Zucker /100g</label>
<input
type="text"
inputMode="decimal"
value={sugar}
onChange={(e) => setSugar(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Ballaststoffe /100g</label>
<input
type="text"
inputMode="decimal"
value={fiber}
onChange={(e) => setFiber(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Ges. Fett /100g</label>
<input
type="text"
inputMode="decimal"
value={saturatedFat}
onChange={(e) => setSaturatedFat(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Salz /100g</label>
<input
type="text"
inputMode="decimal"
value={salt}
onChange={(e) => setSalt(e.target.value)}
className={inputClass}
/>
</div>
</div>
{mode === 'log' && <MealTypeSelect value={mealType} onChange={setMealType} />}
{mode === 'log' && (
<div>
<label className={labelClass}>Menge (Gramm) für heute</label>
<input
type="text"
inputMode="decimal"
value={amountG}
onChange={(e) => setAmountG(e.target.value)}
required
className={inputClass}
/>
</div>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-lg border border-gray-300 py-2.5 text-base font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
Abbrechen
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 rounded-lg bg-emerald-600 py-2.5 text-base font-medium text-white active:bg-emerald-700 disabled:opacity-60"
>
{isSubmitting ? 'Speichern…' : mode === 'createOnly' ? 'Anlegen' : 'Anlegen & Loggen'}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react'
import apiClient from '../api/client'
export default function ProductSearchInput({ onSelect, placeholder = 'Suchen…', autoFocus = true }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isSearching, setIsSearching] = useState(true)
useEffect(() => {
setIsSearching(true)
const timeout = setTimeout(() => {
apiClient
.get('/api/products/search', { params: { q: query } })
.then((res) => setResults(res.data))
.finally(() => setIsSearching(false))
}, 300)
return () => clearTimeout(timeout)
}, [query])
return (
<>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
autoFocus={autoFocus}
className="mb-3 w-full rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
/>
<div className="flex-1 overflow-y-auto">
{isSearching ? (
<p className="py-4 text-center text-sm text-gray-500 dark:text-gray-400">Suche…</p>
) : results.length === 0 ? (
<p className="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
{query ? 'Keine Treffer.' : 'Noch keine Produkte vorhanden.'}
</p>
) : (
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{results.map((product) => (
<li key={product.barcode}>
<button
onClick={() => onSelect(product)}
className="flex w-full items-center justify-between py-2.5 text-left text-sm"
>
<span className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-gray-50">{product.name}</span>
{product.is_dish && (
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
Gericht
</span>
)}
{product.last_amount_g != null && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
zuletzt {Math.round(product.last_amount_g)}g
</span>
)}
</span>
<span className="shrink-0 text-gray-500 dark:text-gray-400">
{Math.round(product.calories)} kcal/100g
</span>
</button>
</li>
))}
</ul>
)}
</div>
</>
)
}
@@ -0,0 +1,20 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function ProtectedRoute({ children }) {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
Lädt…
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return children
}
+34
View File
@@ -0,0 +1,34 @@
import { useTheme } from '../context/ThemeContext'
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
const isDark = theme === 'dark'
return (
<button
onClick={toggleTheme}
aria-label={isDark ? 'Zu hellem Modus wechseln' : 'Zu dunklem Modus wechseln'}
className="rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
>
{isDark ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
>
<path d="M12 3a1 1 0 0 1 1 1v1a1 1 0 1 1-2 0V4a1 1 0 0 1 1-1Zm0 15a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm9-6a1 1 0 0 1-1 1h-1a1 1 0 1 1 0-2h1a1 1 0 0 1 1 1ZM5 12a1 1 0 0 1-1 1H3a1 1 0 1 1 0-2h1a1 1 0 0 1 1 1Zm13.657-6.657a1 1 0 0 1 0 1.414l-.707.707a1 1 0 1 1-1.414-1.414l.707-.707a1 1 0 0 1 1.414 0ZM7.464 17.536a1 1 0 0 1 0 1.414l-.707.707a1 1 0 0 1-1.414-1.414l.707-.707a1 1 0 0 1 1.414 0Zm11.193 1.414a1 1 0 0 1-1.414 0l-.707-.707a1 1 0 1 1 1.414-1.414l.707.707a1 1 0 0 1 0 1.414ZM6.757 6.757a1 1 0 0 1-1.414 0l-.707-.707a1 1 0 0 1 1.414-1.414l.707.707a1 1 0 0 1 0 1.414ZM12 20a1 1 0 0 1 1 1v1a1 1 0 1 1-2 0v-1a1 1 0 0 1 1-1Z" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
>
<path d="M20.742 13.045a8.088 8.088 0 0 1-2.077.267c-4.476 0-8.106-3.63-8.106-8.106 0-.712.093-1.403.267-2.06a1 1 0 0 0-1.281-1.226C5.117 3.077 2 7.15 2 11.945 2 17.5 6.5 22 12.055 22c4.795 0 8.868-3.117 10.023-7.545a1 1 0 0 0-1.336-1.41Z" />
</svg>
)}
</button>
)
}