640 lines
20 KiB
JavaScript
640 lines
20 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 GroceryLists from "./components/GroceryLists";
|
||
import PinnedGroceryLists from "./components/PinnedGroceryLists";
|
||
import AdminPanel from "./components/AdminPanel";
|
||
import Modal from "./components/Modal";
|
||
import ToastContainer from "./components/ToastContainer";
|
||
import ThemeToggle from "./components/ThemeToggle";
|
||
import Login from "./components/Login";
|
||
import Register from "./components/Register";
|
||
import ResetPassword from "./components/ResetPassword";
|
||
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
||
import { getToken, removeToken, getMe } from "./authApi";
|
||
|
||
function App() {
|
||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||
const [user, setUser] = useState(null);
|
||
const [authView, setAuthView] = useState("login"); // "login" or "register"
|
||
const [loadingAuth, setLoadingAuth] = useState(true);
|
||
const [resetToken, setResetToken] = useState(null);
|
||
const [currentView, setCurrentView] = useState(() => {
|
||
try {
|
||
return localStorage.getItem("currentView") || "recipes";
|
||
} catch {
|
||
return "recipes";
|
||
}
|
||
}); // "recipes", "grocery-lists", or "admin"
|
||
|
||
const [selectedGroceryListId, setSelectedGroceryListId] = useState(null);
|
||
|
||
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 [filterOwner, setFilterOwner] = 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 [logoutModal, setLogoutModal] = useState(false);
|
||
const [toasts, setToasts] = useState([]);
|
||
const [theme, setTheme] = useState(() => {
|
||
try {
|
||
return localStorage.getItem("theme") || "dark";
|
||
} catch {
|
||
return "dark";
|
||
}
|
||
});
|
||
const [showPinnedSidebar, setShowPinnedSidebar] = useState(false);
|
||
|
||
// Swipe gesture handling for mobile sidebar
|
||
const [touchStart, setTouchStart] = useState(null);
|
||
const [touchEnd, setTouchEnd] = useState(null);
|
||
|
||
// Minimum swipe distance (in px)
|
||
const minSwipeDistance = 50;
|
||
|
||
const onTouchStart = (e) => {
|
||
setTouchEnd(null);
|
||
setTouchStart(e.targetTouches[0].clientX);
|
||
};
|
||
|
||
const onTouchMove = (e) => {
|
||
setTouchEnd(e.targetTouches[0].clientX);
|
||
};
|
||
|
||
const onTouchEnd = () => {
|
||
if (!touchStart || !touchEnd) return;
|
||
|
||
const distance = touchStart - touchEnd;
|
||
const isLeftSwipe = distance > minSwipeDistance;
|
||
|
||
if (isLeftSwipe) {
|
||
setShowPinnedSidebar(false);
|
||
}
|
||
|
||
setTouchStart(null);
|
||
setTouchEnd(null);
|
||
};
|
||
|
||
// Check authentication on mount
|
||
useEffect(() => {
|
||
const checkAuth = async () => {
|
||
// Check for reset token in URL
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const resetTokenParam = urlParams.get('reset_token');
|
||
if (resetTokenParam) {
|
||
setResetToken(resetTokenParam);
|
||
// Clean URL
|
||
window.history.replaceState({}, document.title, window.location.pathname);
|
||
setLoadingAuth(false);
|
||
return;
|
||
}
|
||
|
||
const token = getToken();
|
||
if (token) {
|
||
try {
|
||
const userData = await getMe(token);
|
||
setUser(userData);
|
||
setIsAuthenticated(true);
|
||
} catch (err) {
|
||
// Only remove token on authentication errors (401), not network errors
|
||
if (err.status === 401) {
|
||
console.log("Token invalid or expired, logging out");
|
||
removeToken();
|
||
setIsAuthenticated(false);
|
||
} else {
|
||
// Network error or server error - keep user logged in
|
||
console.warn("Auth check failed but keeping session:", err.message);
|
||
setIsAuthenticated(true); // Assume still authenticated
|
||
}
|
||
}
|
||
}
|
||
setLoadingAuth(false);
|
||
};
|
||
checkAuth();
|
||
}, []);
|
||
|
||
// Save currentView to localStorage
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem("currentView", currentView);
|
||
} catch (err) {
|
||
console.error("Unable to save view", err);
|
||
}
|
||
}, [currentView]);
|
||
|
||
// Load recipes for everyone (readonly for non-authenticated)
|
||
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 (username)
|
||
if (filterOwner && (!recipe.made_by || recipe.made_by !== filterOwner)) {
|
||
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 token = getToken();
|
||
const created = await createRecipe(payload, token);
|
||
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 {
|
||
const token = getToken();
|
||
await updateRecipe(editingRecipe.id, payload, token);
|
||
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 {
|
||
const token = getToken();
|
||
await deleteRecipe(recipeId, token);
|
||
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);
|
||
}
|
||
};
|
||
|
||
const handleLoginSuccess = async () => {
|
||
const token = getToken();
|
||
const userData = await getMe(token);
|
||
setUser(userData);
|
||
setIsAuthenticated(true);
|
||
await loadRecipes();
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
setLogoutModal(true);
|
||
};
|
||
|
||
const confirmLogout = () => {
|
||
removeToken();
|
||
setUser(null);
|
||
setIsAuthenticated(false);
|
||
setRecipes([]);
|
||
setSelectedRecipe(null);
|
||
setLogoutModal(false);
|
||
addToast('התנתקת בהצלחה', 'success');
|
||
};
|
||
|
||
// Show loading state while checking auth
|
||
if (loadingAuth) {
|
||
return (
|
||
<div className="app-root">
|
||
<div style={{ textAlign: "center", padding: "3rem", color: "var(--text-muted)" }}>
|
||
טוען...
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Show main app (readonly if not authenticated)
|
||
return (
|
||
<div className="app-root">
|
||
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
|
||
|
||
{/* Pinned notes toggle button - only visible on recipes view for authenticated users */}
|
||
{isAuthenticated && currentView === "recipes" && (
|
||
<button
|
||
className="pinned-toggle-btn mobile-only"
|
||
onClick={() => setShowPinnedSidebar(!showPinnedSidebar)}
|
||
aria-label="הצג תזכירים"
|
||
title="תזכירים נעוצים"
|
||
>
|
||
<span className="note-icon-lines">
|
||
<span></span>
|
||
<span></span>
|
||
<span></span>
|
||
</span>
|
||
</button>
|
||
)}
|
||
|
||
{/* User greeting above TopBar */}
|
||
{isAuthenticated && user && (
|
||
<div className="user-greeting-header">
|
||
שלום, {user.display_name || user.username} 👋
|
||
</div>
|
||
)}
|
||
|
||
{/* Show login/register option in TopBar if not authenticated */}
|
||
{!isAuthenticated ? (
|
||
<header className="topbar">
|
||
<div className="topbar-left">
|
||
<span className="logo-emoji" role="img" aria-label="plate">🍽</span>
|
||
<div className="brand">
|
||
<div className="brand-title">מה לבשל היום?</div>
|
||
<div className="brand-subtitle">מנהל המתכונים האישי שלך</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||
<button className="btn ghost" onClick={() => setAuthView("login")}>
|
||
התחבר
|
||
</button>
|
||
<button className="btn primary" onClick={() => setAuthView("register")}>
|
||
הירשם
|
||
</button>
|
||
</div>
|
||
</header>
|
||
) : (
|
||
<TopBar
|
||
onAddClick={() => setDrawerOpen(true)}
|
||
user={user}
|
||
onLogout={handleLogout}
|
||
onShowToast={addToast}
|
||
onNotificationClick={(listId) => {
|
||
setCurrentView("grocery-lists");
|
||
setSelectedGroceryListId(listId);
|
||
}}
|
||
onAdminClick={() => setCurrentView("admin")}
|
||
/>
|
||
)}
|
||
|
||
{/* Show auth modal if needed */}
|
||
{!isAuthenticated && authView !== null && !resetToken && (
|
||
<div className="drawer-backdrop" onClick={() => setAuthView(null)}>
|
||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||
{authView === "login" ? (
|
||
<Login
|
||
onSuccess={handleLoginSuccess}
|
||
onSwitchToRegister={() => setAuthView("register")}
|
||
/>
|
||
) : (
|
||
<Register
|
||
onSuccess={handleLoginSuccess}
|
||
onSwitchToLogin={() => setAuthView("login")}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Show reset password if token present */}
|
||
{!isAuthenticated && resetToken && (
|
||
<div className="drawer-backdrop">
|
||
<div className="auth-modal">
|
||
<ResetPassword
|
||
token={resetToken}
|
||
onSuccess={() => {
|
||
setResetToken(null);
|
||
setAuthView("login");
|
||
addToast("הסיסמה עודכנה בהצלחה! כעת תוכל להתחבר עם הסיסמה החדשה", "success");
|
||
}}
|
||
onBack={() => {
|
||
setResetToken(null);
|
||
setAuthView("login");
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isAuthenticated && (
|
||
<nav className="main-navigation">
|
||
<button
|
||
className={`nav-tab ${currentView === "recipes" ? "active" : ""}`}
|
||
onClick={() => setCurrentView("recipes")}
|
||
>
|
||
📖 מתכונים
|
||
</button>
|
||
<button
|
||
className={`nav-tab ${currentView === "grocery-lists" ? "active" : ""}`}
|
||
onClick={() => setCurrentView("grocery-lists")}
|
||
>
|
||
🛒 רשימות קניות
|
||
</button>
|
||
{user?.is_admin && (
|
||
<button
|
||
className={`nav-tab ${currentView === "admin" ? "active" : ""}`}
|
||
onClick={() => setCurrentView("admin")}
|
||
>
|
||
🛡️ ניהול
|
||
</button>
|
||
)}
|
||
</nav>
|
||
)}
|
||
|
||
<main className="layout">
|
||
{currentView === "admin" ? (
|
||
<div className="admin-view">
|
||
<AdminPanel onShowToast={addToast} />
|
||
</div>
|
||
) : currentView === "grocery-lists" ? (
|
||
<GroceryLists
|
||
user={user}
|
||
onShowToast={addToast}
|
||
selectedListIdFromNotification={selectedGroceryListId}
|
||
onListSelected={() => setSelectedGroceryListId(null)}
|
||
/>
|
||
) : (
|
||
<>
|
||
{isAuthenticated && (
|
||
<>
|
||
<aside
|
||
className={`pinned-lists-sidebar ${showPinnedSidebar ? 'mobile-visible' : ''}`}
|
||
onTouchStart={onTouchStart}
|
||
onTouchMove={onTouchMove}
|
||
onTouchEnd={onTouchEnd}
|
||
>
|
||
<button
|
||
className="close-sidebar-btn mobile-only"
|
||
onClick={() => setShowPinnedSidebar(false)}
|
||
aria-label="סגור תזכירים"
|
||
>
|
||
✕
|
||
</button>
|
||
<PinnedGroceryLists onShowToast={addToast} />
|
||
</aside>
|
||
{showPinnedSidebar && (
|
||
<div
|
||
className="sidebar-backdrop mobile-only"
|
||
onClick={() => setShowPinnedSidebar(false)}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
<section className="content-wrapper">
|
||
<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}
|
||
isAuthenticated={isAuthenticated}
|
||
currentUser={user}
|
||
/>
|
||
</section>
|
||
|
||
<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}
|
||
filterOwner={filterOwner}
|
||
onOwnerChange={setFilterOwner}
|
||
/>
|
||
</section>
|
||
</section>
|
||
</>
|
||
)}
|
||
</main>
|
||
|
||
{isAuthenticated && (
|
||
<RecipeFormDrawer
|
||
open={drawerOpen}
|
||
onClose={() => {
|
||
setDrawerOpen(false);
|
||
setEditingRecipe(null);
|
||
}}
|
||
onSubmit={handleFormSubmit}
|
||
editingRecipe={editingRecipe}
|
||
currentUser={user}
|
||
allRecipes={recipes}
|
||
/>
|
||
)}
|
||
|
||
<Modal
|
||
isOpen={deleteModal.isOpen}
|
||
title="מחק מתכון"
|
||
message={`בטוח שאתה רוצה למחוק את "${deleteModal.recipeName}"?`}
|
||
confirmText="מחק"
|
||
cancelText="ביטול"
|
||
isDangerous={true}
|
||
onConfirm={handleConfirmDelete}
|
||
onCancel={handleCancelDelete}
|
||
/>
|
||
|
||
<Modal
|
||
isOpen={logoutModal}
|
||
title="התנתקות"
|
||
message="האם אתה בטוח שברצונך להתנתק?"
|
||
confirmText="התנתק"
|
||
cancelText="ביטול"
|
||
isDangerous={false}
|
||
onConfirm={confirmLogout}
|
||
onCancel={() => setLogoutModal(false)}
|
||
/>
|
||
|
||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|