Add search and filter
This commit is contained in:
parent
c6eaae7321
commit
9b302c86e5
Binary file not shown.
Binary file not shown.
@ -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 {
|
||||
@ -755,3 +873,379 @@ 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);
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
<main className="layout">
|
||||
<section className="sidebar">
|
||||
<RecipeSearchList
|
||||
recipes={getFilteredRecipes()}
|
||||
selectedId={selectedRecipe?.id}
|
||||
onSelect={setSelectedRecipe}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filterMealType={filterMealType}
|
||||
onMealTypeChange={setFilterMealType}
|
||||
filterMaxTime={filterMaxTime}
|
||||
onMaxTimeChange={setFilterMaxTime}
|
||||
filterTags={filterTags}
|
||||
onTagsChange={setFilterTags}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{/* Random Recipe Suggester - Top Left */}
|
||||
<section className="panel filter-panel">
|
||||
<h3>חיפוש מתכון רנדומלי</h3>
|
||||
<div className="panel-grid">
|
||||
@ -214,15 +272,7 @@ function App() {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<RecipeList
|
||||
recipes={recipes}
|
||||
selectedId={selectedRecipe?.id}
|
||||
onSelect={setSelectedRecipe}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{/* Recipe Details Card */}
|
||||
<RecipeDetails
|
||||
recipe={selectedRecipe}
|
||||
onEditClick={handleEditRecipe}
|
||||
|
||||
@ -13,6 +13,13 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
||||
|
||||
return (
|
||||
<section className="panel recipe-card">
|
||||
{/* Recipe Image */}
|
||||
{recipe.image && (
|
||||
<div className="recipe-image-container">
|
||||
<img src={recipe.image} alt={recipe.name} className="recipe-image" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header className="recipe-header">
|
||||
<div>
|
||||
<h2>{recipe.name}</h2>
|
||||
|
||||
157
frontend/src/components/RecipeFilters.jsx
Normal file
157
frontend/src/components/RecipeFilters.jsx
Normal file
@ -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 (
|
||||
<section className="panel search-filter-panel">
|
||||
<div className="panel-header">
|
||||
<h3>חיפוש וסינון</h3>
|
||||
{hasActiveFilters && (
|
||||
<button className="btn ghost small" onClick={clearAllFilters}>
|
||||
נקה הכל
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Box */}
|
||||
<div className="search-box-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="search-box"
|
||||
placeholder="חיפוש לפי שם מתכון..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="search-clear"
|
||||
onClick={() => onSearchChange("")}
|
||||
title="נקה חיפוש"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters Grid */}
|
||||
<div className="filters-grid">
|
||||
{/* Meal Type Filter */}
|
||||
{mealTypes.length > 0 && (
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">סוג ארוחה</label>
|
||||
<div className="filter-options">
|
||||
<button
|
||||
className={`filter-btn ${filterMealType === "" ? "active" : ""}`}
|
||||
onClick={() => onMealTypeChange("")}
|
||||
>
|
||||
הכל
|
||||
</button>
|
||||
{mealTypes.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
className={`filter-btn ${filterMealType === type ? "active" : ""}`}
|
||||
onClick={() => onMealTypeChange(type)}
|
||||
>
|
||||
{translateMealType(type)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prep Time Filter */}
|
||||
{maxTimeInRecipes > 0 && (
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">
|
||||
זמן הכנה {filterMaxTime ? `עד ${filterMaxTime} דקות` : ""}
|
||||
</label>
|
||||
<div className="time-filter-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxTimeInRecipes}
|
||||
value={filterMaxTime || 0}
|
||||
onChange={(e) => onMaxTimeChange(e.target.value === "0" ? "" : e.target.value)}
|
||||
className="time-slider"
|
||||
/>
|
||||
<div className="time-labels">
|
||||
<span>0</span>
|
||||
<span>{maxTimeInRecipes}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags Filter */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">תגיות</label>
|
||||
<div className="tag-filter-wrapper">
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
className={`tag-filter-btn ${filterTags.includes(tag) ? "active" : ""}`}
|
||||
onClick={() => handleTagToggle(tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function translateMealType(type) {
|
||||
switch (type) {
|
||||
case "breakfast":
|
||||
return "בוקר";
|
||||
case "lunch":
|
||||
return "צהריים";
|
||||
case "dinner":
|
||||
return "ערב";
|
||||
case "snack":
|
||||
return "נשנוש";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
export default RecipeFilters;
|
||||
@ -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 }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>תמונה של המתכון</label>
|
||||
<div className="image-upload-wrapper">
|
||||
{image && (
|
||||
<div className="image-preview">
|
||||
<img src={image} alt="preview" />
|
||||
<button
|
||||
type="button"
|
||||
className="image-remove-btn"
|
||||
onClick={handleRemoveImage}
|
||||
title="הסרת תמונה"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="image-input"
|
||||
id="recipe-image-input"
|
||||
/>
|
||||
<label htmlFor="recipe-image-input" className="image-upload-label">
|
||||
{image ? "החלף תמונה" : "בחר תמונה"}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>תגיות (מופרד בפסיקים)</label>
|
||||
<input
|
||||
|
||||
213
frontend/src/components/RecipeSearchList.jsx
Normal file
213
frontend/src/components/RecipeSearchList.jsx
Normal file
@ -0,0 +1,213 @@
|
||||
import { useState } from "react";
|
||||
|
||||
function RecipeSearchList({
|
||||
recipes,
|
||||
selectedId,
|
||||
onSelect,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
filterMealType,
|
||||
onMealTypeChange,
|
||||
filterMaxTime,
|
||||
onMaxTimeChange,
|
||||
filterTags,
|
||||
onTagsChange,
|
||||
}) {
|
||||
const [expandFilters, setExpandFilters] = useState(false);
|
||||
|
||||
// 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 (
|
||||
<section className="panel secondary recipe-search-list">
|
||||
{/* Header with Search */}
|
||||
<div className="recipe-search-header">
|
||||
<div className="search-box-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="search-box"
|
||||
placeholder="חיפוש לפי שם מתכון..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="search-clear"
|
||||
onClick={() => onSearchChange("")}
|
||||
title="נקה חיפוש"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Toggle Button */}
|
||||
<button
|
||||
className="filter-toggle-btn"
|
||||
onClick={() => setExpandFilters(!expandFilters)}
|
||||
title={expandFilters ? "הסתר סינונים" : "הצג סינונים"}
|
||||
>
|
||||
<span className="filter-icon">☰</span>
|
||||
{hasActiveFilters && <span className="filter-badge">{Object.values([filterMealType, filterMaxTime, filterTags.length > 0 ? "tags" : null]).filter(Boolean).length}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expandable Filters */}
|
||||
{expandFilters && (
|
||||
<div className="recipe-filters-expanded">
|
||||
<div className="filters-grid">
|
||||
{/* Meal Type Filter */}
|
||||
{mealTypes.length > 0 && (
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">סוג ארוחה</label>
|
||||
<div className="filter-options">
|
||||
<button
|
||||
className={`filter-btn ${filterMealType === "" ? "active" : ""}`}
|
||||
onClick={() => onMealTypeChange("")}
|
||||
>
|
||||
הכל
|
||||
</button>
|
||||
{mealTypes.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
className={`filter-btn ${filterMealType === type ? "active" : ""}`}
|
||||
onClick={() => onMealTypeChange(type)}
|
||||
>
|
||||
{translateMealType(type)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prep Time Filter */}
|
||||
{maxTimeInRecipes > 0 && (
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">
|
||||
זמן הכנה {filterMaxTime ? `עד ${filterMaxTime} דקות` : ""}
|
||||
</label>
|
||||
<div className="time-filter-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxTimeInRecipes}
|
||||
value={filterMaxTime || 0}
|
||||
onChange={(e) => onMaxTimeChange(e.target.value === "0" ? "" : e.target.value)}
|
||||
className="time-slider"
|
||||
/>
|
||||
<div className="time-labels">
|
||||
<span>0</span>
|
||||
<span>{maxTimeInRecipes}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags Filter */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">תגיות</label>
|
||||
<div className="tag-filter-wrapper">
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
className={`tag-filter-btn ${filterTags.includes(tag) ? "active" : ""}`}
|
||||
onClick={() => handleTagToggle(tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clear All Button */}
|
||||
{hasActiveFilters && (
|
||||
<button className="btn ghost full" onClick={clearAllFilters} style={{ marginTop: "0.6rem" }}>
|
||||
נקה הכל
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recipe List Header */}
|
||||
<div className="recipe-list-header">
|
||||
<h3>כל המתכונים</h3>
|
||||
<span className="badge">{recipes.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Recipe List */}
|
||||
{recipes.length === 0 ? (
|
||||
<p className="muted">עדיין אין מתכונים שתואמים את הסינונים שלך.</p>
|
||||
) : (
|
||||
<ul className="recipe-list">
|
||||
{recipes.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className={
|
||||
selectedId === r.id ? "recipe-list-item active" : "recipe-list-item"
|
||||
}
|
||||
onClick={() => onSelect(r)}
|
||||
>
|
||||
{r.image && (
|
||||
<div className="recipe-list-image">
|
||||
<img src={r.image} alt={r.name} />
|
||||
</div>
|
||||
)}
|
||||
<div className="recipe-list-main">
|
||||
<div className="recipe-list-name">{r.name}</div>
|
||||
<div className="recipe-list-meta">
|
||||
<span>{r.time_minutes} דק׳</span>
|
||||
<span>{translateMealType(r.meal_type)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function translateMealType(type) {
|
||||
switch (type) {
|
||||
case "breakfast":
|
||||
return "בוקר";
|
||||
case "lunch":
|
||||
return "צהריים";
|
||||
case "dinner":
|
||||
return "ערב";
|
||||
case "snack":
|
||||
return "נשנוש";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
export default RecipeSearchList;
|
||||
Loading…
x
Reference in New Issue
Block a user