Compare commits

...

2 Commits

Author SHA1 Message Date
c3782810bf Create social network 2025-12-19 15:18:25 +02:00
9b95ba95b8 Make this app to be social network 2025-12-19 07:10:47 +02:00
32 changed files with 5499 additions and 444 deletions

View File

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

@ -12,6 +12,7 @@ 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:
@ -86,7 +87,7 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
# Optional dependency - returns None if no token provided
def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Optional[dict]:
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

239
backend/chat_db_utils.py Normal file
View File

@ -0,0 +1,239 @@
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,20 +48,43 @@ def get_conn():
return psycopg2.connect(dsn, cursor_factory=RealDictCursor)
def list_recipes_db() -> List[Dict[str, Any]]:
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."""
conn = get_conn()
try:
with conn.cursor() as cur:
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,
u.display_name as owner_display_name
FROM recipes r
LEFT JOIN users u ON r.user_id = u.id
ORDER BY r.id
"""
)
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)
)
rows = cur.fetchall()
return rows
finally:
@ -70,7 +93,7 @@ def list_recipes_db() -> 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
recipe_data: name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, visibility
"""
conn = get_conn()
try:
@ -85,9 +108,10 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
ingredients = %s,
steps = %s,
image = %s,
made_by = %s
made_by = %s,
visibility = %s
WHERE id = %s
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id, visibility
""",
(
recipe_data["name"],
@ -98,6 +122,7 @@ 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,
),
)
@ -135,9 +160,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)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id
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
""",
(
recipe_data["name"],
@ -149,6 +174,7 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
recipe_data.get("image"),
recipe_data.get("made_by"),
recipe_data.get("user_id"),
recipe_data.get("visibility", "public"),
),
)
row = cur.fetchone()
@ -161,19 +187,34 @@ 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,
u.display_name as owner_display_name
r.visibility, u.display_name as owner_display_name
FROM recipes r
LEFT JOIN users u ON r.user_id = u.id
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"
params.append(meal_type.lower())

380
backend/groups_db_utils.py Normal file
View File

@ -0,0 +1,380 @@
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()

View File

@ -24,6 +24,7 @@ from auth_utils import (
verify_password,
create_access_token,
get_current_user,
get_current_user_optional,
ACCESS_TOKEN_EXPIRE_MINUTES,
)
@ -64,6 +65,9 @@ from email_utils import (
from oauth_utils import oauth
# Import routers
from routers import friends, chat, groups, ratings_comments
class RecipeBase(BaseModel):
name: str
@ -74,6 +78,7 @@ class RecipeBase(BaseModel):
ingredients: List[str] = []
steps: List[str] = []
image: Optional[str] = None # Base64-encoded image or image URL
visibility: str = "public" # public, private, friends, groups
class RecipeCreate(RecipeBase):
@ -233,14 +238,22 @@ app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
expose_headers=["*"],
)
# Include social network routers
app.include_router(friends.router)
app.include_router(chat.router)
app.include_router(groups.router)
app.include_router(ratings_comments.router)
@app.get("/recipes", response_model=List[Recipe])
def list_recipes():
rows = list_recipes_db()
def list_recipes(current_user: Optional[dict] = Depends(get_current_user_optional)):
user_id = current_user["user_id"] if current_user else None
rows = list_recipes_db(user_id)
recipes = [
Recipe(
id=r["id"],
@ -254,6 +267,7 @@ def list_recipes():
image=r.get("image"),
user_id=r.get("user_id"),
owner_display_name=r.get("owner_display_name"),
visibility=r.get("visibility", "public"),
)
for r in rows
]
@ -280,6 +294,7 @@ def create_recipe(recipe_in: RecipeCreate, current_user: dict = Depends(get_curr
image=row.get("image"),
user_id=row.get("user_id"),
owner_display_name=current_user.get("display_name"),
visibility=row.get("visibility", "public"),
)
@app.put("/recipes/{recipe_id}", response_model=Recipe)
@ -317,6 +332,7 @@ def update_recipe(recipe_id: int, recipe_in: RecipeUpdate, current_user: dict =
image=row.get("image"),
user_id=row.get("user_id"),
owner_display_name=current_user.get("display_name"),
visibility=row.get("visibility", "public"),
)
@ -350,8 +366,10 @@ def random_recipe(
None,
description="רשימת מרכיבים (ingredients=ביצה&ingredients=עגבניה...)",
),
current_user: Optional[dict] = Depends(get_current_user_optional),
):
rows = get_recipes_by_filters_db(meal_type, max_time)
user_id = current_user["user_id"] if current_user else None
rows = get_recipes_by_filters_db(meal_type, max_time, user_id)
recipes = [
Recipe(
@ -366,6 +384,7 @@ def random_recipe(
image=r.get("image"),
user_id=r.get("user_id"),
owner_display_name=r.get("owner_display_name"),
visibility=r.get("visibility", "public"),
)
for r in rows
]

View File

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

88
backend/routers/chat.py Normal file
View File

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

152
backend/routers/friends.py Normal file
View File

@ -0,0 +1,152 @@
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"])

163
backend/routers/groups.py Normal file
View File

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

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

@ -87,4 +87,42 @@ INSERT INTO users (username, email, password_hash, first_name, last_name, displa
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;

202
backend/social_db_utils.py Normal file
View File

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

@ -174,11 +174,7 @@ body {
@media (min-width: 960px) {
.layout {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.layout:has(.pinned-lists-sidebar) {
grid-template-columns: 320px minmax(0, 1fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
}
}
@ -189,10 +185,18 @@ body {
@media (min-width: 960px) {
.pinned-lists-sidebar {
display: block;
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
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);
}
}
@ -261,12 +265,19 @@ body {
}
.sidebar,
.sidebar-right,
.content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.sidebar-right {
position: sticky;
top: 1rem;
align-self: start;
}
/* Panels */
.panel {
@ -492,31 +503,31 @@ select {
min-height: 260px;
display: flex;
flex-direction: column;
}
.recipe-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.8rem;
margin-bottom: 0.8rem;
gap: 1rem;
margin-bottom: 1rem;
}
.recipe-header h2 {
margin: 0;
font-size: 1.3rem;
font-size: 1.6rem;
line-height: 1.3;
}
.recipe-subtitle {
margin: 0.2rem 0 0;
font-size: 0.85rem;
margin: 0.3rem 0 0;
font-size: 0.9rem;
color: var(--text-muted);
}
.recipe-made-by {
margin: 0.3rem 0 0;
font-size: 0.8rem;
margin: 0.4rem 0 0;
font-size: 0.85rem;
color: var(--accent);
font-weight: 500;
}
@ -524,25 +535,34 @@ select {
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
gap: 0.4rem;
align-items: flex-start;
}
.pill {
padding: 0.25rem 0.6rem;
padding: 0.35rem 0.75rem;
border-radius: 999px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid rgba(148, 163, 184, 0.7);
font-size: 0.78rem;
font-size: 0.8rem;
white-space: nowrap;
}
/* Recipe Image */
.recipe-image-container {
width: 100%;
height: 250px;
border-radius: 12px;
height: 280px;
border-radius: 14px;
overflow: hidden;
margin-bottom: 0.8rem;
margin-bottom: 1.2rem;
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 {
@ -554,26 +574,34 @@ select {
.recipe-body {
display: grid;
gap: 0.8rem;
gap: 1.2rem;
flex: 1;
margin-bottom: 1rem;
}
@media (min-width: 720px) {
@media (min-width: 600px) {
.recipe-body {
grid-template-columns: 1fr 1.2fr;
grid-template-columns: 1fr 1fr;
}
}
.recipe-column h3 {
margin: 0 0 0.3rem;
font-size: 0.95rem;
margin: 0 0 0.6rem;
font-size: 1.1rem;
color: var(--accent);
font-weight: 600;
}
.recipe-column ul,
.recipe-column ol {
margin: 0;
padding-right: 1rem;
font-size: 0.9rem;
padding-right: 1.2rem;
font-size: 0.95rem;
line-height: 1.6;
}
.recipe-column li {
margin-bottom: 0.4rem;
}
.recipe-actions {

View File

@ -7,6 +7,9 @@ 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";
@ -110,23 +113,41 @@ function App() {
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) {
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 - keep user logged in
console.warn("Auth check failed but keeping session:", err.message);
setIsAuthenticated(true); // Assume still authenticated
// 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);
}
setLoadingAuth(false);
};
checkAuth();
}, []);
@ -452,12 +473,36 @@ function App() {
>
🛒 רשימות קניות
</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>
)}
<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 && (
@ -486,10 +531,41 @@ function App() {
</>
)}
<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}
/>
</section>
<section className="content">
{error && <div className="error-banner">{error}</div>}
{/* Random Recipe Suggester - Top Left */}
{/* Recipe Details Card */}
<RecipeDetails
recipe={selectedRecipe}
onEditClick={handleEditRecipe}
onShowDeleteModal={handleShowDeleteModal}
isAuthenticated={isAuthenticated}
currentUser={user}
showToast={addToast}
/>
</section>
<section className="sidebar-right">
{/* Random Recipe Suggester - Right Side */}
<section className="panel filter-panel">
<h3>חיפוש מתכון רנדומלי</h3>
<div className="panel-grid">
@ -507,64 +583,36 @@ function App() {
</select>
</div>
<div className="field">
<label>זמן מקסימלי (דקות)</label>
<input
type="number"
min="1"
value={maxTimeFilter}
onChange={(e) => setMaxTimeFilter(e.target.value)}
placeholder="למשל 20"
/>
</div>
<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>
<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>
{/* Recipe Details Card */}
<RecipeDetails
recipe={selectedRecipe}
onEditClick={handleEditRecipe}
onShowDeleteModal={handleShowDeleteModal}
isAuthenticated={isAuthenticated}
currentUser={user}
/>
</section>
<section className="sidebar">
<RecipeSearchList
allRecipes={recipes}
recipes={getFilteredRecipes()}
selectedId={selectedRecipe?.id}
onSelect={setSelectedRecipe}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filterMealType={filterMealType}
onMealTypeChange={setFilterMealType}
filterMaxTime={filterMaxTime}
onMaxTimeChange={setFilterMaxTime}
filterTags={filterTags}
onTagsChange={setFilterTags}
filterOwner={filterOwner}
onOwnerChange={setFilterOwner}
/>
</section>
<button
className="btn accent full"
onClick={handleRandomClick}
disabled={loadingRandom}
>
{loadingRandom ? "מחפש מתכון..." : "תן לי רעיון לארוחה 🎲"}
</button>
</section>
</section>
</section>
</>
)}

View File

@ -29,30 +29,61 @@ export async function register(username, email, password, firstName, lastName, d
}
export async function login(username, password) {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to login");
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;
}
}
}
return res.json();
throw lastError;
}
export async function getMe(token) {
const res = await fetch(`${API_BASE}/auth/me`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) {
const error = new Error("Failed to get user info");
error.status = res.status;
throw error;
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;
}
return res.json();
}
export async function requestPasswordChangeCode(token) {

View File

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

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

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

@ -0,0 +1,242 @@
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>
);
}

View File

@ -21,6 +21,7 @@ function GroceryLists({ user, onShowToast }) {
const [userSearch, setUserSearch] = useState("");
const [searchResults, setSearchResults] = useState([]);
const [sharePermission, setSharePermission] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(null);
// New list form
const [newListName, setNewListName] = useState("");
@ -167,7 +168,12 @@ function GroceryLists({ user, onShowToast }) {
};
const handleDeleteList = async (listId) => {
if (!confirm("האם אתה בטוח שברצונך למחוק רשימת קניות זו?")) return;
setShowDeleteModal(listId);
};
const confirmDeleteList = async () => {
const listId = showDeleteModal;
setShowDeleteModal(null);
try {
await deleteGroceryList(listId);
@ -234,11 +240,21 @@ function GroceryLists({ user, onShowToast }) {
can_edit: sharePermission,
});
setShares([...shares, share]);
// Check if user already has share - update it, otherwise add new
const existingShareIndex = shares.findIndex(s => s.shared_with_user_id === userId);
if (existingShareIndex >= 0) {
const updatedShares = [...shares];
updatedShares[existingShareIndex] = share;
setShares(updatedShares);
onShowToast(`הרשאות עודכנו עבור ${share.display_name}`, "success");
} else {
setShares([...shares, share]);
onShowToast(`רשימה שותפה עם ${share.display_name}`, "success");
}
setUserSearch("");
setSearchResults([]);
setSharePermission(false);
onShowToast(`רשימה שותפה עם ${share.display_name}`, "success");
} catch (error) {
onShowToast(error.message, "error");
}
@ -298,7 +314,10 @@ function GroceryLists({ user, onShowToast }) {
onClick={() => handleSelectList(list)}
>
<div className="list-item-content">
<h4>{list.name}</h4>
<div className="list-item-header">
<h4>{list.name}</h4>
{list.is_pinned && <span className="pin-badge" title="מוצמד לדף הבית">📌</span>}
</div>
<p className="list-item-meta">
{list.is_owner ? "שלי" : `של ${list.owner_display_name}`}
{" · "}
@ -501,18 +520,51 @@ function GroceryLists({ user, onShowToast }) {
</div>
) : (
<div className="empty-state">
<p>בחר רשימת קניות כדי להציג את הפרטים</p>
<div className="empty-state-content">
<span className="empty-state-icon">📝</span>
<h3>אין רשימה נבחרת</h3>
<p>בחר רשימת קניות מהצד כדי להציג ולערוך את הפרטים</p>
</div>
</div>
)}
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<div className="modal-overlay delete-modal-overlay" onClick={() => setShowDeleteModal(null)}>
<div className="modal delete-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3> מחיקת רשימה</h3>
</div>
<div className="modal-body">
<p>האם אתה בטוח שברצונך למחוק רשימת קניות זו?</p>
<p className="warning-text">פעולה זו אינה ניתנת לביטול!</p>
</div>
<div className="modal-footer">
<button className="btn danger" onClick={confirmDeleteList}>
כן, מחק
</button>
<button className="btn ghost" onClick={() => setShowDeleteModal(null)}>
ביטול
</button>
</div>
</div>
</div>
)}
{/* Share Modal */}
{showShareModal && (
<div className="modal-overlay" onClick={() => setShowShareModal(null)}>
<div className="modal-overlay share-modal-overlay" onClick={() => setShowShareModal(null)}>
<div className="modal share-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>שתף רשימה: {showShareModal.name}</h3>
<div className="modal-title-section">
<span className="share-icon">🔗</span>
<div>
<h3>שיתוף רשימה</h3>
<p className="modal-subtitle">{showShareModal.name}</p>
</div>
</div>
<button className="btn-close" onClick={() => setShowShareModal(null)}>
</button>
@ -520,51 +572,68 @@ function GroceryLists({ user, onShowToast }) {
<div className="modal-body">
<div className="share-search">
<input
type="text"
placeholder="חפש משתמש לפי שם משתמש או שם תצוגה..."
value={userSearch}
onChange={(e) => handleSearchUsers(e.target.value)}
/>
<label className="checkbox-label">
<div className="search-input-wrapper">
<span className="search-icon">🔍</span>
<input
type="text"
placeholder="חפש משתמש לפי שם משתמש או שם תצוגה..."
value={userSearch}
onChange={(e) => handleSearchUsers(e.target.value)}
/>
</div>
<label className="checkbox-label modern-checkbox">
<input
type="checkbox"
checked={sharePermission}
onChange={(e) => setSharePermission(e.target.checked)}
/>
<span>אפשר עריכה</span>
<span className="checkbox-text">
<span className="checkbox-title">אפשר עריכה</span>
<span className="checkbox-desc">המשתמש יוכל לערוך ולהוסיף פריטים</span>
</span>
</label>
{searchResults.length > 0 && (
<ul className="search-results">
{searchResults.map((user) => (
<li
key={user.id}
onClick={() => handleShareWithUser(user.id, user.username)}
>
<div>
<strong>{user.display_name}</strong>
<span className="username">@{user.username}</span>
</div>
<button className="btn small">שתף</button>
</li>
))}
</ul>
<div className="search-results">
<p className="search-results-title">תוצאות חיפוש:</p>
<ul>
{searchResults.map((user) => (
<li
key={user.id}
className="search-result-item"
onClick={() => handleShareWithUser(user.id, user.username)}
>
<div className="user-avatar">👤</div>
<div className="user-info">
<strong>{user.display_name}</strong>
<span className="username">@{user.username}</span>
</div>
<button className="btn primary small">שתף</button>
</li>
))}
</ul>
</div>
)}
</div>
<div className="shares-list">
<h4>משותף עם:</h4>
<h4>🤝 משותף עם:</h4>
{shares.length === 0 ? (
<p className="empty-message">הרשימה לא משותפת עם אף אחד</p>
<div className="empty-shares">
<span className="empty-icon">👥</span>
<p>הרשימה לא משותפת עם אף אחד</p>
</div>
) : (
<ul>
{shares.map((share) => (
<li key={share.id} className="share-item">
<div>
<strong>{share.display_name}</strong>
<span className="username">@{share.username}</span>
{share.can_edit && <span className="badge">עורך</span>}
<div className="user-avatar small">👤</div>
<div className="share-info">
<div>
<strong>{share.display_name}</strong>
<span className="username">@{share.username}</span>
</div>
{share.can_edit && <span className="badge editor-badge"> עורך</span>}
</div>
<button
className="btn danger small"
@ -629,6 +698,22 @@ function GroceryLists({ user, onShowToast }) {
gap: 0.5rem;
}
.list-item-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pin-badge {
font-size: 0.875rem;
animation: pin-pulse 2s ease-in-out infinite;
}
@keyframes pin-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.list-item {
flex: 1;
display: flex;
@ -822,11 +907,32 @@ function GroceryLists({ user, onShowToast }) {
.empty-state,
.empty-message {
text-align: center;
padding: 2rem;
text-align: center;\n padding: 2rem;
opacity: 0.6;
}
.empty-state-content {
text-align: center;
max-width: 400px;
}
.empty-state-icon {
font-size: 4rem;
display: block;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state-content h3 {
margin: 0 0 0.5rem;
color: var(--text-main);
}
.empty-state-content p {
color: var(--text-muted);
line-height: 1.6;
}
.modal-overlay {
position: fixed;
top: 0;
@ -838,6 +944,66 @@ function GroceryLists({ user, onShowToast }) {
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal {
background: var(--panel-bg);
border-radius: 16px;
min-width: 500px;
max-width: 90%;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
animation: slideUp 0.3s ease;
}
.delete-modal {
min-width: 400px;
}
.delete-modal .modal-body {
padding: 2rem 1.5rem;
}
.delete-modal .modal-body p {
margin: 0.5rem 0;
font-size: 1rem;
}
.warning-text {
color: var(--danger, #f97373);
font-weight: 600;
font-size: 0.9rem !important;
}
.modal-footer {
display: flex;
gap: 0.75rem;
padding: 1.5rem;
border-top: 1px solid var(--border-color);
justify-content: flex-end;
}
.modal-footer .btn {
min-width: 100px;
}
.share-modal {
@ -856,50 +1022,169 @@ function GroceryLists({ user, onShowToast }) {
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
background: linear-gradient(135deg, rgba(34, 197, 94, 0.1) 0%, transparent 100%);
}
.modal-title-section {
display: flex;
align-items: center;
gap: 1rem;
}
.share-icon {
font-size: 2rem;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
.modal-title-section h3 {
margin: 0;
font-size: 1.25rem;
}
.modal-subtitle {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: var(--text-muted);
font-weight: normal;
}
.modal-body {
padding: 1.5rem;
max-height: 60vh;
overflow-y: auto;
}
.share-search {
margin-bottom: 2rem;
}
.search-input-wrapper {
position: relative;
margin-bottom: 1rem;
}
.search-icon {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
opacity: 0.5;
}
.share-search input {
width: 100%;
margin-bottom: 0.5rem;
padding-right: 3rem;
font-size: 0.95rem;
}
.checkbox-label {
.modern-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
background: rgba(34, 197, 94, 0.05);
border: 1px solid rgba(34, 197, 94, 0.2);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.modern-checkbox:hover {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
}
.modern-checkbox input[type="checkbox"] {
margin-top: 0.25rem;
width: 18px;
height: 18px;
cursor: pointer;
}
.checkbox-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.checkbox-title {
font-weight: 600;
color: var(--text-main);
}
.checkbox-desc {
font-size: 0.8rem;
color: var(--text-muted);
}
.search-results {
margin: 1.5rem 0;
}
.search-results-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.search-results ul {
list-style: none;
padding: 0;
margin: 1rem 0;
border: 1px solid var(--border-color);
border-radius: 8px;
max-height: 200px;
overflow-y: auto;
}
.search-results li {
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.search-result-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
gap: 1rem;
padding: 1rem;
background: var(--card-soft, rgba(0, 0, 0, 0.2));
border: 1px solid var(--border-color);
border-radius: 10px;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
transition: all 0.2s;
}
.search-results li:last-child {
border-bottom: none;
}
.search-results li:hover {
.search-result-item:hover {
background: var(--hover-bg);
border-color: var(--accent, #22c55e);
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.user-avatar {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(34, 197, 94, 0.1));
border-radius: 50%;
font-size: 1.25rem;
flex-shrink: 0;
}
.user-avatar.small {
width: 32px;
height: 32px;
font-size: 1rem;
}
.user-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.user-info strong {
font-size: 0.95rem;
}
.username {
@ -910,30 +1195,80 @@ function GroceryLists({ user, onShowToast }) {
.shares-list {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 2px solid var(--border-color);
}
.shares-list h4 {
margin-bottom: 1rem;
margin: 0 0 1rem;
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.empty-shares {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
.empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 0.5rem;
opacity: 0.5;
}
.shares-list ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.share-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: var(--hover-bg);
border-radius: 8px;
margin-bottom: 0.5rem;
gap: 1rem;
padding: 1rem;
background: var(--card-soft, rgba(0, 0, 0, 0.2));
border: 1px solid var(--border-color);
border-radius: 10px;
transition: all 0.2s;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: var(--primary-color);
color: white;
border-radius: 6px;
.share-item:hover {
border-color: rgba(34, 197, 94, 0.3);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.share-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.share-info > div {
display: flex;
align-items: center;
gap: 0.5rem;
}
.editor-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(34, 197, 94, 0.1));
color: var(--accent, #22c55e);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 999px;
font-size: 0.75rem;
margin-right: 0.5rem;
font-weight: 600;
}
.btn-icon-action {

View File

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

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

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

@ -5,11 +5,13 @@ import {
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(() => {
@ -47,12 +49,25 @@ function NotificationBell({ onShowToast }) {
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(
@ -62,7 +77,19 @@ function NotificationBell({ onShowToast }) {
);
setUnreadCount(Math.max(0, unreadCount - 1));
} catch (error) {
onShowToast?.(error.message, "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;
});
}
};
@ -155,9 +182,10 @@ function NotificationBell({ onShowToast }) {
<button
className="btn-icon-small"
onClick={() => handleMarkAsRead(notification.id)}
disabled={processingIds.has(notification.id)}
title="סמן כנקרא"
>
{processingIds.has(notification.id) ? "..." : "✓"}
</button>
)}
<button
@ -174,225 +202,6 @@ function NotificationBell({ onShowToast }) {
</div>
</div>
)}
<style jsx>{`
.notification-bell-container {
position: relative;
}
.notification-bell-btn {
position: relative;
background: var(--card-soft);
border: 1px solid var(--border-color);
padding: 0.5rem 0.75rem;
cursor: pointer;
border-radius: 8px;
font-size: 1.25rem;
transition: all 0.2s;
line-height: 1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.notification-bell-btn:hover {
background: var(--hover-bg);
transform: scale(1.05);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.notification-badge {
position: absolute;
top: -6px;
right: -6px;
background: #ef4444;
color: white;
font-size: 0.7rem;
font-weight: bold;
padding: 0.2rem 0.45rem;
border-radius: 12px;
min-width: 20px;
text-align: center;
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.3);
}
.notification-dropdown {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 420px;
max-height: 550px;
background: var(--panel-bg);
backdrop-filter: blur(10px);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
opacity: 0.98;
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--card-soft);
}
.notification-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.btn-link {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: all 0.2s;
font-weight: 500;
}
.btn-link:hover {
background: var(--accent-soft);
color: var(--accent);
}
.notification-list {
overflow-y: auto;
max-height: 450px;
}
.notification-list::-webkit-scrollbar {
width: 8px;
}
.notification-list::-webkit-scrollbar-track {
background: var(--panel-bg);
}
.notification-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
.notification-list::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.notification-empty {
text-align: center;
padding: 3rem 2rem;
color: var(--text-muted);
font-size: 0.95rem;
}
.notification-item {
display: flex;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
transition: all 0.2s;
position: relative;
}
.notification-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: transparent;
transition: background 0.2s;
}
.notification-item.unread::before {
background: var(--accent);
}
.notification-item:hover {
background: var(--hover-bg);
}
.notification-item:last-child {
border-bottom: none;
}
.notification-item.unread {
background: var(--accent-soft);
}
.notification-item.unread:hover {
background: var(--hover-bg);
}
.notification-content {
flex: 1;
min-width: 0;
}
.notification-message {
margin: 0 0 0.5rem 0;
font-size: 0.95rem;
line-height: 1.5;
color: var(--text-primary);
font-weight: 500;
}
.notification-item.read .notification-message {
font-weight: 400;
color: var(--text-secondary);
}
.notification-time {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 400;
}
.notification-actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
align-items: flex-start;
}
.btn-icon-small {
background: var(--panel-bg);
border: 1px solid var(--border-color);
padding: 0.4rem 0.65rem;
cursor: pointer;
border-radius: 6px;
font-size: 0.9rem;
transition: all 0.2s;
line-height: 1;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.btn-icon-small:hover {
background: var(--hover-bg);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn-icon-small.delete {
color: #ef4444;
}
.btn-icon-small.delete:hover {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
`}</style>
</div>
);
}

View File

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

@ -0,0 +1,267 @@
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,6 +1,7 @@
import placeholderImage from "../assets/placeholder.svg";
import RatingsComments from "./RatingsComments";
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal, isAuthenticated, currentUser }) {
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal, isAuthenticated, currentUser, showToast }) {
if (!recipe) {
return (
<section className="panel placeholder">
@ -85,6 +86,13 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal,
</button>
</div>
)}
{/* Ratings and Comments */}
<RatingsComments
recipeId={recipe.id}
isAuthenticated={isAuthenticated}
showToast={showToast}
/>
</section>
);
}

View File

@ -7,6 +7,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
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([""]);
@ -25,6 +26,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
setMadeBy(editingRecipe.made_by || "");
setTags((editingRecipe.tags || []).join(" "));
setImage(editingRecipe.image || "");
setVisibility(editingRecipe.visibility || "public");
setIngredients(editingRecipe.ingredients || [""]);
setSteps(editingRecipe.steps || [""]);
} else {
@ -34,6 +36,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
setMadeBy(currentUser?.username || "");
setTags("");
setImage("");
setVisibility("public");
setIngredients([""]);
setSteps([""]);
}
@ -107,6 +110,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
ingredients: cleanIngredients,
steps: cleanSteps,
made_by: madeBy.trim() || currentUser?.username || "",
visibility: visibility,
};
if (image) {
@ -149,17 +153,26 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
</div>
<div className="field">
<label>זמן הכנה (דקות)</label>
<input
type="number"
min="1"
value={timeMinutes}
onChange={(e) => setTimeMinutes(e.target.value)}
required
/>
<label>מי יכול לראות?</label>
<select value={visibility} onChange={(e) => setVisibility(e.target.value)}>
<option value="public">ציבורי - כולם</option>
<option value="friends">חברים בלבד</option>
<option value="private">פרטי - רק אני</option>
</select>
</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

View File

@ -27,17 +27,25 @@ export async function getNotifications(unreadOnly = false) {
}
export async function markNotificationAsRead(notificationId) {
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}/read`, {
method: "PATCH",
headers: getAuthHeaders(),
});
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");
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;
}
return response.json();
}
export async function markAllNotificationsAsRead() {

312
frontend/src/socialApi.js Normal file
View File

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

@ -7,6 +7,12 @@ export default defineConfig({
assetsInclude: ['**/*.svg'],
server: {
port: 5174,
// port: 5173, // Default port - uncomment to switch back
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})