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 && ( - + <> + + + )} {selectedList.can_edit && (