Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01369a743d | |||
| 7c6703354e | |||
| 080977cdb7 | |||
| df7510da2e | |||
| e1515442f4 | |||
|
|
6d5b8f2314 | ||
|
|
5841e7b9d4 | ||
|
|
b2877877dd | ||
|
|
0f3aa43b89 | ||
|
|
e0b3102007 | ||
|
|
a5d87b8e25 | ||
|
|
22639a489a | ||
| 8d81d16682 | |||
| fa5ba578bb | |||
| 53ca792988 | |||
| e160357256 | |||
| c912663c3d | |||
| 66d2aa0a66 | |||
| 1d33e52100 | |||
| b35100c92f | |||
| 81acc68aaa |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
my-recipes-chart/
|
||||||
@ -63,7 +63,7 @@ steps:
|
|||||||
- |
|
- |
|
||||||
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||||
echo "💡 Setting frontend tag to: $TAG"
|
echo "💡 Setting frontend tag to: $TAG"
|
||||||
yq -i ".frontend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
yq -i ".frontend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||||
git add manifests/${CI_REPO_NAME}/values.yaml
|
git add manifests/${CI_REPO_NAME}/values.yaml
|
||||||
git commit -m "frontend: update tag to $TAG" || echo "No changes"
|
git commit -m "frontend: update tag to $TAG" || echo "No changes"
|
||||||
git push origin HEAD
|
git push origin HEAD
|
||||||
@ -93,7 +93,7 @@ steps:
|
|||||||
- |
|
- |
|
||||||
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||||
echo "💡 Setting backend tag to: $TAG"
|
echo "💡 Setting backend tag to: $TAG"
|
||||||
yq -i ".backend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
yq -i ".backend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||||
git add manifests/${CI_REPO_NAME}/values.yaml
|
git add manifests/${CI_REPO_NAME}/values.yaml
|
||||||
git commit -m "backend: update tag to $TAG" || echo "No changes"
|
git commit -m "backend: update tag to $TAG" || echo "No changes"
|
||||||
git push origin HEAD
|
git push origin HEAD
|
||||||
|
|||||||
BIN
backend/__pycache__/auth_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/auth_utils.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/__pycache__/grocery_db_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/grocery_db_utils.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/__pycache__/notification_db_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/notification_db_utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/user_db_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/user_db_utils.cpython-313.pyc
Normal file
Binary file not shown.
5
backend/add_is_pinned_column.sql
Normal file
5
backend/add_is_pinned_column.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- Add is_pinned column to grocery_lists table
|
||||||
|
ALTER TABLE grocery_lists ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Verify the column was added
|
||||||
|
\d grocery_lists
|
||||||
96
backend/auth_utils.py
Normal file
96
backend/auth_utils.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
import bcrypt
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Secret key for JWT (use environment variable in production)
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a password for storing."""
|
||||||
|
# Bcrypt has a 72 byte limit, truncate if necessary
|
||||||
|
password_bytes = password.encode('utf-8')[:72]
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||||
|
return hashed.decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify a stored password against one provided by user"""
|
||||||
|
# Bcrypt has a 72 byte limit, truncate if necessary
|
||||||
|
password_bytes = plain_password.encode('utf-8')[:72]
|
||||||
|
hashed_bytes = hashed_password.encode('utf-8')
|
||||||
|
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||||
|
"""Create JWT access token"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
"""Decode and verify JWT token"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid authentication credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
|
||||||
|
"""Get current user from JWT token (for protected routes)"""
|
||||||
|
from user_db_utils import get_user_by_id
|
||||||
|
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = decode_token(token)
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid authentication credentials",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get full user info from database to include is_admin
|
||||||
|
user = get_user_by_id(int(user_id))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user["id"],
|
||||||
|
"username": user["username"],
|
||||||
|
"display_name": user["display_name"],
|
||||||
|
"is_admin": user.get("is_admin", False)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Optional dependency - returns None if no token provided
|
||||||
|
def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Optional[dict]:
|
||||||
|
"""Get current user if authenticated, otherwise None"""
|
||||||
|
if not credentials:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return get_current_user(credentials)
|
||||||
|
except HTTPException:
|
||||||
|
return None
|
||||||
@ -54,10 +54,12 @@ def list_recipes_db() -> List[Dict[str, Any]]:
|
|||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, name, meal_type, time_minutes,
|
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
||||||
tags, ingredients, steps, image, made_by
|
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
||||||
FROM recipes
|
u.display_name as owner_display_name
|
||||||
ORDER BY id
|
FROM recipes r
|
||||||
|
LEFT JOIN users u ON r.user_id = u.id
|
||||||
|
ORDER BY r.id
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
@ -85,7 +87,7 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
|
|||||||
image = %s,
|
image = %s,
|
||||||
made_by = %s
|
made_by = %s
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
recipe_data["name"],
|
recipe_data["name"],
|
||||||
@ -133,9 +135,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by)
|
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
recipe_data["name"],
|
recipe_data["name"],
|
||||||
@ -146,6 +148,7 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
json.dumps(recipe_data.get("steps", [])),
|
json.dumps(recipe_data.get("steps", [])),
|
||||||
recipe_data.get("image"),
|
recipe_data.get("image"),
|
||||||
recipe_data.get("made_by"),
|
recipe_data.get("made_by"),
|
||||||
|
recipe_data.get("user_id"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@ -162,19 +165,21 @@ def get_recipes_by_filters_db(
|
|||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
SELECT id, name, meal_type, time_minutes,
|
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
||||||
tags, ingredients, steps, image, made_by
|
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
||||||
FROM recipes
|
u.display_name as owner_display_name
|
||||||
|
FROM recipes r
|
||||||
|
LEFT JOIN users u ON r.user_id = u.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
"""
|
"""
|
||||||
params: List = []
|
params: List = []
|
||||||
|
|
||||||
if meal_type:
|
if meal_type:
|
||||||
query += " AND meal_type = %s"
|
query += " AND r.meal_type = %s"
|
||||||
params.append(meal_type.lower())
|
params.append(meal_type.lower())
|
||||||
|
|
||||||
if max_time:
|
if max_time:
|
||||||
query += " AND time_minutes <= %s"
|
query += " AND r.time_minutes <= %s"
|
||||||
params.append(max_time)
|
params.append(max_time)
|
||||||
|
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|||||||
253
backend/grocery_db_utils.py
Normal file
253
backend/grocery_db_utils.py
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
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.is_pinned, gl.created_at, gl.updated_at,
|
||||||
|
u.display_name as owner_display_name,
|
||||||
|
CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit,
|
||||||
|
CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner
|
||||||
|
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.is_pinned, gl.created_at, gl.updated_at,
|
||||||
|
u.display_name as owner_display_name,
|
||||||
|
CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit,
|
||||||
|
CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_grocery_list_pin(list_id: int, user_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Toggle pin status for a grocery list (owner only)"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Check if user is owner
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, is_pinned FROM grocery_lists WHERE id = %s AND owner_id = %s",
|
||||||
|
(list_id, user_id)
|
||||||
|
)
|
||||||
|
result = cur.fetchone()
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Toggle pin status
|
||||||
|
new_pin_status = not result["is_pinned"]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE grocery_lists
|
||||||
|
SET is_pinned = %s, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, name, items, owner_id, is_pinned, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(new_pin_status, list_id)
|
||||||
|
)
|
||||||
|
updated = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(updated) if updated else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
575
backend/main.py
575
backend/main.py
@ -1,9 +1,10 @@
|
|||||||
import random
|
import random
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Query, Depends, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
@ -14,6 +15,42 @@ from db_utils import (
|
|||||||
get_recipes_by_filters_db,
|
get_recipes_by_filters_db,
|
||||||
update_recipe_db,
|
update_recipe_db,
|
||||||
delete_recipe_db,
|
delete_recipe_db,
|
||||||
|
get_conn,
|
||||||
|
)
|
||||||
|
|
||||||
|
from auth_utils import (
|
||||||
|
hash_password,
|
||||||
|
verify_password,
|
||||||
|
create_access_token,
|
||||||
|
get_current_user,
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
from user_db_utils import (
|
||||||
|
create_user,
|
||||||
|
get_user_by_username,
|
||||||
|
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,
|
||||||
|
toggle_grocery_list_pin,
|
||||||
|
)
|
||||||
|
|
||||||
|
from notification_db_utils import (
|
||||||
|
create_notification,
|
||||||
|
get_user_notifications,
|
||||||
|
mark_notification_as_read,
|
||||||
|
mark_all_notifications_as_read,
|
||||||
|
delete_notification,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -34,12 +71,109 @@ class RecipeCreate(RecipeBase):
|
|||||||
|
|
||||||
class Recipe(RecipeBase):
|
class Recipe(RecipeBase):
|
||||||
id: int
|
id: int
|
||||||
|
user_id: Optional[int] = None # Recipe owner ID
|
||||||
|
owner_display_name: Optional[str] = None # Owner's display name for filtering
|
||||||
|
|
||||||
|
|
||||||
class RecipeUpdate(RecipeBase):
|
class RecipeUpdate(RecipeBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# User models
|
||||||
|
class UserRegister(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
@field_validator('username')
|
||||||
|
@classmethod
|
||||||
|
def username_must_be_english(cls, v: str) -> str:
|
||||||
|
if not v.isascii():
|
||||||
|
raise ValueError('שם משתמש חייב להיות באנגלית בלבד')
|
||||||
|
if not all(c.isalnum() or c in '_-' for c in v):
|
||||||
|
raise ValueError('שם משתמש יכול להכיל רק אותיות, מספרים, _ ו-')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
display_name: str
|
||||||
|
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
|
||||||
|
is_pinned: bool = False
|
||||||
|
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",
|
||||||
@ -49,6 +183,7 @@ app = FastAPI(
|
|||||||
# Allow CORS from frontend domains
|
# Allow CORS from frontend domains
|
||||||
allowed_origins = [
|
allowed_origins = [
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
|
"http://localhost:5174",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"https://my-recipes.dvirlabs.com",
|
"https://my-recipes.dvirlabs.com",
|
||||||
"http://my-recipes.dvirlabs.com",
|
"http://my-recipes.dvirlabs.com",
|
||||||
@ -77,6 +212,8 @@ def list_recipes():
|
|||||||
ingredients=r["ingredients"] or [],
|
ingredients=r["ingredients"] or [],
|
||||||
steps=r["steps"] or [],
|
steps=r["steps"] or [],
|
||||||
image=r.get("image"),
|
image=r.get("image"),
|
||||||
|
user_id=r.get("user_id"),
|
||||||
|
owner_display_name=r.get("owner_display_name"),
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@ -84,9 +221,10 @@ def list_recipes():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/recipes", response_model=Recipe, status_code=201)
|
@app.post("/recipes", response_model=Recipe, status_code=201)
|
||||||
def create_recipe(recipe_in: RecipeCreate):
|
def create_recipe(recipe_in: RecipeCreate, current_user: dict = Depends(get_current_user)):
|
||||||
data = recipe_in.dict()
|
data = recipe_in.dict()
|
||||||
data["meal_type"] = data["meal_type"].lower()
|
data["meal_type"] = data["meal_type"].lower()
|
||||||
|
data["user_id"] = current_user["user_id"]
|
||||||
|
|
||||||
row = create_recipe_db(data)
|
row = create_recipe_db(data)
|
||||||
|
|
||||||
@ -100,10 +238,26 @@ def create_recipe(recipe_in: RecipeCreate):
|
|||||||
ingredients=row["ingredients"] or [],
|
ingredients=row["ingredients"] or [],
|
||||||
steps=row["steps"] or [],
|
steps=row["steps"] or [],
|
||||||
image=row.get("image"),
|
image=row.get("image"),
|
||||||
|
user_id=row.get("user_id"),
|
||||||
|
owner_display_name=current_user.get("display_name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.put("/recipes/{recipe_id}", response_model=Recipe)
|
@app.put("/recipes/{recipe_id}", response_model=Recipe)
|
||||||
def update_recipe(recipe_id: int, recipe_in: RecipeUpdate):
|
def update_recipe(recipe_id: int, recipe_in: RecipeUpdate, current_user: dict = Depends(get_current_user)):
|
||||||
|
# Check ownership BEFORE updating (admins can edit any recipe)
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT user_id FROM recipes WHERE id = %s", (recipe_id,))
|
||||||
|
recipe = cur.fetchone()
|
||||||
|
if not recipe:
|
||||||
|
raise HTTPException(status_code=404, detail="המתכון לא נמצא")
|
||||||
|
# Allow if user is owner OR admin
|
||||||
|
if recipe["user_id"] != current_user["user_id"] and not current_user.get("is_admin", False):
|
||||||
|
raise HTTPException(status_code=403, detail="אין לך הרשאה לערוך מתכון זה")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
data = recipe_in.dict()
|
data = recipe_in.dict()
|
||||||
data["meal_type"] = data["meal_type"].lower()
|
data["meal_type"] = data["meal_type"].lower()
|
||||||
|
|
||||||
@ -121,11 +275,27 @@ def update_recipe(recipe_id: int, recipe_in: RecipeUpdate):
|
|||||||
ingredients=row["ingredients"] or [],
|
ingredients=row["ingredients"] or [],
|
||||||
steps=row["steps"] or [],
|
steps=row["steps"] or [],
|
||||||
image=row.get("image"),
|
image=row.get("image"),
|
||||||
|
user_id=row.get("user_id"),
|
||||||
|
owner_display_name=current_user.get("display_name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/recipes/{recipe_id}", status_code=204)
|
@app.delete("/recipes/{recipe_id}", status_code=204)
|
||||||
def delete_recipe(recipe_id: int):
|
def delete_recipe(recipe_id: int, current_user: dict = Depends(get_current_user)):
|
||||||
|
# Get recipe first to check ownership (admins can delete any recipe)
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT user_id FROM recipes WHERE id = %s", (recipe_id,))
|
||||||
|
recipe = cur.fetchone()
|
||||||
|
if not recipe:
|
||||||
|
raise HTTPException(status_code=404, detail="המתכון לא נמצא")
|
||||||
|
# Allow if user is owner OR admin
|
||||||
|
if recipe["user_id"] != current_user["user_id"] and not current_user.get("is_admin", False):
|
||||||
|
raise HTTPException(status_code=403, detail="אין לך הרשאה למחוק מתכון זה")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
deleted = delete_recipe_db(recipe_id)
|
deleted = delete_recipe_db(recipe_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail="המתכון לא נמצא")
|
raise HTTPException(status_code=404, detail="המתכון לא נמצא")
|
||||||
@ -154,6 +324,8 @@ def random_recipe(
|
|||||||
ingredients=r["ingredients"] or [],
|
ingredients=r["ingredients"] or [],
|
||||||
steps=r["steps"] or [],
|
steps=r["steps"] or [],
|
||||||
image=r.get("image"),
|
image=r.get("image"),
|
||||||
|
user_id=r.get("user_id"),
|
||||||
|
owner_display_name=r.get("owner_display_name"),
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@ -177,6 +349,397 @@ def random_recipe(
|
|||||||
return random.choice(recipes)
|
return random.choice(recipes)
|
||||||
|
|
||||||
|
|
||||||
|
# Authentication endpoints
|
||||||
|
@app.post("/auth/register", response_model=UserResponse, status_code=201)
|
||||||
|
def register(user: UserRegister):
|
||||||
|
"""Register a new user"""
|
||||||
|
print(f"[REGISTER] Starting registration for username: {user.username}")
|
||||||
|
|
||||||
|
# Check if username already exists
|
||||||
|
print(f"[REGISTER] Checking if username exists...")
|
||||||
|
existing_user = get_user_by_username(user.username)
|
||||||
|
if existing_user:
|
||||||
|
print(f"[REGISTER] Username already exists")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="שם המשתמש כבר קיים במערכת"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if email already exists
|
||||||
|
print(f"[REGISTER] Checking if email exists...")
|
||||||
|
existing_email = get_user_by_email(user.email)
|
||||||
|
if existing_email:
|
||||||
|
print(f"[REGISTER] Email already exists")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="האימייל כבר רשום במערכת"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if display_name already exists
|
||||||
|
print(f"[REGISTER] Checking if display_name exists...")
|
||||||
|
from user_db_utils import get_user_by_display_name
|
||||||
|
existing_display_name = get_user_by_display_name(user.display_name)
|
||||||
|
if existing_display_name:
|
||||||
|
print(f"[REGISTER] Display name already exists")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="שם התצוגה כבר קיים במערכת"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hash password and create user
|
||||||
|
print(f"[REGISTER] Hashing password...")
|
||||||
|
password_hash = hash_password(user.password)
|
||||||
|
print(f"[REGISTER] Creating user in database...")
|
||||||
|
new_user = create_user(
|
||||||
|
user.username,
|
||||||
|
user.email,
|
||||||
|
password_hash,
|
||||||
|
user.first_name,
|
||||||
|
user.last_name,
|
||||||
|
user.display_name
|
||||||
|
)
|
||||||
|
print(f"[REGISTER] User created successfully: {new_user['id']}")
|
||||||
|
|
||||||
|
return UserResponse(
|
||||||
|
id=new_user["id"],
|
||||||
|
username=new_user["username"],
|
||||||
|
email=new_user["email"],
|
||||||
|
first_name=new_user.get("first_name"),
|
||||||
|
last_name=new_user.get("last_name"),
|
||||||
|
display_name=new_user["display_name"],
|
||||||
|
is_admin=new_user.get("is_admin", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/login", response_model=Token)
|
||||||
|
def login(user: UserLogin):
|
||||||
|
"""Login user and return JWT token"""
|
||||||
|
# Get user from database
|
||||||
|
db_user = get_user_by_username(user.username)
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="שם משתמש או סיסמה שגויים"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify password
|
||||||
|
if not verify_password(user.password, db_user["password_hash"]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="שם משתמש או סיסמה שגויים"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create access token
|
||||||
|
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": str(db_user["id"]), "username": db_user["username"]},
|
||||||
|
expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
|
||||||
|
return Token(access_token=access_token, token_type="bearer")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/me", response_model=UserResponse)
|
||||||
|
def get_me(current_user: dict = Depends(get_current_user)):
|
||||||
|
"""Get current logged-in user info"""
|
||||||
|
from user_db_utils import get_user_by_id
|
||||||
|
user = get_user_by_id(current_user["user_id"])
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="משתמש לא נמצא")
|
||||||
|
|
||||||
|
return UserResponse(
|
||||||
|
id=user["id"],
|
||||||
|
username=user["username"],
|
||||||
|
email=user["email"],
|
||||||
|
first_name=user.get("first_name"),
|
||||||
|
last_name=user.get("last_name"),
|
||||||
|
display_name=user["display_name"],
|
||||||
|
is_admin=user.get("is_admin", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============= 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.patch("/grocery-lists/{list_id}/pin", response_model=GroceryList)
|
||||||
|
def toggle_pin_grocery_list_endpoint(list_id: int, current_user: dict = Depends(get_current_user)):
|
||||||
|
"""Toggle pin status for a grocery list (owner only)"""
|
||||||
|
updated = toggle_grocery_list_pin(list_id, current_user["user_id"])
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=404, detail="רשימת קניות לא נמצאה או שאין לך הרשאה")
|
||||||
|
|
||||||
|
# Get full details with permissions
|
||||||
|
result = get_grocery_list_by_id(list_id, current_user["user_id"])
|
||||||
|
result["created_at"] = str(result["created_at"])
|
||||||
|
result["updated_at"] = str(result["updated_at"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/grocery-lists/{list_id}/share", response_model=GroceryListShare)
|
||||||
|
def share_grocery_list_endpoint(
|
||||||
|
list_id: int,
|
||||||
|
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)
|
||||||
|
|
||||||
124
backend/notification_db_utils.py
Normal file
124
backend/notification_db_utils.py
Normal 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
|
||||||
@ -2,6 +2,13 @@ fastapi==0.115.0
|
|||||||
uvicorn[standard]==0.30.1
|
uvicorn[standard]==0.30.1
|
||||||
|
|
||||||
pydantic==2.7.4
|
pydantic==2.7.4
|
||||||
|
pydantic[email]==2.7.4
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
|
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.9
|
||||||
|
bcrypt==4.1.2
|
||||||
|
|||||||
@ -1,14 +1,32 @@
|
|||||||
|
-- Create users table
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
|
display_name TEXT UNIQUE NOT NULL,
|
||||||
|
is_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
|
||||||
|
|
||||||
-- Create recipes table
|
-- Create recipes table
|
||||||
CREATE TABLE IF NOT EXISTS recipes (
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack
|
meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack
|
||||||
time_minutes INTEGER NOT NULL,
|
time_minutes INTEGER NOT NULL,
|
||||||
|
tags TEXT[] NOT NULL DEFAULT '{}', -- {"מהיר", "בריא"}
|
||||||
|
ingredients TEXT[] NOT NULL DEFAULT '{}', -- {"ביצה", "עגבניה", "מלח"}
|
||||||
|
steps TEXT[] NOT NULL DEFAULT '{}', -- {"לחתוך", "לבשל", ...}
|
||||||
|
image TEXT, -- Base64-encoded image or image URL
|
||||||
made_by TEXT, -- Person who created this recipe version
|
made_by TEXT, -- Person who created this recipe version
|
||||||
tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"]
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- Recipe owner
|
||||||
ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"]
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
steps JSONB NOT NULL DEFAULT '[]', -- ["לחתוך", "לבשל", ...]
|
|
||||||
image TEXT -- Base64-encoded image or image URL
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Optional: index for filters
|
-- Optional: index for filters
|
||||||
@ -21,9 +39,52 @@ CREATE INDEX IF NOT EXISTS idx_recipes_time_minutes
|
|||||||
CREATE INDEX IF NOT EXISTS idx_recipes_made_by
|
CREATE INDEX IF NOT EXISTS idx_recipes_made_by
|
||||||
ON recipes (made_by);
|
ON recipes (made_by);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipes_tags_jsonb
|
CREATE INDEX IF NOT EXISTS idx_recipes_user_id
|
||||||
ON recipes USING GIN (tags);
|
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,
|
||||||
|
is_pinned BOOLEAN DEFAULT FALSE,
|
||||||
|
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)
|
||||||
|
VALUES ('admin', 'admin@myrecipes.local', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5lE7UGf3rCvHC', 'Admin', 'User', 'מנהל', TRUE)
|
||||||
|
ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipes_ingredients_jsonb
|
|
||||||
ON recipes USING GIN (ingredients);
|
|
||||||
|
|
||||||
|
|||||||
102
backend/user_db_utils.py
Normal file
102
backend/user_db_utils.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import os
|
||||||
|
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_user(username: str, email: str, password_hash: str, first_name: str = None, last_name: str = None, display_name: str = None, is_admin: bool = False):
|
||||||
|
"""Create a new user"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Use display_name if provided, otherwise use username
|
||||||
|
final_display_name = display_name if display_name else username
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (username, email, password_hash, first_name, last_name, display_name, is_admin)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, username, email, first_name, last_name, display_name, is_admin, created_at
|
||||||
|
""",
|
||||||
|
(username, email, password_hash, first_name, last_name, final_display_name, is_admin)
|
||||||
|
)
|
||||||
|
user = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(user)
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_username(username: str):
|
||||||
|
"""Get user by username"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, created_at FROM users WHERE username = %s",
|
||||||
|
(username,)
|
||||||
|
)
|
||||||
|
user = cur.fetchone()
|
||||||
|
return dict(user) if user else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_email(email: str):
|
||||||
|
"""Get user by email"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, created_at FROM users WHERE email = %s",
|
||||||
|
(email,)
|
||||||
|
)
|
||||||
|
user = cur.fetchone()
|
||||||
|
return dict(user) if user else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_id(user_id: int):
|
||||||
|
"""Get user by ID"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, email, first_name, last_name, display_name, is_admin, created_at FROM users WHERE id = %s",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
user = cur.fetchone()
|
||||||
|
return dict(user) if user else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_display_name(display_name: str):
|
||||||
|
"""Get user by display name"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, email, display_name, is_admin, created_at FROM users WHERE display_name = %s",
|
||||||
|
(display_name,)
|
||||||
|
)
|
||||||
|
user = cur.fetchone()
|
||||||
|
return dict(user) if user else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
@ -1,10 +1,10 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="he" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍽️</text></svg>">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>My Recipes | המתכונים שלי</title>
|
||||||
<!-- Load environment variables before app starts -->
|
<!-- Load environment variables before app starts -->
|
||||||
<script src="/env.js"></script>
|
<script src="/env.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -32,11 +32,21 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-greeting-header {
|
||||||
|
text-align: right;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
.app-root {
|
.app-root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
padding-top: 4.5rem; /* Add space for fixed theme toggle */
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,6 +84,15 @@ body {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-greeting {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(79, 70, 229, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
@ -83,7 +102,37 @@ body {
|
|||||||
|
|
||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
.layout {
|
.layout {
|
||||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout:has(.pinned-lists-sidebar) {
|
||||||
|
grid-template-columns: 320px minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-lists-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.pinned-lists-sidebar {
|
||||||
|
display: block;
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.content-wrapper {
|
||||||
|
display: contents;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -338,7 +387,7 @@ select {
|
|||||||
/* Recipe Image */
|
/* Recipe Image */
|
||||||
.recipe-image-container {
|
.recipe-image-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 250px;
|
height: 250px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 0.8rem;
|
margin-bottom: 0.8rem;
|
||||||
@ -436,6 +485,9 @@ select {
|
|||||||
border-left: 1px solid var(--border-subtle);
|
border-left: 1px solid var(--border-subtle);
|
||||||
padding: 1rem 1rem 1rem 1.2rem;
|
padding: 1rem 1rem 1rem 1.2rem;
|
||||||
box-shadow: 18px 0 40px rgba(0, 0, 0, 0.7);
|
box-shadow: 18px 0 40px rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-header {
|
.drawer-header {
|
||||||
@ -446,14 +498,20 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drawer-body {
|
.drawer-body {
|
||||||
max-height: calc(100vh - 4rem);
|
flex: 1;
|
||||||
overflow: auto;
|
overflow-y: auto;
|
||||||
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-footer {
|
.drawer-footer {
|
||||||
margin-top: 0.7rem;
|
margin-top: auto;
|
||||||
|
padding-top: 0.7rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
@ -631,7 +689,7 @@ select {
|
|||||||
|
|
||||||
.toast-container {
|
.toast-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1.5rem;
|
bottom: 5rem;
|
||||||
right: 1.5rem;
|
right: 1.5rem;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -737,7 +795,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] body {
|
[data-theme="light"] body {
|
||||||
background: linear-gradient(180deg, #ac8d75 0%, #f6f8fa 100%);
|
background: linear-gradient(180deg, #ac8d75 0%, #ede9e5 100%);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1256,3 +1314,242 @@ html {
|
|||||||
[data-theme="light"] .recipe-list-image {
|
[data-theme="light"] .recipe-list-image {
|
||||||
background: rgba(229, 231, 235, 0.5);
|
background: rgba(229, 231, 235, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Auth Pages */
|
||||||
|
.auth-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: radial-gradient(circle at top, #0f172a 0, #020617 55%);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: #020617;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7);
|
||||||
|
margin: 2rem auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode auth styles */
|
||||||
|
[data-theme="light"] .auth-container {
|
||||||
|
background: linear-gradient(180deg, #ac8d75 0%, #ede9e5 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .auth-card {
|
||||||
|
background: #d1b29b;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,12 +5,29 @@ 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 PinnedGroceryLists from "./components/PinnedGroceryLists";
|
||||||
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";
|
||||||
|
import Login from "./components/Login";
|
||||||
|
import Register from "./components/Register";
|
||||||
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
||||||
|
import { getToken, removeToken, getMe } from "./authApi";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
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 [recipes, setRecipes] = useState([]);
|
||||||
const [selectedRecipe, setSelectedRecipe] = useState(null);
|
const [selectedRecipe, setSelectedRecipe] = useState(null);
|
||||||
|
|
||||||
@ -19,7 +36,7 @@ function App() {
|
|||||||
const [filterMealType, setFilterMealType] = useState("");
|
const [filterMealType, setFilterMealType] = useState("");
|
||||||
const [filterMaxTime, setFilterMaxTime] = useState("");
|
const [filterMaxTime, setFilterMaxTime] = useState("");
|
||||||
const [filterTags, setFilterTags] = useState([]);
|
const [filterTags, setFilterTags] = useState([]);
|
||||||
const [filterMadeBy, setFilterMadeBy] = useState("");
|
const [filterOwner, setFilterOwner] = useState("");
|
||||||
|
|
||||||
// Random recipe filters
|
// Random recipe filters
|
||||||
const [mealTypeFilter, setMealTypeFilter] = useState("");
|
const [mealTypeFilter, setMealTypeFilter] = useState("");
|
||||||
@ -33,6 +50,7 @@ function App() {
|
|||||||
const [editingRecipe, setEditingRecipe] = useState(null);
|
const [editingRecipe, setEditingRecipe] = useState(null);
|
||||||
|
|
||||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
|
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
|
||||||
|
const [logoutModal, setLogoutModal] = useState(false);
|
||||||
const [toasts, setToasts] = useState([]);
|
const [toasts, setToasts] = useState([]);
|
||||||
const [theme, setTheme] = useState(() => {
|
const [theme, setTheme] = useState(() => {
|
||||||
try {
|
try {
|
||||||
@ -42,6 +60,36 @@ function App() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check authentication on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
const token = getToken();
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const userData = await getMe(token);
|
||||||
|
setUser(userData);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
} catch (err) {
|
||||||
|
// Token invalid or expired
|
||||||
|
removeToken();
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoadingAuth(false);
|
||||||
|
};
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
loadRecipes();
|
loadRecipes();
|
||||||
}, []);
|
}, []);
|
||||||
@ -96,8 +144,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by made_by
|
// Filter by made_by (username)
|
||||||
if (filterMadeBy && (!recipe.made_by || recipe.made_by !== filterMadeBy)) {
|
if (filterOwner && (!recipe.made_by || recipe.made_by !== filterOwner)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,7 +182,8 @@ function App() {
|
|||||||
|
|
||||||
const handleCreateRecipe = async (payload) => {
|
const handleCreateRecipe = async (payload) => {
|
||||||
try {
|
try {
|
||||||
const created = await createRecipe(payload);
|
const token = getToken();
|
||||||
|
const created = await createRecipe(payload, token);
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
setEditingRecipe(null);
|
setEditingRecipe(null);
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
@ -153,7 +202,8 @@ function App() {
|
|||||||
|
|
||||||
const handleUpdateRecipe = async (payload) => {
|
const handleUpdateRecipe = async (payload) => {
|
||||||
try {
|
try {
|
||||||
await updateRecipe(editingRecipe.id, payload);
|
const token = getToken();
|
||||||
|
await updateRecipe(editingRecipe.id, payload, token);
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
setEditingRecipe(null);
|
setEditingRecipe(null);
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
@ -177,7 +227,8 @@ function App() {
|
|||||||
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteRecipe(recipeId);
|
const token = getToken();
|
||||||
|
await deleteRecipe(recipeId, token);
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
setSelectedRecipe(null);
|
setSelectedRecipe(null);
|
||||||
addToast("המתכון נמחק בהצלחה!", "success");
|
addToast("המתכון נמחק בהצלחה!", "success");
|
||||||
@ -208,51 +259,141 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLoginSuccess = async () => {
|
||||||
|
const token = getToken();
|
||||||
|
const userData = await getMe(token);
|
||||||
|
setUser(userData);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
await loadRecipes();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
setLogoutModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmLogout = () => {
|
||||||
|
removeToken();
|
||||||
|
setUser(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setRecipes([]);
|
||||||
|
setSelectedRecipe(null);
|
||||||
|
setLogoutModal(false);
|
||||||
|
addToast('התנתקת בהצלחה', 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show loading state while checking auth
|
||||||
|
if (loadingAuth) {
|
||||||
|
return (
|
||||||
|
<div className="app-root">
|
||||||
|
<div style={{ textAlign: "center", padding: "3rem", color: "var(--text-muted)" }}>
|
||||||
|
טוען...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show main app (readonly if not authenticated)
|
||||||
return (
|
return (
|
||||||
<div className="app-root">
|
<div className="app-root">
|
||||||
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
|
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
|
||||||
<TopBar onAddClick={() => setDrawerOpen(true)} />
|
|
||||||
|
{/* User greeting above TopBar */}
|
||||||
|
{isAuthenticated && user && (
|
||||||
|
<div className="user-greeting-header">
|
||||||
|
שלום, {user.display_name || user.username} 👋
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show login/register option in TopBar if not authenticated */}
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar-left">
|
||||||
|
<span className="logo-emoji" role="img" aria-label="plate">🍽</span>
|
||||||
|
<div className="brand">
|
||||||
|
<div className="brand-title">מה לבשל היום?</div>
|
||||||
|
<div className="brand-subtitle">מנהל המתכונים האישי שלך</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||||
|
<button className="btn ghost" onClick={() => setAuthView("login")}>
|
||||||
|
התחבר
|
||||||
|
</button>
|
||||||
|
<button className="btn primary" onClick={() => setAuthView("register")}>
|
||||||
|
הירשם
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
) : (
|
||||||
|
<TopBar onAddClick={() => setDrawerOpen(true)} user={user} onLogout={handleLogout} onShowToast={addToast} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show auth modal if needed */}
|
||||||
|
{!isAuthenticated && authView !== null && (
|
||||||
|
<div className="drawer-backdrop" onClick={() => setAuthView(null)}>
|
||||||
|
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{authView === "login" ? (
|
||||||
|
<Login
|
||||||
|
onSuccess={handleLoginSuccess}
|
||||||
|
onSwitchToRegister={() => setAuthView("register")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Register
|
||||||
|
onSuccess={handleLoginSuccess}
|
||||||
|
onSwitchToLogin={() => setAuthView("login")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
<section className="sidebar">
|
{currentView === "grocery-lists" ? (
|
||||||
<RecipeSearchList
|
<GroceryLists user={user} onShowToast={addToast} />
|
||||||
allRecipes={recipes}
|
) : (
|
||||||
recipes={getFilteredRecipes()}
|
<>
|
||||||
selectedId={selectedRecipe?.id}
|
{isAuthenticated && (
|
||||||
onSelect={setSelectedRecipe}
|
<aside className="pinned-lists-sidebar">
|
||||||
searchQuery={searchQuery}
|
<PinnedGroceryLists onShowToast={addToast} />
|
||||||
onSearchChange={setSearchQuery}
|
</aside>
|
||||||
filterMealType={filterMealType}
|
)}
|
||||||
onMealTypeChange={setFilterMealType}
|
<section className="content-wrapper">
|
||||||
filterMaxTime={filterMaxTime}
|
<section className="content">
|
||||||
onMaxTimeChange={setFilterMaxTime}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
filterTags={filterTags}
|
|
||||||
onTagsChange={setFilterTags}
|
|
||||||
filterMadeBy={filterMadeBy}
|
|
||||||
onMadeByChange={setFilterMadeBy}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="content">
|
{/* Random Recipe Suggester - Top Left */}
|
||||||
{error && <div className="error-banner">{error}</div>}
|
<section className="panel filter-panel">
|
||||||
|
<h3>חיפוש מתכון רנדומלי</h3>
|
||||||
{/* Random Recipe Suggester - Top Left */}
|
<div className="panel-grid">
|
||||||
<section className="panel filter-panel">
|
<div className="field">
|
||||||
<h3>חיפוש מתכון רנדומלי</h3>
|
<label>סוג ארוחה</label>
|
||||||
<div className="panel-grid">
|
<select
|
||||||
<div className="field">
|
value={mealTypeFilter}
|
||||||
<label>סוג ארוחה</label>
|
onChange={(e) => setMealTypeFilter(e.target.value)}
|
||||||
<select
|
>
|
||||||
value={mealTypeFilter}
|
<option value="">לא משנה</option>
|
||||||
onChange={(e) => setMealTypeFilter(e.target.value)}
|
<option value="breakfast">בוקר</option>
|
||||||
>
|
<option value="lunch">צהריים</option>
|
||||||
<option value="">לא משנה</option>
|
<option value="dinner">ערב</option>
|
||||||
<option value="breakfast">בוקר</option>
|
<option value="snack">קינוחים</option>
|
||||||
<option value="lunch">צהריים</option>
|
</select>
|
||||||
<option value="dinner">ערב</option>
|
</div>
|
||||||
<option value="snack">נשנוש</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>זמן מקסימלי (דקות)</label>
|
<label>זמן מקסימלי (דקות)</label>
|
||||||
@ -289,19 +430,46 @@ function App() {
|
|||||||
recipe={selectedRecipe}
|
recipe={selectedRecipe}
|
||||||
onEditClick={handleEditRecipe}
|
onEditClick={handleEditRecipe}
|
||||||
onShowDeleteModal={handleShowDeleteModal}
|
onShowDeleteModal={handleShowDeleteModal}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
currentUser={user}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="sidebar">
|
||||||
|
<RecipeSearchList
|
||||||
|
allRecipes={recipes}
|
||||||
|
recipes={getFilteredRecipes()}
|
||||||
|
selectedId={selectedRecipe?.id}
|
||||||
|
onSelect={setSelectedRecipe}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
filterMealType={filterMealType}
|
||||||
|
onMealTypeChange={setFilterMealType}
|
||||||
|
filterMaxTime={filterMaxTime}
|
||||||
|
onMaxTimeChange={setFilterMaxTime}
|
||||||
|
filterTags={filterTags}
|
||||||
|
onTagsChange={setFilterTags}
|
||||||
|
filterOwner={filterOwner}
|
||||||
|
onOwnerChange={setFilterOwner}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<RecipeFormDrawer
|
{isAuthenticated && (
|
||||||
open={drawerOpen}
|
<RecipeFormDrawer
|
||||||
onClose={() => {
|
open={drawerOpen}
|
||||||
setDrawerOpen(false);
|
onClose={() => {
|
||||||
setEditingRecipe(null);
|
setDrawerOpen(false);
|
||||||
}}
|
setEditingRecipe(null);
|
||||||
onSubmit={handleFormSubmit}
|
}}
|
||||||
editingRecipe={editingRecipe}
|
onSubmit={handleFormSubmit}
|
||||||
/>
|
editingRecipe={editingRecipe}
|
||||||
|
currentUser={user}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={deleteModal.isOpen}
|
isOpen={deleteModal.isOpen}
|
||||||
@ -314,6 +482,17 @@ function App() {
|
|||||||
onCancel={handleCancelDelete}
|
onCancel={handleCancelDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={logoutModal}
|
||||||
|
title="התנתקות"
|
||||||
|
message="האם אתה בטוח שברצונך להתנתק?"
|
||||||
|
confirmText="התנתק"
|
||||||
|
cancelText="ביטול"
|
||||||
|
isDangerous={false}
|
||||||
|
onConfirm={confirmLogout}
|
||||||
|
onCancel={() => setLogoutModal(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -37,10 +37,14 @@ export async function getRandomRecipe(filters) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRecipe(recipe) {
|
export async function createRecipe(recipe, token) {
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE}/recipes`, {
|
const res = await fetch(`${API_BASE}/recipes`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
body: JSON.stringify(recipe),
|
body: JSON.stringify(recipe),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -49,10 +53,14 @@ export async function createRecipe(recipe) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRecipe(id, payload) {
|
export async function updateRecipe(id, payload, token) {
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -61,9 +69,14 @@ export async function updateRecipe(id, payload) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRecipe(id) {
|
export async function deleteRecipe(id, token) {
|
||||||
|
const headers = {};
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
if (!res.ok && res.status !== 204) {
|
if (!res.ok && res.status !== 204) {
|
||||||
throw new Error("Failed to delete recipe");
|
throw new Error("Failed to delete recipe");
|
||||||
|
|||||||
67
frontend/src/authApi.js
Normal file
67
frontend/src/authApi.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
// Get API base from injected env.js or fallback to /api relative path
|
||||||
|
const getApiBase = () => {
|
||||||
|
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
|
||||||
|
return window.__ENV__.API_BASE;
|
||||||
|
}
|
||||||
|
return "/api";
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE = getApiBase();
|
||||||
|
|
||||||
|
export async function register(username, email, password, firstName, lastName, displayName) {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
display_name: displayName
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to register");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username, password) {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to login");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(token) {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to get user info");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth helpers
|
||||||
|
export function saveToken(token) {
|
||||||
|
localStorage.setItem("auth_token", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return localStorage.getItem("auth_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeToken() {
|
||||||
|
localStorage.removeItem("auth_token");
|
||||||
|
}
|
||||||
970
frontend/src/components/GroceryLists.jsx
Normal file
970
frontend/src/components/GroceryLists.jsx
Normal file
@ -0,0 +1,970 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
getGroceryLists,
|
||||||
|
createGroceryList,
|
||||||
|
updateGroceryList,
|
||||||
|
deleteGroceryList,
|
||||||
|
shareGroceryList,
|
||||||
|
getGroceryListShares,
|
||||||
|
unshareGroceryList,
|
||||||
|
searchUsers,
|
||||||
|
togglePinGroceryList,
|
||||||
|
} 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 handleTogglePin = async (list) => {
|
||||||
|
try {
|
||||||
|
const updated = await togglePinGroceryList(list.id);
|
||||||
|
setLists(lists.map((l) => (l.id === updated.id ? updated : l)));
|
||||||
|
if (selectedList?.id === updated.id) {
|
||||||
|
setSelectedList(updated);
|
||||||
|
}
|
||||||
|
const message = updated.is_pinned
|
||||||
|
? "רשימה הוצמדה לדף הבית"
|
||||||
|
: "רשימה הוסרה מדף הבית";
|
||||||
|
onShowToast(message, "success");
|
||||||
|
} catch (error) {
|
||||||
|
onShowToast(error.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowShareModal = async (list) => {
|
||||||
|
setShowShareModal(list);
|
||||||
|
setUserSearch("");
|
||||||
|
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 ${selectedList.is_pinned ? "pinned" : ""}`}
|
||||||
|
onClick={() => handleTogglePin(selectedList)}
|
||||||
|
title={selectedList.is_pinned ? "הסר הצמדה" : "הצמד לדף הבית"}
|
||||||
|
>
|
||||||
|
📌
|
||||||
|
</button>
|
||||||
|
<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;
|
||||||
77
frontend/src/components/Login.jsx
Normal file
77
frontend/src/components/Login.jsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { login, saveToken } from "../authApi";
|
||||||
|
|
||||||
|
function Login({ onSuccess, onSwitchToRegister }) {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await login(username, password);
|
||||||
|
saveToken(data.access_token);
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-container">
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-title">התחברות</h1>
|
||||||
|
<p className="auth-subtitle">ברוכים השבים למתכונים שלכם</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>שם משתמש</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="הזן שם משתמש"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>סיסמה</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="הזן סיסמה"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn primary full-width" disabled={loading}>
|
||||||
|
{loading ? "מתחבר..." : "התחבר"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="auth-footer">
|
||||||
|
<p>
|
||||||
|
עדיין אין לך חשבון?{" "}
|
||||||
|
<button className="link-btn" onClick={onSwitchToRegister}>
|
||||||
|
הירשם עכשיו
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
400
frontend/src/components/NotificationBell.jsx
Normal file
400
frontend/src/components/NotificationBell.jsx
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
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) {
|
||||||
|
// If unauthorized (401), user is not logged in - don't show errors
|
||||||
|
if (error.message.includes("401") || error.message.includes("Unauthorized") || error.message.includes("User not found")) {
|
||||||
|
setNotifications([]);
|
||||||
|
setUnreadCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Silent fail for other polling errors
|
||||||
|
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);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0.98;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
222
frontend/src/components/PinnedGroceryLists.jsx
Normal file
222
frontend/src/components/PinnedGroceryLists.jsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getGroceryLists, updateGroceryList } from "../groceryApi";
|
||||||
|
|
||||||
|
function PinnedGroceryLists({ onShowToast }) {
|
||||||
|
const [pinnedLists, setPinnedLists] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPinnedLists();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPinnedLists = async () => {
|
||||||
|
try {
|
||||||
|
const allLists = await getGroceryLists();
|
||||||
|
const pinned = allLists.filter((list) => list.is_pinned);
|
||||||
|
setPinnedLists(pinned);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load pinned lists", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleItem = async (listId, itemIndex) => {
|
||||||
|
const list = pinnedLists.find((l) => l.id === listId);
|
||||||
|
if (!list || !list.can_edit) return;
|
||||||
|
|
||||||
|
const updatedItems = [...list.items];
|
||||||
|
const item = updatedItems[itemIndex];
|
||||||
|
|
||||||
|
if (item.startsWith("✓ ")) {
|
||||||
|
updatedItems[itemIndex] = item.substring(2);
|
||||||
|
} else {
|
||||||
|
updatedItems[itemIndex] = "✓ " + item;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateGroceryList(listId, { items: updatedItems });
|
||||||
|
setPinnedLists(
|
||||||
|
pinnedLists.map((l) =>
|
||||||
|
l.id === listId ? { ...l, items: updatedItems } : l
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
onShowToast?.(error.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pinnedLists.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pinned-grocery-lists">
|
||||||
|
{pinnedLists.map((list) => (
|
||||||
|
<div key={list.id} className="pinned-note">
|
||||||
|
<div className="pin-icon">📌</div>
|
||||||
|
<h3 className="note-title">{list.name}</h3>
|
||||||
|
<ul className="note-items">
|
||||||
|
{list.items.length === 0 ? (
|
||||||
|
<li className="empty-note">הרשימה ריקה</li>
|
||||||
|
) : (
|
||||||
|
list.items.map((item, index) => {
|
||||||
|
const isChecked = item.startsWith("✓ ");
|
||||||
|
const itemText = isChecked ? item.substring(2) : item;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className={`note-item ${isChecked ? "checked" : ""}`}
|
||||||
|
onClick={() =>
|
||||||
|
list.can_edit && handleToggleItem(list.id, index)
|
||||||
|
}
|
||||||
|
style={{ cursor: list.can_edit ? "pointer" : "default" }}
|
||||||
|
>
|
||||||
|
<span className="checkbox">{isChecked ? "☑" : "☐"}</span>
|
||||||
|
<span className="item-text">{itemText}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;700&display=swap');
|
||||||
|
|
||||||
|
.pinned-grocery-lists {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-note {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, #fff9e6 0%, #fffaed 100%);
|
||||||
|
padding: 2rem 1.5rem 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.08),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||||
|
border: 1px solid #f5e6c8;
|
||||||
|
transform: rotate(-1deg);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-family: 'Caveat', cursive;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-note:nth-child(even) {
|
||||||
|
transform: rotate(1deg);
|
||||||
|
background: linear-gradient(135deg, #fff5e1 0%, #fff9eb 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-note:hover {
|
||||||
|
transform: rotate(0deg) scale(1.02);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 8px rgba(0, 0, 0, 0.15),
|
||||||
|
0 8px 20px rgba(0, 0, 0, 0.1),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: 20px;
|
||||||
|
font-size: 2rem;
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.2));
|
||||||
|
transform: rotate(25deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: #5a4a2a;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 2px solid rgba(90, 74, 42, 0.2);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-items {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-note {
|
||||||
|
text-align: center;
|
||||||
|
color: #9a8a6a;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
padding: 1rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: #4a3a1a;
|
||||||
|
line-height: 1.6;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item:hover {
|
||||||
|
color: #2a1a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item.checked .item-text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-text {
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paper texture overlay */
|
||||||
|
.pinned-note::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 31px,
|
||||||
|
rgba(90, 74, 42, 0.03) 31px,
|
||||||
|
rgba(90, 74, 42, 0.03) 32px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode specific adjustments */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.pinned-note {
|
||||||
|
background: linear-gradient(135deg, #fffbf0 0%, #fffef8 100%);
|
||||||
|
border-color: #f0e0c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-note:nth-child(even) {
|
||||||
|
background: linear-gradient(135deg, #fff8e8 0%, #fffcf3 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PinnedGroceryLists;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import placeholderImage from "../assets/placeholder.svg";
|
import placeholderImage from "../assets/placeholder.svg";
|
||||||
|
|
||||||
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) {
|
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal, isAuthenticated, currentUser }) {
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
return (
|
return (
|
||||||
<section className="panel placeholder">
|
<section className="panel placeholder">
|
||||||
@ -13,6 +13,15 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
|||||||
onShowDeleteModal(recipe.id, recipe.name);
|
onShowDeleteModal(recipe.id, recipe.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Debug ownership check
|
||||||
|
console.log('Recipe ownership check:', {
|
||||||
|
recipeUserId: recipe.user_id,
|
||||||
|
recipeUserIdType: typeof recipe.user_id,
|
||||||
|
currentUserId: currentUser?.id,
|
||||||
|
currentUserIdType: typeof currentUser?.id,
|
||||||
|
isEqual: recipe.user_id === currentUser?.id
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel recipe-card">
|
<section className="panel recipe-card">
|
||||||
{/* Recipe Image */}
|
{/* Recipe Image */}
|
||||||
@ -26,8 +35,8 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
|||||||
<p className="recipe-subtitle">
|
<p className="recipe-subtitle">
|
||||||
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
|
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
|
||||||
</p>
|
</p>
|
||||||
{recipe.made_by && (
|
{(recipe.owner_display_name || recipe.made_by) && (
|
||||||
<h4 className="recipe-made-by">המתכון של: {recipe.made_by}</h4>
|
<h4 className="recipe-made-by">המתכון של: {recipe.owner_display_name || recipe.made_by}</h4>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pill-row">
|
<div className="pill-row">
|
||||||
@ -66,14 +75,16 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
|||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="recipe-actions">
|
{isAuthenticated && currentUser && (Number(recipe.user_id) === Number(currentUser.id) || currentUser.is_admin) && (
|
||||||
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
|
<div className="recipe-actions">
|
||||||
✏️ ערוך
|
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
|
||||||
</button>
|
✏️ ערוך
|
||||||
<button className="btn ghost small" onClick={handleDelete}>
|
</button>
|
||||||
🗑 מחק
|
<button className="btn ghost small" onClick={handleDelete}>
|
||||||
</button>
|
🗑 מחק
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -87,7 +98,7 @@ function translateMealType(type) {
|
|||||||
case "dinner":
|
case "dinner":
|
||||||
return "ערב";
|
return "ערב";
|
||||||
case "snack":
|
case "snack":
|
||||||
return "נשנוש";
|
return "קינוחים";
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -148,7 +148,7 @@ function translateMealType(type) {
|
|||||||
case "dinner":
|
case "dinner":
|
||||||
return "ערב";
|
return "ערב";
|
||||||
case "snack":
|
case "snack":
|
||||||
return "נשנוש";
|
return "קינוחים";
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
|
||||||
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, currentUser = null }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [mealType, setMealType] = useState("lunch");
|
const [mealType, setMealType] = useState("lunch");
|
||||||
const [timeMinutes, setTimeMinutes] = useState(15);
|
const [timeMinutes, setTimeMinutes] = useState(15);
|
||||||
@ -11,6 +11,9 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
const [ingredients, setIngredients] = useState([""]);
|
const [ingredients, setIngredients] = useState([""]);
|
||||||
const [steps, setSteps] = useState([""]);
|
const [steps, setSteps] = useState([""]);
|
||||||
|
|
||||||
|
const lastIngredientRef = useRef(null);
|
||||||
|
const lastStepRef = useRef(null);
|
||||||
|
|
||||||
const isEditMode = !!editingRecipe;
|
const isEditMode = !!editingRecipe;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -20,7 +23,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
setMealType(editingRecipe.meal_type || "lunch");
|
setMealType(editingRecipe.meal_type || "lunch");
|
||||||
setTimeMinutes(editingRecipe.time_minutes || 15);
|
setTimeMinutes(editingRecipe.time_minutes || 15);
|
||||||
setMadeBy(editingRecipe.made_by || "");
|
setMadeBy(editingRecipe.made_by || "");
|
||||||
setTags((editingRecipe.tags || []).join(", "));
|
setTags((editingRecipe.tags || []).join(" "));
|
||||||
setImage(editingRecipe.image || "");
|
setImage(editingRecipe.image || "");
|
||||||
setIngredients(editingRecipe.ingredients || [""]);
|
setIngredients(editingRecipe.ingredients || [""]);
|
||||||
setSteps(editingRecipe.steps || [""]);
|
setSteps(editingRecipe.steps || [""]);
|
||||||
@ -28,19 +31,23 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
setName("");
|
setName("");
|
||||||
setMealType("lunch");
|
setMealType("lunch");
|
||||||
setTimeMinutes(15);
|
setTimeMinutes(15);
|
||||||
setMadeBy("");
|
setMadeBy(currentUser?.username || "");
|
||||||
setTags("");
|
setTags("");
|
||||||
setImage("");
|
setImage("");
|
||||||
setIngredients([""]);
|
setIngredients([""]);
|
||||||
setSteps([""]);
|
setSteps([""]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, editingRecipe, isEditMode]);
|
}, [open, editingRecipe, isEditMode, currentUser]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const handleAddIngredient = () => {
|
const handleAddIngredient = () => {
|
||||||
setIngredients((prev) => [...prev, ""]);
|
setIngredients((prev) => [...prev, ""]);
|
||||||
|
setTimeout(() => {
|
||||||
|
lastIngredientRef.current?.focus();
|
||||||
|
lastIngredientRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeIngredient = (idx, value) => {
|
const handleChangeIngredient = (idx, value) => {
|
||||||
@ -53,6 +60,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
|
|
||||||
const handleAddStep = () => {
|
const handleAddStep = () => {
|
||||||
setSteps((prev) => [...prev, ""]);
|
setSteps((prev) => [...prev, ""]);
|
||||||
|
setTimeout(() => {
|
||||||
|
lastStepRef.current?.focus();
|
||||||
|
lastStepRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeStep = (idx, value) => {
|
const handleChangeStep = (idx, value) => {
|
||||||
@ -84,7 +95,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
const cleanIngredients = ingredients.map((s) => s.trim()).filter(Boolean);
|
const cleanIngredients = ingredients.map((s) => s.trim()).filter(Boolean);
|
||||||
const cleanSteps = steps.map((s) => s.trim()).filter(Boolean);
|
const cleanSteps = steps.map((s) => s.trim()).filter(Boolean);
|
||||||
const tagsArr = tags
|
const tagsArr = tags
|
||||||
.split(",")
|
.split(" ")
|
||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
@ -95,12 +106,9 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
tags: tagsArr,
|
tags: tagsArr,
|
||||||
ingredients: cleanIngredients,
|
ingredients: cleanIngredients,
|
||||||
steps: cleanSteps,
|
steps: cleanSteps,
|
||||||
|
made_by: madeBy.trim() || currentUser?.username || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (madeBy.trim()) {
|
|
||||||
payload.made_by = madeBy.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (image) {
|
if (image) {
|
||||||
payload.image = image;
|
payload.image = image;
|
||||||
}
|
}
|
||||||
@ -136,7 +144,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
<option value="breakfast">בוקר</option>
|
<option value="breakfast">בוקר</option>
|
||||||
<option value="lunch">צהריים</option>
|
<option value="lunch">צהריים</option>
|
||||||
<option value="dinner">ערב</option>
|
<option value="dinner">ערב</option>
|
||||||
<option value="snack">נשנוש</option>
|
<option value="snack">קינוחים</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -191,11 +199,11 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>תגיות (מופרד בפסיקים)</label>
|
<label>תגיות (מופרד ברווחים)</label>
|
||||||
<input
|
<input
|
||||||
value={tags}
|
value={tags}
|
||||||
onChange={(e) => setTags(e.target.value)}
|
onChange={(e) => setTags(e.target.value)}
|
||||||
placeholder="מהיר, טבעוני, משפחתי..."
|
placeholder="מהיר טבעוני משפחתי..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -205,6 +213,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
{ingredients.map((val, idx) => (
|
{ingredients.map((val, idx) => (
|
||||||
<div key={idx} className="dynamic-row">
|
<div key={idx} className="dynamic-row">
|
||||||
<input
|
<input
|
||||||
|
ref={idx === ingredients.length - 1 ? lastIngredientRef : null}
|
||||||
value={val}
|
value={val}
|
||||||
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
|
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
|
||||||
placeholder="למשל: 2 ביצים"
|
placeholder="למשל: 2 ביצים"
|
||||||
@ -234,6 +243,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
{steps.map((val, idx) => (
|
{steps.map((val, idx) => (
|
||||||
<div key={idx} className="dynamic-row">
|
<div key={idx} className="dynamic-row">
|
||||||
<input
|
<input
|
||||||
|
ref={idx === steps.length - 1 ? lastStepRef : null}
|
||||||
value={val}
|
value={val}
|
||||||
onChange={(e) => handleChangeStep(idx, e.target.value)}
|
onChange={(e) => handleChangeStep(idx, e.target.value)}
|
||||||
placeholder="למשל: לחמם את התנור ל־180 מעלות"
|
placeholder="למשל: לחמם את התנור ל־180 מעלות"
|
||||||
|
|||||||
@ -42,7 +42,7 @@ function translateMealType(type) {
|
|||||||
case "dinner":
|
case "dinner":
|
||||||
return "ערב";
|
return "ערב";
|
||||||
case "snack":
|
case "snack":
|
||||||
return "נשנוש";
|
return "קינוחים";
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,8 +14,8 @@ function RecipeSearchList({
|
|||||||
onMaxTimeChange,
|
onMaxTimeChange,
|
||||||
filterTags,
|
filterTags,
|
||||||
onTagsChange,
|
onTagsChange,
|
||||||
filterMadeBy,
|
filterOwner,
|
||||||
onMadeByChange,
|
onOwnerChange,
|
||||||
}) {
|
}) {
|
||||||
const [expandFilters, setExpandFilters] = useState(false);
|
const [expandFilters, setExpandFilters] = useState(false);
|
||||||
|
|
||||||
@ -27,8 +27,14 @@ function RecipeSearchList({
|
|||||||
// Extract unique meal types from ALL recipes (not filtered)
|
// Extract unique meal types from ALL recipes (not filtered)
|
||||||
const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort();
|
const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort();
|
||||||
|
|
||||||
// Extract unique made_by from ALL recipes (not filtered)
|
// Extract unique made_by (username) from ALL recipes and map to display names
|
||||||
const allMadeBy = Array.from(new Set(allRecipes.map((r) => r.made_by).filter(Boolean))).sort();
|
const madeByMap = new Map();
|
||||||
|
allRecipes.forEach((r) => {
|
||||||
|
if (r.made_by && r.owner_display_name) {
|
||||||
|
madeByMap.set(r.made_by, r.owner_display_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const allMadeBy = Array.from(madeByMap.keys()).sort();
|
||||||
|
|
||||||
// Extract max time for slider from ALL recipes (not filtered) - add 10 to make it comfortable to select max time recipes
|
// Extract max time for slider from ALL recipes (not filtered) - add 10 to make it comfortable to select max time recipes
|
||||||
const maxTimeInRecipes = Math.max(...allRecipes.map((r) => r.time_minutes), 0);
|
const maxTimeInRecipes = Math.max(...allRecipes.map((r) => r.time_minutes), 0);
|
||||||
@ -47,10 +53,10 @@ function RecipeSearchList({
|
|||||||
onMealTypeChange("");
|
onMealTypeChange("");
|
||||||
onMaxTimeChange("");
|
onMaxTimeChange("");
|
||||||
onTagsChange([]);
|
onTagsChange([]);
|
||||||
onMadeByChange("");
|
onOwnerChange("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterMadeBy;
|
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterOwner;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel secondary recipe-search-list">
|
<section className="panel secondary recipe-search-list">
|
||||||
@ -165,18 +171,18 @@ function RecipeSearchList({
|
|||||||
<label className="filter-label">המתכונים של:</label>
|
<label className="filter-label">המתכונים של:</label>
|
||||||
<div className="filter-options">
|
<div className="filter-options">
|
||||||
<button
|
<button
|
||||||
className={`filter-btn ${filterMadeBy === "" ? "active" : ""}`}
|
className={`filter-btn ${filterOwner === "" ? "active" : ""}`}
|
||||||
onClick={() => onMadeByChange("")}
|
onClick={() => onOwnerChange("")}
|
||||||
>
|
>
|
||||||
הכל
|
הכל
|
||||||
</button>
|
</button>
|
||||||
{allMadeBy.map((person) => (
|
{allMadeBy.map((madeBy) => (
|
||||||
<button
|
<button
|
||||||
key={person}
|
key={madeBy}
|
||||||
className={`filter-btn ${filterMadeBy === person ? "active" : ""}`}
|
className={`filter-btn ${filterOwner === madeBy ? "active" : ""}`}
|
||||||
onClick={() => onMadeByChange(person)}
|
onClick={() => onOwnerChange(madeBy)}
|
||||||
>
|
>
|
||||||
{person}
|
{madeByMap.get(madeBy) || madeBy}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -239,7 +245,7 @@ function translateMealType(type) {
|
|||||||
case "dinner":
|
case "dinner":
|
||||||
return "ערב";
|
return "ערב";
|
||||||
case "snack":
|
case "snack":
|
||||||
return "נשנוש";
|
return "קינוחים";
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|||||||
164
frontend/src/components/Register.jsx
Normal file
164
frontend/src/components/Register.jsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { register, login, saveToken } from "../authApi";
|
||||||
|
|
||||||
|
function Register({ onSuccess, onSwitchToLogin }) {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("הסיסמאות אינן תואמות");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError("הסיסמה חייבת להכיל לפחות 6 תווים");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!displayName.trim()) {
|
||||||
|
setError("שם תצוגה הוא שדה חובה");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Register the user
|
||||||
|
await register(username, email, password, firstName, lastName, displayName);
|
||||||
|
|
||||||
|
// Automatically login after successful registration
|
||||||
|
const response = await login(username, password);
|
||||||
|
saveToken(response.access_token);
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-container">
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-title">הרשמה</h1>
|
||||||
|
<p className="auth-subtitle">צור חשבון חדש והתחל לנהל את המתכונים שלך</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>שם פרטי</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
placeholder="שם פרטי (אופציונלי)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>שם משפחה</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
placeholder="שם משפחה (אופציונלי)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>שם תצוגה *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="איך תרצה שיופיע שמך?"
|
||||||
|
minLength={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>שם משתמש * (אנגלית בלבד)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="username (English only)"
|
||||||
|
autoComplete="username"
|
||||||
|
minLength={3}
|
||||||
|
pattern="[a-zA-Z0-9_-]+"
|
||||||
|
title="שם משתמש יכול להכיל רק אותיות באנגלית, מספרים, _ ו-"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>אימייל *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="your@email.com"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>סיסמה *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="בחר סיסמה חזקה"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>אימות סיסמה *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="הזן סיסמה שוב"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn primary full-width" disabled={loading}>
|
||||||
|
{loading ? "נרשם..." : "הירשם"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="auth-footer">
|
||||||
|
<p>
|
||||||
|
כבר יש לך חשבון?{" "}
|
||||||
|
<button className="link-btn" onClick={onSwitchToLogin}>
|
||||||
|
התחבר
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Register;
|
||||||
@ -1,4 +1,6 @@
|
|||||||
function TopBar({ onAddClick }) {
|
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,9 +14,17 @@ function TopBar({ onAddClick }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||||
<button className="btn primary" onClick={onAddClick}>
|
{user && <NotificationBell onShowToast={onShowToast} />}
|
||||||
+ מתכון חדש
|
{user && (
|
||||||
</button>
|
<button className="btn primary" onClick={onAddClick}>
|
||||||
|
+ מתכון חדש
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onLogout && (
|
||||||
|
<button className="btn ghost" onClick={onLogout}>
|
||||||
|
יציאה
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
131
frontend/src/groceryApi.js
Normal file
131
frontend/src/groceryApi.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
const API_URL = window.__ENV__?.API_BASE || 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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle pin status for a grocery list
|
||||||
|
export const togglePinGroceryList = async (id) => {
|
||||||
|
const res = await fetch(`${API_URL}/grocery-lists/${id}/pin`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let errorMessage = "Failed to toggle pin status";
|
||||||
|
try {
|
||||||
|
const error = await res.json();
|
||||||
|
errorMessage = error.detail || errorMessage;
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage = `HTTP ${res.status}: ${res.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Share a grocery list
|
||||||
|
export const shareGroceryList = async (listId, data) => {
|
||||||
|
const res = await fetch(`${API_URL}/grocery-lists/${listId}/share`, {
|
||||||
|
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();
|
||||||
|
};
|
||||||
69
frontend/src/notificationApi.js
Normal file
69
frontend/src/notificationApi.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
const API_BASE_URL = window.__ENV__?.API_BASE || 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) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user