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
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
security_optional = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
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
|
# 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"""
|
"""Get current user if authenticated, otherwise None"""
|
||||||
if not credentials:
|
if not credentials:
|
||||||
return None
|
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)
|
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()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
if user_id is None:
|
||||||
"""
|
# Not authenticated - only public recipes
|
||||||
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
cur.execute(
|
||||||
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
"""
|
||||||
u.display_name as owner_display_name
|
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
||||||
FROM recipes r
|
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
||||||
LEFT JOIN users u ON r.user_id = u.id
|
r.visibility, u.display_name as owner_display_name
|
||||||
ORDER BY r.id
|
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()
|
rows = cur.fetchall()
|
||||||
return rows
|
return rows
|
||||||
finally:
|
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]]:
|
def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
עדכון מתכון קיים לפי id.
|
עדכון מתכון קיים לפי 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()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
@ -85,9 +108,10 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
|
|||||||
ingredients = %s,
|
ingredients = %s,
|
||||||
steps = %s,
|
steps = %s,
|
||||||
image = %s,
|
image = %s,
|
||||||
made_by = %s
|
made_by = %s,
|
||||||
|
visibility = %s
|
||||||
WHERE id = %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"],
|
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", [])),
|
json.dumps(recipe_data.get("steps", [])),
|
||||||
recipe_data.get("image"),
|
recipe_data.get("image"),
|
||||||
recipe_data.get("made_by"),
|
recipe_data.get("made_by"),
|
||||||
|
recipe_data.get("visibility", "public"),
|
||||||
recipe_id,
|
recipe_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -135,9 +160,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, 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)
|
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
|
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id, visibility
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
recipe_data["name"],
|
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("image"),
|
||||||
recipe_data.get("made_by"),
|
recipe_data.get("made_by"),
|
||||||
recipe_data.get("user_id"),
|
recipe_data.get("user_id"),
|
||||||
|
recipe_data.get("visibility", "public"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
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(
|
def get_recipes_by_filters_db(
|
||||||
meal_type: Optional[str],
|
meal_type: Optional[str],
|
||||||
max_time: Optional[int],
|
max_time: Optional[int],
|
||||||
|
user_id: Optional[int] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
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.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
|
FROM recipes r
|
||||||
LEFT JOIN users u ON r.user_id = u.id
|
LEFT JOIN users u ON r.user_id = u.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
"""
|
"""
|
||||||
params: List = []
|
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:
|
if meal_type:
|
||||||
query += " AND r.meal_type = %s"
|
query += " AND r.meal_type = %s"
|
||||||
params.append(meal_type.lower())
|
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,
|
verify_password,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
|
get_current_user_optional,
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -64,6 +65,9 @@ from email_utils import (
|
|||||||
|
|
||||||
from oauth_utils import oauth
|
from oauth_utils import oauth
|
||||||
|
|
||||||
|
# Import routers
|
||||||
|
from routers import friends, chat, groups, ratings_comments
|
||||||
|
|
||||||
|
|
||||||
class RecipeBase(BaseModel):
|
class RecipeBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@ -74,6 +78,7 @@ class RecipeBase(BaseModel):
|
|||||||
ingredients: List[str] = []
|
ingredients: List[str] = []
|
||||||
steps: List[str] = []
|
steps: List[str] = []
|
||||||
image: Optional[str] = None # Base64-encoded image or image URL
|
image: Optional[str] = None # Base64-encoded image or image URL
|
||||||
|
visibility: str = "public" # public, private, friends, groups
|
||||||
|
|
||||||
|
|
||||||
class RecipeCreate(RecipeBase):
|
class RecipeCreate(RecipeBase):
|
||||||
@ -237,10 +242,17 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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])
|
@app.get("/recipes", response_model=List[Recipe])
|
||||||
def list_recipes():
|
def list_recipes(current_user: Optional[dict] = Depends(get_current_user_optional)):
|
||||||
rows = list_recipes_db()
|
user_id = current_user["user_id"] if current_user else None
|
||||||
|
rows = list_recipes_db(user_id)
|
||||||
recipes = [
|
recipes = [
|
||||||
Recipe(
|
Recipe(
|
||||||
id=r["id"],
|
id=r["id"],
|
||||||
@ -254,6 +266,7 @@ def list_recipes():
|
|||||||
image=r.get("image"),
|
image=r.get("image"),
|
||||||
user_id=r.get("user_id"),
|
user_id=r.get("user_id"),
|
||||||
owner_display_name=r.get("owner_display_name"),
|
owner_display_name=r.get("owner_display_name"),
|
||||||
|
visibility=r.get("visibility", "public"),
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@ -280,6 +293,7 @@ def create_recipe(recipe_in: RecipeCreate, current_user: dict = Depends(get_curr
|
|||||||
image=row.get("image"),
|
image=row.get("image"),
|
||||||
user_id=row.get("user_id"),
|
user_id=row.get("user_id"),
|
||||||
owner_display_name=current_user.get("display_name"),
|
owner_display_name=current_user.get("display_name"),
|
||||||
|
visibility=row.get("visibility", "public"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.put("/recipes/{recipe_id}", response_model=Recipe)
|
@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"),
|
image=row.get("image"),
|
||||||
user_id=row.get("user_id"),
|
user_id=row.get("user_id"),
|
||||||
owner_display_name=current_user.get("display_name"),
|
owner_display_name=current_user.get("display_name"),
|
||||||
|
visibility=row.get("visibility", "public"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -350,8 +365,10 @@ def random_recipe(
|
|||||||
None,
|
None,
|
||||||
description="רשימת מרכיבים (ingredients=ביצה&ingredients=עגבניה...)",
|
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 = [
|
recipes = [
|
||||||
Recipe(
|
Recipe(
|
||||||
@ -366,6 +383,7 @@ def random_recipe(
|
|||||||
image=r.get("image"),
|
image=r.get("image"),
|
||||||
user_id=r.get("user_id"),
|
user_id=r.get("user_id"),
|
||||||
owner_display_name=r.get("owner_display_name"),
|
owner_display_name=r.get("owner_display_name"),
|
||||||
|
visibility=r.get("visibility", "public"),
|
||||||
)
|
)
|
||||||
for r in rows
|
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)
|
VALUES ('admin', 'admin@myrecipes.local', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5lE7UGf3rCvHC', 'Admin', 'User', 'מנהל', TRUE)
|
||||||
ON CONFLICT (username) DO NOTHING;
|
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) {
|
@media (min-width: 960px) {
|
||||||
.layout {
|
.layout {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
|
||||||
}
|
|
||||||
|
|
||||||
.layout:has(.pinned-lists-sidebar) {
|
|
||||||
grid-template-columns: 320px minmax(0, 1fr) minmax(0, 1fr);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,10 +185,18 @@ body {
|
|||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
.pinned-lists-sidebar {
|
.pinned-lists-sidebar {
|
||||||
display: block;
|
display: block;
|
||||||
position: sticky;
|
position: fixed;
|
||||||
top: 1rem;
|
right: 1rem;
|
||||||
max-height: calc(100vh - 2rem);
|
top: 5rem;
|
||||||
|
width: 280px;
|
||||||
|
max-height: calc(100vh - 6rem);
|
||||||
overflow-y: auto;
|
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,
|
||||||
|
.sidebar-right,
|
||||||
.content {
|
.content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-right {
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
/* Panels */
|
/* Panels */
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
@ -492,31 +503,31 @@ select {
|
|||||||
min-height: 260px;
|
min-height: 260px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-header {
|
.recipe-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.8rem;
|
gap: 1rem;
|
||||||
margin-bottom: 0.8rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-header h2 {
|
.recipe-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.3rem;
|
font-size: 1.6rem;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-subtitle {
|
.recipe-subtitle {
|
||||||
margin: 0.2rem 0 0;
|
margin: 0.3rem 0 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-made-by {
|
.recipe-made-by {
|
||||||
margin: 0.3rem 0 0;
|
margin: 0.4rem 0 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.85rem;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@ -524,25 +535,34 @@ select {
|
|||||||
.pill-row {
|
.pill-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.3rem;
|
gap: 0.4rem;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.35rem 0.75rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(15, 23, 42, 0.95);
|
background: rgba(15, 23, 42, 0.95);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.7);
|
border: 1px solid rgba(148, 163, 184, 0.7);
|
||||||
font-size: 0.78rem;
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Recipe Image */
|
/* Recipe Image */
|
||||||
.recipe-image-container {
|
.recipe-image-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 250px;
|
height: 280px;
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 0.8rem;
|
margin-bottom: 1.2rem;
|
||||||
background: rgba(15, 23, 42, 0.5);
|
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 {
|
.recipe-image {
|
||||||
@ -554,26 +574,34 @@ select {
|
|||||||
|
|
||||||
.recipe-body {
|
.recipe-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.8rem;
|
gap: 1.2rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 720px) {
|
@media (min-width: 600px) {
|
||||||
.recipe-body {
|
.recipe-body {
|
||||||
grid-template-columns: 1fr 1.2fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-column h3 {
|
.recipe-column h3 {
|
||||||
margin: 0 0 0.3rem;
|
margin: 0 0 0.6rem;
|
||||||
font-size: 0.95rem;
|
font-size: 1.1rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-column ul,
|
.recipe-column ul,
|
||||||
.recipe-column ol {
|
.recipe-column ol {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-right: 1rem;
|
padding-right: 1.2rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-column li {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-actions {
|
.recipe-actions {
|
||||||
|
|||||||
@ -7,6 +7,9 @@ import RecipeDetails from "./components/RecipeDetails";
|
|||||||
import RecipeFormDrawer from "./components/RecipeFormDrawer";
|
import RecipeFormDrawer from "./components/RecipeFormDrawer";
|
||||||
import GroceryLists from "./components/GroceryLists";
|
import GroceryLists from "./components/GroceryLists";
|
||||||
import PinnedGroceryLists from "./components/PinnedGroceryLists";
|
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 Modal from "./components/Modal";
|
||||||
import ToastContainer from "./components/ToastContainer";
|
import ToastContainer from "./components/ToastContainer";
|
||||||
import ThemeToggle from "./components/ThemeToggle";
|
import ThemeToggle from "./components/ThemeToggle";
|
||||||
@ -110,23 +113,41 @@ function App() {
|
|||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
|
console.log("Checking authentication...");
|
||||||
const userData = await getMe(token);
|
const userData = await getMe(token);
|
||||||
|
console.log("Auth successful:", userData);
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error("Auth check error:", err);
|
||||||
// Only remove token on authentication errors (401), not network errors
|
// 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");
|
console.log("Token invalid or expired, logging out");
|
||||||
removeToken();
|
removeToken();
|
||||||
setIsAuthenticated(false);
|
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 {
|
} else {
|
||||||
// Network error or server error - keep user logged in
|
// Network error or server error - assume not authenticated to avoid being stuck
|
||||||
console.warn("Auth check failed but keeping session:", err.message);
|
console.warn("Auth check failed, removing token:", err.message);
|
||||||
setIsAuthenticated(true); // Assume still authenticated
|
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();
|
checkAuth();
|
||||||
}, []);
|
}, []);
|
||||||
@ -452,12 +473,36 @@ function App() {
|
|||||||
>
|
>
|
||||||
🛒 רשימות קניות
|
🛒 רשימות קניות
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main className="layout">
|
<main className="layout">
|
||||||
{currentView === "grocery-lists" ? (
|
{currentView === "grocery-lists" ? (
|
||||||
<GroceryLists user={user} onShowToast={addToast} />
|
<GroceryLists user={user} onShowToast={addToast} />
|
||||||
|
) : currentView === "friends" ? (
|
||||||
|
<Friends showToast={addToast} />
|
||||||
|
) : currentView === "chat" ? (
|
||||||
|
<Chat showToast={addToast} />
|
||||||
|
) : currentView === "groups" ? (
|
||||||
|
<Groups showToast={addToast} onRecipeSelect={setSelectedRecipe} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
@ -486,10 +531,41 @@ function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<section className="content-wrapper">
|
<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">
|
<section className="content">
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{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">
|
<section className="panel filter-panel">
|
||||||
<h3>חיפוש מתכון רנדומלי</h3>
|
<h3>חיפוש מתכון רנדומלי</h3>
|
||||||
<div className="panel-grid">
|
<div className="panel-grid">
|
||||||
@ -507,64 +583,36 @@ function App() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>זמן מקסימלי (דקות)</label>
|
<label>זמן מקסימלי (דקות)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
value={maxTimeFilter}
|
value={maxTimeFilter}
|
||||||
onChange={(e) => setMaxTimeFilter(e.target.value)}
|
onChange={(e) => setMaxTimeFilter(e.target.value)}
|
||||||
placeholder="למשל 20"
|
placeholder="למשל 20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field field-full">
|
<div className="field field-full">
|
||||||
<label>מצרכים שיש בבית (מופרד בפסיקים)</label>
|
<label>מצרכים שיש בבית (מופרד בפסיקים)</label>
|
||||||
<input
|
<input
|
||||||
value={ingredientsFilter}
|
value={ingredientsFilter}
|
||||||
onChange={(e) => setIngredientsFilter(e.target.value)}
|
onChange={(e) => setIngredientsFilter(e.target.value)}
|
||||||
placeholder="ביצה, עגבניה, פסטה..."
|
placeholder="ביצה, עגבניה, פסטה..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn accent full"
|
className="btn accent full"
|
||||||
onClick={handleRandomClick}
|
onClick={handleRandomClick}
|
||||||
disabled={loadingRandom}
|
disabled={loadingRandom}
|
||||||
>
|
>
|
||||||
{loadingRandom ? "מחפש מתכון..." : "תן לי רעיון לארוחה 🎲"}
|
{loadingRandom ? "מחפש מתכון..." : "תן לי רעיון לארוחה 🎲"}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
</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>
|
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -29,30 +29,61 @@ export async function register(username, email, password, firstName, lastName, d
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function login(username, password) {
|
export async function login(username, password) {
|
||||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
let lastError;
|
||||||
method: "POST",
|
const maxRetries = 2;
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username, password }),
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
});
|
try {
|
||||||
if (!res.ok) {
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||||
const error = await res.json();
|
method: "POST",
|
||||||
throw new Error(error.detail || "Failed to login");
|
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) {
|
export async function getMe(token) {
|
||||||
const res = await fetch(`${API_BASE}/auth/me`, {
|
const controller = new AbortController();
|
||||||
headers: {
|
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
try {
|
||||||
});
|
const res = await fetch(`${API_BASE}/auth/me`, {
|
||||||
if (!res.ok) {
|
headers: {
|
||||||
const error = new Error("Failed to get user info");
|
Authorization: `Bearer ${token}`,
|
||||||
error.status = res.status;
|
},
|
||||||
throw error;
|
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) {
|
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 [userSearch, setUserSearch] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState([]);
|
const [searchResults, setSearchResults] = useState([]);
|
||||||
const [sharePermission, setSharePermission] = useState(false);
|
const [sharePermission, setSharePermission] = useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(null);
|
||||||
|
|
||||||
// New list form
|
// New list form
|
||||||
const [newListName, setNewListName] = useState("");
|
const [newListName, setNewListName] = useState("");
|
||||||
@ -167,7 +168,12 @@ function GroceryLists({ user, onShowToast }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteList = async (listId) => {
|
const handleDeleteList = async (listId) => {
|
||||||
if (!confirm("האם אתה בטוח שברצונך למחוק רשימת קניות זו?")) return;
|
setShowDeleteModal(listId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteList = async () => {
|
||||||
|
const listId = showDeleteModal;
|
||||||
|
setShowDeleteModal(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteGroceryList(listId);
|
await deleteGroceryList(listId);
|
||||||
@ -234,11 +240,21 @@ function GroceryLists({ user, onShowToast }) {
|
|||||||
can_edit: sharePermission,
|
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("");
|
setUserSearch("");
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setSharePermission(false);
|
setSharePermission(false);
|
||||||
onShowToast(`רשימה שותפה עם ${share.display_name}`, "success");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onShowToast(error.message, "error");
|
onShowToast(error.message, "error");
|
||||||
}
|
}
|
||||||
@ -298,7 +314,10 @@ function GroceryLists({ user, onShowToast }) {
|
|||||||
onClick={() => handleSelectList(list)}
|
onClick={() => handleSelectList(list)}
|
||||||
>
|
>
|
||||||
<div className="list-item-content">
|
<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">
|
<p className="list-item-meta">
|
||||||
{list.is_owner ? "שלי" : `של ${list.owner_display_name}`}
|
{list.is_owner ? "שלי" : `של ${list.owner_display_name}`}
|
||||||
{" · "}
|
{" · "}
|
||||||
@ -501,18 +520,51 @@ function GroceryLists({ user, onShowToast }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state">
|
<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>
|
</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 */}
|
{/* Share Modal */}
|
||||||
{showShareModal && (
|
{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 share-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<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 className="btn-close" onClick={() => setShowShareModal(null)}>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@ -520,51 +572,68 @@ function GroceryLists({ user, onShowToast }) {
|
|||||||
|
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
<div className="share-search">
|
<div className="share-search">
|
||||||
<input
|
<div className="search-input-wrapper">
|
||||||
type="text"
|
<span className="search-icon">🔍</span>
|
||||||
placeholder="חפש משתמש לפי שם משתמש או שם תצוגה..."
|
<input
|
||||||
value={userSearch}
|
type="text"
|
||||||
onChange={(e) => handleSearchUsers(e.target.value)}
|
placeholder="חפש משתמש לפי שם משתמש או שם תצוגה..."
|
||||||
/>
|
value={userSearch}
|
||||||
<label className="checkbox-label">
|
onChange={(e) => handleSearchUsers(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="checkbox-label modern-checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={sharePermission}
|
checked={sharePermission}
|
||||||
onChange={(e) => setSharePermission(e.target.checked)}
|
onChange={(e) => setSharePermission(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>אפשר עריכה</span>
|
<span className="checkbox-text">
|
||||||
|
<span className="checkbox-title">אפשר עריכה</span>
|
||||||
|
<span className="checkbox-desc">המשתמש יוכל לערוך ולהוסיף פריטים</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
<ul className="search-results">
|
<div className="search-results">
|
||||||
{searchResults.map((user) => (
|
<p className="search-results-title">תוצאות חיפוש:</p>
|
||||||
<li
|
<ul>
|
||||||
key={user.id}
|
{searchResults.map((user) => (
|
||||||
onClick={() => handleShareWithUser(user.id, user.username)}
|
<li
|
||||||
>
|
key={user.id}
|
||||||
<div>
|
className="search-result-item"
|
||||||
<strong>{user.display_name}</strong>
|
onClick={() => handleShareWithUser(user.id, user.username)}
|
||||||
<span className="username">@{user.username}</span>
|
>
|
||||||
</div>
|
<div className="user-avatar">👤</div>
|
||||||
<button className="btn small">שתף</button>
|
<div className="user-info">
|
||||||
</li>
|
<strong>{user.display_name}</strong>
|
||||||
))}
|
<span className="username">@{user.username}</span>
|
||||||
</ul>
|
</div>
|
||||||
|
<button className="btn primary small">שתף</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shares-list">
|
<div className="shares-list">
|
||||||
<h4>משותף עם:</h4>
|
<h4>🤝 משותף עם:</h4>
|
||||||
{shares.length === 0 ? (
|
{shares.length === 0 ? (
|
||||||
<p className="empty-message">הרשימה לא משותפת עם אף אחד</p>
|
<div className="empty-shares">
|
||||||
|
<span className="empty-icon">👥</span>
|
||||||
|
<p>הרשימה לא משותפת עם אף אחד</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul>
|
<ul>
|
||||||
{shares.map((share) => (
|
{shares.map((share) => (
|
||||||
<li key={share.id} className="share-item">
|
<li key={share.id} className="share-item">
|
||||||
<div>
|
<div className="user-avatar small">👤</div>
|
||||||
<strong>{share.display_name}</strong>
|
<div className="share-info">
|
||||||
<span className="username">@{share.username}</span>
|
<div>
|
||||||
{share.can_edit && <span className="badge">עורך</span>}
|
<strong>{share.display_name}</strong>
|
||||||
|
<span className="username">@{share.username}</span>
|
||||||
|
</div>
|
||||||
|
{share.can_edit && <span className="badge editor-badge">✏️ עורך</span>}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn danger small"
|
className="btn danger small"
|
||||||
@ -629,6 +698,22 @@ function GroceryLists({ user, onShowToast }) {
|
|||||||
gap: 0.5rem;
|
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 {
|
.list-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -822,11 +907,32 @@ function GroceryLists({ user, onShowToast }) {
|
|||||||
|
|
||||||
.empty-state,
|
.empty-state,
|
||||||
.empty-message {
|
.empty-message {
|
||||||
text-align: center;
|
text-align: center;\n padding: 2rem;
|
||||||
padding: 2rem;
|
|
||||||
opacity: 0.6;
|
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 {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -838,6 +944,66 @@ function GroceryLists({ user, onShowToast }) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
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 {
|
.share-modal {
|
||||||
@ -856,50 +1022,169 @@ function GroceryLists({ user, onShowToast }) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-bottom: 1px solid var(--border-color);
|
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 {
|
.modal-body {
|
||||||
padding: 1.5rem;
|
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 {
|
.share-search input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 0.5rem;
|
padding-right: 3rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-label {
|
.modern-checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 0.5rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 1rem;
|
padding: 1rem;
|
||||||
|
background: rgba(34, 197, 94, 0.05);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
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 {
|
.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;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 1rem 0;
|
margin: 0;
|
||||||
border: 1px solid var(--border-color);
|
display: flex;
|
||||||
border-radius: 8px;
|
flex-direction: column;
|
||||||
max-height: 200px;
|
gap: 0.5rem;
|
||||||
overflow-y: auto;
|
}
|
||||||
}
|
|
||||||
|
.search-result-item {
|
||||||
.search-results li {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
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;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid var(--border-color);
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-results li:last-child {
|
.search-result-item:hover {
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-results li:hover {
|
|
||||||
background: var(--hover-bg);
|
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 {
|
.username {
|
||||||
@ -910,30 +1195,80 @@ function GroceryLists({ user, onShowToast }) {
|
|||||||
|
|
||||||
.shares-list {
|
.shares-list {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 2px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shares-list h4 {
|
.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 {
|
.share-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.75rem;
|
gap: 1rem;
|
||||||
background: var(--hover-bg);
|
padding: 1rem;
|
||||||
border-radius: 8px;
|
background: var(--card-soft, rgba(0, 0, 0, 0.2));
|
||||||
margin-bottom: 0.5rem;
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.share-item:hover {
|
||||||
display: inline-block;
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
padding: 0.25rem 0.5rem;
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
background: var(--primary-color);
|
}
|
||||||
color: white;
|
|
||||||
border-radius: 6px;
|
.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;
|
font-size: 0.75rem;
|
||||||
margin-right: 0.5rem;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon-action {
|
.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 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) {
|
if (!recipe) {
|
||||||
return (
|
return (
|
||||||
<section className="panel placeholder">
|
<section className="panel placeholder">
|
||||||
@ -85,6 +86,13 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ratings and Comments */}
|
||||||
|
<RatingsComments
|
||||||
|
recipeId={recipe.id}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
showToast={showToast}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
|||||||
const [madeBy, setMadeBy] = useState("");
|
const [madeBy, setMadeBy] = useState("");
|
||||||
const [tags, setTags] = useState("");
|
const [tags, setTags] = useState("");
|
||||||
const [image, setImage] = useState("");
|
const [image, setImage] = useState("");
|
||||||
|
const [visibility, setVisibility] = useState("public");
|
||||||
|
|
||||||
const [ingredients, setIngredients] = useState([""]);
|
const [ingredients, setIngredients] = useState([""]);
|
||||||
const [steps, setSteps] = useState([""]);
|
const [steps, setSteps] = useState([""]);
|
||||||
@ -25,6 +26,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
|||||||
setMadeBy(editingRecipe.made_by || "");
|
setMadeBy(editingRecipe.made_by || "");
|
||||||
setTags((editingRecipe.tags || []).join(" "));
|
setTags((editingRecipe.tags || []).join(" "));
|
||||||
setImage(editingRecipe.image || "");
|
setImage(editingRecipe.image || "");
|
||||||
|
setVisibility(editingRecipe.visibility || "public");
|
||||||
setIngredients(editingRecipe.ingredients || [""]);
|
setIngredients(editingRecipe.ingredients || [""]);
|
||||||
setSteps(editingRecipe.steps || [""]);
|
setSteps(editingRecipe.steps || [""]);
|
||||||
} else {
|
} else {
|
||||||
@ -34,6 +36,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
|||||||
setMadeBy(currentUser?.username || "");
|
setMadeBy(currentUser?.username || "");
|
||||||
setTags("");
|
setTags("");
|
||||||
setImage("");
|
setImage("");
|
||||||
|
setVisibility("public");
|
||||||
setIngredients([""]);
|
setIngredients([""]);
|
||||||
setSteps([""]);
|
setSteps([""]);
|
||||||
}
|
}
|
||||||
@ -107,6 +110,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
|||||||
ingredients: cleanIngredients,
|
ingredients: cleanIngredients,
|
||||||
steps: cleanSteps,
|
steps: cleanSteps,
|
||||||
made_by: madeBy.trim() || currentUser?.username || "",
|
made_by: madeBy.trim() || currentUser?.username || "",
|
||||||
|
visibility: visibility,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (image) {
|
if (image) {
|
||||||
@ -149,17 +153,26 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>זמן הכנה (דקות)</label>
|
<label>מי יכול לראות?</label>
|
||||||
<input
|
<select value={visibility} onChange={(e) => setVisibility(e.target.value)}>
|
||||||
type="number"
|
<option value="public">ציבורי - כולם</option>
|
||||||
min="1"
|
<option value="friends">חברים בלבד</option>
|
||||||
value={timeMinutes}
|
<option value="private">פרטי - רק אני</option>
|
||||||
onChange={(e) => setTimeMinutes(e.target.value)}
|
</select>
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="field">
|
||||||
<label>המתכון של:</label>
|
<label>המתכון של:</label>
|
||||||
<input
|
<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'],
|
assetsInclude: ['**/*.svg'],
|
||||||
server: {
|
server: {
|
||||||
port: 5174,
|
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