2025-12-20 23:02:47 +02:00

615 lines
19 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 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" or "grocery-lists"
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}
/>
)}
{/* 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>
</nav>
)}
<main className="layout">
{currentView === "grocery-lists" ? (
<GroceryLists user={user} onShowToast={addToast} />
) : (
<>
{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;