Make this app to be social network
This commit is contained in:
parent
3270788902
commit
9b95ba95b8
127
backend/add_social_features.sql
Normal file
127
backend/add_social_features.sql
Normal 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);
|
||||
@ -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
239
backend/chat_db_utils.py
Normal 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()
|
||||
@ -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
380
backend/groups_db_utils.py
Normal 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()
|
||||
@ -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):
|
||||
@ -237,10 +242,17 @@ app.add_middleware(
|
||||
allow_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 +266,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 +293,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 +331,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 +365,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 +383,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
|
||||
]
|
||||
|
||||
1
backend/routers/__init__.py
Normal file
1
backend/routers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Router package initialization
|
||||
88
backend/routers/chat.py
Normal file
88
backend/routers/chat.py
Normal 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
152
backend/routers/friends.py
Normal 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
163
backend/routers/groups.py
Normal 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
|
||||
91
backend/routers/ratings_comments.py
Normal file
91
backend/routers/ratings_comments.py
Normal 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)
|
||||
@ -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;
|
||||
|
||||
|
||||
|
||||
189
backend/social_db_utils.py
Normal file
189
backend/social_db_utils.py
Normal file
@ -0,0 +1,189 @@
|
||||
import os
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
"""Get database connection"""
|
||||
return psycopg2.connect(
|
||||
host=os.getenv("DB_HOST", "localhost"),
|
||||
port=int(os.getenv("DB_PORT", "5432")),
|
||||
database=os.getenv("DB_NAME", "recipes_db"),
|
||||
user=os.getenv("DB_USER", "recipes_user"),
|
||||
password=os.getenv("DB_PASSWORD", "recipes_password"),
|
||||
)
|
||||
|
||||
|
||||
# ============= 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)
|
||||
|
||||
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)
|
||||
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()
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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) {
|
||||
|
||||
482
frontend/src/components/Chat.css
Normal file
482
frontend/src/components/Chat.css
Normal file
@ -0,0 +1,482 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 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%;
|
||||
}
|
||||
}
|
||||
268
frontend/src/components/Chat.jsx
Normal file
268
frontend/src/components/Chat.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
frontend/src/components/Friends.css
Normal file
205
frontend/src/components/Friends.css
Normal 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;
|
||||
}
|
||||
}
|
||||
242
frontend/src/components/Friends.jsx
Normal file
242
frontend/src/components/Friends.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
534
frontend/src/components/Groups.css
Normal file
534
frontend/src/components/Groups.css
Normal file
@ -0,0 +1,534 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
378
frontend/src/components/Groups.jsx
Normal file
378
frontend/src/components/Groups.jsx
Normal 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("Group created!", "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("Member added!", "success");
|
||||
setShowAddMember(false);
|
||||
await loadGroupDetails();
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveMember(userId) {
|
||||
if (!confirm("Remove this member?")) return;
|
||||
|
||||
try {
|
||||
await removeGroupMember(selectedGroup.group_id, userId);
|
||||
showToast("Member removed", "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("Recipe shared!", "success");
|
||||
setShowShareRecipe(false);
|
||||
await loadGroupRecipes();
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="groups-loading">Loading groups...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="groups-container">
|
||||
<div className="groups-sidebar">
|
||||
<div className="groups-sidebar-header">
|
||||
<h2>Recipe Groups</h2>
|
||||
<button onClick={() => setActiveTab("create")} className="btn-new-group">
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="groups-list">
|
||||
{groups.length === 0 ? (
|
||||
<p className="no-groups">No groups yet. Create one!</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} members · {group.recipe_count || 0} recipes
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="groups-main">
|
||||
{activeTab === "create" ? (
|
||||
<div className="create-group-form">
|
||||
<h3>Create New Group</h3>
|
||||
<form onSubmit={handleCreateGroup}>
|
||||
<div className="form-field">
|
||||
<label>Group Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder="Family Recipes, Vegan Friends, etc."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
value={newGroupDescription}
|
||||
onChange={(e) => setNewGroupDescription(e.target.value)}
|
||||
placeholder="What's this group about?"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field checkbox-field">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPrivate}
|
||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||
/>
|
||||
<span>Private Group (invite only)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-create">
|
||||
Create Group
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("groups")}
|
||||
className="btn-cancel"
|
||||
>
|
||||
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")}
|
||||
>
|
||||
Recipes ({groupRecipes.length})
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === "members" ? "active" : ""}
|
||||
onClick={() => setActiveTab("members")}
|
||||
>
|
||||
Members ({groupDetails?.members?.length || 0})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "recipes" && (
|
||||
<div className="group-content">
|
||||
<div className="content-header">
|
||||
<h4>Shared Recipes</h4>
|
||||
{groupDetails?.is_admin && (
|
||||
<button onClick={handleShowShareRecipe} className="btn-share">
|
||||
+ Share Recipe
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showShareRecipe && (
|
||||
<div className="share-recipe-modal">
|
||||
<div className="modal-content">
|
||||
<h4>Share Recipe to Group</h4>
|
||||
<div className="recipes-selection">
|
||||
{myRecipes.map((recipe) => (
|
||||
<div key={recipe.id} className="recipe-option">
|
||||
<span>{recipe.name}</span>
|
||||
<button onClick={() => handleShareRecipe(recipe.id)}>Share</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setShowShareRecipe(false)} className="btn-close">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="recipes-grid">
|
||||
{groupRecipes.length === 0 ? (
|
||||
<p className="empty-state">No recipes shared yet</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">
|
||||
Shared by {recipe.shared_by_username || recipe.shared_by_email}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "members" && (
|
||||
<div className="group-content">
|
||||
<div className="content-header">
|
||||
<h4>Members</h4>
|
||||
{groupDetails?.is_admin && (
|
||||
<button onClick={handleShowAddMember} className="btn-add-member">
|
||||
+ Add Member
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddMember && (
|
||||
<div className="add-member-modal">
|
||||
<div className="modal-content">
|
||||
<h4>Add Member</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)}>Add</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setShowAddMember(false)} className="btn-close">
|
||||
Cancel
|
||||
</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">Admin</span>}
|
||||
</div>
|
||||
{groupDetails.is_admin && !member.is_admin && (
|
||||
<button
|
||||
onClick={() => handleRemoveMember(member.user_id)}
|
||||
className="btn-remove-member"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="no-selection">
|
||||
<p>Select a group or create a new one</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
302
frontend/src/components/RatingsComments.css
Normal file
302
frontend/src/components/RatingsComments.css
Normal 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;
|
||||
}
|
||||
}
|
||||
267
frontend/src/components/RatingsComments.jsx
Normal file
267
frontend/src/components/RatingsComments.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
312
frontend/src/socialApi.js
Normal file
312
frontend/src/socialApi.js
Normal 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");
|
||||
}
|
||||
@ -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/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user