Compare commits

..

No commits in common. "social-network" and "master" have entirely different histories.

77 changed files with 249 additions and 11048 deletions

2
.gitignore vendored
View File

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

View File

@ -63,7 +63,7 @@ steps:
- |
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
echo "💡 Setting frontend tag to: $TAG"
yq -i ".frontend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
yq -i ".frontend.tag = \"$TAG\"" 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 push origin HEAD
@ -93,7 +93,7 @@ steps:
- |
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
echo "💡 Setting backend tag to: $TAG"
yq -i ".backend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
yq -i ".backend.tag = \"$TAG\"" 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 push origin HEAD

View File

@ -4,22 +4,3 @@ DB_USER=recipes_user
DB_NAME=recipes_db
DB_HOST=localhost
DB_PORT=5432
# Email Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=dvirlabs@gmail.com
SMTP_PASSWORD=agaanrhbbazbdytv
SMTP_FROM=dvirlabs@gmail.com
# Google OAuth
GOOGLE_CLIENT_ID=143092846986-hsi59m0on2c9rb5qrdoejfceieao2ioc.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
FRONTEND_URL=http://localhost:5174
# Microsoft Entra ID (Azure AD) OAuth
AZURE_CLIENT_ID=db244cf5-eb11-4738-a2ea-5b0716c9ec0a
AZURE_CLIENT_SECRET=Zad8Q~qRBxaQq8up0lLXAq4pHzrVM2JFGFJhHaDp
AZURE_TENANT_ID=consumers
AZURE_REDIRECT_URI=http://localhost:8000/auth/azure/callback

View File

@ -1,28 +0,0 @@
DATABASE_URL=postgresql://recipes_user:Aa123456@localhost:5432/recipes_db
DB_PASSWORD=Aa123456
DB_USER=recipes_user
DB_NAME=recipes_db
DB_HOST=localhost
DB_PORT=5432
# Email Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=dvirlabs@gmail.com
SMTP_PASSWORD=agaanrhbbazbdytv
SMTP_FROM=dvirlabs@gmail.com
# Secret Key for sessions (OAuth state token)
SECRET_KEY=your-super-secret-key-min-32-chars-dev-only-change-in-prod
# Google OAuth (LOCAL - localhost redirect)
GOOGLE_CLIENT_ID=143092846986-hsi59m0on2c9rb5qrdoejfceieao2ioc.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
FRONTEND_URL=http://localhost:5174
# Microsoft Entra ID (Azure AD) OAuth
AZURE_CLIENT_ID=db244cf5-eb11-4738-a2ea-5b0716c9ec0a
AZURE_CLIENT_SECRET=Zad8Q~qRBxaQq8up0lLXAq4pHzrVM2JFGFJhHaDp
AZURE_TENANT_ID=consumers
AZURE_REDIRECT_URI=http://localhost:8000/auth/azure/callback

1
backend/.gitignore vendored
View File

@ -1 +0,0 @@
__pycache__/

View File

@ -1,21 +0,0 @@
# Database Migration Instructions
## Add auth_provider column to users table
Run this command in your backend directory:
```bash
# Windows (PowerShell)
$env:PGPASSWORD="recipes_password"; psql -h localhost -U recipes_user -d recipes_db -f add_auth_provider_column.sql
# Or using psql directly
psql -h localhost -U recipes_user -d recipes_db -f add_auth_provider_column.sql
```
This will:
1. Add the `auth_provider` column to the users table (default: 'local')
2. Update all existing users to have 'local' as their auth_provider
3. Create an index for faster lookups
4. Display the updated table structure
After running the migration, restart your backend server.

View File

@ -1,30 +0,0 @@
-- Add auth_provider column to users table
-- This tracks whether the user is local or uses OAuth (google, microsoft, etc.)
-- Add the column if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name='users' AND column_name='auth_provider'
) THEN
ALTER TABLE users ADD COLUMN auth_provider VARCHAR(50) DEFAULT 'local' NOT NULL;
END IF;
END $$;
-- Update existing users to have 'local' as their auth_provider
UPDATE users SET auth_provider = 'local' WHERE auth_provider IS NULL;
-- Create index for faster lookups
CREATE INDEX IF NOT EXISTS idx_users_auth_provider ON users(auth_provider);
-- Display the updated users table structure
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'users'
ORDER BY ordinal_position;

View File

@ -1,5 +0,0 @@
-- 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

View File

@ -1,127 +0,0 @@
-- Add social networking features to recipes database
-- Add visibility column to recipes table
ALTER TABLE recipes
ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) DEFAULT 'public' CHECK (visibility IN ('public', 'private', 'friends', 'groups'));
-- Create friendships table
CREATE TABLE IF NOT EXISTS friendships (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
friend_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, friend_id),
CHECK (user_id != friend_id)
);
-- Create friend_requests table
CREATE TABLE IF NOT EXISTS friend_requests (
id SERIAL PRIMARY KEY,
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
receiver_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(sender_id, receiver_id),
CHECK (sender_id != receiver_id)
);
-- Create conversations table
CREATE TABLE IF NOT EXISTS conversations (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
is_group BOOLEAN DEFAULT FALSE,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create conversation_members table
CREATE TABLE IF NOT EXISTS conversation_members (
id SERIAL PRIMARY KEY,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(conversation_id, user_id)
);
-- Create messages table
CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
edited_at TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
-- Create recipe_ratings table
CREATE TABLE IF NOT EXISTS recipe_ratings (
id SERIAL PRIMARY KEY,
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(recipe_id, user_id)
);
-- Create recipe_comments table
CREATE TABLE IF NOT EXISTS recipe_comments (
id SERIAL PRIMARY KEY,
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
parent_comment_id INTEGER REFERENCES recipe_comments(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
-- Create groups table
CREATE TABLE IF NOT EXISTS groups (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
is_private BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create group_members table
CREATE TABLE IF NOT EXISTS group_members (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) DEFAULT 'member' CHECK (role IN ('admin', 'moderator', 'member')),
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(group_id, user_id)
);
-- Create recipe_shares table (for sharing recipes to specific groups)
CREATE TABLE IF NOT EXISTS recipe_shares (
id SERIAL PRIMARY KEY,
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
shared_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(recipe_id, group_id)
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_friendships_user_id ON friendships(user_id);
CREATE INDEX IF NOT EXISTS idx_friendships_friend_id ON friendships(friend_id);
CREATE INDEX IF NOT EXISTS idx_friend_requests_receiver ON friend_requests(receiver_id);
CREATE INDEX IF NOT EXISTS idx_friend_requests_sender ON friend_requests(sender_id);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
CREATE INDEX IF NOT EXISTS idx_conversation_members_user ON conversation_members(user_id);
CREATE INDEX IF NOT EXISTS idx_recipe_ratings_recipe ON recipe_ratings(recipe_id);
CREATE INDEX IF NOT EXISTS idx_recipe_comments_recipe ON recipe_comments(recipe_id);
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
CREATE INDEX IF NOT EXISTS idx_recipe_shares_group ON recipe_shares(group_id);
CREATE INDEX IF NOT EXISTS idx_recipes_visibility ON recipes(visibility);

View File

@ -1,97 +0,0 @@
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()
security_optional = HTTPBearer(auto_error=False)
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)) -> 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

@ -1,239 +0,0 @@
import os
import psycopg2
from psycopg2.extras import RealDictCursor
from typing import List, Optional
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"),
)
# ============= Conversations & Messages =============
def create_conversation(user_ids: List[int], is_group: bool = False, name: Optional[str] = None, created_by: int = None):
"""Create a new conversation"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# For private chats, check if conversation already exists
if not is_group and len(user_ids) == 2:
cur.execute(
"""
SELECT c.id FROM conversations c
JOIN conversation_members cm1 ON c.id = cm1.conversation_id
JOIN conversation_members cm2 ON c.id = cm2.conversation_id
WHERE c.is_group = FALSE
AND cm1.user_id = %s AND cm2.user_id = %s
""",
(user_ids[0], user_ids[1])
)
existing = cur.fetchone()
if existing:
return get_conversation(existing["id"])
# Create conversation
cur.execute(
"""
INSERT INTO conversations (name, is_group, created_by)
VALUES (%s, %s, %s)
RETURNING id, name, is_group, created_by, created_at
""",
(name, is_group, created_by)
)
conversation = dict(cur.fetchone())
conversation_id = conversation["id"]
# Add members
for user_id in user_ids:
cur.execute(
"INSERT INTO conversation_members (conversation_id, user_id) VALUES (%s, %s)",
(conversation_id, user_id)
)
conn.commit()
# Return conversation with conversation_id field
conversation["conversation_id"] = conversation["id"]
return conversation
finally:
cur.close()
conn.close()
def get_conversation(conversation_id: int):
"""Get conversation details"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"SELECT id, name, is_group, created_by, created_at FROM conversations WHERE id = %s",
(conversation_id,)
)
conversation = cur.fetchone()
if not conversation:
return None
# Get members
cur.execute(
"""
SELECT u.id, u.username, u.display_name
FROM conversation_members cm
JOIN users u ON u.id = cm.user_id
WHERE cm.conversation_id = %s
""",
(conversation_id,)
)
members = [dict(row) for row in cur.fetchall()]
result = dict(conversation)
result["members"] = members
return result
finally:
cur.close()
conn.close()
def get_user_conversations(user_id: int):
"""Get all conversations for a user"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT c.id AS conversation_id, c.name, c.is_group, c.created_at,
(SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id AND m.created_at > cm.last_read_at) AS unread_count,
(SELECT m.content FROM messages m WHERE m.conversation_id = c.id ORDER BY m.created_at DESC LIMIT 1) AS last_message,
(SELECT m.created_at FROM messages m WHERE m.conversation_id = c.id ORDER BY m.created_at DESC LIMIT 1) AS last_message_at
FROM conversations c
JOIN conversation_members cm ON c.id = cm.conversation_id
WHERE cm.user_id = %s
ORDER BY last_message_at DESC NULLS LAST, c.created_at DESC
""",
(user_id,)
)
conversations = [dict(row) for row in cur.fetchall()]
# Get members for each conversation and add other_member_name for private chats
for conv in conversations:
cur.execute(
"""
SELECT u.id, u.username, u.display_name, u.email
FROM conversation_members cm
JOIN users u ON u.id = cm.user_id
WHERE cm.conversation_id = %s AND u.id != %s
""",
(conv["conversation_id"], user_id)
)
members = [dict(row) for row in cur.fetchall()]
conv["members"] = members
# For private chats, add other_member_name
if not conv["is_group"] and len(members) > 0:
conv["other_member_name"] = members[0].get("display_name") or members[0].get("username") or members[0].get("email")
return conversations
finally:
cur.close()
conn.close()
def send_message(conversation_id: int, sender_id: int, content: str):
"""Send a message in a conversation"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Verify user is member of conversation
cur.execute(
"SELECT 1 FROM conversation_members WHERE conversation_id = %s AND user_id = %s",
(conversation_id, sender_id)
)
if not cur.fetchone():
return {"error": "Not a member of this conversation"}
cur.execute(
"""
INSERT INTO messages (conversation_id, sender_id, content)
VALUES (%s, %s, %s)
RETURNING id, conversation_id, sender_id, content, created_at
""",
(conversation_id, sender_id, content)
)
message = cur.fetchone()
# Update conversation updated_at
cur.execute(
"UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(conversation_id,)
)
conn.commit()
return dict(message)
finally:
cur.close()
conn.close()
def get_messages(conversation_id: int, user_id: int, limit: int = 50, before_id: Optional[int] = None):
"""Get messages from a conversation"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Verify user is member
cur.execute(
"SELECT 1 FROM conversation_members WHERE conversation_id = %s AND user_id = %s",
(conversation_id, user_id)
)
if not cur.fetchone():
return {"error": "Not a member of this conversation"}
# Get messages
if before_id:
cur.execute(
"""
SELECT m.id AS message_id, m.sender_id, m.content, m.created_at, m.edited_at,
u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email,
CASE WHEN m.sender_id = %s THEN TRUE ELSE FALSE END AS is_mine
FROM messages m
JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = %s AND m.is_deleted = FALSE AND m.id < %s
ORDER BY m.created_at DESC
LIMIT %s
""",
(user_id, conversation_id, before_id, limit)
)
else:
cur.execute(
"""
SELECT m.id AS message_id, m.sender_id, m.content, m.created_at, m.edited_at,
u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email,
CASE WHEN m.sender_id = %s THEN TRUE ELSE FALSE END AS is_mine
FROM messages m
JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = %s AND m.is_deleted = FALSE
ORDER BY m.created_at DESC
LIMIT %s
""",
(user_id, conversation_id, limit)
)
messages = [dict(row) for row in cur.fetchall()]
messages.reverse() # Return in chronological order
# Mark as read
cur.execute(
"UPDATE conversation_members SET last_read_at = CURRENT_TIMESTAMP WHERE conversation_id = %s AND user_id = %s",
(conversation_id, user_id)
)
conn.commit()
return messages
finally:
cur.close()
conn.close()

View File

@ -48,43 +48,18 @@ def get_conn():
return psycopg2.connect(dsn, cursor_factory=RealDictCursor)
def list_recipes_db(user_id: Optional[int] = None) -> List[Dict[str, Any]]:
"""List recipes visible to the user. If user_id is None, only show public recipes."""
def list_recipes_db() -> List[Dict[str, Any]]:
conn = get_conn()
try:
with conn.cursor() as cur:
if user_id is None:
# Not authenticated - only public recipes
cur.execute(
"""
SELECT r.id, r.name, r.meal_type, r.time_minutes,
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
r.visibility, u.display_name as owner_display_name
FROM recipes r
LEFT JOIN users u ON r.user_id = u.id
WHERE r.visibility = 'public'
ORDER BY r.id
"""
)
else:
# Authenticated - show public, own recipes, friends' recipes
cur.execute(
"""
SELECT r.id, r.name, r.meal_type, r.time_minutes,
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
r.visibility, u.display_name as owner_display_name
FROM recipes r
LEFT JOIN users u ON r.user_id = u.id
WHERE r.visibility = 'public'
OR r.user_id = %s
OR (r.visibility = 'friends' AND EXISTS (
SELECT 1 FROM friendships f
WHERE f.user_id = %s AND f.friend_id = r.user_id
))
ORDER BY r.id
""",
(user_id, user_id)
)
cur.execute(
"""
SELECT id, name, meal_type, time_minutes,
tags, ingredients, steps, image, made_by
FROM recipes
ORDER BY id
"""
)
rows = cur.fetchall()
return rows
finally:
@ -93,7 +68,7 @@ def list_recipes_db(user_id: Optional[int] = None) -> List[Dict[str, Any]]:
def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
עדכון מתכון קיים לפי id.
recipe_data: name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, visibility
recipe_data: name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
"""
conn = get_conn()
try:
@ -108,10 +83,9 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
ingredients = %s,
steps = %s,
image = %s,
made_by = %s,
visibility = %s
made_by = %s
WHERE id = %s
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id, visibility
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
""",
(
recipe_data["name"],
@ -122,7 +96,6 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
json.dumps(recipe_data.get("steps", [])),
recipe_data.get("image"),
recipe_data.get("made_by"),
recipe_data.get("visibility", "public"),
recipe_id,
),
)
@ -160,9 +133,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id, visibility)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id, visibility
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
""",
(
recipe_data["name"],
@ -173,8 +146,6 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
json.dumps(recipe_data.get("steps", [])),
recipe_data.get("image"),
recipe_data.get("made_by"),
recipe_data.get("user_id"),
recipe_data.get("visibility", "public"),
),
)
row = cur.fetchone()
@ -187,40 +158,23 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
def get_recipes_by_filters_db(
meal_type: Optional[str],
max_time: Optional[int],
user_id: Optional[int] = None,
) -> List[Dict[str, Any]]:
conn = get_conn()
try:
query = """
SELECT r.id, r.name, r.meal_type, r.time_minutes,
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
r.visibility, u.display_name as owner_display_name
FROM recipes r
LEFT JOIN users u ON r.user_id = u.id
SELECT id, name, meal_type, time_minutes,
tags, ingredients, steps, image, made_by
FROM recipes
WHERE 1=1
"""
params: List = []
# Visibility filter
if user_id is None:
query += " AND r.visibility = 'public'"
else:
query += """ AND (
r.visibility = 'public'
OR r.user_id = %s
OR (r.visibility = 'friends' AND EXISTS (
SELECT 1 FROM friendships f
WHERE f.user_id = %s AND f.friend_id = r.user_id
))
)"""
params.extend([user_id, user_id])
if meal_type:
query += " AND r.meal_type = %s"
query += " AND meal_type = %s"
params.append(meal_type.lower())
if max_time:
query += " AND r.time_minutes <= %s"
query += " AND time_minutes <= %s"
params.append(max_time)
with conn.cursor() as cur:

View File

@ -1,211 +0,0 @@
import os
import random
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from dotenv import load_dotenv
load_dotenv()
# In-memory storage for verification codes (in production, use Redis or database)
verification_codes = {}
password_reset_tokens = {}
def generate_verification_code():
"""Generate a 6-digit verification code"""
return str(random.randint(100000, 999999))
async def send_verification_email(email: str, code: str, purpose: str = "password_change"):
"""Send verification code via email"""
smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
smtp_port = int(os.getenv("SMTP_PORT", "587"))
smtp_user = os.getenv("SMTP_USER")
smtp_password = os.getenv("SMTP_PASSWORD")
smtp_from = os.getenv("SMTP_FROM", smtp_user)
if not smtp_user or not smtp_password:
raise Exception("SMTP credentials not configured")
# Create message
message = MIMEMultipart("alternative")
message["Subject"] = "קוד אימות - מתכונים שלי"
message["From"] = smtp_from
message["To"] = email
# Email content
if purpose == "password_change":
text = f"""
שלום,
קוד האימות שלך לשינוי סיסמה הוא: {code}
הקוד תקף ל-10 דקות.
אם לא ביקשת לשנות את הסיסמה, התעלם מהודעה זו.
בברכה,
צוות מתכונים שלי
"""
html = f"""
<html dir="rtl">
<body style="font-family: Arial, sans-serif; direction: rtl; text-align: right;">
<h2>שינוי סיסמה</h2>
<p>קוד האימות שלך הוא:</p>
<h1 style="color: #22c55e; font-size: 32px; letter-spacing: 5px;">{code}</h1>
<p>הקוד תקף ל-<strong>10 דקות</strong>.</p>
<hr style="border: 1px solid #e5e7eb; margin: 20px 0;">
<p style="color: #6b7280; font-size: 14px;">
אם לא ביקשת לשנות את הסיסמה, התעלם מהודעה זו.
</p>
</body>
</html>
"""
part1 = MIMEText(text, "plain")
part2 = MIMEText(html, "html")
message.attach(part1)
message.attach(part2)
# Send email
await aiosmtplib.send(
message,
hostname=smtp_host,
port=smtp_port,
username=smtp_user,
password=smtp_password,
start_tls=True,
)
def store_verification_code(user_id: int, code: str):
"""Store verification code with expiry"""
expiry = datetime.now() + timedelta(minutes=10)
verification_codes[user_id] = {
"code": code,
"expiry": expiry
}
def verify_code(user_id: int, code: str) -> bool:
"""Verify if code is correct and not expired"""
if user_id not in verification_codes:
return False
stored = verification_codes[user_id]
# Check if expired
if datetime.now() > stored["expiry"]:
del verification_codes[user_id]
return False
# Check if code matches
if stored["code"] != code:
return False
# Code is valid, remove it
del verification_codes[user_id]
return True
async def send_password_reset_email(email: str, token: str, frontend_url: str):
"""Send password reset link via email"""
smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
smtp_port = int(os.getenv("SMTP_PORT", "587"))
smtp_user = os.getenv("SMTP_USER")
smtp_password = os.getenv("SMTP_PASSWORD")
smtp_from = os.getenv("SMTP_FROM", smtp_user)
if not smtp_user or not smtp_password:
raise Exception("SMTP credentials not configured")
reset_link = f"{frontend_url}?reset_token={token}"
# Create message
message = MIMEMultipart("alternative")
message["Subject"] = "איפוס סיסמה - מתכונים שלי"
message["From"] = smtp_from
message["To"] = email
text = f"""
שלום,
קיבלנו בקשה לאיפוס הסיסמה שלך.
לחץ על הקישור הבא כדי לאפס את הסיסמה (תקף ל-30 דקות):
{reset_link}
אם לא ביקשת לאפס את הסיסמה, התעלם מהודעה זו.
בברכה,
צוות מתכונים שלי
"""
html = f"""
<html dir="rtl">
<body style="font-family: Arial, sans-serif; direction: rtl; text-align: right;">
<h2>איפוס סיסמה</h2>
<p>קיבלנו בקשה לאיפוס הסיסמה שלך.</p>
<p>לחץ על הכפתור למטה כדי לאפס את הסיסמה:</p>
<div style="margin: 30px 0; text-align: center;">
<a href="{reset_link}"
style="background-color: #22c55e; color: white; padding: 12px 30px;
text-decoration: none; border-radius: 6px; display: inline-block;
font-weight: bold;">
איפוס סיסמה
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
הקישור תקף ל-<strong>30 דקות</strong>.
</p>
<hr style="border: 1px solid #e5e7eb; margin: 20px 0;">
<p style="color: #6b7280; font-size: 14px;">
אם לא ביקשת לאפס את הסיסמה, התעלם מהודעה זו.
</p>
</body>
</html>
"""
part1 = MIMEText(text, "plain")
part2 = MIMEText(html, "html")
message.attach(part1)
message.attach(part2)
# Send email
await aiosmtplib.send(
message,
hostname=smtp_host,
port=smtp_port,
username=smtp_user,
password=smtp_password,
start_tls=True,
)
def store_password_reset_token(email: str, token: str):
"""Store password reset token with expiry"""
expiry = datetime.now() + timedelta(minutes=30)
password_reset_tokens[token] = {
"email": email,
"expiry": expiry
}
def verify_reset_token(token: str) -> str:
"""Verify reset token and return email if valid"""
if token not in password_reset_tokens:
return None
stored = password_reset_tokens[token]
# Check if expired
if datetime.now() > stored["expiry"]:
del password_reset_tokens[token]
return None
return stored["email"]
def consume_reset_token(token: str):
"""Remove token after use"""
if token in password_reset_tokens:
del password_reset_tokens[token]

View File

@ -1,253 +0,0 @@
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,380 +0,0 @@
import os
import psycopg2
from psycopg2.extras import RealDictCursor
from typing import List, Optional
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"),
)
# ============= Groups =============
def create_group(name: str, description: str, created_by: int, is_private: bool = False):
"""Create a new group"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
INSERT INTO groups (name, description, created_by, is_private)
VALUES (%s, %s, %s, %s)
RETURNING id, name, description, created_by, is_private, created_at
""",
(name, description, created_by, is_private)
)
group = dict(cur.fetchone())
# Add creator as admin
cur.execute(
"INSERT INTO group_members (group_id, user_id, role) VALUES (%s, %s, 'admin')",
(group["id"], created_by)
)
conn.commit()
return group
finally:
cur.close()
conn.close()
def get_group(group_id: int):
"""Get group details"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"SELECT id, name, description, created_by, is_private, created_at FROM groups WHERE id = %s",
(group_id,)
)
group = cur.fetchone()
if not group:
return None
# Get members
cur.execute(
"""
SELECT gm.role, gm.joined_at, u.id, u.username, u.display_name
FROM group_members gm
JOIN users u ON u.id = gm.user_id
WHERE gm.group_id = %s
ORDER BY gm.role, u.display_name
""",
(group_id,)
)
members = [dict(row) for row in cur.fetchall()]
result = dict(group)
result["members"] = members
result["member_count"] = len(members)
return result
finally:
cur.close()
conn.close()
def get_user_groups(user_id: int):
"""Get all groups user is member of"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT g.id, g.name, g.description, g.is_private, g.created_at, gm.role,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count
FROM groups g
JOIN group_members gm ON g.id = gm.group_id
WHERE gm.user_id = %s
ORDER BY g.name
""",
(user_id,)
)
return [dict(row) for row in cur.fetchall()]
finally:
cur.close()
conn.close()
def add_group_member(group_id: int, user_id: int, added_by: int):
"""Add a member to a group"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Check if added_by is admin/moderator
cur.execute(
"SELECT role FROM group_members WHERE group_id = %s AND user_id = %s",
(group_id, added_by)
)
adder = cur.fetchone()
if not adder or adder["role"] not in ["admin", "moderator"]:
return {"error": "Only admins and moderators can add members"}
cur.execute(
"""
INSERT INTO group_members (group_id, user_id, role)
VALUES (%s, %s, 'member')
ON CONFLICT (group_id, user_id) DO NOTHING
RETURNING id
""",
(group_id, user_id)
)
result = cur.fetchone()
conn.commit()
if result:
return {"success": True}
else:
return {"error": "User is already a member"}
finally:
cur.close()
conn.close()
def remove_group_member(group_id: int, user_id: int, removed_by: int):
"""Remove a member from a group"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Check permissions
cur.execute(
"SELECT role FROM group_members WHERE group_id = %s AND user_id = %s",
(group_id, removed_by)
)
remover = cur.fetchone()
# User can remove themselves, or admins/moderators can remove others
if removed_by != user_id:
if not remover or remover["role"] not in ["admin", "moderator"]:
return {"error": "Only admins and moderators can remove members"}
cur.execute(
"DELETE FROM group_members WHERE group_id = %s AND user_id = %s",
(group_id, user_id)
)
conn.commit()
return {"success": True}
finally:
cur.close()
conn.close()
# ============= Recipe Ratings & Comments =============
def add_or_update_rating(recipe_id: int, user_id: int, rating: int):
"""Add or update a rating for a recipe"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
INSERT INTO recipe_ratings (recipe_id, user_id, rating)
VALUES (%s, %s, %s)
ON CONFLICT (recipe_id, user_id)
DO UPDATE SET rating = EXCLUDED.rating, updated_at = CURRENT_TIMESTAMP
RETURNING id, recipe_id, user_id, rating, created_at, updated_at
""",
(recipe_id, user_id, rating)
)
result = cur.fetchone()
conn.commit()
return dict(result)
finally:
cur.close()
conn.close()
def get_recipe_rating_stats(recipe_id: int):
"""Get rating statistics for a recipe"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT
COUNT(*) as rating_count,
AVG(rating)::DECIMAL(3,2) as average_rating,
COUNT(CASE WHEN rating = 5 THEN 1 END) as five_star,
COUNT(CASE WHEN rating = 4 THEN 1 END) as four_star,
COUNT(CASE WHEN rating = 3 THEN 1 END) as three_star,
COUNT(CASE WHEN rating = 2 THEN 1 END) as two_star,
COUNT(CASE WHEN rating = 1 THEN 1 END) as one_star
FROM recipe_ratings
WHERE recipe_id = %s
""",
(recipe_id,)
)
return dict(cur.fetchone())
finally:
cur.close()
conn.close()
def get_user_recipe_rating(recipe_id: int, user_id: int):
"""Get user's rating for a recipe"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"SELECT rating FROM recipe_ratings WHERE recipe_id = %s AND user_id = %s",
(recipe_id, user_id)
)
result = cur.fetchone()
return dict(result) if result else None
finally:
cur.close()
conn.close()
def add_comment(recipe_id: int, user_id: int, content: str, parent_comment_id: Optional[int] = None):
"""Add a comment to a recipe"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
INSERT INTO recipe_comments (recipe_id, user_id, content, parent_comment_id)
VALUES (%s, %s, %s, %s)
RETURNING id, recipe_id, user_id, content, parent_comment_id, created_at
""",
(recipe_id, user_id, content, parent_comment_id)
)
result = cur.fetchone()
conn.commit()
return dict(result)
finally:
cur.close()
conn.close()
def get_recipe_comments(recipe_id: int):
"""Get all comments for a recipe"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT c.id, c.content, c.parent_comment_id, c.created_at, c.updated_at,
u.id AS user_id, u.username, u.display_name
FROM recipe_comments c
JOIN users u ON u.id = c.user_id
WHERE c.recipe_id = %s AND c.is_deleted = FALSE
ORDER BY c.created_at ASC
""",
(recipe_id,)
)
return [dict(row) for row in cur.fetchall()]
finally:
cur.close()
conn.close()
def update_comment(comment_id: int, user_id: int, content: str):
"""Update a comment"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"""
UPDATE recipe_comments
SET content = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND user_id = %s AND is_deleted = FALSE
""",
(content, comment_id, user_id)
)
conn.commit()
return {"success": True}
finally:
cur.close()
conn.close()
def delete_comment(comment_id: int, user_id: int):
"""Soft delete a comment"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"UPDATE recipe_comments SET is_deleted = TRUE WHERE id = %s AND user_id = %s",
(comment_id, user_id)
)
conn.commit()
return {"success": True}
finally:
cur.close()
conn.close()
# ============= Recipe Shares to Groups =============
def share_recipe_to_group(recipe_id: int, group_id: int, user_id: int):
"""Share a recipe to a group"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Check if user is member of group
cur.execute(
"SELECT 1 FROM group_members WHERE group_id = %s AND user_id = %s",
(group_id, user_id)
)
if not cur.fetchone():
return {"error": "Not a member of this group"}
cur.execute(
"""
INSERT INTO recipe_shares (recipe_id, group_id, shared_by)
VALUES (%s, %s, %s)
ON CONFLICT (recipe_id, group_id) DO NOTHING
RETURNING id
""",
(recipe_id, group_id, user_id)
)
result = cur.fetchone()
conn.commit()
if result:
return {"success": True}
else:
return {"error": "Recipe already shared to this group"}
finally:
cur.close()
conn.close()
def get_group_recipes(group_id: int, user_id: int):
"""Get all recipes shared to a group"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Verify user is member
cur.execute(
"SELECT 1 FROM group_members WHERE group_id = %s AND user_id = %s",
(group_id, user_id)
)
if not cur.fetchone():
return {"error": "Not a member of this group"}
cur.execute(
"""
SELECT r.*, u.username AS owner_username, u.display_name AS owner_display_name,
rs.shared_at, rs.shared_by,
u2.username AS shared_by_username, u2.display_name AS shared_by_display_name
FROM recipe_shares rs
JOIN recipes r ON r.id = rs.recipe_id
JOIN users u ON u.id = r.user_id
JOIN users u2 ON u2.id = rs.shared_by
WHERE rs.group_id = %s
ORDER BY rs.shared_at DESC
""",
(group_id,)
)
return [dict(row) for row in cur.fetchall()]
finally:
cur.close()
conn.close()

File diff suppressed because it is too large Load Diff

View File

@ -1,124 +0,0 @@
"""
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

@ -1,33 +0,0 @@
import os
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
# Load config
config = Config('.env')
# Initialize OAuth
oauth = OAuth(config)
# Register Google OAuth
oauth.register(
name='google',
client_id=os.getenv('GOOGLE_CLIENT_ID'),
client_secret=os.getenv('GOOGLE_CLIENT_SECRET'),
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid email profile'
}
)
# Register Microsoft Entra ID (Azure AD) OAuth
# Use 'common' for multi-tenant + personal accounts, or 'consumers' for personal accounts only
tenant_id = os.getenv('AZURE_TENANT_ID', 'common')
oauth.register(
name='azure',
client_id=os.getenv('AZURE_CLIENT_ID'),
client_secret=os.getenv('AZURE_CLIENT_SECRET'),
server_metadata_url=f'https://login.microsoftonline.com/{tenant_id}/v2.0/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid email profile'
}
)

View File

@ -2,21 +2,6 @@ fastapi==0.115.0
uvicorn[standard]==0.30.1
pydantic==2.7.4
pydantic[email]==2.7.4
python-dotenv==1.0.1
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
# Email
aiosmtplib==3.0.2
# OAuth
authlib==1.3.0
httpx==0.27.0
itsdangerous==2.1.2

View File

@ -1,41 +0,0 @@
import psycopg2
import bcrypt
import os
from dotenv import load_dotenv
load_dotenv()
# New password for admin
new_password = "admin123" # Change this to whatever you want
# Hash the password
salt = bcrypt.gensalt()
password_hash = bcrypt.hashpw(new_password.encode('utf-8'), salt).decode('utf-8')
# Update in database
conn = psycopg2.connect(os.getenv('DATABASE_URL'))
cur = conn.cursor()
# Update admin password
cur.execute(
"UPDATE users SET password_hash = %s WHERE username = %s",
(password_hash, 'admin')
)
conn.commit()
# Verify
cur.execute("SELECT username, email, is_admin FROM users WHERE username = 'admin'")
user = cur.fetchone()
if user:
print(f"✓ Admin password updated successfully!")
print(f" Username: {user[0]}")
print(f" Email: {user[1]}")
print(f" Is Admin: {user[2]}")
print(f"\nYou can now login with:")
print(f" Username: admin")
print(f" Password: {new_password}")
else:
print("✗ Admin user not found!")
cur.close()
conn.close()

View File

@ -1 +0,0 @@
# Router package initialization

View File

@ -1,88 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import List, Optional
from auth_utils import get_current_user
from chat_db_utils import (
create_conversation,
get_conversation,
get_user_conversations,
send_message,
get_messages,
)
router = APIRouter(prefix="/conversations", tags=["chat"])
class ConversationCreate(BaseModel):
user_ids: List[int]
is_group: bool = False
name: Optional[str] = None
class MessageCreate(BaseModel):
content: str
@router.post("")
def create_conversation_endpoint(
data: ConversationCreate,
current_user: dict = Depends(get_current_user)
):
"""Create a new conversation (private or group chat)"""
user_ids = data.user_ids
if current_user["user_id"] not in user_ids:
user_ids.append(current_user["user_id"])
return create_conversation(
user_ids=user_ids,
is_group=data.is_group,
name=data.name,
created_by=current_user["user_id"]
)
@router.get("")
def get_conversations_endpoint(
current_user: dict = Depends(get_current_user)
):
"""Get all conversations for current user"""
return get_user_conversations(current_user["user_id"])
@router.get("/{conversation_id}")
def get_conversation_endpoint(
conversation_id: int,
current_user: dict = Depends(get_current_user)
):
"""Get conversation details"""
conversation = get_conversation(conversation_id)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
return conversation
@router.post("/{conversation_id}/messages")
def send_message_endpoint(
conversation_id: int,
data: MessageCreate,
current_user: dict = Depends(get_current_user)
):
"""Send a message in a conversation"""
result = send_message(conversation_id, current_user["user_id"], data.content)
if "error" in result:
raise HTTPException(status_code=403, detail=result["error"])
return result
@router.get("/{conversation_id}/messages")
def get_messages_endpoint(
conversation_id: int,
limit: int = Query(50, le=100),
before_id: Optional[int] = None,
current_user: dict = Depends(get_current_user)
):
"""Get messages from a conversation"""
result = get_messages(conversation_id, current_user["user_id"], limit, before_id)
if isinstance(result, dict) and "error" in result:
raise HTTPException(status_code=403, detail=result["error"])
return result

View File

@ -1,152 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import List
from auth_utils import get_current_user
from social_db_utils import (
send_friend_request,
accept_friend_request,
reject_friend_request,
get_friend_requests,
get_friends,
remove_friend,
search_users,
)
from notification_db_utils import create_notification
from user_db_utils import get_user_by_id
router = APIRouter(prefix="/friends", tags=["friends"])
class FriendRequestModel(BaseModel):
receiver_id: int
@router.post("/request")
def send_friend_request_endpoint(
request: FriendRequestModel,
current_user: dict = Depends(get_current_user)
):
"""Send a friend request to another user"""
result = send_friend_request(current_user["user_id"], request.receiver_id)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
# Create notification for receiver
create_notification(
user_id=request.receiver_id,
type="friend_request",
message=f"{current_user['display_name']} שלח לך בקשת חברות",
related_id=result.get("id")
)
return result
@router.get("/requests")
def get_friend_requests_endpoint(
current_user: dict = Depends(get_current_user)
):
"""Get pending friend requests"""
return get_friend_requests(current_user["user_id"])
@router.post("/requests/{request_id}/accept")
def accept_friend_request_endpoint(
request_id: int,
current_user: dict = Depends(get_current_user)
):
"""Accept a friend request"""
# Get request details before accepting
from social_db_utils import get_db_connection
from psycopg2.extras import RealDictCursor
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"SELECT sender_id, receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'",
(request_id,)
)
request_data = cur.fetchone()
# Verify current user is the receiver
if not request_data:
raise HTTPException(status_code=404, detail="Request not found")
if request_data["receiver_id"] != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Not authorized to accept this request")
finally:
cur.close()
conn.close()
result = accept_friend_request(request_id)
if "error" in result:
raise HTTPException(status_code=404, detail=result["error"])
# Create notification for sender that their request was accepted
if request_data:
create_notification(
user_id=request_data["sender_id"],
type="friend_accepted",
message=f"{current_user['display_name']} קיבל את בקשת החברות שלך",
related_id=current_user["user_id"]
)
return result
@router.post("/requests/{request_id}/reject")
def reject_friend_request_endpoint(
request_id: int,
current_user: dict = Depends(get_current_user)
):
"""Reject a friend request"""
# Verify current user is the receiver
from social_db_utils import get_db_connection
from psycopg2.extras import RealDictCursor
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"SELECT receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'",
(request_id,)
)
request_data = cur.fetchone()
if not request_data:
raise HTTPException(status_code=404, detail="Request not found")
if request_data["receiver_id"] != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Not authorized to reject this request")
finally:
cur.close()
conn.close()
return reject_friend_request(request_id)
@router.get("")
def get_friends_endpoint(
current_user: dict = Depends(get_current_user)
):
"""Get list of user's friends"""
return get_friends(current_user["user_id"])
@router.delete("/{friend_id}")
def remove_friend_endpoint(
friend_id: int,
current_user: dict = Depends(get_current_user)
):
"""Remove a friend"""
from fastapi.responses import Response
remove_friend(current_user["user_id"], friend_id)
return Response(status_code=204)
@router.get("/search")
def search_users_for_friends_endpoint(
q: str = Query(..., min_length=1),
current_user: dict = Depends(get_current_user)
):
"""Search users to add as friends"""
return search_users(q, current_user["user_id"])

View File

@ -1,163 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from typing import Optional
from auth_utils import get_current_user
from groups_db_utils import (
create_group,
get_group,
get_user_groups,
add_group_member,
remove_group_member,
share_recipe_to_group,
get_group_recipes,
)
from notification_db_utils import create_notification
router = APIRouter(prefix="/groups", tags=["groups"])
class GroupCreate(BaseModel):
name: str
description: str = ""
is_private: bool = False
@router.post("")
def create_group_endpoint(
data: GroupCreate,
current_user: dict = Depends(get_current_user)
):
"""Create a new group"""
return create_group(
name=data.name,
description=data.description,
created_by=current_user["user_id"],
is_private=data.is_private
)
@router.get("")
def get_user_groups_endpoint(
current_user: dict = Depends(get_current_user)
):
"""Get all groups user is member of"""
return get_user_groups(current_user["user_id"])
@router.get("/{group_id}")
def get_group_endpoint(
group_id: int,
current_user: dict = Depends(get_current_user)
):
"""Get group details"""
group = get_group(group_id)
if not group:
raise HTTPException(status_code=404, detail="Group not found")
return group
@router.post("/{group_id}/members/{user_id}")
def add_group_member_endpoint(
group_id: int,
user_id: int,
current_user: dict = Depends(get_current_user)
):
"""Add a member to a group"""
# Get group name
group = get_group(group_id)
result = add_group_member(group_id, user_id, current_user["user_id"])
if "error" in result:
raise HTTPException(status_code=403, detail=result["error"])
# Notify the added user
if group:
create_notification(
user_id=user_id,
type="group_invite",
message=f"{current_user['display_name']} הוסיף אותך לקבוצה '{group['name']}'" ,
related_id=group_id
)
return result
@router.delete("/{group_id}/members/{user_id}")
def remove_group_member_endpoint(
group_id: int,
user_id: int,
current_user: dict = Depends(get_current_user)
):
"""Remove a member from a group"""
result = remove_group_member(group_id, user_id, current_user["user_id"])
if "error" in result:
raise HTTPException(status_code=403, detail=result["error"])
return Response(status_code=204)
@router.post("/{group_id}/recipes/{recipe_id}")
def share_recipe_to_group_endpoint(
group_id: int,
recipe_id: int,
current_user: dict = Depends(get_current_user)
):
"""Share a recipe to a group"""
from groups_db_utils import get_db_connection
from psycopg2.extras import RealDictCursor
from db_utils import get_conn
# Get group members and names
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""SELECT gm.user_id, g.name as group_name
FROM group_members gm
JOIN groups g ON gm.group_id = g.id
WHERE gm.group_id = %s AND gm.user_id != %s""",
(group_id, current_user["user_id"])
)
members = cur.fetchall()
finally:
cur.close()
conn.close()
# Get recipe name
recipe_conn = get_conn()
recipe_cur = recipe_conn.cursor(cursor_factory=RealDictCursor)
try:
recipe_cur.execute("SELECT name FROM recipes WHERE id = %s", (recipe_id,))
recipe = recipe_cur.fetchone()
finally:
recipe_cur.close()
recipe_conn.close()
result = share_recipe_to_group(recipe_id, group_id, current_user["user_id"])
if "error" in result:
raise HTTPException(status_code=403, detail=result["error"])
# Notify all group members except the sharer
if members and recipe:
group_name = members[0]["group_name"] if members else ""
for member in members:
create_notification(
user_id=member["user_id"],
type="recipe_shared",
message=f"{current_user['display_name']} שיתף מתכון '{recipe['name']}' בקבוצה '{group_name}'",
related_id=recipe_id
)
return result
@router.get("/{group_id}/recipes")
def get_group_recipes_endpoint(
group_id: int,
current_user: dict = Depends(get_current_user)
):
"""Get all recipes shared to a group"""
result = get_group_recipes(group_id, current_user["user_id"])
if isinstance(result, dict) and "error" in result:
raise HTTPException(status_code=403, detail=result["error"])
return result

View File

@ -1,91 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from typing import Optional
from auth_utils import get_current_user
from groups_db_utils import (
add_or_update_rating,
get_recipe_rating_stats,
get_user_recipe_rating,
add_comment,
get_recipe_comments,
update_comment,
delete_comment,
)
router = APIRouter(tags=["ratings-comments"])
class RatingCreate(BaseModel):
rating: int
class CommentCreate(BaseModel):
content: str
parent_comment_id: Optional[int] = None
@router.post("/recipes/{recipe_id}/rating")
def rate_recipe_endpoint(
recipe_id: int,
data: RatingCreate,
current_user: dict = Depends(get_current_user)
):
"""Add or update rating for a recipe"""
if data.rating < 1 or data.rating > 5:
raise HTTPException(status_code=400, detail="Rating must be between 1 and 5")
return add_or_update_rating(recipe_id, current_user["user_id"], data.rating)
@router.get("/recipes/{recipe_id}/rating/stats")
def get_recipe_rating_stats_endpoint(recipe_id: int):
"""Get rating statistics for a recipe"""
return get_recipe_rating_stats(recipe_id)
@router.get("/recipes/{recipe_id}/rating/mine")
def get_my_recipe_rating_endpoint(
recipe_id: int,
current_user: dict = Depends(get_current_user)
):
"""Get current user's rating for a recipe"""
rating = get_user_recipe_rating(recipe_id, current_user["user_id"])
if not rating:
return {"rating": None}
return rating
@router.post("/recipes/{recipe_id}/comments")
def add_comment_endpoint(
recipe_id: int,
data: CommentCreate,
current_user: dict = Depends(get_current_user)
):
"""Add a comment to a recipe"""
return add_comment(recipe_id, current_user["user_id"], data.content, data.parent_comment_id)
@router.get("/recipes/{recipe_id}/comments")
def get_comments_endpoint(recipe_id: int):
"""Get all comments for a recipe"""
return get_recipe_comments(recipe_id)
@router.patch("/comments/{comment_id}")
def update_comment_endpoint(
comment_id: int,
data: CommentCreate,
current_user: dict = Depends(get_current_user)
):
"""Update a comment"""
return update_comment(comment_id, current_user["user_id"], data.content)
@router.delete("/comments/{comment_id}")
def delete_comment_endpoint(
comment_id: int,
current_user: dict = Depends(get_current_user)
):
"""Delete a comment"""
delete_comment(comment_id, current_user["user_id"])
return Response(status_code=204)

View File

@ -1,32 +1,14 @@
-- 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 TABLE IF NOT EXISTS recipes (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack
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
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- Recipe owner
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"]
ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"]
steps JSONB NOT NULL DEFAULT '[]', -- ["לחתוך", "לבשל", ...]
image TEXT -- Base64-encoded image or image URL
);
-- Optional: index for filters
@ -39,90 +21,9 @@ CREATE INDEX IF NOT EXISTS idx_recipes_time_minutes
CREATE INDEX IF NOT EXISTS idx_recipes_made_by
ON recipes (made_by);
CREATE INDEX IF NOT EXISTS idx_recipes_user_id
ON recipes (user_id);
-- Create grocery lists table
CREATE TABLE IF NOT EXISTS grocery_lists (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
items TEXT[] NOT NULL DEFAULT '{}', -- Array of grocery items
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
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 demo recipes
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, made_by, user_id, visibility)
VALUES
(
'שקשוקה ביתית',
'breakfast',
25,
ARRAY['מהיר', 'בריא', 'צמחוני'],
ARRAY['4 ביצים', '2 עגבניות גדולות', '1 בצל', '2 שיני שום', 'פלפל אדום', 'כוסברה', 'כמון', 'מלח ופלפל'],
ARRAY['לחתוך את הבצל והשום דק', 'לחמם שמן בסיר ולהזהיב את הבצל', 'להוסיף עגבניות קצוצות ותבלינים', 'לבשל 15 דקות עד שמתעבה', 'לפתוח גומות ולשבור ביצים', 'לכסות ולבשל 5 דקות', 'לקשט בכוסברה ולהגיש עם לחם'],
'מנהל',
(SELECT id FROM users WHERE username = 'admin'),
'public'
),
(
'פסטה ברוטב שמנת ופטריות',
'lunch',
30,
ARRAY['מהיר', 'מנת ערב', 'איטלקי'],
ARRAY['500 גרם פסטה', '300 גרם פטריות', '200 מ"ל שמנת מתוקה', '2 שיני שום', 'פרמזן', 'חמאה', 'פטרוזיליה', 'מלח ופלפל'],
ARRAY['להרתיח מים ולבשל את הפסטה לפי ההוראות', 'לחתוך פטריות ושום דק', 'לחמם חמאה ולטגן פטריות 5 דקות', 'להוסיף שום ולטגן דקה', 'להוסיף שמנת ופרמזן ולערבב', 'להוסיף את הפסטה המסוננת לרוטב', 'לערבב היטב ולהגיש עם פרמזן'],
'מנהל',
(SELECT id FROM users WHERE username = 'admin'),
'public'
),
(
'עוגת שוקולד פאדג׳',
'snack',
45,
ARRAY['קינוח', 'שוקולד', 'מתוק'],
ARRAY['200 גרם שוקולד מריר', '150 גרם חמאה', '3 ביצים', '1 כוס סוכר', '3/4 כוס קמח', 'אבקת אפייה', 'וניל'],
ARRAY['לחמם תנור ל-180 מעלות', 'להמיס שוקולד וחמאה במיקרו', 'להקציף ביצים עם סוכר', 'להוסיף שוקולד מומס ולערבב', 'להוסיף קמח ואבקת אפייה', 'לשפוך לתבנית משומנת', 'לאפות 30 דקות', 'להוציא ולהגיש עם גלידה'],
'מנהל',
(SELECT id FROM users WHERE username = 'admin'),
'public'
)
ON CONFLICT DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_recipes_tags_jsonb
ON recipes USING GIN (tags);
CREATE INDEX IF NOT EXISTS idx_recipes_ingredients_jsonb
ON recipes USING GIN (ingredients);

View File

@ -1,202 +0,0 @@
import os
import psycopg2
from psycopg2.extras import RealDictCursor
from psycopg2 import errors
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"),
)
# ============= Friends System =============
def send_friend_request(sender_id: int, receiver_id: int):
"""Send a friend request"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Check if already friends
cur.execute(
"SELECT 1 FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)",
(sender_id, receiver_id, receiver_id, sender_id)
)
if cur.fetchone():
return {"error": "Already friends"}
# Check if request already exists
cur.execute(
"SELECT * FROM friend_requests WHERE sender_id = %s AND receiver_id = %s AND status = 'pending'",
(sender_id, receiver_id)
)
existing = cur.fetchone()
if existing:
return dict(existing)
try:
cur.execute(
"""
INSERT INTO friend_requests (sender_id, receiver_id)
VALUES (%s, %s)
RETURNING id, sender_id, receiver_id, status, created_at
""",
(sender_id, receiver_id)
)
request = cur.fetchone()
conn.commit()
return dict(request)
except errors.UniqueViolation:
# Request already exists, fetch and return it
conn.rollback()
cur.execute(
"SELECT id, sender_id, receiver_id, status, created_at FROM friend_requests WHERE sender_id = %s AND receiver_id = %s",
(sender_id, receiver_id)
)
existing_request = cur.fetchone()
if existing_request:
return dict(existing_request)
return {"error": "Friend request already exists"}
finally:
cur.close()
conn.close()
def accept_friend_request(request_id: int):
"""Accept a friend request and create friendship"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Get request details
cur.execute(
"SELECT sender_id, receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'",
(request_id,)
)
request = cur.fetchone()
if not request:
return {"error": "Request not found or already processed"}
sender_id = request["sender_id"]
receiver_id = request["receiver_id"]
# Create bidirectional friendship
cur.execute(
"INSERT INTO friendships (user_id, friend_id) VALUES (%s, %s), (%s, %s) ON CONFLICT DO NOTHING",
(sender_id, receiver_id, receiver_id, sender_id)
)
# Update request status
cur.execute(
"UPDATE friend_requests SET status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(request_id,)
)
conn.commit()
return {"success": True}
finally:
cur.close()
conn.close()
def reject_friend_request(request_id: int):
"""Reject a friend request"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"UPDATE friend_requests SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = %s AND status = 'pending'",
(request_id,)
)
conn.commit()
return {"success": True}
finally:
cur.close()
conn.close()
def get_friend_requests(user_id: int):
"""Get pending friend requests for a user"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT fr.id AS request_id, fr.sender_id, fr.receiver_id, fr.status, fr.created_at,
u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email
FROM friend_requests fr
JOIN users u ON u.id = fr.sender_id
WHERE fr.receiver_id = %s AND fr.status = 'pending'
ORDER BY fr.created_at DESC
""",
(user_id,)
)
return [dict(row) for row in cur.fetchall()]
finally:
cur.close()
conn.close()
def get_friends(user_id: int):
"""Get list of user's friends"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT u.id, u.username, u.display_name, u.email, f.created_at AS friends_since
FROM friendships f
JOIN users u ON u.id = f.friend_id
WHERE f.user_id = %s
ORDER BY u.display_name
""",
(user_id,)
)
return [dict(row) for row in cur.fetchall()]
finally:
cur.close()
conn.close()
def remove_friend(user_id: int, friend_id: int):
"""Remove a friend"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"DELETE FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)",
(user_id, friend_id, friend_id, user_id)
)
conn.commit()
return {"success": True}
finally:
cur.close()
conn.close()
def search_users(query: str, current_user_id: int, limit: int = 20):
"""Search for users by username or display name"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
search_pattern = f"%{query}%"
cur.execute(
"""
SELECT u.id, u.username, u.display_name, u.email,
EXISTS(SELECT 1 FROM friendships WHERE user_id = %s AND friend_id = u.id) AS is_friend,
EXISTS(SELECT 1 FROM friend_requests WHERE sender_id = %s AND receiver_id = u.id AND status = 'pending') AS request_sent
FROM users u
WHERE (u.username ILIKE %s OR u.display_name ILIKE %s) AND u.id != %s
ORDER BY u.display_name
LIMIT %s
""",
(current_user_id, current_user_id, search_pattern, search_pattern, current_user_id, limit)
)
return [dict(row) for row in cur.fetchall()]
finally:
cur.close()
conn.close()

View File

@ -1,117 +0,0 @@
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, auth_provider: str = "local"):
"""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, auth_provider)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, username, email, first_name, last_name, display_name, is_admin, auth_provider, created_at
""",
(username, email, password_hash, first_name, last_name, final_display_name, is_admin, auth_provider)
)
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, auth_provider, 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, auth_provider, 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, password_hash, first_name, last_name, display_name, is_admin, auth_provider, 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()
def update_user_auth_provider(user_id: int, auth_provider: str):
"""Update user's auth provider"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"UPDATE users SET auth_provider = %s WHERE id = %s",
(auth_provider, user_id)
)
conn.commit()
finally:
cur.close()
conn.close()

View File

@ -1,178 +0,0 @@
-- Demo recipes for user dvir (id=3)
-- Recipe 1: שקשוקה
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'שקשוקה',
'breakfast',
25,
'["מהיר", "בריא", "צמחוני"]'::jsonb,
'["4 ביצים", "4 עגבניות", "1 בצל", "2 שיני שום", "פלפל חריף", "כמון", "מלח", "שמן זית"]'::jsonb,
'[
"לחתוך את הבצל והשום דק",
"לחמם שמן בסיר ולטגן את הבצל עד שקוף",
"להוסיף שום ופלפל חריף ולטגן דקה",
"לקצוץ עגבניות ולהוסיף לסיר",
"לתבל בכמון ומלח, לבשל 10 דקות",
"לפתוח גומות ברוטב ולשבור ביצה בכל גומה",
"לכסות ולבשל עד שהביצים מתקשות"
]'::jsonb,
'https://images.unsplash.com/photo-1525351484163-7529414344d8?w=500',
'דביר',
3
);
-- Recipe 2: פסטה ברוטב עגבניות
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'פסטה ברוטב עגבניות',
'lunch',
20,
'["מהיר", "צמחוני", "ילדים אוהבים"]'::jsonb,
'["500 גרם פסטה", "רסק עגבניות", "בזיליקום טרי", "3 שיני שום", "שמן זית", "מלח", "פלפל"]'::jsonb,
'[
"להרתיח מים מלוחים ולבשל את הפסטה לפי ההוראות",
"בינתיים, לחמם שמן בסיר",
"לטגן שום כתוש דקה",
"להוסיף רסק עגבניות ולתבל",
"לבשל על אש בינונית 10 דקות",
"להוסיף בזיליקום קרוע",
"לערבב את הפסטה המסוננת עם הרוטב"
]'::jsonb,
'https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?w=500',
'דביר',
3
);
-- Recipe 3: סלט ישראלי
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'סלט ישראלי',
'snack',
10,
'["מהיר", "בריא", "טבעוני", "צמחוני"]'::jsonb,
'["4 עגבניות", "2 מלפפונים", "1 בצל", "פטרוזיליה", "לימון", "שמן זית", "מלח"]'::jsonb,
'[
"לחתוך עגבניות ומלפפונים לקוביות קטנות",
"לקצוץ בצל דק",
"לקצוץ פטרוזיליה",
"לערבב הכל בקערה",
"להוסיף מיץ לימון ושמן זית",
"לתבל במלח ולערבב היטב"
]'::jsonb,
'https://images.unsplash.com/photo-1512621776951-a57141f2eefd?w=500',
'דביר',
3
);
-- Recipe 4: חביתה עם ירקות
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'חביתה עם ירקות',
'breakfast',
15,
'["מהיר", "בריא", "חלבוני", "צמחוני"]'::jsonb,
'["3 ביצים", "1 בצל", "1 פלפל", "עגבניה", "גבינה צהובה", "מלח", "פלפל שחור", "שמן"]'::jsonb,
'[
"לקצוץ את הירקות לקוביות קטנות",
"לטגן את הירקות בשמן עד שמתרככים",
"להקציף את הביצים במזלג",
"לשפוך את הביצים על הירקות",
"לפזר גבינה קצוצה",
"לבשל עד שהתחתית מוזהבת",
"להפוך או לקפל לחצי ולהגיש"
]'::jsonb,
'https://images.unsplash.com/photo-1525351159099-122d10e7960e?w=500',
'דביר',
3
);
-- Recipe 5: עוף בתנור עם תפוחי אדמה
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'עוף בתנור עם תפוחי אדמה',
'dinner',
60,
'["משפחתי", "חגיגי"]'::jsonb,
'["1 עוף שלם", "1 ק״ג תפוחי אדמה", "פפריקה", "כורכום", "שום", "מלח", "פלפל", "שמן זית", "לימון"]'::jsonb,
'[
"לחמם תנור ל-200 מעלות",
"לחתוך תפוחי אדמה לרבעים",
"לשפשף את העוף בתבלינים, שמן ומיץ לימון",
"לסדר תפוחי אדמה בתבנית",
"להניח את העוף על התפוחי אדמה",
"לאפות כשעה עד שהעוף מוזהב",
"להוציא, לחתוך ולהגיש עם הירקות"
]'::jsonb,
'https://images.unsplash.com/photo-1598103442097-8b74394b95c6?w=500',
'דביר',
3
);
-- Recipe 6: סנדוויץ טונה
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'סנדוויץ טונה',
'lunch',
5,
'["מהיר", "קר", "חלבוני"]'::jsonb,
'["קופסת טונה", "2 פרוסות לחם", "מיונז", "חסה", "עגבניה", "מלפפון חמוץ", "מלח", "פלפל"]'::jsonb,
'[
"לסנן את הטונה",
"לערבב את הטונה עם מיונז",
"לתבל במלח ופלפל",
"למרוח על פרוסת לחם",
"להוסיף חסה, עגבניה ומלפפון",
"לכסות בפרוסת לחם שנייה"
]'::jsonb,
'https://images.unsplash.com/photo-1528735602780-2552fd46c7af?w=500',
'דביר',
3
);
-- Recipe 7: בראוניז שוקולד
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'בראוניז שוקולד',
'snack',
35,
'["קינוח", "שוקולד", "אפייה"]'::jsonb,
'["200 גרם שוקולד מריר", "150 גרם חמאה", "3 ביצים", "כוס סוכר", "חצי כוס קמח", "אבקת קקאו", "וניל"]'::jsonb,
'[
"לחמם תנור ל-180 מעלות",
"להמיס שוקולד וחמאה במיקרוגל",
"להקציף ביצים וסוכר",
"להוסיף את תערובת השוקולד",
"להוסיף קמח וקקאו ולערבב",
"לשפוך לתבנית משומנת",
"לאפות 25 דקות",
"להוציא ולהניח להתקרר לפני חיתוך"
]'::jsonb,
'https://images.unsplash.com/photo-1606313564200-e75d5e30476c?w=500',
'דביר',
3
);
-- Recipe 8: מרק עדשים
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'מרק עדשים',
'dinner',
40,
'["בריא", "צמחוני", "טבעוני", "חם"]'::jsonb,
'["2 כוסות עדשים כתומות", "בצל", "גזר", "3 שיני שום", "כמון", "כורכום", "מלח", "לימון"]'::jsonb,
'[
"לשטוף את העדשים",
"לקצוץ בצל, גזר ושום",
"לטגן את הבצל עד שקוף",
"להוסיף שום ותבלינים",
"להוסיף גזר ועדשים",
"להוסיף 6 כוסות מים",
"לבשל 30 דקות עד שהעדשים רכים",
"לטחון חלק מהמרק לקבלת מרקם עבה",
"להוסיף מיץ לימון לפני הגשה"
]'::jsonb,
'https://images.unsplash.com/photo-1547592166-23ac45744acd?w=500',
'דביר',
3
);

View File

@ -1,13 +1,12 @@
<!doctype html>
<html lang="he" dir="rtl">
<html lang="en">
<head>
<meta charset="UTF-8" />
<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>">
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="google-site-verification" content="xzgN8wy4yNKqR0_qZZeUqj-wfnje0v7koYpUyU1ti2I" />
<title>My Recipes | המתכונים שלי</title>
<title>frontend</title>
<!-- Load environment variables before app starts -->
<script src="/env.js?v=20251219"></script>
<script src="/env.js"></script>
</head>
<body>
<div id="root"></div>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://my-recipes.dvirlabs.com/</loc>
</url>
</urlset>

View File

@ -32,68 +32,32 @@ body {
justify-content: center;
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 {
min-height: 100vh;
max-width: 1200px;
margin: 0 auto;
padding: 0.75rem;
padding-top: 4rem; /* Add space for fixed theme toggle */
padding: 1.5rem;
direction: rtl;
}
@media (min-width: 768px) {
.app-root {
padding: 1.5rem;
padding-top: 4.5rem;
}
}
/* Top bar */
.topbar {
display: flex;
flex-direction: column;
gap: 0.8rem;
justify-content: space-between;
align-items: stretch;
align-items: center;
background: linear-gradient(90deg, #020617, #020617f2);
border-radius: 18px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.45);
padding: 0.8rem 1rem;
margin-bottom: 1.2rem;
padding: 0.8rem 1.2rem;
margin-bottom: 1.6rem;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.9);
}
@media (min-width: 768px) {
.topbar {
flex-direction: row;
align-items: center;
border-radius: 999px;
padding: 0.8rem 1.2rem;
margin-bottom: 1.6rem;
}
}
.topbar-left {
display: flex;
align-items: center;
gap: 0.65rem;
justify-content: center;
}
@media (min-width: 768px) {
.topbar-left {
justify-content: flex-start;
}
}
.logo-emoji {
@ -102,69 +66,14 @@ body {
.brand-title {
font-weight: 800;
font-size: 1rem;
}
@media (min-width: 768px) {
.brand-title {
font-size: 1.2rem;
}
font-size: 1.2rem;
}
.brand-subtitle {
font-size: 0.75rem;
font-size: 0.8rem;
color: var(--text-muted);
}
@media (min-width: 768px) {
.brand-subtitle {
font-size: 0.8rem;
}
}
.topbar-actions {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
}
@media (min-width: 768px) {
.topbar-actions {
gap: 0.6rem;
justify-content: flex-end;
}
}
/* Mobile-compact buttons */
.btn-mobile-compact .btn-text-desktop {
display: none;
}
.btn-mobile-compact .btn-text-mobile {
display: inline;
}
@media (min-width: 768px) {
.btn-mobile-compact .btn-text-desktop {
display: inline;
}
.btn-mobile-compact .btn-text-mobile {
display: none;
}
}
.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 {
@ -174,127 +83,27 @@ body {
@media (min-width: 960px) {
.layout {
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
}
}
.pinned-lists-sidebar {
display: none;
}
@media (min-width: 960px) {
.pinned-lists-sidebar {
display: block;
position: fixed;
right: 1rem;
top: 5rem;
width: 280px;
max-height: calc(100vh - 6rem);
overflow-y: auto;
z-index: 50;
background: var(--card);
border-radius: 16px;
padding: 1rem;
border: 1px solid var(--border-subtle);
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.7);
}
}
/* Mobile pinned sidebar - slides in from left */
@media (max-width: 959px) {
.pinned-lists-sidebar {
position: fixed;
top: 0;
left: -100%;
width: 90%;
max-width: 360px;
height: 100vh;
background: var(--bg);
z-index: 70;
overflow-y: auto;
padding: 1rem;
transition: left 0.3s ease;
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5);
}
.pinned-lists-sidebar.mobile-visible {
display: block;
left: 0;
}
.sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.8);
z-index: 65;
}
.close-sidebar-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: transparent;
border: none;
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: #5a4a2a;
cursor: pointer;
z-index: 10;
padding: 0.25rem;
}
.close-sidebar-btn:hover {
color: #3a2a1a;
}
}
.content-wrapper {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.4rem;
}
@media (min-width: 960px) {
.content-wrapper {
display: contents;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
}
}
.sidebar,
.sidebar-right,
.content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.sidebar-right {
position: sticky;
top: 1rem;
align-self: start;
}
/* Panels */
.panel {
background: var(--card);
border-radius: 16px;
padding: 0.9rem 1rem;
border-radius: 18px;
padding: 1.1rem 1.2rem;
border: 1px solid var(--border-subtle);
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.7);
}
@media (min-width: 768px) {
.panel {
border-radius: 18px;
padding: 1.1rem 1.2rem;
}
}
.panel.secondary {
background: var(--card-soft);
}
@ -352,41 +161,21 @@ select {
border: 1px solid rgba(148, 163, 184, 0.6);
background: #020617;
color: var(--text-main);
padding: 0.55rem 0.75rem;
padding: 0.4rem 0.65rem;
font-size: 0.9rem;
min-height: 44px; /* Better touch target for mobile */
}
@media (min-width: 768px) {
input,
select {
padding: 0.4rem 0.65rem;
min-height: auto;
}
}
/* Buttons */
.btn {
border-radius: 999px;
padding: 0.65rem 1rem;
padding: 0.55rem 1.2rem;
border: none;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.08s ease, box-shadow 0.08s ease,
background-color 0.08s ease;
min-height: 44px; /* Better touch target for mobile */
display: inline-flex;
align-items: center;
justify-content: center;
}
@media (min-width: 768px) {
.btn {
padding: 0.55rem 1.2rem;
min-height: auto;
}
}
.btn.full {
@ -503,31 +292,31 @@ select {
min-height: 260px;
display: flex;
flex-direction: column;
}
.recipe-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
gap: 0.8rem;
margin-bottom: 0.8rem;
}
.recipe-header h2 {
margin: 0;
font-size: 1.6rem;
line-height: 1.3;
font-size: 1.3rem;
}
.recipe-subtitle {
margin: 0.3rem 0 0;
font-size: 0.9rem;
margin: 0.2rem 0 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.recipe-made-by {
margin: 0.4rem 0 0;
font-size: 0.85rem;
margin: 0.3rem 0 0;
font-size: 0.8rem;
color: var(--accent);
font-weight: 500;
}
@ -535,34 +324,25 @@ select {
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: flex-start;
gap: 0.3rem;
}
.pill {
padding: 0.35rem 0.75rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid rgba(148, 163, 184, 0.7);
font-size: 0.8rem;
white-space: nowrap;
font-size: 0.78rem;
}
/* Recipe Image */
.recipe-image-container {
width: 100%;
height: 280px;
border-radius: 14px;
max-height: 250px;
border-radius: 12px;
overflow: hidden;
margin-bottom: 1.2rem;
margin-bottom: 0.8rem;
background: rgba(15, 23, 42, 0.5);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
@media (min-width: 960px) {
.recipe-image-container {
height: 320px;
}
}
.recipe-image {
@ -574,48 +354,33 @@ select {
.recipe-body {
display: grid;
gap: 1.2rem;
gap: 0.8rem;
flex: 1;
margin-bottom: 1rem;
}
@media (min-width: 600px) {
@media (min-width: 720px) {
.recipe-body {
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr 1.2fr;
}
}
.recipe-column h3 {
margin: 0 0 0.6rem;
font-size: 1.1rem;
color: var(--accent);
font-weight: 600;
margin: 0 0 0.3rem;
font-size: 0.95rem;
}
.recipe-column ul,
.recipe-column ol {
margin: 0;
padding-right: 1.2rem;
font-size: 0.95rem;
line-height: 1.6;
}
.recipe-column li {
margin-bottom: 0.4rem;
padding-right: 1rem;
font-size: 0.9rem;
}
.recipe-actions {
display: flex;
gap: 0.5rem;
gap: 0.4rem;
margin-top: 0.8rem;
justify-content: flex-end;
flex-wrap: wrap;
}
@media (min-width: 768px) {
.recipe-actions {
gap: 0.4rem;
}
}
.tags {
@ -666,22 +431,11 @@ select {
}
.drawer {
width: 100%;
max-width: 420px;
width: min(420px, 90vw);
background: #020617;
border-left: 1px solid var(--border-subtle);
padding: 1rem;
padding: 1rem 1rem 1rem 1.2rem;
box-shadow: 18px 0 40px rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
overflow: hidden;
}
@media (min-width: 768px) {
.drawer {
width: min(420px, 90vw);
padding: 1rem 1rem 1rem 1.2rem;
}
}
.drawer-header {
@ -692,20 +446,14 @@ select {
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding-bottom: 1rem;
max-height: calc(100vh - 4rem);
overflow: auto;
}
.drawer-footer {
margin-top: auto;
padding-top: 0.7rem;
margin-top: 0.7rem;
display: flex;
gap: 0.5rem;
position: sticky;
bottom: 0;
background: var(--bg-primary);
border-top: 1px solid var(--border);
}
.icon-btn {
@ -758,25 +506,16 @@ select {
justify-content: center;
align-items: center;
z-index: 50;
padding: 1rem;
}
.modal {
background: var(--card);
border-radius: 16px;
padding: 1.2rem;
border-radius: 18px;
padding: 1.5rem;
border: 1px solid var(--border-subtle);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.9);
max-width: 400px;
width: 100%;
}
@media (min-width: 768px) {
.modal {
border-radius: 18px;
padding: 1.5rem;
width: 90vw;
}
width: 90vw;
}
.modal-header {
@ -785,26 +524,13 @@ select {
.modal-header h2 {
margin: 0;
font-size: 1rem;
}
@media (min-width: 768px) {
.modal-header h2 {
font-size: 1.1rem;
}
font-size: 1.1rem;
}
.modal-body {
margin-bottom: 1.2rem;
color: var(--text-muted);
line-height: 1.5;
font-size: 0.9rem;
}
@media (min-width: 768px) {
.modal-body {
font-size: 1rem;
}
}
.modal-footer {
@ -905,27 +631,18 @@ select {
.toast-container {
position: fixed;
bottom: 1rem;
right: 0.5rem;
left: 0.5rem;
bottom: 1.5rem;
right: 1.5rem;
z-index: 60;
display: flex;
flex-direction: column;
gap: 0.8rem;
max-width: 400px;
pointer-events: none;
}
@media (min-width: 768px) {
.toast-container {
bottom: 5rem;
right: 1.5rem;
left: auto;
max-width: 400px;
}
}
.toast {
padding: 0.9rem 1rem;
padding: 1rem 1.2rem;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
@ -934,14 +651,7 @@ select {
gap: 0.8rem;
animation: slideIn 0.3s ease-out;
pointer-events: auto;
font-size: 0.85rem;
}
@media (min-width: 768px) {
.toast {
padding: 1rem 1.2rem;
font-size: 0.9rem;
}
font-size: 0.9rem;
}
.toast.success {
@ -976,16 +686,16 @@ select {
/* Theme Toggle (fixed floating button) */
.theme-toggle {
position: fixed;
top: 0.75rem;
right: 0.75rem;
top: 1.5rem;
right: 1.5rem;
z-index: 100;
width: 2.75rem;
height: 2.75rem;
width: 3rem;
height: 3rem;
border-radius: 50%;
border: 1px solid var(--border-subtle);
background: var(--card);
color: var(--text-main);
font-size: 1.1rem;
font-size: 1.2rem;
cursor: pointer;
display: flex;
align-items: center;
@ -994,16 +704,6 @@ select {
transition: all 180ms ease;
}
@media (min-width: 768px) {
.theme-toggle {
top: 1.5rem;
right: 1.5rem;
width: 3rem;
height: 3rem;
font-size: 1.2rem;
}
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.4);
@ -1013,90 +713,6 @@ select {
transform: scale(0.95);
}
/* Pinned notes toggle button - mobile only - styled like a mini sticky note */
.pinned-toggle-btn {
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 100;
width: 3rem;
height: 3rem;
border-radius: 4px;
border: 1px solid #f5e6c8;
background: linear-gradient(135deg, #fff9e6 0%, #fffaed 100%);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.5rem;
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);
transition: all 180ms ease;
transform: rotate(-2deg);
}
.pinned-toggle-btn::before {
content: '📌';
position: absolute;
top: -6px;
right: 8px;
font-size: 1rem;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
transform: rotate(15deg);
}
.pinned-toggle-btn:hover {
transform: rotate(0deg) scale(1.05);
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);
}
.pinned-toggle-btn:active {
transform: rotate(0deg) scale(0.95);
}
.note-icon-lines {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 1.5rem;
}
.note-icon-lines span {
height: 2px;
background: rgba(90, 74, 42, 0.4);
border-radius: 1px;
}
.note-icon-lines span:nth-child(1) {
width: 100%;
}
.note-icon-lines span:nth-child(2) {
width: 85%;
}
.note-icon-lines span:nth-child(3) {
width: 70%;
}
/* Mobile only utility class */
.mobile-only {
display: block;
}
@media (min-width: 960px) {
.mobile-only {
display: none !important;
}
}
/* Update body to apply bg properly in both themes */
body {
background: var(--bg);
@ -1121,7 +737,7 @@ body {
}
[data-theme="light"] body {
background: linear-gradient(180deg, #ac8d75 0%, #ede9e5 100%);
background: linear-gradient(180deg, #ac8d75 0%, #f6f8fa 100%);
color: var(--text-main);
}
@ -1640,242 +1256,3 @@ html {
[data-theme="light"] .recipe-list-image {
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,34 +5,12 @@ import TopBar from "./components/TopBar";
import RecipeSearchList from "./components/RecipeSearchList";
import RecipeDetails from "./components/RecipeDetails";
import RecipeFormDrawer from "./components/RecipeFormDrawer";
import GroceryLists from "./components/GroceryLists";
import PinnedGroceryLists from "./components/PinnedGroceryLists";
import Friends from "./components/Friends";
import Chat from "./components/Chat";
import Groups from "./components/Groups";
import Modal from "./components/Modal";
import ToastContainer from "./components/ToastContainer";
import ThemeToggle from "./components/ThemeToggle";
import Login from "./components/Login";
import Register from "./components/Register";
import ResetPassword from "./components/ResetPassword";
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
import { getToken, removeToken, getMe } from "./authApi";
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 [resetToken, setResetToken] = useState(null);
const [currentView, setCurrentView] = useState(() => {
try {
return localStorage.getItem("currentView") || "recipes";
} catch {
return "recipes";
}
}); // "recipes" or "grocery-lists"
const [recipes, setRecipes] = useState([]);
const [selectedRecipe, setSelectedRecipe] = useState(null);
@ -41,7 +19,7 @@ function App() {
const [filterMealType, setFilterMealType] = useState("");
const [filterMaxTime, setFilterMaxTime] = useState("");
const [filterTags, setFilterTags] = useState([]);
const [filterOwner, setFilterOwner] = useState("");
const [filterMadeBy, setFilterMadeBy] = useState("");
// Random recipe filters
const [mealTypeFilter, setMealTypeFilter] = useState("");
@ -55,7 +33,6 @@ function App() {
const [editingRecipe, setEditingRecipe] = useState(null);
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
const [logoutModal, setLogoutModal] = useState(false);
const [toasts, setToasts] = useState([]);
const [theme, setTheme] = useState(() => {
try {
@ -64,104 +41,7 @@ function App() {
return "dark";
}
});
const [showPinnedSidebar, setShowPinnedSidebar] = useState(false);
// Swipe gesture handling for mobile sidebar
const [touchStart, setTouchStart] = useState(null);
const [touchEnd, setTouchEnd] = useState(null);
// Minimum swipe distance (in px)
const minSwipeDistance = 50;
const onTouchStart = (e) => {
setTouchEnd(null);
setTouchStart(e.targetTouches[0].clientX);
};
const onTouchMove = (e) => {
setTouchEnd(e.targetTouches[0].clientX);
};
const onTouchEnd = () => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > minSwipeDistance;
if (isLeftSwipe) {
setShowPinnedSidebar(false);
}
setTouchStart(null);
setTouchEnd(null);
};
// Check authentication on mount
useEffect(() => {
const checkAuth = async () => {
// Check for reset token in URL
const urlParams = new URLSearchParams(window.location.search);
const resetTokenParam = urlParams.get('reset_token');
if (resetTokenParam) {
setResetToken(resetTokenParam);
// Clean URL
window.history.replaceState({}, document.title, window.location.pathname);
setLoadingAuth(false);
return;
}
const token = getToken();
if (token) {
try {
console.log("Checking authentication...");
const userData = await getMe(token);
console.log("Auth successful:", userData);
setUser(userData);
setIsAuthenticated(true);
} catch (err) {
console.error("Auth check error:", err);
// Only remove token on authentication errors (401), not network errors
if (err.status === 401 || err.message.includes('401')) {
console.log("Token invalid or expired, logging out");
removeToken();
setIsAuthenticated(false);
setUser(null);
} else if (err.status === 408 || err.name === 'AbortError') {
// Timeout - assume not authenticated
console.warn("Auth check timeout, removing token");
removeToken();
setIsAuthenticated(false);
setUser(null);
} else {
// Network error or server error - assume not authenticated to avoid being stuck
console.warn("Auth check failed, removing token:", err.message);
removeToken();
setIsAuthenticated(false);
setUser(null);
}
} finally {
// Always set loading to false, even if there was an error
console.log("Setting loadingAuth to false");
setLoadingAuth(false);
}
} else {
console.log("No token found");
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(() => {
loadRecipes();
}, []);
@ -216,8 +96,8 @@ function App() {
}
}
// Filter by made_by (username)
if (filterOwner && (!recipe.made_by || recipe.made_by !== filterOwner)) {
// Filter by made_by
if (filterMadeBy && (!recipe.made_by || recipe.made_by !== filterMadeBy)) {
return false;
}
@ -254,8 +134,7 @@ function App() {
const handleCreateRecipe = async (payload) => {
try {
const token = getToken();
const created = await createRecipe(payload, token);
const created = await createRecipe(payload);
setDrawerOpen(false);
setEditingRecipe(null);
await loadRecipes();
@ -274,8 +153,7 @@ function App() {
const handleUpdateRecipe = async (payload) => {
try {
const token = getToken();
await updateRecipe(editingRecipe.id, payload, token);
await updateRecipe(editingRecipe.id, payload);
setDrawerOpen(false);
setEditingRecipe(null);
await loadRecipes();
@ -299,8 +177,7 @@ function App() {
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
try {
const token = getToken();
await deleteRecipe(recipeId, token);
await deleteRecipe(recipeId);
await loadRecipes();
setSelectedRecipe(null);
addToast("המתכון נמחק בהצלחה!", "success");
@ -331,305 +208,100 @@ 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 (
<div className="app-root">
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
{/* Pinned notes toggle button - only visible on recipes view for authenticated users */}
{isAuthenticated && currentView === "recipes" && (
<button
className="pinned-toggle-btn mobile-only"
onClick={() => setShowPinnedSidebar(!showPinnedSidebar)}
aria-label="הצג תזכירים"
title="תזכירים נעוצים"
>
<span className="note-icon-lines">
<span></span>
<span></span>
<span></span>
</span>
</button>
)}
{/* 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 && !resetToken && (
<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>
)}
{/* Show reset password if token present */}
{!isAuthenticated && resetToken && (
<div className="drawer-backdrop">
<div className="auth-modal">
<ResetPassword
token={resetToken}
onSuccess={() => {
setResetToken(null);
setAuthView("login");
addToast("הסיסמה עודכנה בהצלחה! כעת תוכל להתחבר עם הסיסמה החדשה", "success");
}}
onBack={() => {
setResetToken(null);
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>
<button
className={`nav-tab ${currentView === "friends" ? "active" : ""}`}
onClick={() => setCurrentView("friends")}
>
👥 חברים
</button>
<button
className={`nav-tab ${currentView === "chat" ? "active" : ""}`}
onClick={() => setCurrentView("chat")}
>
💬 שיחות
</button>
<button
className={`nav-tab ${currentView === "groups" ? "active" : ""}`}
onClick={() => setCurrentView("groups")}
>
👨👩👧👦 קבוצות
</button>
</nav>
)}
<TopBar onAddClick={() => setDrawerOpen(true)} />
<main className="layout">
{currentView === "grocery-lists" ? (
<GroceryLists user={user} onShowToast={addToast} />
) : currentView === "friends" ? (
<Friends showToast={addToast} />
) : currentView === "chat" ? (
<Chat showToast={addToast} />
) : currentView === "groups" ? (
<Groups showToast={addToast} onRecipeSelect={setSelectedRecipe} />
) : (
<>
{isAuthenticated && (
<>
<aside
className={`pinned-lists-sidebar ${showPinnedSidebar ? 'mobile-visible' : ''}`}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
<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}
filterMadeBy={filterMadeBy}
onMadeByChange={setFilterMadeBy}
/>
</section>
<section className="content">
{error && <div className="error-banner">{error}</div>}
{/* Random Recipe Suggester - Top Left */}
<section className="panel filter-panel">
<h3>חיפוש מתכון רנדומלי</h3>
<div className="panel-grid">
<div className="field">
<label>סוג ארוחה</label>
<select
value={mealTypeFilter}
onChange={(e) => setMealTypeFilter(e.target.value)}
>
<button
className="close-sidebar-btn mobile-only"
onClick={() => setShowPinnedSidebar(false)}
aria-label="סגור תזכירים"
>
</button>
<PinnedGroceryLists onShowToast={addToast} />
</aside>
{showPinnedSidebar && (
<div
className="sidebar-backdrop mobile-only"
onClick={() => setShowPinnedSidebar(false)}
/>
)}
</>
)}
<section className="content-wrapper">
<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}
<option value="">לא משנה</option>
<option value="breakfast">בוקר</option>
<option value="lunch">צהריים</option>
<option value="dinner">ערב</option>
<option value="snack">נשנוש</option>
</select>
</div>
<div className="field">
<label>זמן מקסימלי (דקות)</label>
<input
type="number"
min="1"
value={maxTimeFilter}
onChange={(e) => setMaxTimeFilter(e.target.value)}
placeholder="למשל 20"
/>
</section>
</div>
<section className="content">
{error && <div className="error-banner">{error}</div>}
{/* Recipe Details Card */}
<RecipeDetails
recipe={selectedRecipe}
onEditClick={handleEditRecipe}
onShowDeleteModal={handleShowDeleteModal}
isAuthenticated={isAuthenticated}
currentUser={user}
showToast={addToast}
<div className="field field-full">
<label>מצרכים שיש בבית (מופרד בפסיקים)</label>
<input
value={ingredientsFilter}
onChange={(e) => setIngredientsFilter(e.target.value)}
placeholder="ביצה, עגבניה, פסטה..."
/>
</section>
</div>
</div>
<section className="sidebar-right">
{/* Random Recipe Suggester - Right Side */}
<section className="panel filter-panel">
<h3>חיפוש מתכון רנדומלי</h3>
<div className="panel-grid">
<div className="field">
<label>סוג ארוחה</label>
<select
value={mealTypeFilter}
onChange={(e) => setMealTypeFilter(e.target.value)}
>
<option value="">לא משנה</option>
<option value="breakfast">בוקר</option>
<option value="lunch">צהריים</option>
<option value="dinner">ערב</option>
<option value="snack">קינוחים</option>
</select>
</div>
<button
className="btn accent full"
onClick={handleRandomClick}
disabled={loadingRandom}
>
{loadingRandom ? "מחפש מתכון..." : "תן לי רעיון לארוחה 🎲"}
</button>
</section>
<div className="field">
<label>זמן מקסימלי (דקות)</label>
<input
type="number"
min="1"
value={maxTimeFilter}
onChange={(e) => setMaxTimeFilter(e.target.value)}
placeholder="למשל 20"
/>
</div>
<div className="field field-full">
<label>מצרכים שיש בבית (מופרד בפסיקים)</label>
<input
value={ingredientsFilter}
onChange={(e) => setIngredientsFilter(e.target.value)}
placeholder="ביצה, עגבניה, פסטה..."
/>
</div>
</div>
<button
className="btn accent full"
onClick={handleRandomClick}
disabled={loadingRandom}
>
{loadingRandom ? "מחפש מתכון..." : "תן לי רעיון לארוחה 🎲"}
</button>
</section>
</section>
</section>
</>
)}
{/* Recipe Details Card */}
<RecipeDetails
recipe={selectedRecipe}
onEditClick={handleEditRecipe}
onShowDeleteModal={handleShowDeleteModal}
/>
</section>
</main>
{isAuthenticated && (
<RecipeFormDrawer
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
setEditingRecipe(null);
}}
onSubmit={handleFormSubmit}
editingRecipe={editingRecipe}
currentUser={user}
/>
)}
<RecipeFormDrawer
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
setEditingRecipe(null);
}}
onSubmit={handleFormSubmit}
editingRecipe={editingRecipe}
/>
<Modal
isOpen={deleteModal.isOpen}
@ -642,17 +314,6 @@ function App() {
onCancel={handleCancelDelete}
/>
<Modal
isOpen={logoutModal}
title="התנתקות"
message="האם אתה בטוח שברצונך להתנתק?"
confirmText="התנתק"
cancelText="ביטול"
isDangerous={false}
onConfirm={confirmLogout}
onCancel={() => setLogoutModal(false)}
/>
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
);

View File

@ -1,5 +1,5 @@
// Get API base from injected env.js or fallback to /api relative path
export const getApiBase = () => {
const getApiBase = () => {
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
return window.__ENV__.API_BASE;
}
@ -37,14 +37,10 @@ export async function getRandomRecipe(filters) {
return res.json();
}
export async function createRecipe(recipe, token) {
const headers = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
export async function createRecipe(recipe) {
const res = await fetch(`${API_BASE}/recipes`, {
method: "POST",
headers,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(recipe),
});
if (!res.ok) {
@ -53,14 +49,10 @@ export async function createRecipe(recipe, token) {
return res.json();
}
export async function updateRecipe(id, payload, token) {
const headers = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
export async function updateRecipe(id, payload) {
const res = await fetch(`${API_BASE}/recipes/${id}`, {
method: "PUT",
headers,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
@ -69,14 +61,9 @@ export async function updateRecipe(id, payload, token) {
return res.json();
}
export async function deleteRecipe(id, token) {
const headers = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
export async function deleteRecipe(id) {
const res = await fetch(`${API_BASE}/recipes/${id}`, {
method: "DELETE",
headers,
});
if (!res.ok && res.status !== 204) {
throw new Error("Failed to delete recipe");

View File

@ -1,134 +0,0 @@
// 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) {
let lastError;
const maxRetries = 2;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
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();
} catch (err) {
lastError = err;
if (attempt < maxRetries - 1) {
// Wait before retry (100ms)
await new Promise(resolve => setTimeout(resolve, 100));
continue;
}
}
}
throw lastError;
}
export async function getMe(token) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
try {
const res = await fetch(`${API_BASE}/auth/me`, {
headers: {
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) {
const error = new Error("Failed to get user info");
error.status = res.status;
throw error;
}
return res.json();
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
const timeoutError = new Error("Request timeout");
timeoutError.status = 408;
throw timeoutError;
}
throw err;
}
}
export async function requestPasswordChangeCode(token) {
const res = await fetch(`${API_BASE}/auth/request-password-change-code`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to send verification code");
}
return res.json();
}
export async function changePassword(verificationCode, currentPassword, newPassword, token) {
const res = await fetch(`${API_BASE}/auth/change-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
verification_code: verificationCode,
current_password: currentPassword,
new_password: newPassword,
}),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to change password");
}
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

@ -1,176 +0,0 @@
import { useState } from "react";
import { changePassword, requestPasswordChangeCode } from "../authApi";
export default function ChangePassword({ token, onClose, onSuccess }) {
const [step, setStep] = useState(1); // 1: request code, 2: enter code & passwords
const [verificationCode, setVerificationCode] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [codeSent, setCodeSent] = useState(false);
const handleRequestCode = async () => {
setError("");
setLoading(true);
try {
await requestPasswordChangeCode(token);
setCodeSent(true);
setStep(2);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
// Validation
if (!verificationCode || !currentPassword || !newPassword || !confirmPassword) {
setError("נא למלא את כל השדות");
return;
}
if (verificationCode.length !== 6) {
setError("קוד האימות חייב להכיל 6 ספרות");
return;
}
if (newPassword !== confirmPassword) {
setError("הסיסמאות החדשות אינן תואמות");
return;
}
if (newPassword.length < 6) {
setError("הסיסמה חייבת להכיל לפחות 6 תווים");
return;
}
setLoading(true);
try {
await changePassword(verificationCode, currentPassword, newPassword, token);
onSuccess?.();
onClose();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>שינוי סיסמה</h2>
<button className="close-btn" onClick={onClose}>
×
</button>
</div>
<div className="modal-body">
{error && <div className="error-message">{error}</div>}
{step === 1 && (
<div>
<p style={{ marginBottom: "1rem", color: "var(--text-muted)" }}>
קוד אימות יישלח לכתובת המייל שלך. הקוד תקף ל-10 דקות.
</p>
<button
className="btn btn-primary full"
onClick={handleRequestCode}
disabled={loading}
>
{loading ? "שולח..." : "שלח קוד אימות"}
</button>
</div>
)}
{step === 2 && (
<form onSubmit={handleSubmit}>
{codeSent && (
<div style={{
padding: "0.75rem",
background: "rgba(34, 197, 94, 0.1)",
borderRadius: "8px",
marginBottom: "1rem",
color: "var(--accent)"
}}>
קוד אימות נשלח לכתובת המייל שלך
</div>
)}
<div className="field">
<label>קוד אימות (6 ספרות)</label>
<input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
disabled={loading}
autoFocus
placeholder="123456"
maxLength={6}
style={{ fontSize: "1.2rem", letterSpacing: "0.3rem", textAlign: "center" }}
/>
</div>
<div className="field">
<label>סיסמה נוכחית</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={loading}
/>
</div>
<div className="field">
<label>סיסמה חדשה</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={loading}
/>
</div>
<div className="field">
<label>אימות סיסמה חדשה</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={loading}
/>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
ביטול
</button>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
>
{loading ? "משנה..." : "שמור סיסמה"}
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
}

View File

@ -1,490 +0,0 @@
.chat-container {
display: flex;
height: calc(100vh - 200px);
max-width: 1200px;
margin: 2rem auto;
background: var(--card);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
direction: rtl;
}
.chat-loading {
text-align: center;
padding: 3rem;
font-size: 1.2rem;
color: var(--text);
}
/* Sidebar */
.chat-sidebar {
width: 320px;
border-left: 1px solid var(--border-subtle);
border-right: none;
display: flex;
flex-direction: column;
background: var(--card);
}
.chat-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--card-hover);
}
.chat-sidebar-header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text);
}
.btn-new-chat {
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}
.btn-new-chat:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.conversations-list {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-subtle) transparent;
}
.conversations-list::-webkit-scrollbar {
width: 6px;
}
.conversations-list::-webkit-scrollbar-track {
background: transparent;
}
.conversations-list::-webkit-scrollbar-thumb {
background: var(--border-subtle);
border-radius: 3px;
}
.no-conversations {
padding: 2rem 1rem;
text-align: center;
color: var(--text-muted);
font-style: italic;
}
.conversation-item {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-subtle);
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.conversation-item:hover {
background-color: var(--card-hover);
}
.conversation-item.active {
background: linear-gradient(90deg, rgba(102, 126, 234, 0.1) 0%, transparent 100%);
border-right: 3px solid #667eea;
border-left: none;
}
.conversation-name {
font-weight: 600;
margin-bottom: 0.25rem;
color: var(--text);
font-size: 1rem;
}
.conversation-preview {
font-size: 0.875rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Main Chat Area */
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg);
}
.chat-header {
padding: 1.25rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--card);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.chat-header h3 {
margin: 0;
color: var(--text);
font-weight: 600;
font-size: 1.25rem;
}
.no-selection {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 1.1rem;
background: var(--bg);
padding: 2rem;
text-align: center;
}
.no-selection p {
white-space: normal;
word-wrap: break-word;
max-width: 300px;
}
/* Messages */
.messages-container {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
background: var(--bg);
scrollbar-width: thin;
scrollbar-color: var(--border-subtle) transparent;
}
.messages-container::-webkit-scrollbar {
width: 6px;
}
.messages-container::-webkit-scrollbar-track {
background: transparent;
}
.messages-container::-webkit-scrollbar-thumb {
background: var(--border-subtle);
border-radius: 3px;
}
.no-messages {
text-align: center;
padding: 2rem;
color: var(--text-muted);
font-style: italic;
}
.message {
display: flex;
flex-direction: column;
max-width: 70%;
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message.mine {
align-self: flex-start;
align-items: flex-start;
}
.message.theirs {
align-self: flex-end;
align-items: flex-end;
}
.message-sender {
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.25rem;
font-weight: 600;
}
.message-bubble {
padding: 0.875rem 1.125rem;
border-radius: 18px;
word-wrap: break-word;
white-space: pre-wrap;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
line-height: 1.5;
}
.message.mine .message-bubble {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-bottom-right-radius: 6px;
}
.message.theirs .message-bubble {
background: var(--card);
color: var(--text);
border: 1px solid var(--border-subtle);
border-bottom-left-radius: 6px;
}
.message-time {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
opacity: 0.7;
}
/* Message Input */
.message-input-form {
display: flex;
gap: 0.75rem;
padding: 1.25rem;
border-top: 1px solid var(--border-subtle);
background: var(--card);
box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.05);
}
.message-input-form input {
flex: 1;
padding: 0.875rem 1.125rem;
border: 1px solid var(--border-subtle);
border-radius: 24px;
font-size: 1rem;
background: var(--bg);
color: var(--text);
transition: all 0.2s;
}
.message-input-form input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.message-input-form button {
padding: 0.875rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 24px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}
.message-input-form button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.message-input-form button:disabled {
background: var(--border-subtle);
cursor: not-allowed;
box-shadow: none;
opacity: 0.6;
}
/* New Chat Form */
.new-chat-form {
padding: 2rem;
overflow-y: auto;
background: var(--bg);
}
.new-chat-form h3 {
margin-bottom: 1.5rem;
color: var(--text);
font-weight: 600;
font-size: 1.5rem;
}
.friends-selection {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
max-height: 300px;
overflow-y: auto;
padding: 1rem;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: var(--card);
scrollbar-width: thin;
scrollbar-color: var(--border-subtle) transparent;
}
.friends-selection::-webkit-scrollbar {
width: 6px;
}
.friends-selection::-webkit-scrollbar-track {
background: transparent;
}
.friends-selection::-webkit-scrollbar-thumb {
background: var(--border-subtle);
border-radius: 3px;
}
.friend-checkbox {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
padding: 0.75rem;
border-radius: 8px;
transition: all 0.2s;
color: var(--text);
}
.friend-checkbox:hover {
background: var(--card-hover);
}
.friend-checkbox input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: #667eea;
}
.group-name-input {
margin-bottom: 1.5rem;
}
.group-name-input label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--text);
}
.group-name-input input {
width: 100%;
padding: 0.875rem 1.125rem;
border: 1px solid var(--border-subtle);
border-radius: 12px;
font-size: 1rem;
background: var(--card);
color: var(--text);
transition: all 0.2s;
}
.group-name-input input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.new-chat-actions {
display: flex;
gap: 0.75rem;
}
.btn-create {
flex: 1;
padding: 0.875rem;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
color: white;
border: none;
border-radius: 12px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(17, 153, 142, 0.3);
}
.btn-create:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(17, 153, 142, 0.4);
}
.btn-cancel {
flex: 1;
padding: 0.875rem;
background: var(--card);
color: var(--text);
border: 1px solid var(--border-subtle);
border-radius: 12px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-cancel:hover {
background: var(--card-hover);
}
/* Responsive */
@media (max-width: 768px) {
.chat-container {
margin: 0;
border-radius: 0;
height: calc(100vh - 150px);
}
.chat-sidebar {
width: 280px;
}
.message {
max-width: 85%;
}
.message-input-form {
padding: 1rem;
}
.message-input-form button {
padding: 0.875rem 1.25rem;
}
.new-chat-form {
padding: 1.5rem;
}
}
@media (max-width: 480px) {
.chat-sidebar {
width: 100%;
position: absolute;
z-index: 10;
height: 100%;
}
.chat-main {
width: 100%;
}
.message {
max-width: 90%;
}
}

View File

@ -1,268 +0,0 @@
import { useState, useEffect, useRef } from "react";
import {
getConversations,
createConversation,
getMessages,
sendMessage,
getFriends,
} from "../socialApi";
import "./Chat.css";
export default function Chat({ showToast }) {
const [conversations, setConversations] = useState([]);
const [selectedConversation, setSelectedConversation] = useState(null);
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState("");
const [loading, setLoading] = useState(true);
const [showNewChat, setShowNewChat] = useState(false);
const [friends, setFriends] = useState([]);
const [selectedFriends, setSelectedFriends] = useState([]);
const [groupName, setGroupName] = useState("");
const messagesEndRef = useRef(null);
useEffect(() => {
loadConversations();
}, []);
useEffect(() => {
if (selectedConversation) {
loadMessages(selectedConversation.conversation_id);
const interval = setInterval(() => {
loadMessages(selectedConversation.conversation_id, true);
}, 3000); // Poll for new messages every 3 seconds
return () => clearInterval(interval);
}
}, [selectedConversation]);
useEffect(() => {
scrollToBottom();
}, [messages]);
function scrollToBottom() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
async function loadConversations() {
try {
setLoading(true);
const data = await getConversations();
setConversations(data);
} catch (error) {
showToast(error.message, "error");
} finally {
setLoading(false);
}
}
async function loadMessages(conversationId, silent = false) {
try {
const data = await getMessages(conversationId);
setMessages(data);
} catch (error) {
if (!silent) showToast(error.message, "error");
}
}
async function handleSendMessage(e) {
e.preventDefault();
if (!newMessage.trim() || !selectedConversation) return;
try {
await sendMessage(selectedConversation.conversation_id, newMessage);
setNewMessage("");
await loadMessages(selectedConversation.conversation_id);
} catch (error) {
showToast(error.message, "error");
}
}
async function handleStartNewChat() {
try {
const friendsData = await getFriends();
setFriends(friendsData);
setSelectedFriends([]);
setGroupName("");
setShowNewChat(true);
} catch (error) {
showToast(error.message, "error");
}
}
function toggleFriendSelection(friendId) {
setSelectedFriends((prev) =>
prev.includes(friendId) ? prev.filter((id) => id !== friendId) : [...prev, friendId]
);
}
async function handleCreateConversation() {
if (selectedFriends.length === 0) {
showToast("בחר לפחות חבר אחד", "error");
return;
}
const isGroup = selectedFriends.length > 1;
// Only validate group name if it's actually a group chat
if (isGroup && !groupName.trim()) {
showToast("הכנס שם קבוצה", "error");
return;
}
try {
console.log("Creating conversation with friends:", selectedFriends);
console.log("Is group:", isGroup);
console.log("Group name:", isGroup ? groupName : "(private chat)");
const conversation = await createConversation(
selectedFriends,
isGroup,
isGroup ? groupName : null
);
showToast("השיחה נוצרה בהצלחה!", "success");
setShowNewChat(false);
setSelectedFriends([]);
setGroupName("");
await loadConversations();
setSelectedConversation(conversation);
} catch (error) {
showToast(error.message, "error");
}
}
if (loading) {
return <div className="chat-loading">טוען שיחות...</div>;
}
return (
<div className="chat-container">
<div className="chat-sidebar">
<div className="chat-sidebar-header">
<h2>הודעות</h2>
<button onClick={handleStartNewChat} className="btn-new-chat">
+ חדש
</button>
</div>
<div className="conversations-list">
{conversations.length === 0 ? (
<p className="no-conversations">אין שיחות עדיין</p>
) : (
conversations.map((conv) => (
<div
key={conv.conversation_id}
className={`conversation-item ${
selectedConversation?.conversation_id === conv.conversation_id ? "active" : ""
}`}
onClick={() => setSelectedConversation(conv)}
>
<div className="conversation-name">
{conv.name || conv.other_member_name || "Conversation"}
</div>
{conv.last_message && (
<div className="conversation-preview">{conv.last_message}</div>
)}
</div>
))
)}
</div>
</div>
<div className="chat-main">
{showNewChat ? (
<div className="new-chat-form">
<h3>שיחה חדשה</h3>
<div className="friends-selection">
{friends.length === 0 ? (
<p>אין חברים לשוחח איתם. הוסף חברים תחילה!</p>
) : (
friends.map((friend) => (
<label key={friend.id} className="friend-checkbox">
<input
type="checkbox"
checked={selectedFriends.includes(friend.id)}
onChange={() => toggleFriendSelection(friend.id)}
/>
<span>{friend.username || friend.email}</span>
</label>
))
)}
</div>
{selectedFriends.length > 1 && (
<div className="group-name-input">
<label>שם קבוצה:</label>
<input
type="text"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
placeholder="הכנס שם קבוצה..."
/>
</div>
)}
<div className="new-chat-actions">
<button onClick={handleCreateConversation} className="btn-create">
צור
</button>
<button
onClick={() => {
setShowNewChat(false);
setSelectedFriends([]);
setGroupName("");
}}
className="btn-cancel"
>
ביטול
</button>
</div>
</div>
) : selectedConversation ? (
<>
<div className="chat-header">
<h3>{selectedConversation.name || selectedConversation.other_member_name || "שיחה"}</h3>
</div>
<div className="messages-container">
{messages.length === 0 ? (
<p className="no-messages">אין הודעות עדיין. התחל את השיחה!</p>
) : (
messages.map((msg) => (
<div key={msg.message_id} className={`message ${msg.is_mine ? "mine" : "theirs"}`}>
{!msg.is_mine && (
<div className="message-sender">{msg.sender_username || msg.sender_email}</div>
)}
<div className="message-bubble">{msg.content}</div>
<div className="message-time">
{new Date(msg.created_at).toLocaleTimeString('he-IL', {
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSendMessage} className="message-input-form">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="הקלד הודעה..."
autoFocus
/>
<button type="submit" disabled={!newMessage.trim()}>
שלח
</button>
</form>
</>
) : (
<div className="no-selection">
<p>בחר שיחה או התחל שיחה חדשה</p>
</div>
)}
</div>
</div>
);
}

View File

@ -1,100 +0,0 @@
import { useState } from "react";
import { getApiBase } from "../api";
function ForgotPassword({ onBack }) {
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setMessage("");
setLoading(true);
try {
const response = await fetch(`${getApiBase()}/forgot-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
setMessage(data.message);
setEmail("");
} else {
setError(data.detail || "שגיאה בשליחת הבקשה");
}
} catch (err) {
setError("שגיאה בשליחת הבקשה");
} 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>}
{message && (
<div
style={{
padding: "1rem",
background: "var(--success-bg, #dcfce7)",
border: "1px solid var(--success-border, #22c55e)",
borderRadius: "6px",
color: "var(--success-text, #166534)",
marginBottom: "1rem",
textAlign: "center",
}}
>
{message}
</div>
)}
<div className="field">
<label>כתובת מייל</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="הזן כתובת מייל"
autoComplete="email"
/>
</div>
<button
type="submit"
className="btn primary full-width"
disabled={loading}
>
{loading ? "שולח..." : "שלח קישור לאיפוס"}
</button>
</form>
<div className="auth-footer">
<p>
נזכרת בסיסמה?{" "}
<button className="link-btn" onClick={onBack}>
חזור להתחברות
</button>
</p>
</div>
</div>
</div>
);
}
export default ForgotPassword;

View File

@ -1,205 +0,0 @@
.friends-container {
max-width: 800px;
margin: 2rem auto;
padding: 2rem;
}
.friends-container h2 {
margin-bottom: 1.5rem;
font-size: 2rem;
}
.friends-loading {
text-align: center;
padding: 2rem;
font-size: 1.2rem;
}
.friends-search {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.friends-search-input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.friends-search-btn {
padding: 0.75rem 1.5rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
.friends-search-btn:hover {
background-color: #0056b3;
}
.friends-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 2px solid #eee;
}
.friends-tabs button {
padding: 0.75rem 1.5rem;
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: #666;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.friends-tabs button:hover {
color: #007bff;
}
.friends-tabs button.active {
color: #007bff;
border-bottom-color: #007bff;
font-weight: 600;
}
.friends-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.friends-empty {
text-align: center;
padding: 2rem;
color: #666;
font-size: 1.1rem;
}
.friend-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
background-color: white;
transition: box-shadow 0.2s;
}
.friend-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.friend-info {
flex: 1;
}
.friend-name {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.friend-email {
color: #666;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.friend-since {
color: #999;
font-size: 0.85rem;
}
.friend-actions {
display: flex;
gap: 0.5rem;
}
.friend-btn-add,
.friend-btn-accept {
padding: 0.5rem 1rem;
background-color: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.friend-btn-add:hover,
.friend-btn-accept:hover {
background-color: #218838;
}
.friend-btn-remove,
.friend-btn-reject {
padding: 0.5rem 1rem;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.friend-btn-remove:hover,
.friend-btn-reject:hover {
background-color: #c82333;
}
.friend-status {
padding: 0.5rem 1rem;
color: #666;
font-size: 0.9rem;
font-style: italic;
}
.request-card {
background-color: #f8f9fa;
border-color: #007bff;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.friends-search-input {
background-color: #333;
color: white;
border-color: #555;
}
.friends-tabs {
border-bottom-color: #555;
}
.friend-card {
background-color: #2a2a2a;
border-color: #444;
}
.request-card {
background-color: #1a3a52;
border-color: #007bff;
}
.friend-email {
color: #aaa;
}
.friend-since {
color: #888;
}
}

View File

@ -1,242 +0,0 @@
import { useState, useEffect } from "react";
import {
getFriends,
getFriendRequests,
acceptFriendRequest,
rejectFriendRequest,
sendFriendRequest,
removeFriend,
searchUsers,
} from "../socialApi";
import "./Friends.css";
export default function Friends({ showToast }) {
const [friends, setFriends] = useState([]);
const [requests, setRequests] = useState([]);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("friends"); // friends | requests | search
useEffect(() => {
loadData();
}, []);
async function loadData() {
try {
setLoading(true);
const [friendsData, requestsData] = await Promise.all([
getFriends(),
getFriendRequests(),
]);
setFriends(friendsData);
setRequests(requestsData);
} catch (error) {
showToast(error.message, "error");
} finally {
setLoading(false);
}
}
async function handleSearch(e) {
e.preventDefault();
if (!searchQuery.trim()) return;
try {
const results = await searchUsers(searchQuery);
setSearchResults(results);
setActiveTab("search");
} catch (error) {
showToast(error.message, "error");
}
}
async function handleSendRequest(userId) {
try {
await sendFriendRequest(userId);
showToast("Friend request sent!", "success");
// Update search results to reflect sent request
setSearchResults(
searchResults.map((user) =>
user.id === userId ? { ...user, request_sent: true } : user
)
);
} catch (error) {
showToast(error.message, "error");
}
}
async function handleAcceptRequest(requestId) {
try {
await acceptFriendRequest(requestId);
showToast("Friend request accepted!", "success");
await loadData();
} catch (error) {
showToast(error.message, "error");
}
}
async function handleRejectRequest(requestId) {
try {
await rejectFriendRequest(requestId);
showToast("Friend request rejected", "info");
setRequests(requests.filter((req) => req.request_id !== requestId));
} catch (error) {
showToast(error.message, "error");
}
}
async function handleRemoveFriend(friendId) {
if (!confirm("Remove this friend?")) return;
try {
await removeFriend(friendId);
showToast("Friend removed", "info");
setFriends(friends.filter((friend) => friend.user_id !== friendId));
} catch (error) {
showToast(error.message, "error");
}
}
if (loading) {
return <div className="friends-loading">Loading...</div>;
}
return (
<div className="friends-container">
<h2>Friends</h2>
{/* Search Bar */}
<form onSubmit={handleSearch} className="friends-search">
<input
type="text"
placeholder="Search users by email or username..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="friends-search-input"
/>
<button type="submit" className="friends-search-btn">
Search
</button>
</form>
{/* Tabs */}
<div className="friends-tabs">
<button
className={activeTab === "friends" ? "active" : ""}
onClick={() => setActiveTab("friends")}
>
Friends ({friends.length})
</button>
<button
className={activeTab === "requests" ? "active" : ""}
onClick={() => setActiveTab("requests")}
>
Requests ({requests.length})
</button>
{searchResults.length > 0 && (
<button
className={activeTab === "search" ? "active" : ""}
onClick={() => setActiveTab("search")}
>
Search Results
</button>
)}
</div>
{/* Friends List */}
{activeTab === "friends" && (
<div className="friends-list">
{friends.length === 0 ? (
<p className="friends-empty">No friends yet. Search for users to add!</p>
) : (
friends.map((friend) => (
<div key={friend.id} className="friend-card">
<div className="friend-info">
<div className="friend-name">{friend.username || friend.email}</div>
<div className="friend-email">{friend.email}</div>
<div className="friend-since">
Friends since {new Date(friend.friends_since).toLocaleDateString()}
</div>
</div>
<button
onClick={() => handleRemoveFriend(friend.id)}
className="friend-btn-remove"
>
Remove
</button>
</div>
))
)}
</div>
)}
{/* Friend Requests */}
{activeTab === "requests" && (
<div className="friends-list">
{requests.length === 0 ? (
<p className="friends-empty">No pending friend requests</p>
) : (
requests.map((request) => (
<div key={request.request_id} className="friend-card request-card">
<div className="friend-info">
<div className="friend-name">
{request.sender_username || request.sender_email}
</div>
<div className="friend-email">{request.sender_email}</div>
<div className="friend-since">
Sent {new Date(request.created_at).toLocaleDateString()}
</div>
</div>
<div className="friend-actions">
<button
onClick={() => handleAcceptRequest(request.request_id)}
className="friend-btn-accept"
>
Accept
</button>
<button
onClick={() => handleRejectRequest(request.request_id)}
className="friend-btn-reject"
>
Reject
</button>
</div>
</div>
))
)}
</div>
)}
{/* Search Results */}
{activeTab === "search" && (
<div className="friends-list">
{searchResults.length === 0 ? (
<p className="friends-empty">No users found</p>
) : (
searchResults.map((user) => (
<div key={user.id} className="friend-card">
<div className="friend-info">
<div className="friend-name">{user.username || user.email}</div>
<div className="friend-email">{user.email}</div>
</div>
{user.is_friend ? (
<span className="friend-status">Friends</span>
) : user.request_sent ? (
<span className="friend-status">Request Sent</span>
) : (
<button
onClick={() => handleSendRequest(user.id)}
className="friend-btn-add"
>
Add Friend
</button>
)}
</div>
))
)}
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,542 +0,0 @@
.groups-container {
display: flex;
height: calc(100vh - 200px);
max-width: 1200px;
margin: 2rem auto;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.groups-loading {
text-align: center;
padding: 3rem;
font-size: 1.2rem;
}
/* Sidebar */
.groups-sidebar {
width: 300px;
border-right: 1px solid #ddd;
display: flex;
flex-direction: column;
}
.groups-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #ddd;
}
.groups-sidebar-header h2 {
margin: 0;
font-size: 1.5rem;
}
.btn-new-group {
padding: 0.5rem 1rem;
background-color: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.btn-new-group:hover {
background-color: #218838;
}
.groups-list {
flex: 1;
overflow-y: auto;
}
.no-groups {
padding: 2rem 1rem;
text-align: center;
color: #999;
font-style: italic;
}
.group-item {
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background-color 0.2s;
}
.group-item:hover {
background-color: #f8f9fa;
}
.group-item.active {
background-color: #e7f3ff;
border-left: 3px solid #28a745;
}
.group-name {
font-weight: 600;
margin-bottom: 0.25rem;
font-size: 1.1rem;
}
.group-stats {
font-size: 0.85rem;
color: #666;
}
/* Main Area */
.groups-main {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.no-selection {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 1.1rem;
padding: 2rem;
text-align: center;
}
.no-selection p {
white-space: normal;
word-wrap: break-word;
max-width: 300px;
}
/* Group Header */
.group-header {
padding: 1.5rem;
border-bottom: 1px solid #ddd;
background-color: #f8f9fa;
}
.group-header h3 {
margin: 0 0 0.5rem 0;
font-size: 1.8rem;
}
.group-description {
color: #666;
margin: 0;
}
/* Tabs */
.group-tabs {
display: flex;
border-bottom: 2px solid #eee;
}
.group-tabs button {
padding: 1rem 1.5rem;
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: #666;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.group-tabs button:hover {
color: #28a745;
}
.group-tabs button.active {
color: #28a745;
border-bottom-color: #28a745;
font-weight: 600;
}
/* Content */
.group-content {
padding: 1.5rem;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.content-header h4 {
margin: 0;
font-size: 1.3rem;
}
.btn-share,
.btn-add-member {
padding: 0.5rem 1rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.btn-share:hover,
.btn-add-member:hover {
background-color: #0056b3;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #999;
font-style: italic;
}
/* Recipes Grid */
.recipes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.recipe-card-mini {
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.recipe-card-mini:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.recipe-card-mini .recipe-name {
font-weight: 600;
margin-bottom: 0.5rem;
}
.recipe-card-mini .recipe-meta {
font-size: 0.85rem;
color: #666;
}
/* Members List */
.members-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.member-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
background-color: white;
}
.member-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.member-name {
font-weight: 600;
}
.admin-badge {
padding: 0.25rem 0.5rem;
background-color: #ffc107;
color: #333;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.btn-remove-member {
padding: 0.5rem 1rem;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.btn-remove-member:hover {
background-color: #c82333;
}
/* Create Group Form */
.create-group-form {
padding: 2rem;
}
.create-group-form h3 {
margin-bottom: 1.5rem;
}
.form-field {
margin-bottom: 1.5rem;
}
.form-field label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}
.form-field input[type="text"],
.form-field textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
}
.checkbox-field label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-field input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.btn-create {
flex: 1;
padding: 0.75rem;
background-color: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
.btn-create:hover {
background-color: #218838;
}
.btn-cancel {
flex: 1;
padding: 0.75rem;
background-color: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
.btn-cancel:hover {
background-color: #5a6268;
}
/* Modals */
.share-recipe-modal,
.add-member-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-content h4 {
margin-top: 0;
margin-bottom: 1.5rem;
}
.recipes-selection,
.friends-selection {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
max-height: 400px;
overflow-y: auto;
}
.recipe-option,
.friend-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
transition: background-color 0.2s;
}
.recipe-option:hover,
.friend-option:hover {
background-color: #f8f9fa;
}
.recipe-option button,
.friend-option button {
padding: 0.5rem 1rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.recipe-option button:hover,
.friend-option button:hover {
background-color: #0056b3;
}
.btn-close {
width: 100%;
padding: 0.75rem;
background-color: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.btn-close:hover {
background-color: #5a6268;
}
/* Responsive */
@media (max-width: 768px) {
.groups-container {
margin: 0;
border-radius: 0;
height: calc(100vh - 150px);
}
.groups-sidebar {
width: 250px;
}
.recipes-grid {
grid-template-columns: 1fr;
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.groups-container {
background-color: #2a2a2a;
}
.groups-sidebar {
border-right-color: #444;
}
.groups-sidebar-header {
border-bottom-color: #444;
}
.group-item {
border-bottom-color: #444;
}
.group-item:hover {
background-color: #333;
}
.group-item.active {
background-color: #1a3a52;
}
.group-stats {
color: #aaa;
}
.group-header {
background-color: #333;
border-bottom-color: #444;
}
.group-description {
color: #aaa;
}
.group-tabs {
border-bottom-color: #555;
}
.recipe-card-mini,
.member-item {
background-color: #2a2a2a;
border-color: #444;
}
.recipe-card-mini .recipe-meta {
color: #aaa;
}
.modal-content {
background-color: #2a2a2a;
color: white;
}
.recipe-option,
.friend-option {
border-color: #555;
}
.recipe-option:hover,
.friend-option:hover {
background-color: #333;
}
.form-field input[type="text"],
.form-field textarea {
background-color: #333;
color: white;
border-color: #555;
}
}

View File

@ -1,378 +0,0 @@
import { useState, useEffect } from "react";
import {
getGroups,
createGroup,
getGroup,
addGroupMember,
removeGroupMember,
shareRecipeToGroup,
getGroupRecipes,
getFriends,
} from "../socialApi";
import { getRecipes } from "../api";
import "./Groups.css";
export default function Groups({ showToast, onRecipeSelect }) {
const [groups, setGroups] = useState([]);
const [selectedGroup, setSelectedGroup] = useState(null);
const [groupDetails, setGroupDetails] = useState(null);
const [groupRecipes, setGroupRecipes] = useState([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("groups"); // groups | create | members | recipes
// Create group form
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDescription, setNewGroupDescription] = useState("");
const [isPrivate, setIsPrivate] = useState(true);
// Add member
const [friends, setFriends] = useState([]);
const [showAddMember, setShowAddMember] = useState(false);
// Share recipe
const [myRecipes, setMyRecipes] = useState([]);
const [showShareRecipe, setShowShareRecipe] = useState(false);
useEffect(() => {
loadGroups();
}, []);
useEffect(() => {
if (selectedGroup) {
loadGroupDetails();
loadGroupRecipes();
}
}, [selectedGroup]);
async function loadGroups() {
try {
setLoading(true);
const data = await getGroups();
setGroups(data);
} catch (error) {
showToast(error.message, "error");
} finally {
setLoading(false);
}
}
async function loadGroupDetails() {
try {
const data = await getGroup(selectedGroup.group_id);
setGroupDetails(data);
} catch (error) {
showToast(error.message, "error");
}
}
async function loadGroupRecipes() {
try {
const data = await getGroupRecipes(selectedGroup.group_id);
setGroupRecipes(data);
} catch (error) {
showToast(error.message, "error");
}
}
async function handleCreateGroup(e) {
e.preventDefault();
if (!newGroupName.trim()) return;
try {
await createGroup(newGroupName, newGroupDescription, isPrivate);
showToast("הקבוצה נוצרה!", "success");
setNewGroupName("");
setNewGroupDescription("");
setIsPrivate(true);
setActiveTab("groups");
await loadGroups();
} catch (error) {
showToast(error.message, "error");
}
}
async function handleShowAddMember() {
try {
const friendsData = await getFriends();
setFriends(friendsData);
setShowAddMember(true);
} catch (error) {
showToast(error.message, "error");
}
}
async function handleAddMember(friendId) {
try {
await addGroupMember(selectedGroup.group_id, friendId);
showToast("החבר נוסף!", "success");
setShowAddMember(false);
await loadGroupDetails();
} catch (error) {
showToast(error.message, "error");
}
}
async function handleRemoveMember(userId) {
if (!confirm("להסיר את החבר הזה?")) return;
try {
await removeGroupMember(selectedGroup.group_id, userId);
showToast("החבר הוסר", "info");
await loadGroupDetails();
} catch (error) {
showToast(error.message, "error");
}
}
async function handleShowShareRecipe() {
try {
const recipes = await getRecipes();
setMyRecipes(recipes);
setShowShareRecipe(true);
} catch (error) {
showToast(error.message, "error");
}
}
async function handleShareRecipe(recipeId) {
try {
await shareRecipeToGroup(selectedGroup.group_id, recipeId);
showToast("המתכון שותף!", "success");
setShowShareRecipe(false);
await loadGroupRecipes();
} catch (error) {
showToast(error.message, "error");
}
}
if (loading) {
return <div className="groups-loading">טוען קבוצות...</div>;
}
return (
<div className="groups-container">
<div className="groups-sidebar">
<div className="groups-sidebar-header">
<h2>קבוצות מתכונים</h2>
<button onClick={() => setActiveTab("create")} className="btn-new-group">
+ חדש
</button>
</div>
<div className="groups-list">
{groups.length === 0 ? (
<p className="no-groups">אין קבוצות עדיין. צור אחת!</p>
) : (
groups.map((group) => (
<div
key={group.group_id}
className={`group-item ${selectedGroup?.group_id === group.group_id ? "active" : ""}`}
onClick={() => {
setSelectedGroup(group);
setActiveTab("recipes");
}}
>
<div className="group-name">
{group.is_private ? "🔒 " : "🌐 "}
{group.name}
</div>
<div className="group-stats">
{group.member_count} חברים · {group.recipe_count || 0} מתכונים
</div>
</div>
))
)}
</div>
</div>
<div className="groups-main">
{activeTab === "create" ? (
<div className="create-group-form">
<h3>צור קבוצה חדשה</h3>
<form onSubmit={handleCreateGroup}>
<div className="form-field">
<label>שם הקבוצה *</label>
<input
type="text"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder="מתכוני משפחה, חברים טבעונים וכו'"
required
/>
</div>
<div className="form-field">
<label>תיאור</label>
<textarea
value={newGroupDescription}
onChange={(e) => setNewGroupDescription(e.target.value)}
placeholder="על מה הקבוצה הזאת?"
rows="3"
/>
</div>
<div className="form-field checkbox-field">
<label>
<input
type="checkbox"
checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)}
/>
<span>קבוצה פרטית (בהזמנה בלבד)</span>
</label>
</div>
<div className="form-actions">
<button type="submit" className="btn-create">
צור קבוצה
</button>
<button
type="button"
onClick={() => setActiveTab("groups")}
className="btn-cancel"
>
ביטול
</button>
</div>
</form>
</div>
) : selectedGroup ? (
<>
<div className="group-header">
<div>
<h3>
{selectedGroup.is_private ? "🔒 " : "🌐 "}
{selectedGroup.name}
</h3>
{groupDetails?.description && (
<p className="group-description">{groupDetails.description}</p>
)}
</div>
</div>
<div className="group-tabs">
<button
className={activeTab === "recipes" ? "active" : ""}
onClick={() => setActiveTab("recipes")}
>
מתכונים ({groupRecipes.length})
</button>
<button
className={activeTab === "members" ? "active" : ""}
onClick={() => setActiveTab("members")}
>
חברים ({groupDetails?.members?.length || 0})
</button>
</div>
{activeTab === "recipes" && (
<div className="group-content">
<div className="content-header">
<h4>מתכונים משותפים</h4>
{groupDetails?.is_admin && (
<button onClick={handleShowShareRecipe} className="btn-share">
+ שתף מתכון
</button>
)}
</div>
{showShareRecipe && (
<div className="share-recipe-modal">
<div className="modal-content">
<h4>שתף מתכון לקבוצה</h4>
<div className="recipes-selection">
{myRecipes.map((recipe) => (
<div key={recipe.id} className="recipe-option">
<span>{recipe.name}</span>
<button onClick={() => handleShareRecipe(recipe.id)}>שתף</button>
</div>
))}
</div>
<button onClick={() => setShowShareRecipe(false)} className="btn-close">
ביטול
</button>
</div>
</div>
)}
<div className="recipes-grid">
{groupRecipes.length === 0 ? (
<p className="empty-state">עדיין לא שותפו מתכונים</p>
) : (
groupRecipes.map((recipe) => (
<div
key={recipe.recipe_id}
className="recipe-card-mini"
onClick={() => onRecipeSelect?.(recipe)}
>
<div className="recipe-name">{recipe.recipe_name}</div>
<div className="recipe-meta">
שותף על ידי {recipe.shared_by_username || recipe.shared_by_email}
</div>
</div>
))
)}
</div>
</div>
)}
{activeTab === "members" && (
<div className="group-content">
<div className="content-header">
<h4>חברים</h4>
{groupDetails?.is_admin && (
<button onClick={handleShowAddMember} className="btn-add-member">
+ הוסף חבר
</button>
)}
</div>
{showAddMember && (
<div className="add-member-modal">
<div className="modal-content">
<h4>הוסף חבר</h4>
<div className="friends-selection">
{friends.map((friend) => (
<div key={friend.user_id} className="friend-option">
<span>{friend.username || friend.email}</span>
<button onClick={() => handleAddMember(friend.user_id)}>הוסף</button>
</div>
))}
</div>
<button onClick={() => setShowAddMember(false)} className="btn-close">
ביטול
</button>
</div>
</div>
)}
<div className="members-list">
{groupDetails?.members?.map((member) => (
<div key={member.user_id} className="member-item">
<div className="member-info">
<div className="member-name">{member.username || member.email}</div>
{member.is_admin && <span className="admin-badge">מנהל</span>}
</div>
{groupDetails.is_admin && !member.is_admin && (
<button
onClick={() => handleRemoveMember(member.user_id)}
className="btn-remove-member"
>
הסר
</button>
)}
</div>
))}
</div>
</div>
)}
</>
) : (
<div className="no-selection">
<p>בחר קבוצה או צור קבוצה חדשה</p>
</div>
)}
</div>
</div>
);
}

View File

@ -1,181 +0,0 @@
import { useState, useEffect } from "react";
import { login, saveToken } from "../authApi";
import ForgotPassword from "./ForgotPassword";
function Login({ onSuccess, onSwitchToRegister }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [showForgotPassword, setShowForgotPassword] = useState(false);
// Check for token in URL (from Google OAuth redirect)
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
saveToken(token);
// Clean URL
window.history.replaceState({}, document.title, window.location.pathname);
onSuccess();
}
}, [onSuccess]);
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);
}
};
const handleGoogleLogin = () => {
const apiBase = window.__ENV__?.API_BASE || "http://localhost:8000";
window.location.href = `${apiBase}/auth/google/login`;
};
const handleAzureLogin = () => {
const apiBase = window.__ENV__?.API_BASE || "http://localhost:8000";
window.location.href = `${apiBase}/auth/azure/login`;
};
if (showForgotPassword) {
return <ForgotPassword onBack={() => setShowForgotPassword(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>
<div style={{ textAlign: "center", marginTop: "0.75rem" }}>
<button
type="button"
className="link-btn"
style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}
onClick={() => setShowForgotPassword(true)}
>
שכחת סיסמה?
</button>
</div>
</form>
<div style={{
margin: "1rem 0",
textAlign: "center",
color: "var(--text-muted)",
position: "relative"
}}>
<div style={{
position: "absolute",
top: "50%",
left: 0,
right: 0,
borderTop: "1px solid var(--border-subtle)",
zIndex: 0
}}></div>
<span style={{
background: "var(--card)",
padding: "0 1rem",
position: "relative",
zIndex: 1
}}>או</span>
</div>
<button
type="button"
onClick={handleGoogleLogin}
className="btn ghost full-width"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
border: "1px solid var(--border-subtle)"
}}
>
<svg width="18" height="18" viewBox="0 0 18 18">
<path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"/>
<path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/>
<path fill="#FBBC05" d="M3.964 10.707c-.18-.54-.282-1.117-.282-1.707 0-.593.102-1.17.282-1.709V4.958H.957C.347 6.173 0 7.548 0 9c0 1.452.348 2.827.957 4.042l3.007-2.335z"/>
<path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/>
</svg>
המשך עם Google
</button>
{/* <button
type="button"
onClick={handleAzureLogin}
className="btn ghost full-width"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
border: "1px solid var(--border-subtle)",
marginTop: "0.5rem"
}}
>
<svg width="18" height="18" viewBox="0 0 23 23">
<path fill="#f25022" d="M1 1h10v10H1z"/>
<path fill="#00a4ef" d="M12 1h10v10H12z"/>
<path fill="#7fba00" d="M1 12h10v10H1z"/>
<path fill="#ffb900" d="M12 12h10v10H12z"/>
</svg>
המשך עם Microsoft
</button> */}
<div className="auth-footer">
<p>
עדיין אין לך חשבון?{" "}
<button className="link-btn" onClick={onSwitchToRegister}>
הירשם עכשיו
</button>
</p>
</div>
</div>
</div>
);
}
export default Login;

View File

@ -1,221 +0,0 @@
.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:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-icon-small.delete {
color: #ef4444;
}
.btn-icon-small.delete:hover {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}

View File

@ -1,209 +0,0 @@
import { useState, useEffect, useRef } from "react";
import {
getNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
deleteNotification,
} from "../notificationApi";
import "./NotificationBell.css";
function NotificationBell({ onShowToast }) {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showDropdown, setShowDropdown] = useState(false);
const [processingIds, setProcessingIds] = useState(new Set());
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;
}
// Catch network errors (fetch failed)
if (error.message.includes("Failed to fetch") || error.message.includes("NetworkError") || error.message.includes("fetch")) {
setNotifications([]);
setUnreadCount(0);
return;
}
// Silent fail for other polling errors
console.error("Failed to load notifications", error);
}
};
const handleMarkAsRead = async (notificationId) => {
// Prevent duplicate calls
if (processingIds.has(notificationId)) {
return;
}
setProcessingIds(new Set(processingIds).add(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) {
console.error("Error marking notification as read:", error);
const errorMessage = error.message.includes("Network error")
? "שגיאת רשת: לא ניתן להתחבר לשרת"
: error.message.includes("Failed to fetch")
? "שגיאה בסימון ההתראה - בדוק את החיבור לאינטרנט"
: error.message;
onShowToast?.(errorMessage, "error");
} finally {
setProcessingIds((prev) => {
const newSet = new Set(prev);
newSet.delete(notificationId);
return newSet;
});
}
};
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)}
disabled={processingIds.has(notification.id)}
title="סמן כנקרא"
>
{processingIds.has(notification.id) ? "..." : "✓"}
</button>
)}
<button
className="btn-icon-small delete"
onClick={() => handleDelete(notification.id)}
title="מחק"
>
</button>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
}
export default NotificationBell;

View File

@ -1,222 +0,0 @@
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,302 +0,0 @@
.ratings-comments-container {
margin-top: 2rem;
padding-top: 2rem;
border-top: 2px solid #eee;
}
.ratings-section,
.comments-section {
margin-bottom: 2rem;
}
.ratings-section h3,
.comments-section h3 {
margin-bottom: 1rem;
font-size: 1.5rem;
}
.ratings-loading {
text-align: center;
padding: 2rem;
color: #666;
}
/* Ratings */
.rating-display {
margin-bottom: 1.5rem;
}
.avg-rating {
display: flex;
align-items: center;
gap: 1rem;
}
.rating-number {
font-size: 3rem;
font-weight: 700;
color: #007bff;
}
.stars-display {
display: flex;
gap: 0.25rem;
}
.stars-display .star {
font-size: 1.5rem;
color: #ddd;
}
.stars-display .star.filled {
color: #ffc107;
}
.rating-count {
color: #666;
font-size: 0.9rem;
}
.no-ratings {
color: #999;
font-style: italic;
}
.my-rating {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
}
.stars-input {
display: flex;
gap: 0.25rem;
}
.stars-input .star {
background: none;
border: none;
cursor: pointer;
font-size: 2rem;
color: #ddd;
transition: color 0.2s;
padding: 0;
}
.stars-input .star.filled {
color: #ffc107;
}
.stars-input .star:hover {
transform: scale(1.1);
}
/* Comments */
.comment-form {
margin-bottom: 2rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.reply-indicator {
padding: 0.5rem;
background-color: #e7f3ff;
border-left: 3px solid #007bff;
font-size: 0.9rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.reply-indicator button {
background: none;
border: none;
color: #007bff;
cursor: pointer;
text-decoration: underline;
}
.comment-form textarea {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-family: inherit;
font-size: 1rem;
resize: vertical;
}
.comment-form button[type="submit"] {
align-self: flex-end;
padding: 0.5rem 1.5rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
.comment-form button[type="submit"]:hover:not(:disabled) {
background-color: #0056b3;
}
.comment-form button[type="submit"]:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.no-comments {
text-align: center;
padding: 2rem;
color: #999;
font-style: italic;
}
.comment {
padding: 1rem;
background-color: white;
border: 1px solid #ddd;
border-radius: 8px;
}
.comment.reply {
margin-left: 2rem;
background-color: #f8f9fa;
border-left: 3px solid #007bff;
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.comment-author {
font-weight: 600;
color: #333;
}
.comment-date {
font-size: 0.85rem;
color: #999;
}
.comment-content {
margin-bottom: 0.5rem;
line-height: 1.5;
white-space: pre-wrap;
}
.comment-actions {
display: flex;
gap: 1rem;
}
.comment-actions button {
background: none;
border: none;
color: #007bff;
cursor: pointer;
font-size: 0.9rem;
padding: 0;
text-decoration: underline;
}
.comment-actions button:hover {
color: #0056b3;
}
.comment-edit-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.comment-edit-form textarea {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-family: inherit;
font-size: 1rem;
resize: vertical;
}
.comment-edit-actions {
display: flex;
gap: 0.5rem;
}
.comment-edit-actions button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.comment-edit-actions button:first-child {
background-color: #28a745;
color: white;
}
.comment-edit-actions button:first-child:hover {
background-color: #218838;
}
.comment-edit-actions button:last-child {
background-color: #6c757d;
color: white;
}
.comment-edit-actions button:last-child:hover {
background-color: #5a6268;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.ratings-comments-container {
border-top-color: #555;
}
.my-rating {
background-color: #2a2a2a;
}
.comment-form textarea {
background-color: #333;
color: white;
border-color: #555;
}
.comment {
background-color: #2a2a2a;
border-color: #444;
}
.comment.reply {
background-color: #1a3a52;
}
.comment-author {
color: #fff;
}
.comment-edit-form textarea {
background-color: #333;
color: white;
border-color: #555;
}
}

View File

@ -1,267 +0,0 @@
import { useState, useEffect } from "react";
import {
rateRecipe,
getRecipeRatingStats,
getMyRecipeRating,
addComment,
getRecipeComments,
updateComment,
deleteComment,
} from "../socialApi";
import "./RatingsComments.css";
export default function RatingsComments({ recipeId, isAuthenticated, showToast }) {
const [ratingStats, setRatingStats] = useState(null);
const [myRating, setMyRating] = useState(0);
const [hoverRating, setHoverRating] = useState(0);
const [comments, setComments] = useState([]);
const [newComment, setNewComment] = useState("");
const [replyTo, setReplyTo] = useState(null);
const [editingComment, setEditingComment] = useState(null);
const [editContent, setEditContent] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, [recipeId]);
async function loadData() {
try {
setLoading(true);
const statsPromise = getRecipeRatingStats(recipeId);
const commentsPromise = getRecipeComments(recipeId);
const [stats, commentsData] = await Promise.all([statsPromise, commentsPromise]);
setRatingStats(stats);
setComments(commentsData);
if (isAuthenticated) {
try {
const myRatingData = await getMyRecipeRating(recipeId);
setMyRating(myRatingData.rating || 0);
} catch (error) {
// User hasn't rated yet
setMyRating(0);
}
}
} catch (error) {
showToast(error.message, "error");
} finally {
setLoading(false);
}
}
async function handleRate(rating) {
if (!isAuthenticated) {
showToast("נא להתחבר כדי לדרג", "error");
return;
}
try {
await rateRecipe(recipeId, rating);
setMyRating(rating);
showToast("דירוג נשלח בהצלחה!", "success");
// Reload stats
const stats = await getRecipeRatingStats(recipeId);
setRatingStats(stats);
} catch (error) {
showToast(error.message, "error");
}
}
async function handleAddComment(e) {
e.preventDefault();
if (!isAuthenticated) {
showToast("נא להתחבר כדי להגיב", "error");
return;
}
if (!newComment.trim()) return;
try {
await addComment(recipeId, newComment, replyTo);
setNewComment("");
setReplyTo(null);
showToast("תגובה נוספה!", "success");
const commentsData = await getRecipeComments(recipeId);
setComments(commentsData);
} catch (error) {
showToast(error.message, "error");
}
}
async function handleEditComment(commentId, content) {
try {
await updateComment(commentId, content);
setEditingComment(null);
setEditContent("");
showToast("תגובה עודכנה!", "success");
const commentsData = await getRecipeComments(recipeId);
setComments(commentsData);
} catch (error) {
showToast(error.message, "error");
}
}
async function handleDeleteComment(commentId) {
if (!confirm("למחוק תגובה זו?")) return;
try {
await deleteComment(commentId);
showToast("תגובה נמחקה", "info");
const commentsData = await getRecipeComments(recipeId);
setComments(commentsData);
} catch (error) {
showToast(error.message, "error");
}
}
function startEditing(comment) {
setEditingComment(comment.comment_id);
setEditContent(comment.content);
}
function cancelEditing() {
setEditingComment(null);
setEditContent("");
}
function startReply(commentId) {
setReplyTo(commentId);
}
if (loading) {
return <div className="ratings-loading">טוען...</div>;
}
return (
<div className="ratings-comments-container">
{/* Ratings Section */}
<div className="ratings-section">
<h3>דירוג</h3>
<div className="rating-display">
{ratingStats && ratingStats.total_ratings > 0 ? (
<>
<div className="avg-rating">
<span className="rating-number">{ratingStats.average_rating.toFixed(1)}</span>
<div className="stars-display">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={star <= Math.round(ratingStats.average_rating) ? "star filled" : "star"}
>
</span>
))}
</div>
<span className="rating-count">({ratingStats.total_ratings} דירוגים)</span>
</div>
</>
) : (
<p className="no-ratings">אין דירוגים עדיין</p>
)}
</div>
{isAuthenticated && (
<div className="my-rating">
<span>הדירוג שלך:</span>
<div className="stars-input">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
className={star <= (hoverRating || myRating) ? "star filled" : "star"}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
onClick={() => handleRate(star)}
>
</button>
))}
</div>
</div>
)}
</div>
{/* Comments Section */}
<div className="comments-section">
<h3>תגובות ({comments.length})</h3>
{isAuthenticated && (
<form onSubmit={handleAddComment} className="comment-form">
{replyTo && (
<div className="reply-indicator">
משיב לתגובה...{" "}
<button type="button" onClick={() => setReplyTo(null)}>
ביטול
</button>
</div>
)}
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="הוסף תגובה..."
rows="3"
/>
<button type="submit" disabled={!newComment.trim()}>
פרסם תגובה
</button>
</form>
)}
<div className="comments-list">
{comments.length === 0 ? (
<p className="no-comments">אין תגובות עדיין. היה הראשון!</p>
) : (
comments.map((comment) => (
<div
key={comment.comment_id}
className={`comment ${comment.parent_comment_id ? "reply" : ""}`}
>
<div className="comment-header">
<span className="comment-author">{comment.author_username || comment.author_email}</span>
<span className="comment-date">
{new Date(comment.created_at).toLocaleDateString()}
</span>
</div>
{editingComment === comment.comment_id ? (
<div className="comment-edit-form">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows="3"
/>
<div className="comment-edit-actions">
<button onClick={() => handleEditComment(comment.comment_id, editContent)}>
שמור
</button>
<button onClick={cancelEditing}>ביטול</button>
</div>
</div>
) : (
<>
<div className="comment-content">{comment.content}</div>
<div className="comment-actions">
{isAuthenticated && !comment.parent_comment_id && (
<button onClick={() => startReply(comment.comment_id)}>השב</button>
)}
{isAuthenticated && comment.is_author && (
<>
<button onClick={() => startEditing(comment)}>ערוך</button>
<button onClick={() => handleDeleteComment(comment.comment_id)}>
מחק
</button>
</>
)}
</div>
</>
)}
</div>
))
)}
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,6 @@
import placeholderImage from "../assets/placeholder.svg";
import RatingsComments from "./RatingsComments";
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal, isAuthenticated, currentUser, showToast }) {
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) {
if (!recipe) {
return (
<section className="panel placeholder">
@ -14,15 +13,6 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal,
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 (
<section className="panel recipe-card">
{/* Recipe Image */}
@ -36,8 +26,8 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal,
<p className="recipe-subtitle">
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
</p>
{(recipe.owner_display_name || recipe.made_by) && (
<h4 className="recipe-made-by">המתכון של: {recipe.owner_display_name || recipe.made_by}</h4>
{recipe.made_by && (
<h4 className="recipe-made-by">המתכון של: {recipe.made_by}</h4>
)}
</div>
<div className="pill-row">
@ -76,23 +66,14 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal,
</footer>
)}
{isAuthenticated && currentUser && (Number(recipe.user_id) === Number(currentUser.id) || currentUser.is_admin) && (
<div className="recipe-actions">
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
ערוך
</button>
<button className="btn ghost small" onClick={handleDelete}>
🗑 מחק
</button>
</div>
)}
{/* Ratings and Comments */}
<RatingsComments
recipeId={recipe.id}
isAuthenticated={isAuthenticated}
showToast={showToast}
/>
<div className="recipe-actions">
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
ערוך
</button>
<button className="btn ghost small" onClick={handleDelete}>
🗑 מחק
</button>
</div>
</section>
);
}
@ -106,7 +87,7 @@ function translateMealType(type) {
case "dinner":
return "ערב";
case "snack":
return "קינוחים";
return "נשנוש";
default:
return type;
}

View File

@ -148,7 +148,7 @@ function translateMealType(type) {
case "dinner":
return "ערב";
case "snack":
return "קינוחים";
return "נשנוש";
default:
return type;
}

View File

@ -1,20 +1,16 @@
import { useEffect, useState, useRef } from "react";
import { useEffect, useState } from "react";
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, currentUser = null }) {
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
const [name, setName] = useState("");
const [mealType, setMealType] = useState("lunch");
const [timeMinutes, setTimeMinutes] = useState(15);
const [madeBy, setMadeBy] = useState("");
const [tags, setTags] = useState("");
const [image, setImage] = useState("");
const [visibility, setVisibility] = useState("public");
const [ingredients, setIngredients] = useState([""]);
const [steps, setSteps] = useState([""]);
const lastIngredientRef = useRef(null);
const lastStepRef = useRef(null);
const isEditMode = !!editingRecipe;
useEffect(() => {
@ -24,33 +20,27 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
setMealType(editingRecipe.meal_type || "lunch");
setTimeMinutes(editingRecipe.time_minutes || 15);
setMadeBy(editingRecipe.made_by || "");
setTags((editingRecipe.tags || []).join(" "));
setTags((editingRecipe.tags || []).join(", "));
setImage(editingRecipe.image || "");
setVisibility(editingRecipe.visibility || "public");
setIngredients(editingRecipe.ingredients || [""]);
setSteps(editingRecipe.steps || [""]);
} else {
setName("");
setMealType("lunch");
setTimeMinutes(15);
setMadeBy(currentUser?.username || "");
setMadeBy("");
setTags("");
setImage("");
setVisibility("public");
setIngredients([""]);
setSteps([""]);
}
}
}, [open, editingRecipe, isEditMode, currentUser]);
}, [open, editingRecipe, isEditMode]);
if (!open) return null;
const handleAddIngredient = () => {
setIngredients((prev) => [...prev, ""]);
setTimeout(() => {
lastIngredientRef.current?.focus();
lastIngredientRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 0);
};
const handleChangeIngredient = (idx, value) => {
@ -63,10 +53,6 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
const handleAddStep = () => {
setSteps((prev) => [...prev, ""]);
setTimeout(() => {
lastStepRef.current?.focus();
lastStepRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 0);
};
const handleChangeStep = (idx, value) => {
@ -98,7 +84,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
const cleanIngredients = ingredients.map((s) => s.trim()).filter(Boolean);
const cleanSteps = steps.map((s) => s.trim()).filter(Boolean);
const tagsArr = tags
.split(" ")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
@ -109,10 +95,12 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
tags: tagsArr,
ingredients: cleanIngredients,
steps: cleanSteps,
made_by: madeBy.trim() || currentUser?.username || "",
visibility: visibility,
};
if (madeBy.trim()) {
payload.made_by = madeBy.trim();
}
if (image) {
payload.image = image;
}
@ -148,31 +136,22 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
<option value="breakfast">בוקר</option>
<option value="lunch">צהריים</option>
<option value="dinner">ערב</option>
<option value="snack">קינוחים</option>
<option value="snack">נשנוש</option>
</select>
</div>
<div className="field">
<label>מי יכול לראות?</label>
<select value={visibility} onChange={(e) => setVisibility(e.target.value)}>
<option value="public">ציבורי - כולם</option>
<option value="friends">חברים בלבד</option>
<option value="private">פרטי - רק אני</option>
</select>
<label>זמן הכנה (דקות)</label>
<input
type="number"
min="1"
value={timeMinutes}
onChange={(e) => setTimeMinutes(e.target.value)}
required
/>
</div>
</div>
<div className="field">
<label>זמן הכנה (דקות)</label>
<input
type="number"
min="1"
value={timeMinutes}
onChange={(e) => setTimeMinutes(e.target.value)}
required
/>
</div>
<div className="field">
<label>המתכון של:</label>
<input
@ -212,11 +191,11 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
</div>
<div className="field">
<label>תגיות (מופרד ברווחים)</label>
<label>תגיות (מופרד בפסיקים)</label>
<input
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="מהיר טבעוני משפחתי..."
placeholder="מהיר, טבעוני, משפחתי..."
/>
</div>
@ -226,7 +205,6 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
{ingredients.map((val, idx) => (
<div key={idx} className="dynamic-row">
<input
ref={idx === ingredients.length - 1 ? lastIngredientRef : null}
value={val}
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
placeholder="למשל: 2 ביצים"
@ -256,7 +234,6 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
{steps.map((val, idx) => (
<div key={idx} className="dynamic-row">
<input
ref={idx === steps.length - 1 ? lastStepRef : null}
value={val}
onChange={(e) => handleChangeStep(idx, e.target.value)}
placeholder="למשל: לחמם את התנור ל־180 מעלות"

View File

@ -42,7 +42,7 @@ function translateMealType(type) {
case "dinner":
return "ערב";
case "snack":
return "קינוחים";
return "נשנוש";
default:
return type;
}

View File

@ -14,8 +14,8 @@ function RecipeSearchList({
onMaxTimeChange,
filterTags,
onTagsChange,
filterOwner,
onOwnerChange,
filterMadeBy,
onMadeByChange,
}) {
const [expandFilters, setExpandFilters] = useState(false);
@ -27,14 +27,8 @@ function RecipeSearchList({
// Extract unique meal types from ALL recipes (not filtered)
const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort();
// Extract unique made_by (username) from ALL recipes and map to display names
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 unique made_by from ALL recipes (not filtered)
const allMadeBy = Array.from(new Set(allRecipes.map((r) => r.made_by).filter(Boolean))).sort();
// 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);
@ -53,10 +47,10 @@ function RecipeSearchList({
onMealTypeChange("");
onMaxTimeChange("");
onTagsChange([]);
onOwnerChange("");
onMadeByChange("");
};
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterOwner;
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterMadeBy;
return (
<section className="panel secondary recipe-search-list">
@ -171,18 +165,18 @@ function RecipeSearchList({
<label className="filter-label">המתכונים של:</label>
<div className="filter-options">
<button
className={`filter-btn ${filterOwner === "" ? "active" : ""}`}
onClick={() => onOwnerChange("")}
className={`filter-btn ${filterMadeBy === "" ? "active" : ""}`}
onClick={() => onMadeByChange("")}
>
הכל
</button>
{allMadeBy.map((madeBy) => (
{allMadeBy.map((person) => (
<button
key={madeBy}
className={`filter-btn ${filterOwner === madeBy ? "active" : ""}`}
onClick={() => onOwnerChange(madeBy)}
key={person}
className={`filter-btn ${filterMadeBy === person ? "active" : ""}`}
onClick={() => onMadeByChange(person)}
>
{madeByMap.get(madeBy) || madeBy}
{person}
</button>
))}
</div>
@ -245,7 +239,7 @@ function translateMealType(type) {
case "dinner":
return "ערב";
case "snack":
return "קינוחים";
return "נשנוש";
default:
return type;
}

View File

@ -1,164 +0,0 @@
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,108 +0,0 @@
import { useState, useEffect } from "react";
import { getApiBase } from "../api";
function ResetPassword({ token, onSuccess, onBack }) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
if (password !== confirmPassword) {
setError("הסיסמאות אינן תואמות");
return;
}
if (password.length < 6) {
setError("הסיסמה חייבת להכיל לפחות 6 תווים");
return;
}
setLoading(true);
try {
const response = await fetch(`${getApiBase()}/reset-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
token: token,
new_password: password,
}),
});
const data = await response.json();
if (response.ok) {
onSuccess();
} else {
setError(data.detail || "שגיאה באיפוס הסיסמה");
}
} catch (err) {
setError("שגיאה באיפוס הסיסמה");
} 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="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={onBack}>
חזור להתחברות
</button>
</p>
</div>
</div>
</div>
);
}
export default ResetPassword;

View File

@ -1,6 +1,4 @@
import NotificationBell from "./NotificationBell";
function TopBar({ onAddClick, user, onLogout, onShowToast }) {
function TopBar({ onAddClick }) {
return (
<header className="topbar">
<div className="topbar-left">
@ -13,20 +11,10 @@ function TopBar({ onAddClick, user, onLogout, onShowToast }) {
</div>
</div>
<div className="topbar-actions">
{user && <NotificationBell onShowToast={onShowToast} />}
{user && (
<button className="btn primary btn-mobile-compact" onClick={onAddClick}>
<span className="btn-text-desktop">+ מתכון חדש</span>
<span className="btn-text-mobile">+</span>
</button>
)}
{onLogout && (
<button className="btn ghost btn-mobile-compact" onClick={onLogout}>
<span className="btn-text-desktop">יציאה</span>
<span className="btn-text-mobile"></span>
</button>
)}
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
<button className="btn primary" onClick={onAddClick}>
+ מתכון חדש
</button>
</div>
</header>
);

View File

@ -1,131 +0,0 @@
const API_URL = window.__ENV__?.API_BASE || window.ENV?.VITE_API_URL || "http://192.168.1.100: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

@ -1,77 +0,0 @@
const API_BASE_URL = window.__ENV__?.API_BASE || window._env_?.VITE_API_URL || import.meta.env.VITE_API_URL || "http://192.168.1.100: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) {
try {
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();
} catch (error) {
// If it's a network error (fetch failed), throw a more specific error
if (error.message === "Failed to fetch" || error.name === "TypeError") {
throw new Error("Network error: Unable to connect to server");
}
throw error;
}
}
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();
}

View File

@ -1,312 +0,0 @@
import { getApiBase } from "./api";
import { getToken } from "./authApi";
const API_BASE = getApiBase();
// ============= Friends API =============
export async function sendFriendRequest(receiverId) {
const token = getToken();
const res = await fetch(`${API_BASE}/friends/request`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ receiver_id: receiverId }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to send friend request");
}
return res.json();
}
export async function getFriendRequests() {
const token = getToken();
const res = await fetch(`${API_BASE}/friends/requests`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to fetch friend requests");
return res.json();
}
export async function acceptFriendRequest(requestId) {
const token = getToken();
const res = await fetch(`${API_BASE}/friends/requests/${requestId}/accept`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to accept friend request");
return res.json();
}
export async function rejectFriendRequest(requestId) {
const token = getToken();
const res = await fetch(`${API_BASE}/friends/requests/${requestId}/reject`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to reject friend request");
return res.json();
}
export async function getFriends() {
const token = getToken();
const res = await fetch(`${API_BASE}/friends`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to fetch friends");
return res.json();
}
export async function removeFriend(friendId) {
const token = getToken();
const res = await fetch(`${API_BASE}/friends/${friendId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to remove friend");
}
export async function searchUsers(query) {
const token = getToken();
const res = await fetch(`${API_BASE}/friends/search?q=${encodeURIComponent(query)}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to search users");
return res.json();
}
// ============= Chat API =============
export async function getConversations() {
const token = getToken();
const res = await fetch(`${API_BASE}/conversations`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to fetch conversations");
return res.json();
}
export async function createConversation(userIds, isGroup = false, name = null) {
const token = getToken();
const res = await fetch(`${API_BASE}/conversations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ user_ids: userIds, is_group: isGroup, name }),
});
if (!res.ok) {
const error = await res.json();
console.error("Create conversation error:", error);
throw new Error(error.detail || JSON.stringify(error) || "Failed to create conversation");
}
return res.json();
}
export async function getMessages(conversationId, limit = 50, beforeId = null) {
const token = getToken();
let url = `${API_BASE}/conversations/${conversationId}/messages?limit=${limit}`;
if (beforeId) url += `&before_id=${beforeId}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to fetch messages");
return res.json();
}
export async function sendMessage(conversationId, content) {
const token = getToken();
const res = await fetch(`${API_BASE}/conversations/${conversationId}/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("Failed to send message");
return res.json();
}
// ============= Groups API =============
export async function getGroups() {
const token = getToken();
const res = await fetch(`${API_BASE}/groups`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to fetch groups");
return res.json();
}
export async function createGroup(name, description = "", isPrivate = false) {
const token = getToken();
const res = await fetch(`${API_BASE}/groups`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name, description, is_private: isPrivate }),
});
if (!res.ok) throw new Error("Failed to create group");
return res.json();
}
export async function getGroup(groupId) {
const token = getToken();
const res = await fetch(`${API_BASE}/groups/${groupId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to fetch group");
return res.json();
}
export async function addGroupMember(groupId, userId) {
const token = getToken();
const res = await fetch(`${API_BASE}/groups/${groupId}/members/${userId}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to add member");
return res.json();
}
export async function removeGroupMember(groupId, userId) {
const token = getToken();
const res = await fetch(`${API_BASE}/groups/${groupId}/members/${userId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to remove member");
}
export async function shareRecipeToGroup(groupId, recipeId) {
const token = getToken();
const res = await fetch(`${API_BASE}/groups/${groupId}/recipes/${recipeId}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to share recipe");
return res.json();
}
export async function getGroupRecipes(groupId) {
const token = getToken();
const res = await fetch(`${API_BASE}/groups/${groupId}/recipes`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to fetch group recipes");
return res.json();
}
// ============= Ratings & Comments API =============
export async function rateRecipe(recipeId, rating) {
const token = getToken();
const res = await fetch(`${API_BASE}/recipes/${recipeId}/rating`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ rating }),
});
if (!res.ok) throw new Error("Failed to rate recipe");
return res.json();
}
export async function getRecipeRatingStats(recipeId) {
const res = await fetch(`${API_BASE}/recipes/${recipeId}/rating/stats`);
if (!res.ok) throw new Error("Failed to fetch rating stats");
return res.json();
}
export async function getMyRecipeRating(recipeId) {
const token = getToken();
const res = await fetch(`${API_BASE}/recipes/${recipeId}/rating/mine`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to fetch my rating");
return res.json();
}
export async function addComment(recipeId, content, parentCommentId = null) {
const token = getToken();
const res = await fetch(`${API_BASE}/recipes/${recipeId}/comments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ content, parent_comment_id: parentCommentId }),
});
if (!res.ok) throw new Error("Failed to add comment");
return res.json();
}
export async function getRecipeComments(recipeId) {
const res = await fetch(`${API_BASE}/recipes/${recipeId}/comments`);
if (!res.ok) throw new Error("Failed to fetch comments");
return res.json();
}
export async function updateComment(commentId, content) {
const token = getToken();
const res = await fetch(`${API_BASE}/comments/${commentId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("Failed to update comment");
return res.json();
}
export async function deleteComment(commentId) {
const token = getToken();
const res = await fetch(`${API_BASE}/comments/${commentId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to delete comment");
}

View File

@ -5,14 +5,4 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
assetsInclude: ['**/*.svg'],
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})