Add groceries list and notifications

This commit is contained in:
dvirlabs 2025-12-11 05:21:06 +02:00
parent 6d5b8f2314
commit e1515442f4
15 changed files with 2446 additions and 4 deletions

Binary file not shown.

220
backend/grocery_db_utils.py Normal file
View File

@ -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()

View File

@ -32,6 +32,26 @@ from user_db_utils import (
get_user_by_email, 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): class RecipeBase(BaseModel):
name: str name: str
@ -97,6 +117,62 @@ class UserResponse(BaseModel):
is_admin: bool = False 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( app = FastAPI(
title="Random Recipes API", title="Random Recipes API",
description="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__": if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

@ -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

View File

@ -42,6 +42,44 @@ CREATE INDEX IF NOT EXISTS idx_recipes_made_by
CREATE INDEX IF NOT EXISTS idx_recipes_user_id CREATE INDEX IF NOT EXISTS idx_recipes_user_id
ON 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) -- Create default admin user (password: admin123)
-- Password hash generated with bcrypt for 'admin123' -- Password hash generated with bcrypt for 'admin123'
INSERT INTO users (username, email, password_hash, first_name, last_name, display_name, is_admin) INSERT INTO users (username, email, password_hash, first_name, last_name, display_name, is_admin)

View File

@ -1365,3 +1365,161 @@ html {
border: 1px solid rgba(107, 114, 128, 0.2); border: 1px solid rgba(107, 114, 128, 0.2);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.08); 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);
}

View File

@ -5,6 +5,7 @@ import TopBar from "./components/TopBar";
import RecipeSearchList from "./components/RecipeSearchList"; import RecipeSearchList from "./components/RecipeSearchList";
import RecipeDetails from "./components/RecipeDetails"; import RecipeDetails from "./components/RecipeDetails";
import RecipeFormDrawer from "./components/RecipeFormDrawer"; import RecipeFormDrawer from "./components/RecipeFormDrawer";
import GroceryLists from "./components/GroceryLists";
import Modal from "./components/Modal"; import Modal from "./components/Modal";
import ToastContainer from "./components/ToastContainer"; import ToastContainer from "./components/ToastContainer";
import ThemeToggle from "./components/ThemeToggle"; import ThemeToggle from "./components/ThemeToggle";
@ -18,6 +19,13 @@ function App() {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [authView, setAuthView] = useState("login"); // "login" or "register" const [authView, setAuthView] = useState("login"); // "login" or "register"
const [loadingAuth, setLoadingAuth] = useState(true); 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 [recipes, setRecipes] = useState([]);
const [selectedRecipe, setSelectedRecipe] = useState(null); const [selectedRecipe, setSelectedRecipe] = useState(null);
@ -71,6 +79,15 @@ function App() {
checkAuth(); 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) // Load recipes for everyone (readonly for non-authenticated)
useEffect(() => { useEffect(() => {
loadRecipes(); loadRecipes();
@ -306,7 +323,7 @@ function App() {
</div> </div>
</header> </header>
) : ( ) : (
<TopBar onAddClick={() => setDrawerOpen(true)} user={user} onLogout={handleLogout} /> <TopBar onAddClick={() => setDrawerOpen(true)} user={user} onLogout={handleLogout} onShowToast={addToast} />
)} )}
{/* Show auth modal if needed */} {/* Show auth modal if needed */}
@ -328,7 +345,28 @@ function App() {
</div> </div>
)} )}
{isAuthenticated && (
<nav className="main-navigation">
<button
className={`nav-tab ${currentView === "recipes" ? "active" : ""}`}
onClick={() => setCurrentView("recipes")}
>
📖 מתכונים
</button>
<button
className={`nav-tab ${currentView === "grocery-lists" ? "active" : ""}`}
onClick={() => setCurrentView("grocery-lists")}
>
🛒 רשימות קניות
</button>
</nav>
)}
<main className="layout"> <main className="layout">
{currentView === "grocery-lists" ? (
<GroceryLists user={user} onShowToast={addToast} />
) : (
<>
<section className="sidebar"> <section className="sidebar">
<RecipeSearchList <RecipeSearchList
allRecipes={recipes} allRecipes={recipes}
@ -408,6 +446,8 @@ function App() {
currentUser={user} currentUser={user}
/> />
</section> </section>
</>
)}
</main> </main>
{isAuthenticated && ( {isAuthenticated && (

View File

@ -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 <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"
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 jsx>{`
.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;

View File

@ -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 (
<div className="notification-bell-container" ref={dropdownRef}>
<button
className="notification-bell-btn"
onClick={() => setShowDropdown(!showDropdown)}
title="התראות"
>
🔔
{unreadCount > 0 && (
<span className="notification-badge">{unreadCount}</span>
)}
</button>
{showDropdown && (
<div className="notification-dropdown">
<div className="notification-header">
<h3>התראות</h3>
{unreadCount > 0 && (
<button
className="btn-link"
onClick={handleMarkAllAsRead}
>
סמן הכל כנקרא
</button>
)}
</div>
<div className="notification-list">
{notifications.length === 0 ? (
<div className="notification-empty">אין התראות חדשות</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={`notification-item ${
notification.is_read ? "read" : "unread"
}`}
>
<div className="notification-content">
<p className="notification-message">
{notification.message}
</p>
<span className="notification-time">
{formatTime(notification.created_at)}
</span>
</div>
<div className="notification-actions">
{!notification.is_read && (
<button
className="btn-icon-small"
onClick={() => handleMarkAsRead(notification.id)}
title="סמן כנקרא"
>
</button>
)}
<button
className="btn-icon-small delete"
onClick={() => handleDelete(notification.id)}
title="מחק"
>
</button>
</div>
</div>
))
)}
</div>
</div>
)}
<style jsx>{`
.notification-bell-container {
position: relative;
}
.notification-bell-btn {
position: relative;
background: var(--card-soft);
border: 1px solid var(--border-color);
padding: 0.5rem 0.75rem;
cursor: pointer;
border-radius: 8px;
font-size: 1.25rem;
transition: all 0.2s;
line-height: 1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.notification-bell-btn:hover {
background: var(--hover-bg);
transform: scale(1.05);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.notification-badge {
position: absolute;
top: -6px;
right: -6px;
background: #ef4444;
color: white;
font-size: 0.7rem;
font-weight: bold;
padding: 0.2rem 0.45rem;
border-radius: 12px;
min-width: 20px;
text-align: center;
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.3);
}
.notification-dropdown {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 420px;
max-height: 550px;
background: var(--panel-bg);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--card-soft);
}
.notification-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.btn-link {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: all 0.2s;
font-weight: 500;
}
.btn-link:hover {
background: var(--accent-soft);
color: var(--accent);
}
.notification-list {
overflow-y: auto;
max-height: 450px;
}
.notification-list::-webkit-scrollbar {
width: 8px;
}
.notification-list::-webkit-scrollbar-track {
background: var(--panel-bg);
}
.notification-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
.notification-list::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.notification-empty {
text-align: center;
padding: 3rem 2rem;
color: var(--text-muted);
font-size: 0.95rem;
}
.notification-item {
display: flex;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
transition: all 0.2s;
position: relative;
}
.notification-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: transparent;
transition: background 0.2s;
}
.notification-item.unread::before {
background: var(--accent);
}
.notification-item:hover {
background: var(--hover-bg);
}
.notification-item:last-child {
border-bottom: none;
}
.notification-item.unread {
background: var(--accent-soft);
}
.notification-item.unread:hover {
background: var(--hover-bg);
}
.notification-content {
flex: 1;
min-width: 0;
}
.notification-message {
margin: 0 0 0.5rem 0;
font-size: 0.95rem;
line-height: 1.5;
color: var(--text-primary);
font-weight: 500;
}
.notification-item.read .notification-message {
font-weight: 400;
color: var(--text-secondary);
}
.notification-time {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 400;
}
.notification-actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
align-items: flex-start;
}
.btn-icon-small {
background: var(--panel-bg);
border: 1px solid var(--border-color);
padding: 0.4rem 0.65rem;
cursor: pointer;
border-radius: 6px;
font-size: 0.9rem;
transition: all 0.2s;
line-height: 1;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.btn-icon-small:hover {
background: var(--hover-bg);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn-icon-small.delete {
color: #ef4444;
}
.btn-icon-small.delete:hover {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
`}</style>
</div>
);
}
export default NotificationBell;

View File

@ -1,4 +1,6 @@
function TopBar({ onAddClick, user, onLogout }) { import NotificationBell from "./NotificationBell";
function TopBar({ onAddClick, user, onLogout, onShowToast }) {
return ( return (
<header className="topbar"> <header className="topbar">
<div className="topbar-left"> <div className="topbar-left">
@ -12,6 +14,7 @@ function TopBar({ onAddClick, user, onLogout }) {
</div> </div>
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}> <div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
{user && <NotificationBell onShowToast={onShowToast} />}
{user && ( {user && (
<button className="btn primary" onClick={onAddClick}> <button className="btn primary" onClick={onAddClick}>
+ מתכון חדש + מתכון חדש

112
frontend/src/groceryApi.js Normal file
View File

@ -0,0 +1,112 @@
const API_URL = window.ENV?.VITE_API_URL || "http://localhost:8000";
// Get auth token from localStorage
const getAuthHeaders = () => {
const token = localStorage.getItem("auth_token");
return {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
};
};
// Get all grocery lists
export const getGroceryLists = async () => {
const res = await fetch(`${API_URL}/grocery-lists`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("Failed to fetch grocery lists");
return res.json();
};
// Create a new grocery list
export const createGroceryList = async (data) => {
const res = await fetch(`${API_URL}/grocery-lists`, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to create grocery list");
}
return res.json();
};
// Get a specific grocery list
export const getGroceryList = async (id) => {
const res = await fetch(`${API_URL}/grocery-lists/${id}`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("Failed to fetch grocery list");
return res.json();
};
// Update a grocery list
export const updateGroceryList = async (id, data) => {
const res = await fetch(`${API_URL}/grocery-lists/${id}`, {
method: "PUT",
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to update grocery list");
}
return res.json();
};
// Delete a grocery list
export const deleteGroceryList = async (id) => {
const res = await fetch(`${API_URL}/grocery-lists/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to delete grocery list");
}
};
// Share a grocery list
export const shareGroceryList = async (listId, data) => {
const res = await fetch(`${API_URL}/grocery-lists/${listId}/share`, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to share grocery list");
}
return res.json();
};
// Get grocery list shares
export const getGroceryListShares = async (listId) => {
const res = await fetch(`${API_URL}/grocery-lists/${listId}/shares`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("Failed to fetch shares");
return res.json();
};
// Unshare a grocery list
export const unshareGroceryList = async (listId, userId) => {
const res = await fetch(`${API_URL}/grocery-lists/${listId}/shares/${userId}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to unshare grocery list");
}
};
// Search users
export const searchUsers = async (query) => {
const res = await fetch(`${API_URL}/users/search?q=${encodeURIComponent(query)}`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("Failed to search users");
return res.json();
};

View File

@ -0,0 +1,66 @@
const API_BASE_URL = window._env_?.VITE_API_URL || import.meta.env.VITE_API_URL || "http://localhost:8000";
function getAuthHeaders() {
const token = localStorage.getItem("auth_token");
return {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
};
}
export async function getNotifications(unreadOnly = false) {
const url = `${API_BASE_URL}/notifications${unreadOnly ? '?unread_only=true' : ''}`;
const response = await fetch(url, {
method: "GET",
headers: getAuthHeaders(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Failed to fetch notifications" }));
throw new Error(errorData.detail || "Failed to fetch notifications");
}
return response.json();
}
export async function markNotificationAsRead(notificationId) {
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}/read`, {
method: "PATCH",
headers: getAuthHeaders(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Failed to mark notification as read" }));
throw new Error(errorData.detail || "Failed to mark notification as read");
}
return response.json();
}
export async function markAllNotificationsAsRead() {
const response = await fetch(`${API_BASE_URL}/notifications/read-all`, {
method: "PATCH",
headers: getAuthHeaders(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Failed to mark all notifications as read" }));
throw new Error(errorData.detail || "Failed to mark all notifications as read");
}
return response.json();
}
export async function deleteNotification(notificationId) {
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Failed to delete notification" }));
throw new Error(errorData.detail || "Failed to delete notification");
}
return response.json();
}