diff --git a/backend/__pycache__/db_utils.cpython-313.pyc b/backend/__pycache__/db_utils.cpython-313.pyc index 962f7a6..38fbbe1 100644 Binary files a/backend/__pycache__/db_utils.cpython-313.pyc and b/backend/__pycache__/db_utils.cpython-313.pyc differ diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc index 86b1bb0..54601e3 100644 Binary files a/backend/__pycache__/main.cpython-313.pyc and b/backend/__pycache__/main.cpython-313.pyc differ diff --git a/frontend/src/App.css b/frontend/src/App.css index fbe0e70..6af280a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -248,6 +248,23 @@ select { border: 1px solid rgba(34, 197, 94, 0.6); } +.recipe-list-image { + width: 50px; + height: 50px; + border-radius: 8px; + overflow: hidden; + margin-left: 0.6rem; + flex-shrink: 0; + background: rgba(15, 23, 42, 0.5); +} + +.recipe-list-image img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + .recipe-list-main { flex: 1; } @@ -311,6 +328,23 @@ select { font-size: 0.78rem; } +/* Recipe Image */ +.recipe-image-container { + width: 100%; + max-height: 250px; + border-radius: 12px; + overflow: hidden; + margin-bottom: 0.8rem; + background: rgba(15, 23, 42, 0.5); +} + +.recipe-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + .recipe-body { display: grid; gap: 0.8rem; @@ -502,6 +536,90 @@ select { flex: 1; } +/* Image Upload */ +.image-upload-wrapper { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.image-preview { + position: relative; + width: 100%; + max-height: 180px; + border-radius: 10px; + overflow: hidden; + background: rgba(15, 23, 42, 0.5); +} + +.image-preview img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.image-remove-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.6); + border: none; + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.95rem; + transition: background 0.2s ease; +} + +.image-remove-btn:hover { + background: rgba(249, 115, 115, 0.8); +} + +.image-input { + display: none; +} + +.image-upload-label { + padding: 0.7rem 1rem; + border-radius: 8px; + border: 2px dashed rgba(148, 163, 184, 0.5); + background: transparent; + color: var(--text-main); + text-align: center; + cursor: pointer; + font-weight: 500; + transition: all 0.2s ease; + font-size: 0.9rem; +} + +.image-upload-label:hover { + border-color: var(--accent); + color: var(--accent); + background: rgba(34, 197, 94, 0.05); +} + +/* Light mode image upload */ +[data-theme="light"] .image-preview { + background: rgba(229, 231, 235, 0.5); +} + +[data-theme="light"] .image-upload-label { + border: 2px dashed rgba(107, 114, 128, 0.4); + color: var(--text-main); +} + +[data-theme="light"] .image-upload-label:hover { + border-color: var(--accent); + color: var(--accent); + background: rgba(5, 150, 105, 0.05); +} + /* Toast Notifications */ .toast-container { @@ -754,4 +872,380 @@ html { [data-theme="light"] html { scrollbar-color: rgba(107, 114, 128, 0.4) transparent; +} + +/* Search and Filter Panel */ +.search-filter-panel { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.search-box-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.search-box { + width: 100%; + padding: 0.65rem 2.4rem 0.65rem 0.8rem; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.6); + background: #020617; + color: var(--text-main); + font-size: 0.9rem; +} + +.search-box::placeholder { + color: var(--text-muted); +} + +.search-box:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); +} + +.search-clear { + position: absolute; + left: 0.6rem; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 0.3rem 0.4rem; + font-size: 0.95rem; + transition: color 0.2s ease; +} + +.search-clear:hover { + color: var(--text-main); +} + +.filters-grid { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.filter-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.filter-options { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.filter-btn { + padding: 0.35rem 0.75rem; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.4); + background: transparent; + color: var(--text-main); + font-size: 0.8rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.filter-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.filter-btn.active { + background: var(--accent); + color: #f9fafb; + border-color: var(--accent); + box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3); +} + +.time-filter-wrapper { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.time-slider { + width: 100%; + height: 5px; + border-radius: 5px; + background: rgba(148, 163, 184, 0.2); + outline: none; + appearance: none; + -webkit-appearance: none; +} + +.time-slider::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + box-shadow: 0 2px 6px rgba(34, 197, 94, 0.4); +} + +.time-slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: none; + box-shadow: 0 2px 6px rgba(34, 197, 94, 0.4); +} + +.time-labels { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: var(--text-muted); +} + +.tag-filter-wrapper { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.tag-filter-btn { + padding: 0.3rem 0.65rem; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.4); + background: transparent; + color: var(--text-main); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.tag-filter-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.tag-filter-btn.active { + background: var(--accent); + color: #f9fafb; + border-color: var(--accent); + box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3); +} + +/* Light mode search and filter styles */ +[data-theme="light"] .search-box { + border: 1px solid rgba(107, 114, 128, 0.3); + background: #ffffff; + color: var(--text-main); +} + +[data-theme="light"] .search-box:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.15); +} + +[data-theme="light"] .filter-btn { + border: 1px solid rgba(107, 114, 128, 0.3); + color: var(--text-main); +} + +[data-theme="light"] .filter-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +[data-theme="light"] .filter-btn.active { + background: var(--accent); + color: #ffffff; + box-shadow: 0 4px 12px rgba(5, 150, 105, 0.3); +} + +[data-theme="light"] .time-slider { + background: rgba(107, 114, 128, 0.15); +} + +[data-theme="light"] .time-slider::-webkit-slider-thumb { + background: var(--accent); + box-shadow: 0 2px 6px rgba(5, 150, 105, 0.3); +} + +[data-theme="light"] .time-slider::-moz-range-thumb { + background: var(--accent); + box-shadow: 0 2px 6px rgba(5, 150, 105, 0.3); +} + +[data-theme="light"] .tag-filter-btn { + border: 1px solid rgba(107, 114, 128, 0.3); + color: var(--text-main); +} + +[data-theme="light"] .tag-filter-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +[data-theme="light"] .tag-filter-btn.active { + background: var(--accent); + color: #ffffff; + box-shadow: 0 2px 8px rgba(5, 150, 105, 0.3); +} + +/* Unified Recipe Search List */ +.recipe-search-list { + display: flex; + flex-direction: column; + padding: 0; +} + +.recipe-search-header { + display: flex; + gap: 0.6rem; + align-items: stretch; + padding: 1.1rem 1.2rem 0.8rem; + border-bottom: 1px solid var(--border-subtle); +} + +.search-box-wrapper { + flex: 1; + position: relative; + display: flex; + align-items: center; +} + +.filter-toggle-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.3rem; + padding: 0.5rem 0.8rem; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.4); + background: transparent; + color: var(--text-main); + cursor: pointer; + font-size: 1rem; + transition: all 0.15s ease; + position: relative; +} + +.filter-toggle-btn:hover { + border-color: var(--accent); + background: rgba(34, 197, 94, 0.1); +} + +.filter-icon { + display: block; + font-size: 0.9rem; +} + +.filter-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 0.3rem; + background: var(--accent); + color: #f9fafb; + border-radius: 10px; + font-size: 0.75rem; + font-weight: 600; +} + +.recipe-filters-expanded { + padding: 0.8rem 1.2rem; + border-bottom: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + gap: 0.8rem; + background: rgba(15, 23, 42, 0.5); +} + +.recipe-list-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.8rem 1.2rem 0.6rem; +} + +.recipe-list-header h3 { + margin: 0; + font-size: 0.95rem; +} + +.recipe-list { + list-style: none; + padding: 0.6rem 1.2rem 1rem; + margin: 0; + max-height: 400px; + overflow-y: auto; + flex: 1; +} + +.recipe-list::-webkit-scrollbar { + width: 6px; +} + +.recipe-list::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.4); + border-radius: 3px; +} + +.recipe-list::-webkit-scrollbar-thumb:hover { + background: rgba(148, 163, 184, 0.6); +} + +/* Light mode unified list */ +[data-theme="light"] .recipe-search-header { + border-bottom: 1px solid rgba(107, 114, 128, 0.2); +} + +[data-theme="light"] .filter-toggle-btn { + border: 1px solid rgba(107, 114, 128, 0.3); + color: var(--text-main); +} + +[data-theme="light"] .filter-toggle-btn:hover { + border-color: var(--accent); + background: rgba(5, 150, 105, 0.1); +} + +[data-theme="light"] .recipe-filters-expanded { + border-bottom: 1px solid rgba(107, 114, 128, 0.2); + background: rgba(229, 231, 235, 0.3); +} + +[data-theme="light"] .recipe-list { + max-height: 400px; + overflow-y: auto; +} + +[data-theme="light"] .recipe-list::-webkit-scrollbar-thumb { + background: rgba(107, 114, 128, 0.35); +} + +[data-theme="light"] .recipe-list::-webkit-scrollbar-thumb:hover { + background: rgba(107, 114, 128, 0.5); +} + +/* Light mode recipe list styling */ +[data-theme="light"] .recipe-list-item:hover { + background: rgba(229, 231, 235, 0.6); +} + +[data-theme="light"] .recipe-list-image { + background: rgba(229, 231, 235, 0.5); } \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f3784b3..18bfbad 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import "./App.css"; import TopBar from "./components/TopBar"; -import RecipeList from "./components/RecipeList"; +import RecipeSearchList from "./components/RecipeSearchList"; import RecipeDetails from "./components/RecipeDetails"; import RecipeFormDrawer from "./components/RecipeFormDrawer"; import Modal from "./components/Modal"; @@ -14,6 +14,13 @@ function App() { const [recipes, setRecipes] = useState([]); const [selectedRecipe, setSelectedRecipe] = useState(null); + // Recipe listing filters + const [searchQuery, setSearchQuery] = useState(""); + const [filterMealType, setFilterMealType] = useState(""); + const [filterMaxTime, setFilterMaxTime] = useState(""); + const [filterTags, setFilterTags] = useState([]); + + // Random recipe filters const [mealTypeFilter, setMealTypeFilter] = useState(""); const [maxTimeFilter, setMaxTimeFilter] = useState(""); const [ingredientsFilter, setIngredientsFilter] = useState(""); @@ -57,6 +64,38 @@ function App() { } }; + const getFilteredRecipes = () => { + return recipes.filter((recipe) => { + // Search by name + if (searchQuery && !recipe.name.toLowerCase().includes(searchQuery.toLowerCase())) { + return false; + } + + // Filter by meal type + if (filterMealType && recipe.meal_type !== filterMealType) { + return false; + } + + // Filter by prep time + if (filterMaxTime && recipe.time_minutes > parseInt(filterMaxTime)) { + return false; + } + + // Filter by tags + if (filterTags.length > 0) { + const recipeTags = recipe.tags || []; + const hasAllTags = filterTags.every((tag) => + recipeTags.some((t) => t.toLowerCase() === tag.toLowerCase()) + ); + if (!hasAllTags) { + return false; + } + } + + return true; + }); + }; + const handleRandomClick = async () => { setLoadingRandom(true); setError(""); @@ -167,6 +206,25 @@ function App() {
+ +
+ +
+ {error &&
{error}
} + + {/* Random Recipe Suggester - Top Left */}

חיפוש מתכון רנדומלי

@@ -214,15 +272,7 @@ function App() {
- -
- -
- {error &&
{error}
} + {/* Recipe Details Card */} + {/* Recipe Image */} + {recipe.image && ( +
+ {recipe.name} +
+ )} +

{recipe.name}

diff --git a/frontend/src/components/RecipeFilters.jsx b/frontend/src/components/RecipeFilters.jsx new file mode 100644 index 0000000..0dafe7e --- /dev/null +++ b/frontend/src/components/RecipeFilters.jsx @@ -0,0 +1,157 @@ +function RecipeFilters({ + recipes, + searchQuery, + onSearchChange, + filterMealType, + onMealTypeChange, + filterMaxTime, + onMaxTimeChange, + filterTags, + onTagsChange, +}) { + // Extract unique tags from all recipes + const allTags = Array.from( + new Set(recipes.flatMap((recipe) => recipe.tags || [])) + ).sort(); + + // Extract unique meal types from recipes + const mealTypes = Array.from(new Set(recipes.map((r) => r.meal_type))).sort(); + + // Extract max time for slider + const maxTimeInRecipes = Math.max(...recipes.map((r) => r.time_minutes), 0); + + const handleTagToggle = (tag) => { + if (filterTags.includes(tag)) { + onTagsChange(filterTags.filter((t) => t !== tag)); + } else { + onTagsChange([...filterTags, tag]); + } + }; + + const clearAllFilters = () => { + onSearchChange(""); + onMealTypeChange(""); + onMaxTimeChange(""); + onTagsChange([]); + }; + + const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0; + + return ( +
+
+

חיפוש וסינון

+ {hasActiveFilters && ( + + )} +
+ + {/* Search Box */} +
+ onSearchChange(e.target.value)} + /> + {searchQuery && ( + + )} +
+ + {/* Filters Grid */} +
+ {/* Meal Type Filter */} + {mealTypes.length > 0 && ( +
+ +
+ + {mealTypes.map((type) => ( + + ))} +
+
+ )} + + {/* Prep Time Filter */} + {maxTimeInRecipes > 0 && ( +
+ +
+ onMaxTimeChange(e.target.value === "0" ? "" : e.target.value)} + className="time-slider" + /> +
+ 0 + {maxTimeInRecipes} +
+
+
+ )} + + {/* Tags Filter */} + {allTags.length > 0 && ( +
+ +
+ {allTags.map((tag) => ( + + ))} +
+
+ )} +
+
+ ); +} + +function translateMealType(type) { + switch (type) { + case "breakfast": + return "בוקר"; + case "lunch": + return "צהריים"; + case "dinner": + return "ערב"; + case "snack": + return "נשנוש"; + default: + return type; + } +} + +export default RecipeFilters; diff --git a/frontend/src/components/RecipeFormDrawer.jsx b/frontend/src/components/RecipeFormDrawer.jsx index 7e45de5..618c447 100644 --- a/frontend/src/components/RecipeFormDrawer.jsx +++ b/frontend/src/components/RecipeFormDrawer.jsx @@ -5,6 +5,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { const [mealType, setMealType] = useState("lunch"); const [timeMinutes, setTimeMinutes] = useState(15); const [tags, setTags] = useState(""); + const [image, setImage] = useState(""); const [ingredients, setIngredients] = useState([""]); const [steps, setSteps] = useState([""]); @@ -18,6 +19,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { setMealType(editingRecipe.meal_type || "lunch"); setTimeMinutes(editingRecipe.time_minutes || 15); setTags((editingRecipe.tags || []).join(", ")); + setImage(editingRecipe.image || ""); setIngredients(editingRecipe.ingredients || [""]); setSteps(editingRecipe.steps || [""]); } else { @@ -25,6 +27,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { setMealType("lunch"); setTimeMinutes(15); setTags(""); + setImage(""); setIngredients([""]); setSteps([""]); } @@ -57,6 +60,21 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { setSteps((prev) => prev.filter((_, i) => i !== idx || prev.length === 1)); }; + const handleImageChange = (e) => { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onloadend = () => { + setImage(reader.result); + }; + reader.readAsDataURL(file); + } + }; + + const handleRemoveImage = () => { + setImage(""); + }; + const handleSubmit = (e) => { e.preventDefault(); @@ -67,14 +85,20 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { .map((t) => t.trim()) .filter(Boolean); - onSubmit({ + const payload = { name, meal_type: mealType, time_minutes: Number(timeMinutes), tags: tagsArr, ingredients: cleanIngredients, steps: cleanSteps, - }); + }; + + if (image) { + payload.image = image; + } + + onSubmit(payload); }; return ( @@ -121,6 +145,35 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
+
+ +
+ {image && ( +
+ preview + +
+ )} + + +
+
+
recipe.tags || [])) + ).sort(); + + // Extract unique meal types from recipes + const mealTypes = Array.from(new Set(recipes.map((r) => r.meal_type))).sort(); + + // Extract max time for slider + const maxTimeInRecipes = Math.max(...recipes.map((r) => r.time_minutes), 0); + + const handleTagToggle = (tag) => { + if (filterTags.includes(tag)) { + onTagsChange(filterTags.filter((t) => t !== tag)); + } else { + onTagsChange([...filterTags, tag]); + } + }; + + const clearAllFilters = () => { + onSearchChange(""); + onMealTypeChange(""); + onMaxTimeChange(""); + onTagsChange([]); + }; + + const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0; + + return ( +
+ {/* Header with Search */} +
+
+ onSearchChange(e.target.value)} + /> + {searchQuery && ( + + )} +
+ + {/* Filter Toggle Button */} + +
+ + {/* Expandable Filters */} + {expandFilters && ( +
+
+ {/* Meal Type Filter */} + {mealTypes.length > 0 && ( +
+ +
+ + {mealTypes.map((type) => ( + + ))} +
+
+ )} + + {/* Prep Time Filter */} + {maxTimeInRecipes > 0 && ( +
+ +
+ onMaxTimeChange(e.target.value === "0" ? "" : e.target.value)} + className="time-slider" + /> +
+ 0 + {maxTimeInRecipes} +
+
+
+ )} + + {/* Tags Filter */} + {allTags.length > 0 && ( +
+ +
+ {allTags.map((tag) => ( + + ))} +
+
+ )} +
+ + {/* Clear All Button */} + {hasActiveFilters && ( + + )} +
+ )} + + {/* Recipe List Header */} +
+

כל המתכונים

+ {recipes.length} +
+ + {/* Recipe List */} + {recipes.length === 0 ? ( +

עדיין אין מתכונים שתואמים את הסינונים שלך.

+ ) : ( +
    + {recipes.map((r) => ( +
  • onSelect(r)} + > + {r.image && ( +
    + {r.name} +
    + )} +
    +
    {r.name}
    +
    + {r.time_minutes} דק׳ + {translateMealType(r.meal_type)} +
    +
    +
  • + ))} +
+ )} +
+ ); +} + +function translateMealType(type) { + switch (type) { + case "breakfast": + return "בוקר"; + case "lunch": + return "צהריים"; + case "dinner": + return "ערב"; + case "snack": + return "נשנוש"; + default: + return type; + } +} + +export default RecipeSearchList;