diff --git a/backend/grocery_db_utils.py b/backend/grocery_db_utils.py index ff80334..de7ac8c 100644 --- a/backend/grocery_db_utils.py +++ b/backend/grocery_db_utils.py @@ -136,7 +136,7 @@ def delete_grocery_list(list_id: int) -> bool: def share_grocery_list(list_id: int, shared_with_user_id: int, can_edit: bool = False) -> Dict[str, Any]: - """Share a grocery list with another user""" + """Share a grocery list with another user or update existing share permissions""" conn = get_db_connection() cur = conn.cursor(cursor_factory=RealDictCursor) try: @@ -158,6 +158,28 @@ def share_grocery_list(list_id: int, shared_with_user_id: int, can_edit: bool = conn.close() +def update_share_permission(list_id: int, shared_with_user_id: int, can_edit: bool) -> Optional[Dict[str, Any]]: + """Update edit permission for an existing share""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + UPDATE grocery_list_shares + SET can_edit = %s + WHERE list_id = %s AND shared_with_user_id = %s + RETURNING id, list_id, shared_with_user_id, can_edit, shared_at + """, + (can_edit, list_id, shared_with_user_id) + ) + share = cur.fetchone() + conn.commit() + return dict(share) if share else None + finally: + cur.close() + conn.close() + + def unshare_grocery_list(list_id: int, user_id: int) -> bool: """Remove sharing access for a user""" conn = get_db_connection() diff --git a/backend/main.py b/backend/main.py index 880a062..eda67ba 100644 --- a/backend/main.py +++ b/backend/main.py @@ -45,6 +45,7 @@ from grocery_db_utils import ( get_grocery_list_shares, search_users, toggle_grocery_list_pin, + update_share_permission, ) from notification_db_utils import ( @@ -178,6 +179,10 @@ class ShareGroceryList(BaseModel): can_edit: bool = False +class UpdateSharePermission(BaseModel): + can_edit: bool + + class GroceryListShare(BaseModel): id: int list_id: int @@ -1037,6 +1042,45 @@ def unshare_grocery_list_endpoint( return +@app.patch("/grocery-lists/{list_id}/shares/{user_id}", response_model=GroceryListShare) +def update_share_permission_endpoint( + list_id: int, + user_id: int, + share_data: UpdateSharePermission, + current_user: dict = Depends(get_current_user) +): + """Update share permissions for a user""" + # Check if current user is the owner + existing = get_grocery_list_by_id(list_id, current_user["user_id"]) + if not existing: + raise HTTPException(status_code=404, detail="רשימת קניות לא נמצאה") + + if not existing["is_owner"]: + raise HTTPException(status_code=403, detail="רק הבעלים יכול לשנות הרשאות") + + updated_share = update_share_permission(list_id, user_id, share_data.can_edit) + if not updated_share: + raise HTTPException(status_code=404, detail="שיתוף לא נמצא") + + # Get user details for response + shares = get_grocery_list_shares(list_id) + share_with_details = next((s for s in shares if s["shared_with_user_id"] == user_id), None) + + if not share_with_details: + raise HTTPException(status_code=404, detail="שיתוף לא נמצא") + + return GroceryListShare( + id=share_with_details["id"], + list_id=list_id, + shared_with_user_id=user_id, + username=share_with_details["username"], + display_name=share_with_details["display_name"], + email=share_with_details["email"], + can_edit=share_data.can_edit, + shared_at=share_with_details["shared_at"].isoformat() if hasattr(share_with_details["shared_at"], 'isoformat') else str(share_with_details["shared_at"]) + ) + + @app.get("/users/search", response_model=List[UserSearch]) def search_users_endpoint( q: str = Query(..., min_length=1, description="Search query for username or display name"), diff --git a/frontend/src/App.css b/frontend/src/App.css index 0588f74..c60b7e9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1909,3 +1909,72 @@ html { opacity: 0.5; } +/* Share Modal Search Results Styles */ +.share-search .search-results { + list-style: none; + padding: 0; + margin: 1rem 0; + max-height: 300px; + overflow-y: auto; +} + +.share-search .search-results li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; + transition: background-color 0.2s; +} + +.share-search .search-results li:hover:not(.disabled) { + background-color: var(--accent-soft); +} + +.share-search .search-results li.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.share-search .search-results li.disabled:hover { + background-color: transparent; +} + +.share-search .search-results .username { + color: var(--text-muted); + font-size: 0.85rem; + margin-left: 0.5rem; +} + +.share-search .search-results .badge { + display: inline-block; + background: var(--accent-soft); + color: var(--accent); + padding: 0.2rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + margin-left: 0.5rem; +} + +/* Share Actions */ +.share-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.shares-list .share-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + border-bottom: 1px solid var(--border-subtle); +} + +.shares-list .share-item .username { + color: var(--text-muted); + font-size: 0.85rem; + margin-left: 0.5rem; +} + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c780abf..ea5bd62 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -30,6 +30,8 @@ function App() { } }); // "recipes" or "grocery-lists" + const [selectedGroceryListId, setSelectedGroceryListId] = useState(null); + const [recipes, setRecipes] = useState([]); const [selectedRecipe, setSelectedRecipe] = useState(null); @@ -395,7 +397,11 @@ function App() { onAddClick={() => setDrawerOpen(true)} user={user} onLogout={handleLogout} - onShowToast={addToast} + onShowToast={addToast} + onNotificationClick={(listId) => { + setCurrentView("grocery-lists"); + setSelectedGroceryListId(listId); + }} /> )} @@ -457,7 +463,12 @@ function App() {
{currentView === "grocery-lists" ? ( - + setSelectedGroceryListId(null)} + /> ) : ( <> {isAuthenticated && ( diff --git a/frontend/src/components/GroceryLists.jsx b/frontend/src/components/GroceryLists.jsx index dd9f0be..bbdfe16 100644 --- a/frontend/src/components/GroceryLists.jsx +++ b/frontend/src/components/GroceryLists.jsx @@ -10,9 +10,10 @@ import { unshareGroceryList, searchUsers, togglePinGroceryList, + updateSharePermission, } from "../groceryApi"; -function GroceryLists({ user, onShowToast }) { +function GroceryLists({ user, onShowToast, selectedListIdFromNotification, onListSelected }) { const [lists, setLists] = useState([]); const [selectedList, setSelectedList] = useState(null); const [loading, setLoading] = useState(true); @@ -21,6 +22,7 @@ function GroceryLists({ user, onShowToast }) { const [shares, setShares] = useState([]); const [userSearch, setUserSearch] = useState(""); const [searchResults, setSearchResults] = useState([]); + const [allUsers, setAllUsers] = useState([]); const [sharePermission, setSharePermission] = useState(false); // New list form @@ -45,9 +47,20 @@ function GroceryLists({ user, onShowToast }) { loadLists(); }, []); + // Handle list selection from notification + useEffect(() => { + if (selectedListIdFromNotification && lists.length > 0) { + const listToSelect = lists.find(list => list.id === selectedListIdFromNotification); + if (listToSelect) { + setSelectedList(listToSelect); + onListSelected?.(); + } + } + }, [selectedListIdFromNotification, lists, onListSelected]); + // Restore selected list from localStorage after lists are loaded useEffect(() => { - if (lists.length > 0) { + if (lists.length > 0 && !selectedListIdFromNotification) { try { const savedListId = localStorage.getItem("selectedGroceryListId"); if (savedListId) { @@ -60,7 +73,7 @@ function GroceryLists({ user, onShowToast }) { console.error("Failed to restore selected list", err); } } - }, [lists]); + }, [lists, selectedListIdFromNotification]); const loadLists = async () => { try { @@ -215,26 +228,46 @@ function GroceryLists({ user, onShowToast }) { setSharePermission(false); try { + // Load shares for this list const sharesData = await getGroceryListShares(list.id); setShares(sharesData); + + // Load all users (use a space to bypass minimum length requirement) + const users = await searchUsers(" "); + + // Sort users: recent shares first (those in sharesData), then others alphabetically + const sharedUserIds = new Set(sharesData.map(s => s.shared_with_user_id)); + const recentUsers = users.filter(u => sharedUserIds.has(u.id)); + const otherUsers = users.filter(u => !sharedUserIds.has(u.id)); + + const sortedUsers = [ + ...recentUsers.sort((a, b) => a.display_name.localeCompare(b.display_name)), + ...otherUsers.sort((a, b) => a.display_name.localeCompare(b.display_name)) + ]; + + setAllUsers(sortedUsers); + setSearchResults(sortedUsers); } catch (error) { - onShowToast(error.message, "error"); + console.error("Failed to load users:", error); + onShowToast("לא הצלחנו לטעון את רשימת המשתמשים", "error"); } }; const handleSearchUsers = async (query) => { setUserSearch(query); - if (query.trim().length < 2) { - setSearchResults([]); + + if (query.trim().length === 0) { + // Show all users when search is empty + setSearchResults(allUsers); return; } - try { - const results = await searchUsers(query); - setSearchResults(results); - } catch (error) { - onShowToast(error.message, "error"); - } + // Filter from all users + const filtered = allUsers.filter(user => + user.display_name.toLowerCase().includes(query.toLowerCase()) || + user.username.toLowerCase().includes(query.toLowerCase()) + ); + setSearchResults(filtered); }; const handleShareWithUser = async (userId, username) => { @@ -264,6 +297,22 @@ function GroceryLists({ user, onShowToast }) { } }; + const handleTogglePermission = async (userId, currentCanEdit) => { + try { + const updatedShare = await updateSharePermission(showShareModal.id, userId, !currentCanEdit); + // Update the shares list with the new permission + setShares(shares.map((s) => + s.shared_with_user_id === userId ? { ...s, can_edit: updatedShare.can_edit } : s + )); + const message = updatedShare.can_edit + ? "הרשאת עריכה ניתנה" + : "הרשאת עריכה הוסרה"; + onShowToast(message, "success"); + } catch (error) { + onShowToast(error.message, "error"); + } + }; + if (loading) { return
טוען רשימות קניות...
; } @@ -542,31 +591,26 @@ function GroceryLists({ user, onShowToast }) { value={userSearch} onChange={(e) => handleSearchUsers(e.target.value)} /> - - {searchResults.length > 0 && ( -
    - {searchResults.map((user) => ( +
      + {searchResults.map((user) => { + const alreadyShared = shares.some(s => s.shared_with_user_id === user.id); + return (
    • handleShareWithUser(user.id, user.username)} + onClick={() => !alreadyShared && handleShareWithUser(user.id, user.username)} + className={alreadyShared ? "disabled" : ""} >
      {user.display_name} @{user.username} + {alreadyShared && משותף}
      - + {!alreadyShared && }
    • - ))} -
    - )} + ); + })} +
@@ -580,14 +624,22 @@ function GroceryLists({ user, onShowToast }) {
{share.display_name} @{share.username} - {share.can_edit && עורך}
- +
+ + +
))} diff --git a/frontend/src/components/NotificationBell.css b/frontend/src/components/NotificationBell.css index c01a1e5..227affb 100644 --- a/frontend/src/components/NotificationBell.css +++ b/frontend/src/components/NotificationBell.css @@ -125,6 +125,14 @@ position: relative; } +.notification-item.clickable { + cursor: pointer; +} + +.notification-item.clickable:hover { + background: var(--hover-bg); +} + .notification-item::before { content: ''; position: absolute; diff --git a/frontend/src/components/NotificationBell.jsx b/frontend/src/components/NotificationBell.jsx index 95a6c6e..2ad6c4f 100644 --- a/frontend/src/components/NotificationBell.jsx +++ b/frontend/src/components/NotificationBell.jsx @@ -7,7 +7,7 @@ import { } from "../notificationApi"; import "./NotificationBell.css"; -function NotificationBell({ onShowToast }) { +function NotificationBell({ onShowToast, onNotificationClick }) { const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [showDropdown, setShowDropdown] = useState(false); @@ -67,6 +67,19 @@ function NotificationBell({ onShowToast }) { } }; + const handleNotificationClick = async (notification) => { + // Mark as read + if (!notification.is_read) { + await handleMarkAsRead(notification.id); + } + + // Handle grocery share notifications + if (notification.type === "grocery_share" && notification.related_id) { + setShowDropdown(false); + onNotificationClick?.(notification.related_id); + } + }; + const handleMarkAllAsRead = async () => { try { await markAllNotificationsAsRead(); @@ -141,7 +154,8 @@ function NotificationBell({ onShowToast }) { key={notification.id} className={`notification-item ${ notification.is_read ? "read" : "unread" - }`} + } ${notification.type === "grocery_share" ? "clickable" : ""}`} + onClick={() => handleNotificationClick(notification)} >

@@ -155,7 +169,10 @@ function NotificationBell({ onShowToast }) { {!notification.is_read && (