my-recipes/frontend/src/components/GroceryLists.jsx
2025-12-19 16:17:17 +02:00

971 lines
29 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 { useState, useEffect } from "react";
import {
getGroceryLists,
createGroceryList,
updateGroceryList,
deleteGroceryList,
shareGroceryList,
getGroceryListShares,
unshareGroceryList,
searchUsers,
togglePinGroceryList,
} from "../groceryApi";
function GroceryLists({ user, onShowToast }) {
const [lists, setLists] = useState([]);
const [selectedList, setSelectedList] = useState(null);
const [loading, setLoading] = useState(true);
const [editingList, setEditingList] = useState(null);
const [showShareModal, setShowShareModal] = useState(null);
const [shares, setShares] = useState([]);
const [userSearch, setUserSearch] = useState("");
const [searchResults, setSearchResults] = useState([]);
const [sharePermission, setSharePermission] = useState(false);
// New list form
const [newListName, setNewListName] = useState("");
const [showNewListForm, setShowNewListForm] = useState(false);
// Edit form
const [editName, setEditName] = useState("");
const [editItems, setEditItems] = useState([]);
const [newItem, setNewItem] = useState("");
useEffect(() => {
loadLists();
}, []);
// Restore selected list from localStorage after lists are loaded
useEffect(() => {
if (lists.length > 0) {
try {
const savedListId = localStorage.getItem("selectedGroceryListId");
if (savedListId) {
const listToSelect = lists.find(list => list.id === parseInt(savedListId));
if (listToSelect) {
setSelectedList(listToSelect);
}
}
} catch (err) {
console.error("Failed to restore selected list", err);
}
}
}, [lists]);
const loadLists = async () => {
try {
setLoading(true);
const data = await getGroceryLists();
setLists(data);
} catch (error) {
onShowToast(error.message, "error");
} finally {
setLoading(false);
}
};
const handleCreateList = async (e) => {
e.preventDefault();
if (!newListName.trim()) return;
try {
const newList = await createGroceryList({
name: newListName.trim(),
items: [],
});
setLists([newList, ...lists]);
setNewListName("");
setShowNewListForm(false);
onShowToast("רשימת קניות נוצרה בהצלחה", "success");
} catch (error) {
onShowToast(error.message, "error");
}
};
const handleSelectList = (list) => {
setSelectedList(list);
setEditingList(null);
try {
localStorage.setItem("selectedGroceryListId", list.id.toString());
} catch (err) {
console.error("Failed to save selected list", err);
}
};
const handleEditList = (list) => {
setEditingList(list);
setEditName(list.name);
setEditItems([...list.items]);
setNewItem("");
};
const handleAddItem = () => {
if (!newItem.trim()) return;
setEditItems([...editItems, newItem.trim()]);
setNewItem("");
};
const handleRemoveItem = (index) => {
setEditItems(editItems.filter((_, i) => i !== index));
};
const handleToggleItem = (index) => {
const updated = [...editItems];
const item = updated[index];
if (item.startsWith("✓ ")) {
updated[index] = item.substring(2);
} else {
updated[index] = "✓ " + item;
}
setEditItems(updated);
};
const handleToggleItemInView = async (index) => {
if (!selectedList || !selectedList.can_edit) return;
const updated = [...selectedList.items];
const item = updated[index];
if (item.startsWith("✓ ")) {
updated[index] = item.substring(2);
} else {
updated[index] = "✓ " + item;
}
try {
const updatedList = await updateGroceryList(selectedList.id, {
items: updated,
});
setLists(lists.map((l) => (l.id === updatedList.id ? updatedList : l)));
setSelectedList(updatedList);
} catch (error) {
onShowToast(error.message, "error");
}
};
const handleSaveList = async () => {
if (!editName.trim()) {
onShowToast("שם הרשימה לא יכול להיות ריק", "error");
return;
}
try {
const updated = await updateGroceryList(editingList.id, {
name: editName.trim(),
items: editItems,
});
setLists(lists.map((l) => (l.id === updated.id ? updated : l)));
if (selectedList?.id === updated.id) {
setSelectedList(updated);
}
setEditingList(null);
onShowToast("רשימת קניות עודכנה בהצלחה", "success");
} catch (error) {
onShowToast(error.message, "error");
}
};
const handleDeleteList = async (listId) => {
if (!confirm("האם אתה בטוח שברצונך למחוק רשימת קניות זו?")) return;
try {
await deleteGroceryList(listId);
setLists(lists.filter((l) => l.id !== listId));
if (selectedList?.id === listId) {
setSelectedList(null);
}
setEditingList(null);
onShowToast("רשימת קניות נמחקה בהצלחה", "success");
} catch (error) {
onShowToast(error.message, "error");
}
};
const handleTogglePin = async (list) => {
try {
const updated = await togglePinGroceryList(list.id);
setLists(lists.map((l) => (l.id === updated.id ? updated : l)));
if (selectedList?.id === updated.id) {
setSelectedList(updated);
}
const message = updated.is_pinned
? "רשימה הוצמדה לדף הבית"
: "רשימה הוסרה מדף הבית";
onShowToast(message, "success");
} catch (error) {
onShowToast(error.message, "error");
}
};
const handleShowShareModal = async (list) => {
setShowShareModal(list);
setUserSearch("");
setSearchResults([]);
setSharePermission(false);
try {
const sharesData = await getGroceryListShares(list.id);
setShares(sharesData);
} catch (error) {
onShowToast(error.message, "error");
}
};
const handleSearchUsers = async (query) => {
setUserSearch(query);
if (query.trim().length < 2) {
setSearchResults([]);
return;
}
try {
const results = await searchUsers(query);
setSearchResults(results);
} catch (error) {
onShowToast(error.message, "error");
}
};
const handleShareWithUser = async (userId, username) => {
try {
const share = await shareGroceryList(showShareModal.id, {
user_identifier: username,
can_edit: sharePermission,
});
setShares([...shares, share]);
setUserSearch("");
setSearchResults([]);
setSharePermission(false);
onShowToast(`רשימה שותפה עם ${share.display_name}`, "success");
} catch (error) {
onShowToast(error.message, "error");
}
};
const handleUnshare = async (userId) => {
try {
await unshareGroceryList(showShareModal.id, userId);
setShares(shares.filter((s) => s.shared_with_user_id !== userId));
onShowToast("שיתוף הוסר בהצלחה", "success");
} catch (error) {
onShowToast(error.message, "error");
}
};
if (loading) {
return <div className="loading">טוען רשימות קניות...</div>;
}
return (
<div className="grocery-lists-container">
<div className="grocery-lists-header">
<h2>רשימות הקניות שלי</h2>
<button
className="btn primary"
onClick={() => setShowNewListForm(!showNewListForm)}
>
{showNewListForm ? "ביטול" : "+ רשימה חדשה"}
</button>
</div>
{showNewListForm && (
<form className="new-list-form" onSubmit={handleCreateList}>
<input
type="text"
placeholder="שם הרשימה..."
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
autoFocus
/>
<button type="submit" className="btn primary">
צור רשימה
</button>
</form>
)}
<div className="grocery-lists-layout">
{/* Lists Sidebar */}
<div className="lists-sidebar">
{lists.length === 0 ? (
<p className="empty-message">אין רשימות קניות עדיין</p>
) : (
lists.map((list) => (
<div key={list.id} className="list-item-wrapper">
<div
className={`list-item ${selectedList?.id === list.id ? "active" : ""}`}
onClick={() => handleSelectList(list)}
>
<div className="list-item-content">
<h4>{list.name}</h4>
<p className="list-item-meta">
{list.is_owner ? "שלי" : `של ${list.owner_display_name}`}
{" · "}
{list.items.length} פריטים
</p>
</div>
</div>
{list.is_owner && (
<button
className="share-icon-btn"
onClick={(e) => {
e.stopPropagation();
handleShowShareModal(list);
}}
title="שתף רשימה"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="18" cy="5" r="3"/>
<circle cx="6" cy="12" r="3"/>
<circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
</button>
)}
</div>
))
)}
</div>
{/* List Details */}
<div className="list-details">
{editingList ? (
<div className="edit-list-form">
<div className="form-header">
<h3>עריכת רשימה</h3>
<button className="btn ghost" onClick={() => setEditingList(null)}>
ביטול
</button>
</div>
<div className="form-group">
<label>שם הרשימה</label>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
</div>
<div className="form-group">
<label>פריטים</label>
<div className="add-item-row">
<input
type="text"
placeholder="הוסף פריט..."
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && (e.preventDefault(), handleAddItem())}
/>
<button type="button" className="btn primary" onClick={handleAddItem}>
הוסף
</button>
</div>
<ul className="items-list">
{editItems.map((item, index) => (
<li key={index} className="item-row">
<button
type="button"
className="btn-icon"
onClick={() => handleToggleItem(index)}
>
{item.startsWith("✓ ") ? "☑" : "☐"}
</button>
<span className={item.startsWith("✓ ") ? "checked" : ""}>
{item.startsWith("✓ ") ? item.substring(2) : item}
</span>
<button
type="button"
className="btn-icon delete"
onClick={() => handleRemoveItem(index)}
>
</button>
</li>
))}
</ul>
</div>
<div className="form-actions">
<button className="btn primary" onClick={handleSaveList}>
שמור שינויים
</button>
{editingList.is_owner && (
<>
<button
className="btn secondary small"
onClick={() => {
setEditingList(null);
handleShowShareModal(editingList);
}}
title="שתף רשימה"
>
שתף
</button>
<button
className="btn danger"
onClick={() => handleDeleteList(editingList.id)}
>
מחק רשימה
</button>
</>
)}
</div>
</div>
) : selectedList ? (
<div className="view-list">
<div className="view-header">
<div>
<h3>{selectedList.name}</h3>
<p className="list-meta">
{selectedList.is_owner
? "רשימה שלי"
: `משותפת על ידי ${selectedList.owner_display_name}`}
</p>
</div>
<div className="view-header-actions">
{selectedList.is_owner && (
<>
<button
className={`btn-icon-action ${selectedList.is_pinned ? "pinned" : ""}`}
onClick={() => handleTogglePin(selectedList)}
title={selectedList.is_pinned ? "הסר הצמדה" : "הצמד לדף הבית"}
>
📌
</button>
<button
className="btn-icon-action"
onClick={() => handleShowShareModal(selectedList)}
title="שתף רשימה"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="18" cy="5" r="3"/>
<circle cx="6" cy="12" r="3"/>
<circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
</button>
</>
)}
{selectedList.can_edit && (
<button
className="btn-icon-action"
onClick={() => handleEditList(selectedList)}
title="ערוך רשימה"
>
</button>
)}
</div>
</div>
{selectedList.items.length === 0 ? (
<p className="empty-message">אין פריטים ברשימה</p>
) : (
<ul className="items-list view-mode">
{selectedList.items.map((item, index) => {
const isChecked = item.startsWith("✓ ");
const itemText = isChecked ? item.substring(2) : item;
return (
<li key={index} className={`item-row ${isChecked ? "checked" : ""}`}>
{selectedList.can_edit ? (
<>
<button
type="button"
className="btn-icon"
onClick={() => handleToggleItemInView(index)}
>
{isChecked ? "☑" : "☐"}
</button>
<span className={isChecked ? "checked-text" : ""}>
{itemText}
</span>
</>
) : (
<>
<span className="btn-icon">{isChecked ? "☑" : "☐"}</span>
<span className={isChecked ? "checked-text" : ""}>
{itemText}
</span>
</>
)}
</li>
);
})}
</ul>
)}
</div>
) : (
<div className="empty-state">
<p>בחר רשימת קניות כדי להציג את הפרטים</p>
</div>
)}
</div>
</div>
{/* Share Modal */}
{showShareModal && (
<div className="modal-overlay" onClick={() => setShowShareModal(null)}>
<div className="modal share-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>שתף רשימה: {showShareModal.name}</h3>
<button className="btn-close" onClick={() => setShowShareModal(null)}>
</button>
</div>
<div className="modal-body">
<div className="share-search">
<input
type="text"
placeholder="חפש משתמש לפי שם משתמש או שם תצוגה..."
value={userSearch}
onChange={(e) => handleSearchUsers(e.target.value)}
/>
<label className="checkbox-label">
<input
type="checkbox"
checked={sharePermission}
onChange={(e) => setSharePermission(e.target.checked)}
/>
<span>אפשר עריכה</span>
</label>
{searchResults.length > 0 && (
<ul className="search-results">
{searchResults.map((user) => (
<li
key={user.id}
onClick={() => handleShareWithUser(user.id, user.username)}
>
<div>
<strong>{user.display_name}</strong>
<span className="username">@{user.username}</span>
</div>
<button className="btn small">שתף</button>
</li>
))}
</ul>
)}
</div>
<div className="shares-list">
<h4>משותף עם:</h4>
{shares.length === 0 ? (
<p className="empty-message">הרשימה לא משותפת עם אף אחד</p>
) : (
<ul>
{shares.map((share) => (
<li key={share.id} className="share-item">
<div>
<strong>{share.display_name}</strong>
<span className="username">@{share.username}</span>
{share.can_edit && <span className="badge">עורך</span>}
</div>
<button
className="btn danger small"
onClick={() => handleUnshare(share.shared_with_user_id)}
>
הסר
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
)}
<style>{`
.grocery-lists-container {
padding: 1rem;
max-width: 1400px;
margin: 0 auto;
}
.grocery-lists-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.new-list-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
padding: 1.5rem;
background: var(--panel-bg);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.new-list-form input {
flex: 1;
}
.grocery-lists-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1.5rem;
}
.lists-sidebar {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list-item-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
}
.list-item {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--panel-bg);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.share-icon-btn {
background: var(--card-soft);
border: 1px solid var(--border-color);
padding: 0.5rem;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
flex-shrink: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.share-icon-btn:hover {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
transform: scale(1.05);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.list-item:hover {
background: var(--hover-bg);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.list-item.active {
background: var(--primary-color);
color: white;
}
.list-item-content h4 {
margin: 0 0 0.25rem 0;
}
.list-item-meta {
margin: 0;
font-size: 0.875rem;
opacity: 0.8;
}
.list-details {
background: var(--panel-bg);
border-radius: 16px;
padding: 2rem;
min-height: 400px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.edit-list-form .form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.add-item-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.add-item-row input {
flex: 1;
}
.items-list {
list-style: none;
padding: 0;
margin: 0;
}
.item-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: var(--panel-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 0.75rem;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.item-row:hover {
background: var(--hover-bg);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
.item-row span {
flex: 1;
}
.item-row span.checked-text {
text-decoration: line-through;
opacity: 0.6;
}
.item-row span.checked {
text-decoration: line-through;
opacity: 0.6;
}
.items-list.view-mode .item-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: var(--panel-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.items-list.view-mode .item-row:hover {
background: var(--hover-bg);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
.items-list.view-mode .item-row.checked {
opacity: 0.7;
background: var(--card-soft);
}
.form-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
}
.view-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
}
.view-header > div:first-child {
flex: 1;
min-width: 0;
}
.view-header h3 {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.view-header-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.list-meta {
margin: 0.5rem 0 0 0;
opacity: 0.7;
}
.empty-state,
.empty-message {
text-align: center;
padding: 2rem;
opacity: 0.6;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.share-modal {
background: var(--panel-bg);
border-radius: 16px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-body {
padding: 1.5rem;
}
.share-search input {
width: 100%;
margin-bottom: 0.5rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
cursor: pointer;
}
.search-results {
list-style: none;
padding: 0;
margin: 1rem 0;
border: 1px solid var(--border-color);
border-radius: 8px;
max-height: 200px;
overflow-y: auto;
}
.search-results li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
}
.search-results li:last-child {
border-bottom: none;
}
.search-results li:hover {
background: var(--hover-bg);
}
.username {
font-size: 0.875rem;
opacity: 0.7;
margin-left: 0.5rem;
}
.shares-list {
margin-top: 2rem;
}
.shares-list h4 {
margin-bottom: 1rem;
}
.share-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: var(--hover-bg);
border-radius: 8px;
margin-bottom: 0.5rem;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: var(--primary-color);
color: white;
border-radius: 6px;
font-size: 0.75rem;
margin-right: 0.5rem;
}
.btn-icon-action {
background: var(--card-soft);
border: 1px solid var(--border-color);
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 1.25rem;
border-radius: 8px;
transition: all 0.2s;
line-height: 1;
}
.btn-icon-action:hover {
background: var(--hover-bg);
transform: scale(1.1);
}
@media (max-width: 768px) {
.grocery-lists-layout {
grid-template-columns: 1fr;
}
.lists-sidebar {
max-height: 300px;
overflow-y: auto;
}
}
`}</style>
</div>
);
}
export default GroceryLists;