diff --git a/backend/routes/recipes.js b/backend/routes/recipes.js index 292096a..8fb8b48 100644 --- a/backend/routes/recipes.js +++ b/backend/routes/recipes.js @@ -65,6 +65,52 @@ router.get('/ingredients/suggestions', async (req, res) => { } }); +// Get unit suggestions for a specific ingredient +router.get('/ingredients/units', async (req, res) => { + try { + const { ingredient } = req.query; + + if (!ingredient) { + return res.json([]); + } + + // Find units used with this specific ingredient + const unitSuggestions = await Recipe.aggregate([ + { $unwind: '$ingredients' }, + { + $match: { + 'ingredients.name': { + $regex: ingredient, + $options: 'i' + } + } + }, + { + $group: { + _id: { $toLower: '$ingredients.unit' }, + unit: { $first: '$ingredients.unit' }, + count: { $sum: 1 }, + avgAmount: { $avg: '$ingredients.amount' } + } + }, + { $sort: { count: -1, unit: 1 } }, + { $limit: 8 }, + { + $project: { + _id: 0, + unit: 1, + count: 1, + avgAmount: { $round: ['$avgAmount', 2] } + } + } + ]); + + res.json(unitSuggestions); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // Get all recipes router.get('/', async (req, res) => { try { diff --git a/frontend/src/components/UnitAutocomplete.css b/frontend/src/components/UnitAutocomplete.css new file mode 100644 index 0000000..5903143 --- /dev/null +++ b/frontend/src/components/UnitAutocomplete.css @@ -0,0 +1,230 @@ +.unit-autocomplete { + position: relative; + width: 100%; +} + +.autocomplete-input-container { + position: relative; + width: 100%; +} + +.autocomplete-input { + width: 100%; + padding: 0.75rem; + padding-right: 2.5rem; + border: 2px solid #e9ecef; + border-radius: 8px; + font-size: 1rem; + transition: border-color 0.3s ease, box-shadow 0.3s ease; + background: white; +} + +.autocomplete-input:focus { + outline: none; + border-color: #FF6B35; + box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1); +} + +.autocomplete-loading { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; +} + +.loading-spinner-small { + width: 16px; + height: 16px; + border: 2px solid #e9ecef; + border-top: 2px solid #FF6B35; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.autocomplete-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #e9ecef; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + max-height: 200px; + overflow-y: auto; + margin-top: 4px; +} + +.suggestions-header { + padding: 0.75rem; + background: #f8f9fa; + border-bottom: 1px solid #e9ecef; + font-size: 0.85rem; + font-weight: 600; + color: #495057; + border-radius: 8px 8px 0 0; +} + +.suggestion-item { + padding: 0.75rem; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: background-color 0.2s ease; + border-bottom: 1px solid #f8f9fa; +} + +.suggestion-item:last-child { + border-bottom: none; + border-radius: 0 0 8px 8px; +} + +.suggestion-item:hover, +.suggestion-item.selected { + background-color: #f8f9fa; +} + +.suggestion-item.selected { + background-color: #e3f2fd; +} + +.suggestion-main { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + flex: 1; +} + +.suggestion-unit { + font-weight: 600; + color: #2c3e50; + font-size: 1rem; +} + +.suggestion-meta { + font-size: 0.8rem; + color: #6c757d; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.avg-amount { + color: #28a745; + font-weight: 500; +} + +/* Custom scrollbar for suggestions */ +.autocomplete-suggestions::-webkit-scrollbar { + width: 6px; +} + +.autocomplete-suggestions::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.autocomplete-suggestions::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.autocomplete-suggestions::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .autocomplete-suggestions { + max-height: 150px; + } + + .suggestion-item { + padding: 0.6rem; + } + + .suggestion-unit { + font-size: 0.9rem; + } + + .suggestion-meta { + font-size: 0.75rem; + } + + .suggestions-header { + padding: 0.6rem; + font-size: 0.8rem; + } +} + +/* Animation for suggestions appearing */ +.autocomplete-suggestions { + animation: slideDown 0.2s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Focus states for accessibility */ +.suggestion-item:focus { + outline: 2px solid #FF6B35; + outline-offset: -2px; +} + +/* High contrast mode support */ +@media (prefers-contrast: high) { + .autocomplete-input { + border-color: #000; + } + + .autocomplete-input:focus { + border-color: #FF6B35; + box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.3); + } + + .suggestion-item.selected { + background-color: #FF6B35; + color: white; + } + + .suggestion-item.selected .suggestion-meta { + color: rgba(255, 255, 255, 0.8); + } + + .suggestion-item.selected .avg-amount { + color: rgba(255, 255, 255, 0.9); + } +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + .autocomplete-input, + .suggestion-item, + .loading-spinner-small { + transition: none; + animation: none; + } + + .autocomplete-suggestions { + animation: none; + } +} diff --git a/frontend/src/components/UnitAutocomplete.tsx b/frontend/src/components/UnitAutocomplete.tsx new file mode 100644 index 0000000..d4bc604 --- /dev/null +++ b/frontend/src/components/UnitAutocomplete.tsx @@ -0,0 +1,189 @@ +import React, { useState, useEffect, useRef } from 'react'; +import api from '../services/api'; +import './UnitAutocomplete.css'; + +interface UnitSuggestion { + unit: string; + count: number; + avgAmount: number; +} + +interface UnitAutocompleteProps { + value: string; + onChange: (value: string) => void; + ingredient: string; + placeholder?: string; + className?: string; +} + +const UnitAutocomplete: React.FC = ({ + value, + onChange, + ingredient, + placeholder = "Unit (cups, tsp, etc.)", + className = "" +}) => { + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [loading, setLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const inputRef = useRef(null); + const suggestionsRef = useRef(null); + + // Debounce function to avoid too many API calls + const debounce = (func: Function, wait: number) => { + let timeout: NodeJS.Timeout; + return (...args: any[]) => { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(null, args), wait); + }; + }; + + // Fetch unit suggestions based on ingredient + const fetchUnitSuggestions = async (ingredientName: string) => { + if (!ingredientName || ingredientName.length < 2) { + setSuggestions([]); + setShowSuggestions(false); + return; + } + + setLoading(true); + try { + const response = await api.get(`/recipes/ingredients/units?ingredient=${encodeURIComponent(ingredientName)}`); + const unitData = response.data as UnitSuggestion[]; + setSuggestions(unitData); + setShowSuggestions(unitData.length > 0); + setSelectedIndex(-1); + } catch (error) { + console.error('Error fetching unit suggestions:', error); + setSuggestions([]); + setShowSuggestions(false); + } finally { + setLoading(false); + } + }; + + // Debounced version of fetchUnitSuggestions + const debouncedFetchUnitSuggestions = debounce(fetchUnitSuggestions, 500); + + useEffect(() => { + if (ingredient) { + debouncedFetchUnitSuggestions(ingredient); + } else { + setSuggestions([]); + setShowSuggestions(false); + } + }, [ingredient]); + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + onChange(newValue); + }; + + const handleSuggestionClick = (suggestion: UnitSuggestion) => { + onChange(suggestion.unit); + setShowSuggestions(false); + setSelectedIndex(-1); + inputRef.current?.focus(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!showSuggestions || suggestions.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(prev => + prev < suggestions.length - 1 ? prev + 1 : prev + ); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(prev => prev > 0 ? prev - 1 : -1); + break; + case 'Enter': + e.preventDefault(); + if (selectedIndex >= 0) { + handleSuggestionClick(suggestions[selectedIndex]); + } + break; + case 'Escape': + setShowSuggestions(false); + setSelectedIndex(-1); + break; + } + }; + + const handleBlur = (e: React.FocusEvent) => { + // Delay hiding suggestions to allow for clicks + setTimeout(() => { + if (!suggestionsRef.current?.contains(e.relatedTarget as Node)) { + setShowSuggestions(false); + setSelectedIndex(-1); + } + }, 150); + }; + + const handleFocus = () => { + if (ingredient && suggestions.length > 0) { + setShowSuggestions(true); + } + }; + + return ( +
+
+ + {loading && ( +
+
+
+ )} +
+ + {showSuggestions && suggestions.length > 0 && ( +
+
+ Common units for "{ingredient}": +
+ {suggestions.map((suggestion, index) => ( +
handleSuggestionClick(suggestion)} + onMouseEnter={() => setSelectedIndex(index)} + > +
+ {suggestion.unit} + + {suggestion.count} recipe{suggestion.count !== 1 ? 's' : ''} + {suggestion.avgAmount && ( + + • avg: {suggestion.avgAmount} + + )} + +
+
+ ))} +
+ )} +
+ ); +}; + +export default UnitAutocomplete; diff --git a/frontend/src/pages/CreateRecipe.tsx b/frontend/src/pages/CreateRecipe.tsx index 6ec245c..c1bcff7 100644 --- a/frontend/src/pages/CreateRecipe.tsx +++ b/frontend/src/pages/CreateRecipe.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import api from '../services/api'; import IngredientAutocomplete from '../components/IngredientAutocomplete'; +import UnitAutocomplete from '../components/UnitAutocomplete'; import './CreateRecipe.css'; interface Ingredient { @@ -300,11 +301,11 @@ const CreateRecipe: React.FC = () => { />
- updateIngredient(index, 'unit', e.target.value)} + onChange={(value) => updateIngredient(index, 'unit', value)} + ingredient={ingredient.name} + placeholder="Unit (cups, tsp, etc.)" />