Compare commits

..

21 Commits
master ... test

Author SHA1 Message Date
01369a743d try to fix cahce problem 2025-12-14 02:24:37 +02:00
7c6703354e Fix production API URL to use window.__ENV__.API_BASE 2025-12-11 16:08:38 +02:00
080977cdb7 Add grocery-lists 2025-12-11 16:03:06 +02:00
df7510da2e Add grocery-lists 2025-12-11 15:51:54 +02:00
e1515442f4 Add groceries list and notifications 2025-12-11 05:21:06 +02:00
dvirlabs
6d5b8f2314 Fix pics size 2025-12-08 17:38:01 +02:00
dvirlabs
5841e7b9d4 Set the pics to the same size 2025-12-08 16:19:06 +02:00
dvirlabs
b2877877dd move the header 2025-12-08 16:10:20 +02:00
dvirlabs
0f3aa43b89 Update backend to setup admin 2025-12-08 15:04:37 +02:00
dvirlabs
e0b3102007 Update backend to setup admin 2025-12-08 14:54:27 +02:00
dvirlabs
a5d87b8e25 Add permission to admin 2025-12-08 14:46:34 +02:00
dvirlabs
22639a489a Fix cut off register page 2025-12-08 10:35:08 +02:00
8d81d16682 Add admin user 2025-12-08 09:24:56 +02:00
fa5ba578bb Mapping made_by to displayname 2025-12-08 09:08:32 +02:00
53ca792988 Style register and sign in and create confirmation when sign out 2025-12-08 08:12:35 +02:00
e160357256 Update schema.sql 2025-12-08 07:33:56 +02:00
c912663c3d Update requierments.txt 2025-12-08 07:13:16 +02:00
66d2aa0a66 Update index.html 2025-12-08 07:09:14 +02:00
1d33e52100 Manage users 2025-12-08 07:04:50 +02:00
b35100c92f Check frontend tag 2025-12-08 00:40:33 +02:00
81acc68aaa Fix scroll on add new recipe and change meal type 2025-12-07 22:14:45 +02:00
36 changed files with 3990 additions and 147 deletions

3
.gitignore vendored
View File

@ -1 +1,2 @@
node_modules/ node_modules/
my-recipes-chart/

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

View File

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

View File

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

View File

@ -0,0 +1,124 @@
"""
Database utilities for managing notifications.
"""
from db_utils import get_conn
def create_notification(user_id: int, type: str, message: str, related_id: int = None):
"""Create a new notification for a user."""
conn = get_conn()
cur = conn.cursor()
cur.execute(
"""
INSERT INTO notifications (user_id, type, message, related_id)
VALUES (%s, %s, %s, %s)
RETURNING id, user_id, type, message, related_id, is_read, created_at
""",
(user_id, type, message, related_id)
)
row = cur.fetchone()
conn.commit()
cur.close()
conn.close()
if row:
return {
"id": row["id"],
"user_id": row["user_id"],
"type": row["type"],
"message": row["message"],
"related_id": row["related_id"],
"is_read": row["is_read"],
"created_at": row["created_at"]
}
return None
def get_user_notifications(user_id: int, unread_only: bool = False):
"""Get all notifications for a user."""
conn = get_conn()
cur = conn.cursor()
query = """
SELECT id, user_id, type, message, related_id, is_read, created_at
FROM notifications
WHERE user_id = %s
"""
if unread_only:
query += " AND is_read = FALSE"
query += " ORDER BY created_at DESC"
cur.execute(query, (user_id,))
rows = cur.fetchall()
cur.close()
conn.close()
notifications = []
for row in rows:
notifications.append({
"id": row["id"],
"user_id": row["user_id"],
"type": row["type"],
"message": row["message"],
"related_id": row["related_id"],
"is_read": row["is_read"],
"created_at": row["created_at"]
})
return notifications
def mark_notification_as_read(notification_id: int, user_id: int):
"""Mark a notification as read."""
conn = get_conn()
cur = conn.cursor()
cur.execute(
"""
UPDATE notifications
SET is_read = TRUE
WHERE id = %s AND user_id = %s
""",
(notification_id, user_id)
)
conn.commit()
cur.close()
conn.close()
return True
def mark_all_notifications_as_read(user_id: int):
"""Mark all notifications for a user as read."""
conn = get_conn()
cur = conn.cursor()
cur.execute(
"""
UPDATE notifications
SET is_read = TRUE
WHERE user_id = %s AND is_read = FALSE
""",
(user_id,)
)
conn.commit()
cur.close()
conn.close()
return True
def delete_notification(notification_id: int, user_id: int):
"""Delete a notification."""
conn = get_conn()
cur = conn.cursor()
cur.execute(
"""
DELETE FROM notifications
WHERE id = %s AND user_id = %s
""",
(notification_id, user_id)
)
conn.commit()
cur.close()
conn.close()
return True

View File

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

View File

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

View File

@ -34,4 +34,4 @@ RUN chmod +x /docker-entrypoint.d/10-generate-env.sh && \
EXPOSE 80 EXPOSE 80
# nginx will start automatically; our script in /docker-entrypoint.d runs first # nginx will start automatically; our script in /docker-entrypoint.d runs first

View File

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

View File

@ -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);
} }
@ -1255,4 +1313,243 @@ 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);
}

View File

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

View File

@ -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
View 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");
}

View 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;

View 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;

View 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;

View 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;

View File

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

View File

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

View File

@ -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);
@ -10,6 +10,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;
@ -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 מעלות"

View File

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

View File

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

View 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;

View File

@ -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
View 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();
};

View 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();
}