From e1515442f488ad2c964d77f10706697dbcbc486a Mon Sep 17 00:00:00 2001 From: dvirlabs Date: Thu, 11 Dec 2025 05:21:06 +0200 Subject: [PATCH] Add groceries list and notifications --- .../__pycache__/auth_utils.cpython-313.pyc | Bin 3971 -> 4263 bytes .../grocery_db_utils.cpython-313.pyc | Bin 0 -> 10981 bytes backend/__pycache__/main.cpython-313.pyc | Bin 15337 -> 28034 bytes .../notification_db_utils.cpython-313.pyc | Bin 0 -> 4076 bytes backend/grocery_db_utils.py | 220 ++++ backend/main.py | 345 +++++++ backend/notification_db_utils.py | 124 +++ backend/schema.sql | 38 + frontend/src/App.css | 158 +++ frontend/src/App.jsx | 46 +- frontend/src/components/GroceryLists.jsx | 944 ++++++++++++++++++ frontend/src/components/NotificationBell.jsx | 392 ++++++++ frontend/src/components/TopBar.jsx | 5 +- frontend/src/groceryApi.js | 112 +++ frontend/src/notificationApi.js | 66 ++ 15 files changed, 2446 insertions(+), 4 deletions(-) create mode 100644 backend/__pycache__/grocery_db_utils.cpython-313.pyc create mode 100644 backend/__pycache__/notification_db_utils.cpython-313.pyc create mode 100644 backend/grocery_db_utils.py create mode 100644 backend/notification_db_utils.py create mode 100644 frontend/src/components/GroceryLists.jsx create mode 100644 frontend/src/components/NotificationBell.jsx create mode 100644 frontend/src/groceryApi.js create mode 100644 frontend/src/notificationApi.js diff --git a/backend/__pycache__/auth_utils.cpython-313.pyc b/backend/__pycache__/auth_utils.cpython-313.pyc index 4cf1f1e79f3532c3b57885e5070dda53062e677d..0e4d4a0b8972ac02e3909dd97ea4dc1c9a84bd03 100644 GIT binary patch delta 615 zcmZ8e&ubGw6rS14Y_dPonzSylw6O`)XiyJHt@RgywiYcpbXlYgxFl{23+YyNlS4ff zJm}Ffhyeu;9u&Oje`4stuoUS<@R$(1_f3*sd@$el-uvG7W?)_|e7UR-HBCkk+O=Em zy7ohVBbTC3M&Jw!7@>E_#HR2VJ;EMNBMWU4Q2q-~sBq6HETM?msjx5=DT14aM=bKp zKb?scFS!B zehIp)t7Ml%{{f%kwO`tLLl17Idt@eOY-DI|B%ypi2 zjXc!vH;*;$1*?09d-eN1-GA%=EhXvYDn>%USe*lCetl;5Ia6IrN{}Sihc4@9u+B;B72Zp6A)nwBAkIlF$<8G{DDV? z-wnvH28qN1i3Wzra=eaGUpN>fq-U5dSxGNcorI YF@&Et; diff --git a/backend/__pycache__/grocery_db_utils.cpython-313.pyc b/backend/__pycache__/grocery_db_utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c1f8d99bfd3f632adaaaebc0d0b54505d5ed02d GIT binary patch literal 10981 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

RP0X;fvKHbwlyWP~pqNhm0=f46haOPr5+ znr6wrckcPR=bU@aeedy4`R$9ob+5OA!SAn^$I^EVzTorQY8)^0zO&+I9ZaogW@@G8 z8?~bbV)&RG5o8MmI)&aMEgS&#;Da397 zcH=hXKH~a;+q8|_ua!Z-&1#GKt*A{53^KL#9xg)Kz{%4T`wq}NT1efiwheOn2cpgZ z1I_s@CHY2gd0V??)4H9&R6C+JwX>OxZYYQ_XeZ_Cy|vMHty>MMT~WJM(ZRI6quf#% z;cBC(yV4X(m2?ead_kY;-X`^7@L-sDUf0z9j*k{rHfT9kl zq5ooU?Y(c8Z3Oc~+u1>DZ8>l=m=DqeP7WWwW@r9&xFo zyF8C?)}(Vx>$hs69?*J6Y-$8Gi`h?X>a|C;KC0{iO#!X(9a@u{aV|`Nw^B92{A}vv zt|$&_G4G8b5 zgYs9NpR{#=m2e<*BH$Q^AVL>Hw|vOk;^_f$9sbOWC*@6VyrLCl2toOGZ^9L(rM$;? zSRF(X^^dPQMK3NCgg%6Rgrb5Vt0CkKZvqO}vG!M)>2O;b!!p~}B>&}`T-iLld2ongD)g)2_2&Hz8pa5Z5{&`QxN+be_OdOhBP9<{s?ISvFi6ZACh&o$M|>T>Dr%pzYQve4ImH@L(*4wgwIH|Zf1q7 zC^cWn)F=Z=&4ZiFAW(jHAms%xE0dXoVJzyo^VyWR7ulsUoxnkB79a}Z`d2R%CCWJclpmC4`CMm237%l*3{o})PGV{d-a6n^b9pn zS>rP+6v8;#P(nw5I&PJcs(1|6Gp0Lj%qLQd=_T4s@s><=tJCOUb{&Tfj!eh7Y&Nsw z2x?$s>UT(y34~L!-9KY>U9#|D;5RltE+5w0{TsuFYShWt#H*Epgsr87ie30nJ0cyy zEg1dX(uT`+1iBD9k=k|*;1bIt zmS61gPt(*q087OoE+O|J0Mh{jlQ8Wfd(pto48V`lHN8op!z^vYBJjO;UEbI89uF6~ zdav1QFQT<8rRNgqj1`hDf7c%$4gg>Lf6>XvKz|4$Go8sy7Ml1n`uILVH3CMixW!{g zTanA_1C8NFaQ!HN;Re9S@wX9uo^FE8=${9LpBFwJc$M>ug$MTC;_Z*4@fCz85S~P! z!eSfDh^LtRpub&S4Y!RyjiP4|o<%4_z+lA>5uiPo;<94Hf{Gs@Ttj$HeirsO4*_X< zQ+h_DuE-BZPlhmb zr9xC8r+CJu)5wYRG8GYr!^odA^qdIF6JssS0aSenftp1iWgz6_6Jwnl5sSo9D!aH@ zz}Gz=~3NV(mu`lGHOQ<~&E6EE|$ymvCfpOnu}zR49CKlrzm zouj*Ja#vkeuT10iM-XT#i<7NXna*XAUdma+-;5e`0xg>crZ*2AGoLy)pUUwz3!u=OOY(m)boWv!bp;9Jp$d6*3{HFY8EZ#(CDrnTecaxDXoH(?^ zJzsgtJgzlX=5&d0;p6zzd>^&VFVGP;%i#9O=U{4!2`e_!4uz-C6`OcOUOU`6br!Tm z0!=Cer9-JxPW31{ir_@>BQzk;b%i%3jP4EjkHej9XJN5xV4WGf`o%9na$JU{e++Mi zKTbDqoz~m(%jtq!_0nPa3z!$Muor-MjD6!`&d^^f<(o(6cGOrNNKM+~;uZN| z>Q;odq;sUI(fQLC%`o_|8b5~A6v8P4ESN~j+WY%f@T}=JH66=Yn2gJ~#w*RVFXwZD zUJQ6g;Dv^RDkLgU*|GDf6R5HjI=x)r&dghHo2@Sk(>=MEP31FsOuPqXkRdbv0N)L= zd|}G=wNL5`X9|tyC2x#y*Y(MD29I?+v)#$pY(TC(DBn%r(ct5*SL(iI@IcxAH5*WC N9rDa#z!3(A{|4L1m4E;M diff --git a/backend/__pycache__/notification_db_utils.cpython-313.pyc b/backend/__pycache__/notification_db_utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c6b32e8efa8d60a2861615bba44f5c8ec37c72bd GIT binary patch literal 4076 zcmds4O>7fK6rTO@?s^^PhlD^Ffp? zAF3ds7Q~@&sFXtw9Ge?QP8_Qq6RA?I?IlvHO1(Ma=iK*Z*Nz>GLrYIxY3J?ido!Nd z_kG_RKM99b0@qLPpR&d~2>Bg5wJYd9JSoG&_e3X@=$y_^ag#jdHKKRvVp`AxX;GKp zmeazBqAP>ss(6*70lk}fgK23*)K&kjtcPF(jDmL^_>L;9$LnF%3pV&W*&8)4M0(2a zs&T_HE*Z9|Ejm`gaxBx3zr_@TqSQhnQW<8 zoOR_z+oTyQ=ZenKg6YchrfnN}(^aWiFdQ=nuB_xqA0QZw9hr9m*TJJjP+oG5f zlNhn>s`~?}Kr*n+30jzTw+dQ47?X!cUX6sxhsUT1-&8ZSqIsh!H!N$0 zhPjB9AFG;wP0Z?vWgNz*Zn8%XXB@7*74yT;9r2gbc;)QwbZ)qbvKP zTy<;Is!TD5t>P(CkYa{rBN9bKxl(qK+9m4B=2vER(J@`+vgu^6l!_*}O7ruU;|8*Y zl5IxhRx`RHV%`;C7FVF98#XS>|J3fMW2e$7`2Kb}cip0e^!cJ0r`B~d9XIW3PH7>X zw;a<*&o3RLX4YCT?erxhd(AB7(v2j`dPdTOkm14#a}1 zlMLXJ5_SGqm=F{ZUGSfTR&sIiqYq5EoC31_W=`Oz1@V%GNTj2D;H>Fr zMxn5c{wVS_x%j5eJUua;7@PY~NIafAKhyl1^$vaTGt9oP_qYvJK_ad<+*D|l|NECXxG!CGAj z1YcH6DxofhmTdi~2h-!C+Jl2jac$Kf!P62PDbl3u+z#N_LAhZSZ4ZeI9PfDGXd|;* zP+6#_{Pm#H2#;3ES^t?idJsN#MA(iC{QPxT>}Rk5No<0KUB?E>RvK=58k$$ZM$c{a zA3)>(s~i1svtW8>_J;pbv2wDWpEy)qP>dZb=FZx{5={?bnvh?RT%Yv4Ikt$V+)_L@ z6)|>4x(jfSREWyx9+Kb~*-3V$dxV>faO07XD`zsfQZ|!umD<_XJB$lf(QyUaq3lTO zikC{I0>u)^goq;I*cw*}lMhe6l+pQ8Zn0pVrpLjLieg`frYdk8x5@LI_)H<({!Qr( zuKPi6m4NwijG0G=nECm{Gkm!@dVm{v03I+8bI;IJ&v9K4ZC44HNBUDV)ggQ}=0A7B OGt3F*q5TXETi4%Q6G+qm literal 0 HcmV?d00001 diff --git a/backend/grocery_db_utils.py b/backend/grocery_db_utils.py new file mode 100644 index 0000000..cdb2922 --- /dev/null +++ b/backend/grocery_db_utils.py @@ -0,0 +1,220 @@ +import os +from typing import List, Optional, Dict, Any +import psycopg2 +from psycopg2.extras import RealDictCursor + + +def get_db_connection(): + """Get database connection""" + return psycopg2.connect( + host=os.getenv("DB_HOST", "localhost"), + port=int(os.getenv("DB_PORT", "5432")), + database=os.getenv("DB_NAME", "recipes_db"), + user=os.getenv("DB_USER", "recipes_user"), + password=os.getenv("DB_PASSWORD", "recipes_password"), + ) + + +def create_grocery_list(owner_id: int, name: str, items: List[str] = None) -> Dict[str, Any]: + """Create a new grocery list""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + items = items or [] + cur.execute( + """ + INSERT INTO grocery_lists (owner_id, name, items) + VALUES (%s, %s, %s) + RETURNING id, name, items, owner_id, created_at, updated_at + """, + (owner_id, name, items) + ) + grocery_list = cur.fetchone() + conn.commit() + return dict(grocery_list) + finally: + cur.close() + conn.close() + + +def get_user_grocery_lists(user_id: int) -> List[Dict[str, Any]]: + """Get all grocery lists owned by or shared with a user""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + SELECT DISTINCT gl.id, gl.name, gl.items, gl.owner_id, 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 + FROM grocery_lists gl + LEFT JOIN users u ON gl.owner_id = u.id + LEFT JOIN grocery_list_shares gls ON gl.id = gls.list_id AND gls.shared_with_user_id = %s + WHERE gl.owner_id = %s OR gls.shared_with_user_id = %s + ORDER BY gl.updated_at DESC + """, + (user_id, user_id, user_id, user_id, user_id) + ) + lists = cur.fetchall() + return [dict(row) for row in lists] + finally: + cur.close() + conn.close() + + +def get_grocery_list_by_id(list_id: int, user_id: int) -> Optional[Dict[str, Any]]: + """Get a specific grocery list if user has access""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + SELECT gl.id, gl.name, gl.items, gl.owner_id, 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 + FROM grocery_lists gl + LEFT JOIN users u ON gl.owner_id = u.id + LEFT JOIN grocery_list_shares gls ON gl.id = gls.list_id AND gls.shared_with_user_id = %s + WHERE gl.id = %s AND (gl.owner_id = %s OR gls.shared_with_user_id = %s) + """, + (user_id, user_id, user_id, list_id, user_id, user_id) + ) + grocery_list = cur.fetchone() + return dict(grocery_list) if grocery_list else None + finally: + cur.close() + conn.close() + + +def update_grocery_list(list_id: int, name: str = None, items: List[str] = None) -> Optional[Dict[str, Any]]: + """Update a grocery list""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + updates = [] + params = [] + + if name is not None: + updates.append("name = %s") + params.append(name) + + if items is not None: + updates.append("items = %s") + params.append(items) + + if not updates: + return None + + updates.append("updated_at = CURRENT_TIMESTAMP") + params.append(list_id) + + query = f"UPDATE grocery_lists SET {', '.join(updates)} WHERE id = %s RETURNING id, name, items, owner_id, created_at, updated_at" + + cur.execute(query, params) + grocery_list = cur.fetchone() + conn.commit() + return dict(grocery_list) if grocery_list else None + finally: + cur.close() + conn.close() + + +def delete_grocery_list(list_id: int) -> bool: + """Delete a grocery list""" + conn = get_db_connection() + cur = conn.cursor() + try: + cur.execute("DELETE FROM grocery_lists WHERE id = %s", (list_id,)) + deleted = cur.rowcount > 0 + conn.commit() + return deleted + finally: + cur.close() + conn.close() + + +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""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + INSERT INTO grocery_list_shares (list_id, shared_with_user_id, can_edit) + VALUES (%s, %s, %s) + ON CONFLICT (list_id, shared_with_user_id) + DO UPDATE SET can_edit = EXCLUDED.can_edit, shared_at = CURRENT_TIMESTAMP + RETURNING id, list_id, shared_with_user_id, can_edit, shared_at + """, + (list_id, shared_with_user_id, can_edit) + ) + share = cur.fetchone() + conn.commit() + return dict(share) + 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() + cur = conn.cursor() + try: + cur.execute( + "DELETE FROM grocery_list_shares WHERE list_id = %s AND shared_with_user_id = %s", + (list_id, user_id) + ) + deleted = cur.rowcount > 0 + conn.commit() + return deleted + finally: + cur.close() + conn.close() + + +def get_grocery_list_shares(list_id: int) -> List[Dict[str, Any]]: + """Get all users a grocery list is shared with""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + SELECT gls.id, gls.list_id, gls.shared_with_user_id, gls.can_edit, gls.shared_at, + u.username, u.display_name, u.email + FROM grocery_list_shares gls + JOIN users u ON gls.shared_with_user_id = u.id + WHERE gls.list_id = %s + ORDER BY gls.shared_at DESC + """, + (list_id,) + ) + shares = cur.fetchall() + return [dict(row) for row in shares] + finally: + cur.close() + conn.close() + + +def search_users(query: str, limit: int = 10) -> List[Dict[str, Any]]: + """Search users by username or display_name for autocomplete""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + SELECT id, username, display_name, email + FROM users + WHERE username ILIKE %s OR display_name ILIKE %s + ORDER BY username + LIMIT %s + """, + (f"%{query}%", f"%{query}%", limit) + ) + users = cur.fetchall() + return [dict(row) for row in users] + finally: + cur.close() + conn.close() diff --git a/backend/main.py b/backend/main.py index e5794db..3ac6c3d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -32,6 +32,26 @@ from user_db_utils import ( get_user_by_email, ) +from grocery_db_utils import ( + create_grocery_list, + get_user_grocery_lists, + get_grocery_list_by_id, + update_grocery_list, + delete_grocery_list, + share_grocery_list, + unshare_grocery_list, + get_grocery_list_shares, + search_users, +) + +from notification_db_utils import ( + create_notification, + get_user_notifications, + mark_notification_as_read, + mark_all_notifications_as_read, + delete_notification, +) + class RecipeBase(BaseModel): name: str @@ -97,6 +117,62 @@ class UserResponse(BaseModel): is_admin: bool = False +# Grocery List models +class GroceryListCreate(BaseModel): + name: str + items: List[str] = [] + + +class GroceryListUpdate(BaseModel): + name: Optional[str] = None + items: Optional[List[str]] = None + + +class GroceryList(BaseModel): + id: int + name: str + items: List[str] + owner_id: int + owner_display_name: Optional[str] = None + can_edit: bool = False + is_owner: bool = False + created_at: str + updated_at: str + + +class ShareGroceryList(BaseModel): + user_identifier: str # Can be username or display_name + can_edit: bool = False + + +class GroceryListShare(BaseModel): + id: int + list_id: int + shared_with_user_id: int + username: str + display_name: str + email: str + can_edit: bool + shared_at: str + + +class UserSearch(BaseModel): + id: int + username: str + display_name: str + email: str + + +class Notification(BaseModel): + id: int + user_id: int + type: str + message: str + related_id: Optional[int] = None + is_read: bool + created_at: str + + app = FastAPI( title="Random Recipes API", description="API פשוט לבחירת מתכונים לצהריים / ערב / כל ארוחה 😋", @@ -379,5 +455,274 @@ def get_me(current_user: dict = Depends(get_current_user)): ) +# ============= Grocery Lists Endpoints ============= + +@app.get("/grocery-lists", response_model=List[GroceryList]) +def list_grocery_lists(current_user: dict = Depends(get_current_user)): + """Get all grocery lists owned by or shared with the current user""" + lists = get_user_grocery_lists(current_user["user_id"]) + # Convert datetime objects to strings + for lst in lists: + lst["created_at"] = str(lst["created_at"]) + lst["updated_at"] = str(lst["updated_at"]) + return lists + + +@app.post("/grocery-lists", response_model=GroceryList, status_code=201) +def create_new_grocery_list( + grocery_list: GroceryListCreate, + current_user: dict = Depends(get_current_user) +): + """Create a new grocery list""" + new_list = create_grocery_list( + owner_id=current_user["user_id"], + name=grocery_list.name, + items=grocery_list.items + ) + new_list["owner_display_name"] = current_user.get("display_name") + new_list["can_edit"] = True + new_list["is_owner"] = True + new_list["created_at"] = str(new_list["created_at"]) + new_list["updated_at"] = str(new_list["updated_at"]) + return new_list + + +@app.get("/grocery-lists/{list_id}", response_model=GroceryList) +def get_grocery_list(list_id: int, current_user: dict = Depends(get_current_user)): + """Get a specific grocery list""" + grocery_list = get_grocery_list_by_id(list_id, current_user["user_id"]) + if not grocery_list: + raise HTTPException(status_code=404, detail="רשימת קניות לא נמצאה או שאין לך גישה אליها") + + grocery_list["created_at"] = str(grocery_list["created_at"]) + grocery_list["updated_at"] = str(grocery_list["updated_at"]) + return grocery_list + + +@app.put("/grocery-lists/{list_id}", response_model=GroceryList) +def update_grocery_list_endpoint( + list_id: int, + grocery_list_update: GroceryListUpdate, + current_user: dict = Depends(get_current_user) +): + """Update a grocery list""" + # Check if user has access + existing = get_grocery_list_by_id(list_id, current_user["user_id"]) + if not existing: + raise HTTPException(status_code=404, detail="רשימת קניות לא נמצאה") + + # Check if user has edit permission + if not existing["can_edit"]: + raise HTTPException(status_code=403, detail="אין לך הרשאה לערוך רשימת קניות זו") + + updated = update_grocery_list( + list_id=list_id, + name=grocery_list_update.name, + items=grocery_list_update.items + ) + + 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.delete("/grocery-lists/{list_id}", status_code=204) +def delete_grocery_list_endpoint(list_id: int, current_user: dict = Depends(get_current_user)): + """Delete a grocery list (owner only)""" + # Check if user is 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="רק הבעלים יכול למחוק רשימת קניות") + + deleted = delete_grocery_list(list_id) + if not deleted: + raise HTTPException(status_code=404, detail="רשימת קניות לא נמצאה") + return + + +@app.post("/grocery-lists/{list_id}/share", response_model=GroceryListShare) +def share_grocery_list_endpoint( + list_id: int, + share_data: ShareGroceryList, + current_user: dict = Depends(get_current_user) +): + """Share a grocery list with another user""" + # Check if user is 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="רק הבעלים יכול לשתף רשימת קניות") + + # Find user by username or display_name + target_user = get_user_by_username(share_data.user_identifier) + if not target_user: + from user_db_utils import get_user_by_display_name + target_user = get_user_by_display_name(share_data.user_identifier) + + if not target_user: + raise HTTPException(status_code=404, detail="משתמש לא נמצא") + + # Don't allow sharing with yourself + if target_user["id"] == current_user["user_id"]: + raise HTTPException(status_code=400, detail="לא ניתן לשתף רשימה עם עצמך") + + # Share the list + share = share_grocery_list(list_id, target_user["id"], share_data.can_edit) + + # Create notification for the user who received the share + notification_message = f"רשימת קניות '{existing['name']}' שותפה איתך על ידי {current_user['display_name']}" + create_notification( + user_id=target_user["id"], + type="grocery_share", + message=notification_message, + related_id=list_id + ) + + # Return with user details + return GroceryListShare( + id=share["id"], + list_id=share["list_id"], + shared_with_user_id=share["shared_with_user_id"], + username=target_user["username"], + display_name=target_user["display_name"], + email=target_user["email"], + can_edit=share["can_edit"], + shared_at=str(share["shared_at"]) + ) + + +@app.get("/grocery-lists/{list_id}/shares", response_model=List[GroceryListShare]) +def get_grocery_list_shares_endpoint( + list_id: int, + current_user: dict = Depends(get_current_user) +): + """Get all users a grocery list is shared with""" + # Check if user is 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="רק הבעלים יכול לראות את רשימת השיתופים") + + shares = get_grocery_list_shares(list_id) + return [ + GroceryListShare( + id=share["id"], + list_id=share["list_id"], + shared_with_user_id=share["shared_with_user_id"], + username=share["username"], + display_name=share["display_name"], + email=share["email"], + can_edit=share["can_edit"], + shared_at=str(share["shared_at"]) + ) + for share in shares + ] + + +@app.delete("/grocery-lists/{list_id}/shares/{user_id}", status_code=204) +def unshare_grocery_list_endpoint( + list_id: int, + user_id: int, + current_user: dict = Depends(get_current_user) +): + """Remove sharing access for a user""" + # Check if user is 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="רק הבעלים יכול להסיר שיתופים") + + deleted = unshare_grocery_list(list_id, user_id) + if not deleted: + raise HTTPException(status_code=404, detail="שיתוף לא נמצא") + return + + +@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"), + current_user: dict = Depends(get_current_user) +): + """Search users by username or display name for autocomplete""" + users = search_users(q) + return [ + UserSearch( + id=user["id"], + username=user["username"], + display_name=user["display_name"], + email=user["email"] + ) + for user in users + ] + + +# =========================== +# Notification Endpoints +# =========================== + +@app.get("/notifications", response_model=List[Notification]) +def get_notifications_endpoint( + unread_only: bool = Query(False, description="Get only unread notifications"), + current_user: dict = Depends(get_current_user) +): + """Get all notifications for the current user""" + notifications = get_user_notifications(current_user["user_id"], unread_only) + return [ + Notification( + id=notif["id"], + user_id=notif["user_id"], + type=notif["type"], + message=notif["message"], + related_id=notif["related_id"], + is_read=notif["is_read"], + created_at=str(notif["created_at"]) + ) + for notif in notifications + ] + + +@app.patch("/notifications/{notification_id}/read") +def mark_notification_read_endpoint( + notification_id: int, + current_user: dict = Depends(get_current_user) +): + """Mark a notification as read""" + mark_notification_as_read(notification_id, current_user["user_id"]) + return {"message": "Notification marked as read"} + + +@app.patch("/notifications/read-all") +def mark_all_notifications_read_endpoint( + current_user: dict = Depends(get_current_user) +): + """Mark all notifications as read""" + mark_all_notifications_as_read(current_user["user_id"]) + return {"message": "All notifications marked as read"} + + +@app.delete("/notifications/{notification_id}") +def delete_notification_endpoint( + notification_id: int, + current_user: dict = Depends(get_current_user) +): + """Delete a notification""" + delete_notification(notification_id, current_user["user_id"]) + return {"message": "Notification deleted"} + + if __name__ == "__main__": uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/backend/notification_db_utils.py b/backend/notification_db_utils.py new file mode 100644 index 0000000..0c5557d --- /dev/null +++ b/backend/notification_db_utils.py @@ -0,0 +1,124 @@ +""" +Database utilities for managing notifications. +""" + +from db_utils import get_conn + + +def create_notification(user_id: int, type: str, message: str, related_id: int = None): + """Create a new notification for a user.""" + conn = get_conn() + cur = conn.cursor() + cur.execute( + """ + INSERT INTO notifications (user_id, type, message, related_id) + VALUES (%s, %s, %s, %s) + RETURNING id, user_id, type, message, related_id, is_read, created_at + """, + (user_id, type, message, related_id) + ) + row = cur.fetchone() + conn.commit() + cur.close() + conn.close() + + if row: + return { + "id": row["id"], + "user_id": row["user_id"], + "type": row["type"], + "message": row["message"], + "related_id": row["related_id"], + "is_read": row["is_read"], + "created_at": row["created_at"] + } + return None + + +def get_user_notifications(user_id: int, unread_only: bool = False): + """Get all notifications for a user.""" + conn = get_conn() + cur = conn.cursor() + + query = """ + SELECT id, user_id, type, message, related_id, is_read, created_at + FROM notifications + WHERE user_id = %s + """ + + if unread_only: + query += " AND is_read = FALSE" + + query += " ORDER BY created_at DESC" + + cur.execute(query, (user_id,)) + rows = cur.fetchall() + cur.close() + conn.close() + + notifications = [] + for row in rows: + notifications.append({ + "id": row["id"], + "user_id": row["user_id"], + "type": row["type"], + "message": row["message"], + "related_id": row["related_id"], + "is_read": row["is_read"], + "created_at": row["created_at"] + }) + + return notifications + + +def mark_notification_as_read(notification_id: int, user_id: int): + """Mark a notification as read.""" + conn = get_conn() + cur = conn.cursor() + cur.execute( + """ + UPDATE notifications + SET is_read = TRUE + WHERE id = %s AND user_id = %s + """, + (notification_id, user_id) + ) + conn.commit() + cur.close() + conn.close() + return True + + +def mark_all_notifications_as_read(user_id: int): + """Mark all notifications for a user as read.""" + conn = get_conn() + cur = conn.cursor() + cur.execute( + """ + UPDATE notifications + SET is_read = TRUE + WHERE user_id = %s AND is_read = FALSE + """, + (user_id,) + ) + conn.commit() + cur.close() + conn.close() + return True + + +def delete_notification(notification_id: int, user_id: int): + """Delete a notification.""" + conn = get_conn() + cur = conn.cursor() + cur.execute( + """ + DELETE FROM notifications + WHERE id = %s AND user_id = %s + """, + (notification_id, user_id) + ) + conn.commit() + cur.close() + conn.close() + return True diff --git a/backend/schema.sql b/backend/schema.sql index 94b95c9..70a2aa8 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -42,6 +42,44 @@ CREATE INDEX IF NOT EXISTS idx_recipes_made_by CREATE INDEX IF NOT EXISTS idx_recipes_user_id ON recipes (user_id); +-- Create grocery lists table +CREATE TABLE IF NOT EXISTS grocery_lists ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + items TEXT[] NOT NULL DEFAULT '{}', -- Array of grocery items + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create grocery list shares table +CREATE TABLE IF NOT EXISTS grocery_list_shares ( + id SERIAL PRIMARY KEY, + list_id INTEGER NOT NULL REFERENCES grocery_lists(id) ON DELETE CASCADE, + shared_with_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + can_edit BOOLEAN DEFAULT FALSE, + shared_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(list_id, shared_with_user_id) +); + +CREATE INDEX IF NOT EXISTS idx_grocery_lists_owner_id ON grocery_lists (owner_id); +CREATE INDEX IF NOT EXISTS idx_grocery_list_shares_list_id ON grocery_list_shares (list_id); +CREATE INDEX IF NOT EXISTS idx_grocery_list_shares_user_id ON grocery_list_shares (shared_with_user_id); + +-- Create notifications table +CREATE TABLE IF NOT EXISTS notifications ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, -- 'grocery_share', etc. + message TEXT NOT NULL, + related_id INTEGER, -- Related entity ID (e.g., list_id) + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications (user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications (is_read); + -- Create default admin user (password: admin123) -- Password hash generated with bcrypt for 'admin123' INSERT INTO users (username, email, password_hash, first_name, last_name, display_name, is_admin) diff --git a/frontend/src/App.css b/frontend/src/App.css index 9e29290..d686785 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1365,3 +1365,161 @@ html { border: 1px solid rgba(107, 114, 128, 0.2); box-shadow: 0 20px 50px rgba(0, 0, 0, 0.08); } + +/* Main Navigation Tabs */ +.main-navigation { + display: flex; + gap: 0.5rem; + padding: 1rem 0; + border-bottom: 2px solid var(--border-subtle); + margin-bottom: 1.5rem; +} + +.nav-tab { + padding: 0.75rem 1.5rem; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + border-radius: 8px 8px 0 0; + transition: all 0.2s; +} + +.nav-tab:hover { + background: var(--card-soft); + color: var(--text-main); +} + +.nav-tab.active { + background: var(--accent-soft); + color: var(--accent); + border-bottom: 2px solid var(--accent); +} + +/* Grocery Lists specific styles */ +.grocery-lists-container { + --panel-bg: var(--card); + --hover-bg: var(--card-soft); + --primary-color: var(--accent); + --border-color: var(--border-subtle); +} + +[data-theme="light"] .grocery-lists-container { + --panel-bg: #f9fafb; + --hover-bg: #f3f4f6; + --primary-color: #22c55e; + --border-color: #e5e7eb; +} + +.grocery-lists-container input, +.grocery-lists-container select, +.grocery-lists-container textarea { + width: 100%; + padding: 0.75rem; + background: var(--bg); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-main); + font-size: 1rem; +} + +[data-theme="light"] .grocery-lists-container input, +[data-theme="light"] .grocery-lists-container select, +[data-theme="light"] .grocery-lists-container textarea { + background: white; + color: #1f2937; +} + +.grocery-lists-container .btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 6px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.grocery-lists-container .btn.primary { + background: var(--accent); + color: white; +} + +.grocery-lists-container .btn.primary:hover { + background: var(--accent-strong); +} + +.grocery-lists-container .btn.secondary { + background: var(--card-soft); + color: var(--text-main); + border: 1px solid var(--border-color); +} + +.grocery-lists-container .btn.secondary:hover { + background: var(--card); +} + +.grocery-lists-container .btn.ghost { + background: transparent; + color: var(--text-main); +} + +.grocery-lists-container .btn.ghost:hover { + background: var(--hover-bg); +} + +.grocery-lists-container .btn.danger { + background: var(--danger); + color: white; +} + +.grocery-lists-container .btn.danger:hover { + opacity: 0.9; +} + +.grocery-lists-container .btn.small { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.grocery-lists-container .btn-icon { + background: transparent; + border: none; + padding: 0.5rem; + cursor: pointer; + font-size: 1.25rem; + color: var(--text-muted); + transition: color 0.2s; +} + +.grocery-lists-container .btn-icon:hover { + color: var(--text-main); +} + +.grocery-lists-container .btn-icon.delete { + color: var(--danger); +} + +.grocery-lists-container .btn-close { + background: transparent; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-muted); + padding: 0.25rem; + line-height: 1; +} + +.grocery-lists-container .btn-close:hover { + color: var(--text-main); +} + +.grocery-lists-container .loading { + text-align: center; + padding: 2rem; + font-size: 1.125rem; + color: var(--text-muted); +} + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0b573d7..1ed96cf 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,7 @@ 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 Modal from "./components/Modal"; import ToastContainer from "./components/ToastContainer"; import ThemeToggle from "./components/ThemeToggle"; @@ -18,6 +19,13 @@ function App() { const [user, setUser] = useState(null); const [authView, setAuthView] = useState("login"); // "login" or "register" const [loadingAuth, setLoadingAuth] = useState(true); + const [currentView, setCurrentView] = useState(() => { + try { + return localStorage.getItem("currentView") || "recipes"; + } catch { + return "recipes"; + } + }); // "recipes" or "grocery-lists" const [recipes, setRecipes] = useState([]); const [selectedRecipe, setSelectedRecipe] = useState(null); @@ -71,6 +79,15 @@ function App() { 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(); @@ -306,7 +323,7 @@ function App() { ) : ( - setDrawerOpen(true)} user={user} onLogout={handleLogout} /> + setDrawerOpen(true)} user={user} onLogout={handleLogout} onShowToast={addToast} /> )} {/* Show auth modal if needed */} @@ -328,9 +345,30 @@ function App() { )} + {isAuthenticated && ( +

+ )} +
-
- + ) : ( + <> +
+
+ + )}
{isAuthenticated && ( diff --git a/frontend/src/components/GroceryLists.jsx b/frontend/src/components/GroceryLists.jsx new file mode 100644 index 0000000..91ec98d --- /dev/null +++ b/frontend/src/components/GroceryLists.jsx @@ -0,0 +1,944 @@ +import { useState, useEffect } from "react"; +import { + getGroceryLists, + createGroceryList, + updateGroceryList, + deleteGroceryList, + shareGroceryList, + getGroceryListShares, + unshareGroceryList, + searchUsers, +} 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 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
טוען רשימות קניות...
; + } + + return ( +
+
+

רשימות הקניות שלי

+ +
+ + {showNewListForm && ( +
+ setNewListName(e.target.value)} + autoFocus + /> + +
+ )} + +
+ {/* Lists Sidebar */} +
+ {lists.length === 0 ? ( +

אין רשימות קניות עדיין

+ ) : ( + lists.map((list) => ( +
+
handleSelectList(list)} + > +
+

{list.name}

+

+ {list.is_owner ? "שלי" : `של ${list.owner_display_name}`} + {" · "} + {list.items.length} פריטים +

+
+
+ {list.is_owner && ( + + )} +
+ )) + )} +
+ + {/* List Details */} +
+ {editingList ? ( +
+
+

עריכת רשימה

+ +
+ +
+ + setEditName(e.target.value)} + /> +
+ +
+ +
+ setNewItem(e.target.value)} + onKeyPress={(e) => e.key === "Enter" && (e.preventDefault(), handleAddItem())} + /> + +
+ +
    + {editItems.map((item, index) => ( +
  • + + + {item.startsWith("✓ ") ? item.substring(2) : item} + + +
  • + ))} +
+
+ +
+ + {editingList.is_owner && ( + <> + + + + )} +
+
+ ) : selectedList ? ( +
+
+
+

{selectedList.name}

+

+ {selectedList.is_owner + ? "רשימה שלי" + : `משותפת על ידי ${selectedList.owner_display_name}`} +

+
+
+ {selectedList.is_owner && ( + + )} + {selectedList.can_edit && ( + + )} +
+
+ + {selectedList.items.length === 0 ? ( +

אין פריטים ברשימה

+ ) : ( +
    + {selectedList.items.map((item, index) => { + const isChecked = item.startsWith("✓ "); + const itemText = isChecked ? item.substring(2) : item; + return ( +
  • + {selectedList.can_edit ? ( + <> + + + {itemText} + + + ) : ( + <> + {isChecked ? "☑" : "☐"} + + {itemText} + + + )} +
  • + ); + })} +
+ )} +
+ ) : ( +
+

בחר רשימת קניות כדי להציג את הפרטים

+
+ )} +
+
+ + {/* Share Modal */} + {showShareModal && ( +
setShowShareModal(null)}> +
e.stopPropagation()}> +
+

שתף רשימה: {showShareModal.name}

+ +
+ +
+
+ handleSearchUsers(e.target.value)} + /> + + + {searchResults.length > 0 && ( +
    + {searchResults.map((user) => ( +
  • handleShareWithUser(user.id, user.username)} + > +
    + {user.display_name} + @{user.username} +
    + +
  • + ))} +
+ )} +
+ +
+

משותף עם:

+ {shares.length === 0 ? ( +

הרשימה לא משותפת עם אף אחד

+ ) : ( +
    + {shares.map((share) => ( +
  • +
    + {share.display_name} + @{share.username} + {share.can_edit && עורך} +
    + +
  • + ))} +
+ )} +
+
+
+
+ )} + + +
+ ); +} + +export default GroceryLists; diff --git a/frontend/src/components/NotificationBell.jsx b/frontend/src/components/NotificationBell.jsx new file mode 100644 index 0000000..f25cc29 --- /dev/null +++ b/frontend/src/components/NotificationBell.jsx @@ -0,0 +1,392 @@ +import { useState, useEffect, useRef } from "react"; +import { + getNotifications, + markNotificationAsRead, + markAllNotificationsAsRead, + deleteNotification, +} from "../notificationApi"; + +function NotificationBell({ onShowToast }) { + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [showDropdown, setShowDropdown] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + loadNotifications(); + // Poll for new notifications every 30 seconds + const interval = setInterval(loadNotifications, 30000); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + // Close dropdown when clicking outside + function handleClickOutside(event) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setShowDropdown(false); + } + } + + if (showDropdown) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [showDropdown]); + + const loadNotifications = async () => { + try { + const data = await getNotifications(); + setNotifications(data); + setUnreadCount(data.filter((n) => !n.is_read).length); + } catch (error) { + // Silent fail for polling + console.error("Failed to load notifications", error); + } + }; + + const handleMarkAsRead = async (notificationId) => { + try { + await markNotificationAsRead(notificationId); + setNotifications( + notifications.map((n) => + n.id === notificationId ? { ...n, is_read: true } : n + ) + ); + setUnreadCount(Math.max(0, unreadCount - 1)); + } catch (error) { + onShowToast?.(error.message, "error"); + } + }; + + const handleMarkAllAsRead = async () => { + try { + await markAllNotificationsAsRead(); + setNotifications(notifications.map((n) => ({ ...n, is_read: true }))); + setUnreadCount(0); + onShowToast?.("כל ההתראות סומנו כנקראו", "success"); + } catch (error) { + onShowToast?.(error.message, "error"); + } + }; + + const handleDelete = async (notificationId) => { + try { + await deleteNotification(notificationId); + const notification = notifications.find((n) => n.id === notificationId); + setNotifications(notifications.filter((n) => n.id !== notificationId)); + if (notification && !notification.is_read) { + setUnreadCount(Math.max(0, unreadCount - 1)); + } + } catch (error) { + onShowToast?.(error.message, "error"); + } + }; + + const formatTime = (timestamp) => { + const date = new Date(timestamp); + const now = new Date(); + const diff = now - date; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return "עכשיו"; + if (minutes < 60) return `לפני ${minutes} דקות`; + if (hours < 24) return `לפני ${hours} שעות`; + return `לפני ${days} ימים`; + }; + + return ( +
+ + + {showDropdown && ( +
+
+

התראות

+ {unreadCount > 0 && ( + + )} +
+ +
+ {notifications.length === 0 ? ( +
אין התראות חדשות
+ ) : ( + notifications.map((notification) => ( +
+
+

+ {notification.message} +

+ + {formatTime(notification.created_at)} + +
+
+ {!notification.is_read && ( + + )} + +
+
+ )) + )} +
+
+ )} + + +
+ ); +} + +export default NotificationBell; diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx index 37f1d06..65ee762 100644 --- a/frontend/src/components/TopBar.jsx +++ b/frontend/src/components/TopBar.jsx @@ -1,4 +1,6 @@ -function TopBar({ onAddClick, user, onLogout }) { +import NotificationBell from "./NotificationBell"; + +function TopBar({ onAddClick, user, onLogout, onShowToast }) { return (
@@ -12,6 +14,7 @@ function TopBar({ onAddClick, user, onLogout }) {
+ {user && } {user && (