Initial commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user