diff --git a/backend/__pycache__/grocery_db_utils.cpython-313.pyc b/backend/__pycache__/grocery_db_utils.cpython-313.pyc index 9c1f8d9..7d147f1 100644 Binary files a/backend/__pycache__/grocery_db_utils.cpython-313.pyc and b/backend/__pycache__/grocery_db_utils.cpython-313.pyc differ diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc index c376c59..1aac91c 100644 Binary files a/backend/__pycache__/main.cpython-313.pyc and b/backend/__pycache__/main.cpython-313.pyc differ diff --git a/backend/__pycache__/notification_db_utils.cpython-313.pyc b/backend/__pycache__/notification_db_utils.cpython-313.pyc index c6b32e8..46b9831 100644 Binary files a/backend/__pycache__/notification_db_utils.cpython-313.pyc and b/backend/__pycache__/notification_db_utils.cpython-313.pyc differ diff --git a/backend/add_is_pinned_column.sql b/backend/add_is_pinned_column.sql new file mode 100644 index 0000000..a8761d7 --- /dev/null +++ b/backend/add_is_pinned_column.sql @@ -0,0 +1,5 @@ +-- Add is_pinned column to grocery_lists table +ALTER TABLE grocery_lists ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT FALSE; + +-- Verify the column was added +\d grocery_lists diff --git a/backend/grocery_db_utils.py b/backend/grocery_db_utils.py index cdb2922..ff80334 100644 --- a/backend/grocery_db_utils.py +++ b/backend/grocery_db_utils.py @@ -44,7 +44,7 @@ def get_user_grocery_lists(user_id: int) -> List[Dict[str, Any]]: try: cur.execute( """ - SELECT DISTINCT gl.id, gl.name, gl.items, gl.owner_id, gl.created_at, gl.updated_at, + SELECT DISTINCT gl.id, gl.name, gl.items, gl.owner_id, gl.is_pinned, gl.created_at, gl.updated_at, u.display_name as owner_display_name, CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit, CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner @@ -70,7 +70,7 @@ def get_grocery_list_by_id(list_id: int, user_id: int) -> Optional[Dict[str, Any try: cur.execute( """ - SELECT gl.id, gl.name, gl.items, gl.owner_id, gl.created_at, gl.updated_at, + SELECT gl.id, gl.name, gl.items, gl.owner_id, gl.is_pinned, gl.created_at, gl.updated_at, u.display_name as owner_display_name, CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit, CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner @@ -218,3 +218,36 @@ def search_users(query: str, limit: int = 10) -> List[Dict[str, Any]]: finally: cur.close() conn.close() + + +def toggle_grocery_list_pin(list_id: int, user_id: int) -> Optional[Dict[str, Any]]: + """Toggle pin status for a grocery list (owner only)""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + # Check if user is owner + cur.execute( + "SELECT id, is_pinned FROM grocery_lists WHERE id = %s AND owner_id = %s", + (list_id, user_id) + ) + result = cur.fetchone() + if not result: + return None + + # Toggle pin status + new_pin_status = not result["is_pinned"] + cur.execute( + """ + UPDATE grocery_lists + SET is_pinned = %s, updated_at = CURRENT_TIMESTAMP + WHERE id = %s + RETURNING id, name, items, owner_id, is_pinned, created_at, updated_at + """, + (new_pin_status, list_id) + ) + updated = cur.fetchone() + conn.commit() + return dict(updated) if updated else None + finally: + cur.close() + conn.close() diff --git a/backend/main.py b/backend/main.py index 3ac6c3d..99fee42 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,7 +2,7 @@ import random from typing import List, Optional from datetime import timedelta -from fastapi import FastAPI, HTTPException, Query, Depends +from fastapi import FastAPI, HTTPException, Query, Depends, Response from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, EmailStr, field_validator import os @@ -42,6 +42,7 @@ from grocery_db_utils import ( unshare_grocery_list, get_grocery_list_shares, search_users, + toggle_grocery_list_pin, ) from notification_db_utils import ( @@ -133,6 +134,7 @@ class GroceryList(BaseModel): name: str items: List[str] owner_id: int + is_pinned: bool = False owner_display_name: Optional[str] = None can_edit: bool = False is_owner: bool = False @@ -190,8 +192,9 @@ app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, allow_credentials=True, - allow_methods=["*"], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allow_headers=["*"], + max_age=0, # Disable CORS preflight caching ) @@ -548,6 +551,26 @@ def delete_grocery_list_endpoint(list_id: int, current_user: dict = Depends(get_ return +@app.options("/grocery-lists/{list_id}/pin") +async def options_pin_grocery_list(list_id: int): + """Handle CORS preflight for pin endpoint""" + return Response(status_code=200) + + +@app.patch("/grocery-lists/{list_id}/pin", response_model=GroceryList) +def toggle_pin_grocery_list_endpoint(list_id: int, current_user: dict = Depends(get_current_user)): + """Toggle pin status for a grocery list (owner only)""" + updated = toggle_grocery_list_pin(list_id, current_user["user_id"]) + if not updated: + raise HTTPException(status_code=404, detail="רשימת קניות לא נמצאה או שאין לך הרשאה") + + # Get full details with permissions + result = get_grocery_list_by_id(list_id, current_user["user_id"]) + result["created_at"] = str(result["created_at"]) + result["updated_at"] = str(result["updated_at"]) + return result + + @app.post("/grocery-lists/{list_id}/share", response_model=GroceryListShare) def share_grocery_list_endpoint( list_id: int, diff --git a/backend/schema.sql b/backend/schema.sql index 70a2aa8..5b61e17 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS grocery_lists ( name TEXT NOT NULL, items TEXT[] NOT NULL DEFAULT '{}', -- Array of grocery items owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + is_pinned BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); diff --git a/frontend/src/App.css b/frontend/src/App.css index d686785..6040486 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -104,6 +104,24 @@ body { .layout { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } + + .layout:has(.pinned-lists-sidebar) { + grid-template-columns: 320px minmax(0, 1fr) minmax(0, 1fr); + } +} + +.pinned-lists-sidebar { + display: none; +} + +@media (min-width: 960px) { + .pinned-lists-sidebar { + display: block; + position: sticky; + top: 1rem; + max-height: calc(100vh - 2rem); + overflow-y: auto; + } } .sidebar, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1ed96cf..5d890ee 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ 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"; @@ -367,6 +368,11 @@ function App() { ) : ( <> + {isAuthenticated && ( + + )} { + 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(""); @@ -411,19 +428,28 @@ function GroceryLists({ user, onShowToast }) { {selectedList.is_owner && ( - handleShowShareModal(selectedList)} - title="שתף רשימה" - > - - - - - - - - + <> + handleTogglePin(selectedList)} + title={selectedList.is_pinned ? "הסר הצמדה" : "הצמד לדף הבית"} + > + 📌 + + handleShowShareModal(selectedList)} + title="שתף רשימה" + > + + + + + + + + + > )} {selectedList.can_edit && ( { + loadPinnedLists(); + }, []); + + const loadPinnedLists = async () => { + try { + const allLists = await getGroceryLists(); + const pinned = allLists.filter((list) => list.is_pinned); + setPinnedLists(pinned); + } catch (error) { + console.error("Failed to load pinned lists", error); + } + }; + + const handleToggleItem = async (listId, itemIndex) => { + const list = pinnedLists.find((l) => l.id === listId); + if (!list || !list.can_edit) return; + + const updatedItems = [...list.items]; + const item = updatedItems[itemIndex]; + + if (item.startsWith("✓ ")) { + updatedItems[itemIndex] = item.substring(2); + } else { + updatedItems[itemIndex] = "✓ " + item; + } + + try { + await updateGroceryList(listId, { items: updatedItems }); + setPinnedLists( + pinnedLists.map((l) => + l.id === listId ? { ...l, items: updatedItems } : l + ) + ); + } catch (error) { + onShowToast?.(error.message, "error"); + } + }; + + if (pinnedLists.length === 0) { + return null; + } + + return ( + + {pinnedLists.map((list) => ( + + 📌 + {list.name} + + {list.items.length === 0 ? ( + רשימה ריקה + ) : ( + list.items.map((item, index) => { + const isChecked = item.startsWith("✓ "); + const itemText = isChecked ? item.substring(2) : item; + return ( + + list.can_edit && handleToggleItem(list.id, index) + } + style={{ cursor: list.can_edit ? "pointer" : "default" }} + > + {isChecked ? "☑" : "☐"} + {itemText} + + ); + }) + )} + + + ))} + + + + ); +} + +export default PinnedGroceryLists; diff --git a/frontend/src/groceryApi.js b/frontend/src/groceryApi.js index de222c9..486ef27 100644 --- a/frontend/src/groceryApi.js +++ b/frontend/src/groceryApi.js @@ -67,6 +67,28 @@ export const deleteGroceryList = async (id) => { } }; +// Toggle pin status for a grocery list +export const togglePinGroceryList = async (id) => { + const res = await fetch(`${API_URL}/grocery-lists/${id}/pin`, { + method: "PATCH", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + }); + if (!res.ok) { + let errorMessage = "Failed to toggle pin status"; + try { + const error = await res.json(); + errorMessage = error.detail || errorMessage; + } catch (e) { + errorMessage = `HTTP ${res.status}: ${res.statusText}`; + } + throw new Error(errorMessage); + } + return res.json(); +}; + // Share a grocery list export const shareGroceryList = async (listId, data) => { const res = await fetch(`${API_URL}/grocery-lists/${listId}/share`, {