2025-12-21 03:43:37 +02:00

640 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;