/
klsfv
/
CalorieTracker
Обзор
Документация
Войти
/
klsfv
/
CalorieTracker
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/components/AddProductScreen.tsx
484 строки
23 KB
klsfv
upload files
15 янв 2026, 03:19
15 янв 2026, 03:19
09dece5
Код
Авторство
О чём код?
import React, { useState, useEffect } from 'react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { Label } from './ui/label'; import { Badge } from './ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { ArrowLeft, Search, Plus, Flame, CheckCircle2, PackageSearch, LogOut, X } from 'lucide-react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import type { Product, MealEntry } from '../types'; interface AddProductScreenProps { onNavigate: (screen: string) => void; products: Product[]; onAddMeal: (meal: MealEntry) => void; onLogout: () => void; } const DEFAULT_MEAL_TYPES = ['Завтрак', 'Обед', 'Ужин', 'Перекус']; const CUSTOM_MEAL_TYPES_KEY = 'customMealTypes'; export function AddProductScreen({ onNavigate, products, onAddMeal, onLogout }: AddProductScreenProps) { const [searchQuery, setSearchQuery] = useState(''); const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); const [grams, setGrams] = useState('100'); const [mealType, setMealType] = useState<string>('Завтрак'); const [customMealTypes, setCustomMealTypes] = useState<string[]>([]); const [showNewMealTypeForm, setShowNewMealTypeForm] = useState(false); const [newMealTypeName, setNewMealTypeName] = useState(''); // Загрузка пользовательских типов из localStorage useEffect(() => { const saved = localStorage.getItem(CUSTOM_MEAL_TYPES_KEY); if (saved) { try { setCustomMealTypes(JSON.parse(saved)); } catch (e) { console.error('Error loading custom meal types:', e); } } }, []); // Сохранение пользовательских типов в localStorage const saveCustomMealTypes = (types: string[]) => { localStorage.setItem(CUSTOM_MEAL_TYPES_KEY, JSON.stringify(types)); setCustomMealTypes(types); }; const allMealTypes = [...DEFAULT_MEAL_TYPES, ...customMealTypes]; const handleCreateMealType = () => { const trimmed = newMealTypeName.trim(); if (trimmed && !allMealTypes.includes(trimmed)) { const updated = [...customMealTypes, trimmed]; saveCustomMealTypes(updated); setMealType(trimmed); setNewMealTypeName(''); setShowNewMealTypeForm(false); } }; const handleDeleteCustomMealType = (type: string, e: React.MouseEvent) => { e.stopPropagation(); const updated = customMealTypes.filter(t => t !== type); saveCustomMealTypes(updated); if (mealType === type) { setMealType('Завтрак'); } }; const filteredProducts = products.filter((product) => product.name.toLowerCase().includes(searchQuery.toLowerCase()) ); const handleSelectProduct = (product: Product) => { setSelectedProduct(product); }; const handleAdd = () => { if (!selectedProduct || !grams) return; const gramsNum = parseFloat(grams); const multiplier = gramsNum / 100; const meal: MealEntry = { id: Date.now().toString(), userId: 'current-user', // Будет заменено на реального пользователя productId: selectedProduct.id, productName: selectedProduct.name, grams: gramsNum, calories: Math.round(selectedProduct.caloriesPer100g * multiplier), protein: selectedProduct.proteinPer100g * multiplier, fat: selectedProduct.fatPer100g * multiplier, carbs: selectedProduct.carbsPer100g * multiplier, mealType, date: new Date().toISOString().split('T')[0], createdAt: new Date().toISOString(), }; onAddMeal(meal); onNavigate('diary'); }; return ( <div className="min-h-screen bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-800 relative overflow-hidden"> {/* Декоративные элементы */} <div className="absolute top-0 right-0 w-96 h-96 bg-white/5 rounded-full blur-3xl"></div> <div className="absolute bottom-0 left-0 w-96 h-96 bg-white/5 rounded-full blur-3xl"></div> {/* Header */} <div className="bg-zinc-900/80 backdrop-blur-xl border-b border-zinc-800 sticky top-0 z-10 shadow-lg"> <div className="max-w-4xl mx-auto p-4"> <div className="flex items-center justify-between"> <div className="flex items-center gap-3"> <Button variant="ghost" size="icon" onClick={() => onNavigate('diary')} className="text-zinc-400 hover:text-white hover:bg-zinc-800 hover:border-white/30 border border-transparent transition-all" > <ArrowLeft className="w-5 h-5" /> </Button> <div> <h1 className="text-white flex items-center gap-2"> Добавить продукт <Badge className="bg-white/10 text-white border-white/20"> <Plus className="w-3 h-3 mr-1" /> Новая запись </Badge> </h1> </div> </div> <Button variant="ghost" size="sm" onClick={onLogout} className="text-zinc-400 hover:text-white hover:bg-zinc-800 hover:border-red-500/30 border border-transparent transition-all" > <LogOut className="w-4 h-4 mr-2" /> Выход </Button> </div> </div> </div> <div className="max-w-4xl mx-auto p-4 space-y-4 relative z-10"> {/* Search */} <Card className="bg-zinc-900/80 backdrop-blur border-zinc-800 shadow-xl overflow-hidden"> <div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full blur-2xl"></div> <CardContent className="pt-6 relative"> <Label className="text-zinc-300 mb-3 flex items-center gap-2"> <Search className="w-4 h-4 text-white" /> Поиск продукта </Label> <div className="relative"> <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" /> <Input placeholder="Начните вводить название..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-12 h-14 bg-zinc-800/80 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-white focus:ring-white/20 shadow-inner text-lg" /> {searchQuery && ( <Badge className="absolute right-3 top-1/2 -translate-y-1/2 bg-white/10 text-white border-white/20"> {filteredProducts.length} найдено </Badge> )} </div> </CardContent> </Card> {/* Product Selection */} {searchQuery && ( <Card className="bg-zinc-900/80 backdrop-blur border-zinc-800 shadow-xl"> <CardHeader> <CardTitle className="text-white flex items-center gap-2"> <PackageSearch className="w-5 h-5 text-white" /> Результаты поиска </CardTitle> </CardHeader> <CardContent className="space-y-3"> {filteredProducts.length === 0 ? ( <div className="text-center py-10 space-y-4 border-2 border-dashed border-zinc-800 rounded-xl"> <div className="w-16 h-16 bg-zinc-800 rounded-full mx-auto flex items-center justify-center"> <PackageSearch className="w-8 h-8 text-zinc-600" /> </div> <p className="text-zinc-400">Продукт не найден в базе</p> <Button variant="outline" className="border-zinc-700 bg-zinc-800/50 text-zinc-300 hover:bg-zinc-800 hover:border-white/50" onClick={() => onNavigate('products')} > <Plus className="w-4 h-4 mr-2" /> Добавить новый продукт </Button> </div> ) : ( filteredProducts.map((product) => ( <div key={product.id} onClick={() => handleSelectProduct(product)} className={`p-4 rounded-xl cursor-pointer transition-all ${ selectedProduct?.id === product.id ? 'bg-gradient-to-r from-white/10 to-zinc-100/10 border-2 border-white shadow-lg shadow-white/20' : 'bg-zinc-800/50 hover:bg-zinc-800 border-2 border-transparent hover:border-zinc-700' }`} > <div className="flex items-start justify-between"> <div className="flex-1"> <div className="text-white flex items-center gap-2"> {product.name} {selectedProduct?.id === product.id && ( <CheckCircle2 className="w-4 h-4 text-white" /> )} </div> <div className="text-sm text-zinc-400 mt-2 flex flex-wrap gap-3"> <span className="flex items-center gap-1"> <Flame className="w-3 h-3 text-white" /> {product.caloriesPer100g} ккал </span> <span className="flex items-center gap-1"> <span className="w-2 h-2 bg-zinc-100 rounded-full"></span> Б: {product.proteinPer100g}г </span> <span className="flex items-center gap-1"> <span className="w-2 h-2 bg-yellow-400 rounded-full"></span> Ж: {product.fatPer100g}г </span> <span className="flex items-center gap-1"> <span className="w-2 h-2 bg-purple-400 rounded-full"></span> У: {product.carbsPer100g}г </span> </div> </div> <Badge variant="outline" className="border-zinc-700 text-zinc-400"> на 100г </Badge> </div> </div> )) )} </CardContent> </Card> )} {/* Add Form */} {selectedProduct && ( <Card className="bg-gradient-to-br from-white/5 via-zinc-900/80 to-zinc-800/80 backdrop-blur border-white/20 shadow-xl shadow-white/10"> <CardHeader> <CardTitle className="text-white flex items-center gap-2"> <CheckCircle2 className="w-5 h-5 text-white" /> Добавить: {selectedProduct.name} </CardTitle> </CardHeader> <CardContent className="space-y-5"> <div className="space-y-2"> <div className="flex items-center justify-between"> <Label className="text-zinc-300">Прием пищи</Label> {!showNewMealTypeForm && ( <Button type="button" variant="ghost" size="sm" onClick={() => setShowNewMealTypeForm(true)} className="text-zinc-400 hover:text-white hover:bg-zinc-800 h-8 text-xs" > <Plus className="w-3 h-3 mr-1" /> Создать свой </Button> )} </div> {showNewMealTypeForm ? ( <Card className="bg-gradient-to-br from-white/10 via-zinc-800/80 to-zinc-900/80 backdrop-blur border-white/30 shadow-lg"> <CardContent className="pt-4 space-y-3"> <div className="flex items-center gap-2 mb-2"> <Plus className="w-4 h-4 text-white" /> <Label className="text-white font-medium">Создать новый приём пищи</Label> </div> <div className="flex gap-2"> <Input placeholder="Введите название (например: Полдник, Ланч, Перед тренировкой...)" value={newMealTypeName} onChange={(e) => setNewMealTypeName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { handleCreateMealType(); } else if (e.key === 'Escape') { setShowNewMealTypeForm(false); setNewMealTypeName(''); } }} className="bg-zinc-800/80 border-zinc-700 text-white h-12 shadow-inner flex-1" autoFocus /> <Button type="button" onClick={handleCreateMealType} disabled={!newMealTypeName.trim() || allMealTypes.includes(newMealTypeName.trim())} className="bg-gradient-to-r from-white via-zinc-100 to-zinc-200 hover:from-zinc-100 hover:via-zinc-200 hover:to-zinc-300 text-zinc-900 h-12 px-4 shadow-lg" > <Plus className="w-4 h-4 mr-2" /> Создать </Button> <Button type="button" variant="ghost" onClick={() => { setShowNewMealTypeForm(false); setNewMealTypeName(''); }} className="text-zinc-400 hover:text-white hover:bg-zinc-800 h-12" > <X className="w-4 h-4" /> </Button> </div> {newMealTypeName.trim() && allMealTypes.includes(newMealTypeName.trim()) && ( <p className="text-xs text-red-400 flex items-center gap-1"> <X className="w-3 h-3" /> Такой приём пищи уже существует </p> )} {newMealTypeName.trim() && !allMealTypes.includes(newMealTypeName.trim()) && ( <p className="text-xs text-zinc-400"> Нажмите Enter или кнопку "Создать" для добавления </p> )} </CardContent> </Card> ) : ( <div className="space-y-2"> <Select value={mealType} onValueChange={(value) => { if (value === '__create_new__') { setShowNewMealTypeForm(true); } else { setMealType(value); } }} > <SelectTrigger className="bg-zinc-800/80 border-zinc-700 text-white h-12 shadow-inner"> <SelectValue /> </SelectTrigger> <SelectContent className="bg-zinc-800 border-zinc-700"> {DEFAULT_MEAL_TYPES.map((type) => ( <SelectItem key={type} value={type}> {type === 'Завтрак' && '🌅 '} {type === 'Обед' && '☀️ '} {type === 'Ужин' && '🌙 '} {type === 'Перекус' && '🍎 '} {type} </SelectItem> ))} {customMealTypes.length > 0 && ( <> <div className="h-px bg-zinc-700 my-1" /> {customMealTypes.map((type) => ( <SelectItem key={type} value={type}> ✨ {type} </SelectItem> ))} </> )} <div className="h-px bg-zinc-700 my-1" /> <SelectItem value="__create_new__" className="text-zinc-400 hover:text-white focus:text-white" > <Plus className="w-4 h-4 mr-2 inline" /> Создать новый приём пищи... </SelectItem> </SelectContent> </Select> {customMealTypes.length > 0 && ( <div className="flex flex-wrap gap-2 pt-1"> <span className="text-xs text-zinc-500">Пользовательские:</span> {customMealTypes.map((type) => ( <Badge key={type} variant="outline" className="border-zinc-700 text-zinc-300 bg-zinc-800/50 group" > <span>✨ {type}</span> <Button type="button" variant="ghost" size="icon" className="h-4 w-4 ml-1 -mr-1 hover:bg-red-950/50 hover:text-red-400" onClick={(e) => { e.stopPropagation(); handleDeleteCustomMealType(type, e); }} > <X className="w-3 h-3" /> </Button> </Badge> ))} </div> )} </div> )} </div> <div className="space-y-2"> <Label className="text-zinc-300">Количество (граммы)</Label> <div className="flex gap-2"> <Input type="number" placeholder="100" value={grams} onChange={(e) => setGrams(e.target.value)} className="bg-zinc-800/80 border-zinc-700 text-white h-12 shadow-inner text-lg" /> <Button type="button" variant="outline" onClick={() => setGrams('100')} className="border-zinc-700 text-zinc-400 hover:bg-zinc-800 hover:border-white/50" > 100г </Button> <Button type="button" variant="outline" onClick={() => setGrams('150')} className="border-zinc-700 text-zinc-400 hover:bg-zinc-800 hover:border-white/50" > 150г </Button> </div> </div> {grams && parseFloat(grams) > 0 && ( <Card className="bg-zinc-800/80 backdrop-blur border-zinc-700 shadow-lg"> <CardHeader> <CardTitle className="text-sm text-zinc-400">Пищевая ценность ({grams}г)</CardTitle> </CardHeader> <CardContent> <div className="grid grid-cols-2 gap-4"> <div className="bg-gradient-to-br from-white/10 to-zinc-100/10 p-4 rounded-xl border border-white/20 text-center"> <Flame className="w-6 h-6 text-white mx-auto mb-2" /> <div className="text-2xl text-white">{Math.round(selectedProduct.caloriesPer100g * parseFloat(grams) / 100)}</div> <div className="text-xs text-zinc-400 mt-1">Калории</div> </div> <div className="bg-zinc-900/50 p-4 rounded-xl border border-zinc-700 text-center"> <div className="w-6 h-6 bg-zinc-100/20 rounded-full mx-auto mb-2 flex items-center justify-center"> <div className="w-3 h-3 bg-zinc-100 rounded-full"></div> </div> <div className="text-2xl text-zinc-100">{(selectedProduct.proteinPer100g * parseFloat(grams) / 100).toFixed(1)}</div> <div className="text-xs text-zinc-400 mt-1">Белки (г)</div> </div> <div className="bg-zinc-900/50 p-4 rounded-xl border border-zinc-700 text-center"> <div className="w-6 h-6 bg-yellow-500/20 rounded-full mx-auto mb-2 flex items-center justify-center"> <div className="w-3 h-3 bg-yellow-400 rounded-full"></div> </div> <div className="text-2xl text-yellow-400">{(selectedProduct.fatPer100g * parseFloat(grams) / 100).toFixed(1)}</div> <div className="text-xs text-zinc-400 mt-1">Жиры (г)</div> </div> <div className="bg-zinc-900/50 p-4 rounded-xl border border-zinc-700 text-center"> <div className="w-6 h-6 bg-purple-500/20 rounded-full mx-auto mb-2 flex items-center justify-center"> <div className="w-3 h-3 bg-purple-400 rounded-full"></div> </div> <div className="text-2xl text-purple-400">{(selectedProduct.carbsPer100g * parseFloat(grams) / 100).toFixed(1)}</div> <div className="text-xs text-zinc-400 mt-1">Углеводы (г)</div> </div> </div> </CardContent> </Card> )} <Button onClick={handleAdd} className="w-full bg-gradient-to-r from-white via-zinc-100 to-zinc-200 hover:from-zinc-100 hover:via-zinc-200 hover:to-zinc-300 text-zinc-900 h-14 shadow-xl shadow-white/30 text-lg" disabled={!grams || parseFloat(grams) <= 0} > <Plus className="w-5 h-5 mr-2" /> Добавить в дневник </Button> </CardContent> </Card> )} </div> </div> ); }