323 lines
9.9 KiB
JavaScript
323 lines
9.9 KiB
JavaScript
import { useEffect, useState } from "react";
|
|
import "./App.css";
|
|
|
|
import TopBar from "./components/TopBar";
|
|
import RecipeSearchList from "./components/RecipeSearchList";
|
|
import RecipeDetails from "./components/RecipeDetails";
|
|
import RecipeFormDrawer from "./components/RecipeFormDrawer";
|
|
import Modal from "./components/Modal";
|
|
import ToastContainer from "./components/ToastContainer";
|
|
import ThemeToggle from "./components/ThemeToggle";
|
|
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
|
|
|
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([]);
|
|
const [filterMadeBy, setFilterMadeBy] = useState("");
|
|
|
|
// Random recipe filters
|
|
const [mealTypeFilter, setMealTypeFilter] = useState("");
|
|
const [maxTimeFilter, setMaxTimeFilter] = useState("");
|
|
const [ingredientsFilter, setIngredientsFilter] = useState("");
|
|
|
|
const [loadingRandom, setLoadingRandom] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [editingRecipe, setEditingRecipe] = useState(null);
|
|
|
|
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
|
|
const [toasts, setToasts] = useState([]);
|
|
const [theme, setTheme] = useState(() => {
|
|
try {
|
|
return localStorage.getItem("theme") || "dark";
|
|
} catch {
|
|
return "dark";
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
loadRecipes();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
document.documentElement.setAttribute("data-theme", theme);
|
|
try {
|
|
localStorage.setItem("theme", theme);
|
|
} catch {}
|
|
}, [theme]);
|
|
|
|
const loadRecipes = async () => {
|
|
try {
|
|
const list = await getRecipes();
|
|
setRecipes(list);
|
|
if (!selectedRecipe && list.length > 0) {
|
|
setSelectedRecipe(list[0]);
|
|
}
|
|
} catch {
|
|
setError("לא הצלחנו לטעון את רשימת המתכונים.");
|
|
}
|
|
};
|
|
|
|
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) {
|
|
const maxTime = parseInt(filterMaxTime, 10);
|
|
if (recipe.time_minutes > maxTime) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Filter by made_by
|
|
if (filterMadeBy && (!recipe.made_by || recipe.made_by !== filterMadeBy)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const handleRandomClick = async () => {
|
|
setLoadingRandom(true);
|
|
setError("");
|
|
try {
|
|
const ingredientsArr = ingredientsFilter
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
|
|
const recipe = await getRandomRecipe({
|
|
mealType: mealTypeFilter || undefined,
|
|
maxTime: maxTimeFilter ? Number(maxTimeFilter) : undefined,
|
|
ingredients: ingredientsArr,
|
|
});
|
|
|
|
setSelectedRecipe(recipe);
|
|
} catch (err) {
|
|
if (err.response?.status === 404) {
|
|
setError("לא נמצאו מתכונים שעומדים בפילטרים שלך.");
|
|
} else {
|
|
setError("אירעה שגיאה בחיפוש מתכון.");
|
|
}
|
|
} finally {
|
|
setLoadingRandom(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateRecipe = async (payload) => {
|
|
try {
|
|
const created = await createRecipe(payload);
|
|
setDrawerOpen(false);
|
|
setEditingRecipe(null);
|
|
await loadRecipes();
|
|
setSelectedRecipe(created);
|
|
addToast("המתכון החדש נוצר בהצלחה!", "success");
|
|
} catch {
|
|
setError("שגיאה בשמירת המתכון החדש.");
|
|
addToast("שגיאה בשמירת המתכון החדש", "error");
|
|
}
|
|
};
|
|
|
|
const handleEditRecipe = (recipe) => {
|
|
setEditingRecipe(recipe);
|
|
setDrawerOpen(true);
|
|
};
|
|
|
|
const handleUpdateRecipe = async (payload) => {
|
|
try {
|
|
await updateRecipe(editingRecipe.id, payload);
|
|
setDrawerOpen(false);
|
|
setEditingRecipe(null);
|
|
await loadRecipes();
|
|
const updated = (await getRecipes()).find((r) => r.id === editingRecipe.id);
|
|
if (updated) {
|
|
setSelectedRecipe(updated);
|
|
}
|
|
addToast("המתכון עודכן בהצלחה!", "success");
|
|
} catch {
|
|
setError("שגיאה בעדכון המתכון.");
|
|
addToast("שגיאה בעדכון המתכון", "error");
|
|
}
|
|
};
|
|
|
|
const handleShowDeleteModal = (recipeId, recipeName) => {
|
|
setDeleteModal({ isOpen: true, recipeId, recipeName });
|
|
};
|
|
|
|
const handleConfirmDelete = async () => {
|
|
const recipeId = deleteModal.recipeId;
|
|
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
|
|
|
try {
|
|
await deleteRecipe(recipeId);
|
|
await loadRecipes();
|
|
setSelectedRecipe(null);
|
|
addToast("המתכון נמחק בהצלחה!", "success");
|
|
} catch {
|
|
setError("שגיאה במחיקת המתכון.");
|
|
addToast("שגיאה במחיקת המתכון", "error");
|
|
}
|
|
};
|
|
|
|
const handleCancelDelete = () => {
|
|
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
|
};
|
|
|
|
const addToast = (message, type = "info", duration = 3000) => {
|
|
const id = Date.now();
|
|
setToasts((prev) => [...prev, { id, message, type, duration }]);
|
|
};
|
|
|
|
const removeToast = (id) => {
|
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
|
};
|
|
|
|
const handleFormSubmit = (payload) => {
|
|
if (editingRecipe) {
|
|
handleUpdateRecipe(payload);
|
|
} else {
|
|
handleCreateRecipe(payload);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="app-root">
|
|
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
|
|
<TopBar onAddClick={() => setDrawerOpen(true)} />
|
|
|
|
<main className="layout">
|
|
<section className="sidebar">
|
|
<RecipeSearchList
|
|
allRecipes={recipes}
|
|
recipes={getFilteredRecipes()}
|
|
selectedId={selectedRecipe?.id}
|
|
onSelect={setSelectedRecipe}
|
|
searchQuery={searchQuery}
|
|
onSearchChange={setSearchQuery}
|
|
filterMealType={filterMealType}
|
|
onMealTypeChange={setFilterMealType}
|
|
filterMaxTime={filterMaxTime}
|
|
onMaxTimeChange={setFilterMaxTime}
|
|
filterTags={filterTags}
|
|
onTagsChange={setFilterTags}
|
|
filterMadeBy={filterMadeBy}
|
|
onMadeByChange={setFilterMadeBy}
|
|
/>
|
|
</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">
|
|
<div className="field">
|
|
<label>סוג ארוחה</label>
|
|
<select
|
|
value={mealTypeFilter}
|
|
onChange={(e) => setMealTypeFilter(e.target.value)}
|
|
>
|
|
<option value="">לא משנה</option>
|
|
<option value="breakfast">בוקר</option>
|
|
<option value="lunch">צהריים</option>
|
|
<option value="dinner">ערב</option>
|
|
<option value="snack">נשנוש</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="field">
|
|
<label>זמן מקסימלי (דקות)</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={maxTimeFilter}
|
|
onChange={(e) => setMaxTimeFilter(e.target.value)}
|
|
placeholder="למשל 20"
|
|
/>
|
|
</div>
|
|
|
|
<div className="field field-full">
|
|
<label>מצרכים שיש בבית (מופרד בפסיקים)</label>
|
|
<input
|
|
value={ingredientsFilter}
|
|
onChange={(e) => setIngredientsFilter(e.target.value)}
|
|
placeholder="ביצה, עגבניה, פסטה..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
className="btn accent full"
|
|
onClick={handleRandomClick}
|
|
disabled={loadingRandom}
|
|
>
|
|
{loadingRandom ? "מחפש מתכון..." : "תן לי רעיון לארוחה 🎲"}
|
|
</button>
|
|
</section>
|
|
|
|
{/* Recipe Details Card */}
|
|
<RecipeDetails
|
|
recipe={selectedRecipe}
|
|
onEditClick={handleEditRecipe}
|
|
onShowDeleteModal={handleShowDeleteModal}
|
|
/>
|
|
</section>
|
|
</main>
|
|
|
|
<RecipeFormDrawer
|
|
open={drawerOpen}
|
|
onClose={() => {
|
|
setDrawerOpen(false);
|
|
setEditingRecipe(null);
|
|
}}
|
|
onSubmit={handleFormSubmit}
|
|
editingRecipe={editingRecipe}
|
|
/>
|
|
|
|
<Modal
|
|
isOpen={deleteModal.isOpen}
|
|
title="מחק מתכון"
|
|
message={`בטוח שאתה רוצה למחוק את "${deleteModal.recipeName}"?`}
|
|
confirmText="מחק"
|
|
cancelText="ביטול"
|
|
isDangerous={true}
|
|
onConfirm={handleConfirmDelete}
|
|
onCancel={handleCancelDelete}
|
|
/>
|
|
|
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|