Compare commits
No commits in common. "social-network" and "master" have entirely different histories.
social-net
...
master
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1 @@
|
||||
node_modules/
|
||||
my-recipes/
|
||||
my-recipes-chart/
|
||||
@ -63,7 +63,7 @@ steps:
|
||||
- |
|
||||
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||
echo "💡 Setting frontend tag to: $TAG"
|
||||
yq -i ".frontend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||
yq -i ".frontend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||
git add manifests/${CI_REPO_NAME}/values.yaml
|
||||
git commit -m "frontend: update tag to $TAG" || echo "No changes"
|
||||
git push origin HEAD
|
||||
@ -93,7 +93,7 @@ steps:
|
||||
- |
|
||||
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||
echo "💡 Setting backend tag to: $TAG"
|
||||
yq -i ".backend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||
yq -i ".backend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||
git add manifests/${CI_REPO_NAME}/values.yaml
|
||||
git commit -m "backend: update tag to $TAG" || echo "No changes"
|
||||
git push origin HEAD
|
||||
|
||||
19
backend/.env
19
backend/.env
@ -4,22 +4,3 @@ DB_USER=recipes_user
|
||||
DB_NAME=recipes_db
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=dvirlabs@gmail.com
|
||||
SMTP_PASSWORD=agaanrhbbazbdytv
|
||||
SMTP_FROM=dvirlabs@gmail.com
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=143092846986-hsi59m0on2c9rb5qrdoejfceieao2ioc.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
|
||||
FRONTEND_URL=http://localhost:5174
|
||||
|
||||
# Microsoft Entra ID (Azure AD) OAuth
|
||||
AZURE_CLIENT_ID=db244cf5-eb11-4738-a2ea-5b0716c9ec0a
|
||||
AZURE_CLIENT_SECRET=Zad8Q~qRBxaQq8up0lLXAq4pHzrVM2JFGFJhHaDp
|
||||
AZURE_TENANT_ID=consumers
|
||||
AZURE_REDIRECT_URI=http://localhost:8000/auth/azure/callback
|
||||
@ -1,28 +0,0 @@
|
||||
DATABASE_URL=postgresql://recipes_user:Aa123456@localhost:5432/recipes_db
|
||||
DB_PASSWORD=Aa123456
|
||||
DB_USER=recipes_user
|
||||
DB_NAME=recipes_db
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=dvirlabs@gmail.com
|
||||
SMTP_PASSWORD=agaanrhbbazbdytv
|
||||
SMTP_FROM=dvirlabs@gmail.com
|
||||
|
||||
# Secret Key for sessions (OAuth state token)
|
||||
SECRET_KEY=your-super-secret-key-min-32-chars-dev-only-change-in-prod
|
||||
|
||||
# Google OAuth (LOCAL - localhost redirect)
|
||||
GOOGLE_CLIENT_ID=143092846986-hsi59m0on2c9rb5qrdoejfceieao2ioc.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
|
||||
FRONTEND_URL=http://localhost:5174
|
||||
|
||||
# Microsoft Entra ID (Azure AD) OAuth
|
||||
AZURE_CLIENT_ID=db244cf5-eb11-4738-a2ea-5b0716c9ec0a
|
||||
AZURE_CLIENT_SECRET=Zad8Q~qRBxaQq8up0lLXAq4pHzrVM2JFGFJhHaDp
|
||||
AZURE_TENANT_ID=consumers
|
||||
AZURE_REDIRECT_URI=http://localhost:8000/auth/azure/callback
|
||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@ -1 +0,0 @@
|
||||
__pycache__/
|
||||
@ -1,21 +0,0 @@
|
||||
# Database Migration Instructions
|
||||
|
||||
## Add auth_provider column to users table
|
||||
|
||||
Run this command in your backend directory:
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
$env:PGPASSWORD="recipes_password"; psql -h localhost -U recipes_user -d recipes_db -f add_auth_provider_column.sql
|
||||
|
||||
# Or using psql directly
|
||||
psql -h localhost -U recipes_user -d recipes_db -f add_auth_provider_column.sql
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Add the `auth_provider` column to the users table (default: 'local')
|
||||
2. Update all existing users to have 'local' as their auth_provider
|
||||
3. Create an index for faster lookups
|
||||
4. Display the updated table structure
|
||||
|
||||
After running the migration, restart your backend server.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,30 +0,0 @@
|
||||
-- Add auth_provider column to users table
|
||||
-- This tracks whether the user is local or uses OAuth (google, microsoft, etc.)
|
||||
|
||||
-- Add the column if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name='users' AND column_name='auth_provider'
|
||||
) THEN
|
||||
ALTER TABLE users ADD COLUMN auth_provider VARCHAR(50) DEFAULT 'local' NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Update existing users to have 'local' as their auth_provider
|
||||
UPDATE users SET auth_provider = 'local' WHERE auth_provider IS NULL;
|
||||
|
||||
-- Create index for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_auth_provider ON users(auth_provider);
|
||||
|
||||
-- Display the updated users table structure
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'users'
|
||||
ORDER BY ordinal_position;
|
||||
@ -1,5 +0,0 @@
|
||||
-- Add is_pinned column to grocery_lists table
|
||||
ALTER TABLE grocery_lists ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Verify the column was added
|
||||
\d grocery_lists
|
||||
@ -1,127 +0,0 @@
|
||||
-- Add social networking features to recipes database
|
||||
|
||||
-- Add visibility column to recipes table
|
||||
ALTER TABLE recipes
|
||||
ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) DEFAULT 'public' CHECK (visibility IN ('public', 'private', 'friends', 'groups'));
|
||||
|
||||
-- Create friendships table
|
||||
CREATE TABLE IF NOT EXISTS friendships (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
friend_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, friend_id),
|
||||
CHECK (user_id != friend_id)
|
||||
);
|
||||
|
||||
-- Create friend_requests table
|
||||
CREATE TABLE IF NOT EXISTS friend_requests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
receiver_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(sender_id, receiver_id),
|
||||
CHECK (sender_id != receiver_id)
|
||||
);
|
||||
|
||||
-- Create conversations table
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100),
|
||||
is_group BOOLEAN DEFAULT FALSE,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create conversation_members table
|
||||
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(conversation_id, user_id)
|
||||
);
|
||||
|
||||
-- Create messages table
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
edited_at TIMESTAMP,
|
||||
is_deleted BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Create recipe_ratings table
|
||||
CREATE TABLE IF NOT EXISTS recipe_ratings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(recipe_id, user_id)
|
||||
);
|
||||
|
||||
-- Create recipe_comments table
|
||||
CREATE TABLE IF NOT EXISTS recipe_comments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
parent_comment_id INTEGER REFERENCES recipe_comments(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_deleted BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Create groups table
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
is_private BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create group_members table
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) DEFAULT 'member' CHECK (role IN ('admin', 'moderator', 'member')),
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(group_id, user_id)
|
||||
);
|
||||
|
||||
-- Create recipe_shares table (for sharing recipes to specific groups)
|
||||
CREATE TABLE IF NOT EXISTS recipe_shares (
|
||||
id SERIAL PRIMARY KEY,
|
||||
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(recipe_id, group_id)
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_friendships_user_id ON friendships(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_friendships_friend_id ON friendships(friend_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_friend_requests_receiver ON friend_requests(receiver_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_friend_requests_sender ON friend_requests(sender_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_members_user ON conversation_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipe_ratings_recipe ON recipe_ratings(recipe_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipe_comments_recipe ON recipe_comments(recipe_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipe_shares_group ON recipe_shares(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_visibility ON recipes(visibility);
|
||||
@ -1,97 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
import bcrypt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
import os
|
||||
|
||||
# Secret key for JWT (use environment variable in production)
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||
|
||||
security = HTTPBearer()
|
||||
security_optional = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password for storing."""
|
||||
# Bcrypt has a 72 byte limit, truncate if necessary
|
||||
password_bytes = password.encode('utf-8')[:72]
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a stored password against one provided by user"""
|
||||
# Bcrypt has a 72 byte limit, truncate if necessary
|
||||
password_bytes = plain_password.encode('utf-8')[:72]
|
||||
hashed_bytes = hashed_password.encode('utf-8')
|
||||
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
"""Create JWT access token"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decode and verify JWT token"""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
|
||||
"""Get current user from JWT token (for protected routes)"""
|
||||
from user_db_utils import get_user_by_id
|
||||
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
)
|
||||
|
||||
# Get full user info from database to include is_admin
|
||||
user = get_user_by_id(int(user_id))
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": user["id"],
|
||||
"username": user["username"],
|
||||
"display_name": user["display_name"],
|
||||
"is_admin": user.get("is_admin", False)
|
||||
}
|
||||
|
||||
|
||||
# Optional dependency - returns None if no token provided
|
||||
def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_optional)) -> Optional[dict]:
|
||||
"""Get current user if authenticated, otherwise None"""
|
||||
if not credentials:
|
||||
return None
|
||||
try:
|
||||
return get_current_user(credentials)
|
||||
except HTTPException:
|
||||
return None
|
||||
@ -1,239 +0,0 @@
|
||||
import os
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
"""Get database connection"""
|
||||
return psycopg2.connect(
|
||||
host=os.getenv("DB_HOST", "localhost"),
|
||||
port=int(os.getenv("DB_PORT", "5432")),
|
||||
database=os.getenv("DB_NAME", "recipes_db"),
|
||||
user=os.getenv("DB_USER", "recipes_user"),
|
||||
password=os.getenv("DB_PASSWORD", "recipes_password"),
|
||||
)
|
||||
|
||||
|
||||
# ============= Conversations & Messages =============
|
||||
|
||||
def create_conversation(user_ids: List[int], is_group: bool = False, name: Optional[str] = None, created_by: int = None):
|
||||
"""Create a new conversation"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
# For private chats, check if conversation already exists
|
||||
if not is_group and len(user_ids) == 2:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT c.id FROM conversations c
|
||||
JOIN conversation_members cm1 ON c.id = cm1.conversation_id
|
||||
JOIN conversation_members cm2 ON c.id = cm2.conversation_id
|
||||
WHERE c.is_group = FALSE
|
||||
AND cm1.user_id = %s AND cm2.user_id = %s
|
||||
""",
|
||||
(user_ids[0], user_ids[1])
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
return get_conversation(existing["id"])
|
||||
|
||||
# Create conversation
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO conversations (name, is_group, created_by)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING id, name, is_group, created_by, created_at
|
||||
""",
|
||||
(name, is_group, created_by)
|
||||
)
|
||||
conversation = dict(cur.fetchone())
|
||||
conversation_id = conversation["id"]
|
||||
|
||||
# Add members
|
||||
for user_id in user_ids:
|
||||
cur.execute(
|
||||
"INSERT INTO conversation_members (conversation_id, user_id) VALUES (%s, %s)",
|
||||
(conversation_id, user_id)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Return conversation with conversation_id field
|
||||
conversation["conversation_id"] = conversation["id"]
|
||||
return conversation
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_conversation(conversation_id: int):
|
||||
"""Get conversation details"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT id, name, is_group, created_by, created_at FROM conversations WHERE id = %s",
|
||||
(conversation_id,)
|
||||
)
|
||||
conversation = cur.fetchone()
|
||||
if not conversation:
|
||||
return None
|
||||
|
||||
# Get members
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT u.id, u.username, u.display_name
|
||||
FROM conversation_members cm
|
||||
JOIN users u ON u.id = cm.user_id
|
||||
WHERE cm.conversation_id = %s
|
||||
""",
|
||||
(conversation_id,)
|
||||
)
|
||||
members = [dict(row) for row in cur.fetchall()]
|
||||
|
||||
result = dict(conversation)
|
||||
result["members"] = members
|
||||
return result
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_conversations(user_id: int):
|
||||
"""Get all conversations for a user"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT c.id AS conversation_id, c.name, c.is_group, c.created_at,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id AND m.created_at > cm.last_read_at) AS unread_count,
|
||||
(SELECT m.content FROM messages m WHERE m.conversation_id = c.id ORDER BY m.created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT m.created_at FROM messages m WHERE m.conversation_id = c.id ORDER BY m.created_at DESC LIMIT 1) AS last_message_at
|
||||
FROM conversations c
|
||||
JOIN conversation_members cm ON c.id = cm.conversation_id
|
||||
WHERE cm.user_id = %s
|
||||
ORDER BY last_message_at DESC NULLS LAST, c.created_at DESC
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
conversations = [dict(row) for row in cur.fetchall()]
|
||||
|
||||
# Get members for each conversation and add other_member_name for private chats
|
||||
for conv in conversations:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT u.id, u.username, u.display_name, u.email
|
||||
FROM conversation_members cm
|
||||
JOIN users u ON u.id = cm.user_id
|
||||
WHERE cm.conversation_id = %s AND u.id != %s
|
||||
""",
|
||||
(conv["conversation_id"], user_id)
|
||||
)
|
||||
members = [dict(row) for row in cur.fetchall()]
|
||||
conv["members"] = members
|
||||
|
||||
# For private chats, add other_member_name
|
||||
if not conv["is_group"] and len(members) > 0:
|
||||
conv["other_member_name"] = members[0].get("display_name") or members[0].get("username") or members[0].get("email")
|
||||
|
||||
return conversations
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def send_message(conversation_id: int, sender_id: int, content: str):
|
||||
"""Send a message in a conversation"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
# Verify user is member of conversation
|
||||
cur.execute(
|
||||
"SELECT 1 FROM conversation_members WHERE conversation_id = %s AND user_id = %s",
|
||||
(conversation_id, sender_id)
|
||||
)
|
||||
if not cur.fetchone():
|
||||
return {"error": "Not a member of this conversation"}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO messages (conversation_id, sender_id, content)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING id, conversation_id, sender_id, content, created_at
|
||||
""",
|
||||
(conversation_id, sender_id, content)
|
||||
)
|
||||
message = cur.fetchone()
|
||||
|
||||
# Update conversation updated_at
|
||||
cur.execute(
|
||||
"UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(conversation_id,)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return dict(message)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_messages(conversation_id: int, user_id: int, limit: int = 50, before_id: Optional[int] = None):
|
||||
"""Get messages from a conversation"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
# Verify user is member
|
||||
cur.execute(
|
||||
"SELECT 1 FROM conversation_members WHERE conversation_id = %s AND user_id = %s",
|
||||
(conversation_id, user_id)
|
||||
)
|
||||
if not cur.fetchone():
|
||||
return {"error": "Not a member of this conversation"}
|
||||
|
||||
# Get messages
|
||||
if before_id:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT m.id AS message_id, m.sender_id, m.content, m.created_at, m.edited_at,
|
||||
u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email,
|
||||
CASE WHEN m.sender_id = %s THEN TRUE ELSE FALSE END AS is_mine
|
||||
FROM messages m
|
||||
JOIN users u ON u.id = m.sender_id
|
||||
WHERE m.conversation_id = %s AND m.is_deleted = FALSE AND m.id < %s
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(user_id, conversation_id, before_id, limit)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT m.id AS message_id, m.sender_id, m.content, m.created_at, m.edited_at,
|
||||
u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email,
|
||||
CASE WHEN m.sender_id = %s THEN TRUE ELSE FALSE END AS is_mine
|
||||
FROM messages m
|
||||
JOIN users u ON u.id = m.sender_id
|
||||
WHERE m.conversation_id = %s AND m.is_deleted = FALSE
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(user_id, conversation_id, limit)
|
||||
)
|
||||
|
||||
messages = [dict(row) for row in cur.fetchall()]
|
||||
messages.reverse() # Return in chronological order
|
||||
|
||||
# Mark as read
|
||||
cur.execute(
|
||||
"UPDATE conversation_members SET last_read_at = CURRENT_TIMESTAMP WHERE conversation_id = %s AND user_id = %s",
|
||||
(conversation_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return messages
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
@ -48,43 +48,18 @@ def get_conn():
|
||||
return psycopg2.connect(dsn, cursor_factory=RealDictCursor)
|
||||
|
||||
|
||||
def list_recipes_db(user_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""List recipes visible to the user. If user_id is None, only show public recipes."""
|
||||
def list_recipes_db() -> List[Dict[str, Any]]:
|
||||
conn = get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if user_id is None:
|
||||
# Not authenticated - only public recipes
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
||||
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
||||
r.visibility, u.display_name as owner_display_name
|
||||
FROM recipes r
|
||||
LEFT JOIN users u ON r.user_id = u.id
|
||||
WHERE r.visibility = 'public'
|
||||
ORDER BY r.id
|
||||
SELECT id, name, meal_type, time_minutes,
|
||||
tags, ingredients, steps, image, made_by
|
||||
FROM recipes
|
||||
ORDER BY id
|
||||
"""
|
||||
)
|
||||
else:
|
||||
# Authenticated - show public, own recipes, friends' recipes
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
||||
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
||||
r.visibility, u.display_name as owner_display_name
|
||||
FROM recipes r
|
||||
LEFT JOIN users u ON r.user_id = u.id
|
||||
WHERE r.visibility = 'public'
|
||||
OR r.user_id = %s
|
||||
OR (r.visibility = 'friends' AND EXISTS (
|
||||
SELECT 1 FROM friendships f
|
||||
WHERE f.user_id = %s AND f.friend_id = r.user_id
|
||||
))
|
||||
ORDER BY r.id
|
||||
""",
|
||||
(user_id, user_id)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return rows
|
||||
finally:
|
||||
@ -93,7 +68,7 @@ def list_recipes_db(user_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
עדכון מתכון קיים לפי id.
|
||||
recipe_data: name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, visibility
|
||||
recipe_data: name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
||||
"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
@ -108,10 +83,9 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
|
||||
ingredients = %s,
|
||||
steps = %s,
|
||||
image = %s,
|
||||
made_by = %s,
|
||||
visibility = %s
|
||||
made_by = %s
|
||||
WHERE id = %s
|
||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id, visibility
|
||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
||||
""",
|
||||
(
|
||||
recipe_data["name"],
|
||||
@ -122,7 +96,6 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
|
||||
json.dumps(recipe_data.get("steps", [])),
|
||||
recipe_data.get("image"),
|
||||
recipe_data.get("made_by"),
|
||||
recipe_data.get("visibility", "public"),
|
||||
recipe_id,
|
||||
),
|
||||
)
|
||||
@ -160,9 +133,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id, visibility)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id, visibility
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
||||
""",
|
||||
(
|
||||
recipe_data["name"],
|
||||
@ -173,8 +146,6 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
json.dumps(recipe_data.get("steps", [])),
|
||||
recipe_data.get("image"),
|
||||
recipe_data.get("made_by"),
|
||||
recipe_data.get("user_id"),
|
||||
recipe_data.get("visibility", "public"),
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
@ -187,40 +158,23 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def get_recipes_by_filters_db(
|
||||
meal_type: Optional[str],
|
||||
max_time: Optional[int],
|
||||
user_id: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
conn = get_conn()
|
||||
try:
|
||||
query = """
|
||||
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
||||
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
||||
r.visibility, u.display_name as owner_display_name
|
||||
FROM recipes r
|
||||
LEFT JOIN users u ON r.user_id = u.id
|
||||
SELECT id, name, meal_type, time_minutes,
|
||||
tags, ingredients, steps, image, made_by
|
||||
FROM recipes
|
||||
WHERE 1=1
|
||||
"""
|
||||
params: List = []
|
||||
|
||||
# Visibility filter
|
||||
if user_id is None:
|
||||
query += " AND r.visibility = 'public'"
|
||||
else:
|
||||
query += """ AND (
|
||||
r.visibility = 'public'
|
||||
OR r.user_id = %s
|
||||
OR (r.visibility = 'friends' AND EXISTS (
|
||||
SELECT 1 FROM friendships f
|
||||
WHERE f.user_id = %s AND f.friend_id = r.user_id
|
||||
))
|
||||
)"""
|
||||
params.extend([user_id, user_id])
|
||||
|
||||
if meal_type:
|
||||
query += " AND r.meal_type = %s"
|
||||
query += " AND meal_type = %s"
|
||||
params.append(meal_type.lower())
|
||||
|
||||
if max_time:
|
||||
query += " AND r.time_minutes <= %s"
|
||||
query += " AND time_minutes <= %s"
|
||||
params.append(max_time)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
|
||||
@ -1,211 +0,0 @@
|
||||
import os
|
||||
import random
|
||||
import aiosmtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime, timedelta
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# In-memory storage for verification codes (in production, use Redis or database)
|
||||
verification_codes = {}
|
||||
password_reset_tokens = {}
|
||||
|
||||
def generate_verification_code():
|
||||
"""Generate a 6-digit verification code"""
|
||||
return str(random.randint(100000, 999999))
|
||||
|
||||
async def send_verification_email(email: str, code: str, purpose: str = "password_change"):
|
||||
"""Send verification code via email"""
|
||||
smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
||||
smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
||||
smtp_user = os.getenv("SMTP_USER")
|
||||
smtp_password = os.getenv("SMTP_PASSWORD")
|
||||
smtp_from = os.getenv("SMTP_FROM", smtp_user)
|
||||
|
||||
if not smtp_user or not smtp_password:
|
||||
raise Exception("SMTP credentials not configured")
|
||||
|
||||
# Create message
|
||||
message = MIMEMultipart("alternative")
|
||||
message["Subject"] = "קוד אימות - מתכונים שלי"
|
||||
message["From"] = smtp_from
|
||||
message["To"] = email
|
||||
|
||||
# Email content
|
||||
if purpose == "password_change":
|
||||
text = f"""
|
||||
שלום,
|
||||
|
||||
קוד האימות שלך לשינוי סיסמה הוא: {code}
|
||||
|
||||
הקוד תקף ל-10 דקות.
|
||||
|
||||
אם לא ביקשת לשנות את הסיסמה, התעלם מהודעה זו.
|
||||
|
||||
בברכה,
|
||||
צוות מתכונים שלי
|
||||
"""
|
||||
|
||||
html = f"""
|
||||
<html dir="rtl">
|
||||
<body style="font-family: Arial, sans-serif; direction: rtl; text-align: right;">
|
||||
<h2>שינוי סיסמה</h2>
|
||||
<p>קוד האימות שלך הוא:</p>
|
||||
<h1 style="color: #22c55e; font-size: 32px; letter-spacing: 5px;">{code}</h1>
|
||||
<p>הקוד תקף ל-<strong>10 דקות</strong>.</p>
|
||||
<hr style="border: 1px solid #e5e7eb; margin: 20px 0;">
|
||||
<p style="color: #6b7280; font-size: 14px;">
|
||||
אם לא ביקשת לשנות את הסיסמה, התעלם מהודעה זו.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
part1 = MIMEText(text, "plain")
|
||||
part2 = MIMEText(html, "html")
|
||||
message.attach(part1)
|
||||
message.attach(part2)
|
||||
|
||||
# Send email
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=smtp_host,
|
||||
port=smtp_port,
|
||||
username=smtp_user,
|
||||
password=smtp_password,
|
||||
start_tls=True,
|
||||
)
|
||||
|
||||
def store_verification_code(user_id: int, code: str):
|
||||
"""Store verification code with expiry"""
|
||||
expiry = datetime.now() + timedelta(minutes=10)
|
||||
verification_codes[user_id] = {
|
||||
"code": code,
|
||||
"expiry": expiry
|
||||
}
|
||||
|
||||
def verify_code(user_id: int, code: str) -> bool:
|
||||
"""Verify if code is correct and not expired"""
|
||||
if user_id not in verification_codes:
|
||||
return False
|
||||
|
||||
stored = verification_codes[user_id]
|
||||
|
||||
# Check if expired
|
||||
if datetime.now() > stored["expiry"]:
|
||||
del verification_codes[user_id]
|
||||
return False
|
||||
|
||||
# Check if code matches
|
||||
if stored["code"] != code:
|
||||
return False
|
||||
|
||||
# Code is valid, remove it
|
||||
del verification_codes[user_id]
|
||||
return True
|
||||
|
||||
|
||||
async def send_password_reset_email(email: str, token: str, frontend_url: str):
|
||||
"""Send password reset link via email"""
|
||||
smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
||||
smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
||||
smtp_user = os.getenv("SMTP_USER")
|
||||
smtp_password = os.getenv("SMTP_PASSWORD")
|
||||
smtp_from = os.getenv("SMTP_FROM", smtp_user)
|
||||
|
||||
if not smtp_user or not smtp_password:
|
||||
raise Exception("SMTP credentials not configured")
|
||||
|
||||
reset_link = f"{frontend_url}?reset_token={token}"
|
||||
|
||||
# Create message
|
||||
message = MIMEMultipart("alternative")
|
||||
message["Subject"] = "איפוס סיסמה - מתכונים שלי"
|
||||
message["From"] = smtp_from
|
||||
message["To"] = email
|
||||
|
||||
text = f"""
|
||||
שלום,
|
||||
|
||||
קיבלנו בקשה לאיפוס הסיסמה שלך.
|
||||
|
||||
לחץ על הקישור הבא כדי לאפס את הסיסמה (תקף ל-30 דקות):
|
||||
{reset_link}
|
||||
|
||||
אם לא ביקשת לאפס את הסיסמה, התעלם מהודעה זו.
|
||||
|
||||
בברכה,
|
||||
צוות מתכונים שלי
|
||||
"""
|
||||
|
||||
html = f"""
|
||||
<html dir="rtl">
|
||||
<body style="font-family: Arial, sans-serif; direction: rtl; text-align: right;">
|
||||
<h2>איפוס סיסמה</h2>
|
||||
<p>קיבלנו בקשה לאיפוס הסיסמה שלך.</p>
|
||||
<p>לחץ על הכפתור למטה כדי לאפס את הסיסמה:</p>
|
||||
<div style="margin: 30px 0; text-align: center;">
|
||||
<a href="{reset_link}"
|
||||
style="background-color: #22c55e; color: white; padding: 12px 30px;
|
||||
text-decoration: none; border-radius: 6px; display: inline-block;
|
||||
font-weight: bold;">
|
||||
איפוס סיסמה
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #6b7280; font-size: 14px;">
|
||||
הקישור תקף ל-<strong>30 דקות</strong>.
|
||||
</p>
|
||||
<hr style="border: 1px solid #e5e7eb; margin: 20px 0;">
|
||||
<p style="color: #6b7280; font-size: 14px;">
|
||||
אם לא ביקשת לאפס את הסיסמה, התעלם מהודעה זו.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
part1 = MIMEText(text, "plain")
|
||||
part2 = MIMEText(html, "html")
|
||||
message.attach(part1)
|
||||
message.attach(part2)
|
||||
|
||||
# Send email
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=smtp_host,
|
||||
port=smtp_port,
|
||||
username=smtp_user,
|
||||
password=smtp_password,
|
||||
start_tls=True,
|
||||
)
|
||||
|
||||
|
||||
def store_password_reset_token(email: str, token: str):
|
||||
"""Store password reset token with expiry"""
|
||||
expiry = datetime.now() + timedelta(minutes=30)
|
||||
password_reset_tokens[token] = {
|
||||
"email": email,
|
||||
"expiry": expiry
|
||||
}
|
||||
|
||||
|
||||
def verify_reset_token(token: str) -> str:
|
||||
"""Verify reset token and return email if valid"""
|
||||
if token not in password_reset_tokens:
|
||||
return None
|
||||
|
||||
stored = password_reset_tokens[token]
|
||||
|
||||
# Check if expired
|
||||
if datetime.now() > stored["expiry"]:
|
||||
del password_reset_tokens[token]
|
||||
return None
|
||||
|
||||
return stored["email"]
|
||||
|
||||
|
||||
def consume_reset_token(token: str):
|
||||
"""Remove token after use"""
|
||||
if token in password_reset_tokens:
|
||||
del password_reset_tokens[token]
|
||||
@ -1,253 +0,0 @@
|
||||
import os
|
||||
from typing import List, Optional, Dict, Any
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
"""Get database connection"""
|
||||
return psycopg2.connect(
|
||||
host=os.getenv("DB_HOST", "localhost"),
|
||||
port=int(os.getenv("DB_PORT", "5432")),
|
||||
database=os.getenv("DB_NAME", "recipes_db"),
|
||||
user=os.getenv("DB_USER", "recipes_user"),
|
||||
password=os.getenv("DB_PASSWORD", "recipes_password"),
|
||||
)
|
||||
|
||||
|
||||
def create_grocery_list(owner_id: int, name: str, items: List[str] = None) -> Dict[str, Any]:
|
||||
"""Create a new grocery list"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
items = items or []
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO grocery_lists (owner_id, name, items)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING id, name, items, owner_id, created_at, updated_at
|
||||
""",
|
||||
(owner_id, name, items)
|
||||
)
|
||||
grocery_list = cur.fetchone()
|
||||
conn.commit()
|
||||
return dict(grocery_list)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_grocery_lists(user_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all grocery lists owned by or shared with a user"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT gl.id, gl.name, gl.items, gl.owner_id, gl.is_pinned, gl.created_at, gl.updated_at,
|
||||
u.display_name as owner_display_name,
|
||||
CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit,
|
||||
CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner
|
||||
FROM grocery_lists gl
|
||||
LEFT JOIN users u ON gl.owner_id = u.id
|
||||
LEFT JOIN grocery_list_shares gls ON gl.id = gls.list_id AND gls.shared_with_user_id = %s
|
||||
WHERE gl.owner_id = %s OR gls.shared_with_user_id = %s
|
||||
ORDER BY gl.updated_at DESC
|
||||
""",
|
||||
(user_id, user_id, user_id, user_id, user_id)
|
||||
)
|
||||
lists = cur.fetchall()
|
||||
return [dict(row) for row in lists]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_grocery_list_by_id(list_id: int, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific grocery list if user has access"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT gl.id, gl.name, gl.items, gl.owner_id, gl.is_pinned, gl.created_at, gl.updated_at,
|
||||
u.display_name as owner_display_name,
|
||||
CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit,
|
||||
CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner
|
||||
FROM grocery_lists gl
|
||||
LEFT JOIN users u ON gl.owner_id = u.id
|
||||
LEFT JOIN grocery_list_shares gls ON gl.id = gls.list_id AND gls.shared_with_user_id = %s
|
||||
WHERE gl.id = %s AND (gl.owner_id = %s OR gls.shared_with_user_id = %s)
|
||||
""",
|
||||
(user_id, user_id, user_id, list_id, user_id, user_id)
|
||||
)
|
||||
grocery_list = cur.fetchone()
|
||||
return dict(grocery_list) if grocery_list else None
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_grocery_list(list_id: int, name: str = None, items: List[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Update a grocery list"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if name is not None:
|
||||
updates.append("name = %s")
|
||||
params.append(name)
|
||||
|
||||
if items is not None:
|
||||
updates.append("items = %s")
|
||||
params.append(items)
|
||||
|
||||
if not updates:
|
||||
return None
|
||||
|
||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||
params.append(list_id)
|
||||
|
||||
query = f"UPDATE grocery_lists SET {', '.join(updates)} WHERE id = %s RETURNING id, name, items, owner_id, created_at, updated_at"
|
||||
|
||||
cur.execute(query, params)
|
||||
grocery_list = cur.fetchone()
|
||||
conn.commit()
|
||||
return dict(grocery_list) if grocery_list else None
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_grocery_list(list_id: int) -> bool:
|
||||
"""Delete a grocery list"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("DELETE FROM grocery_lists WHERE id = %s", (list_id,))
|
||||
deleted = cur.rowcount > 0
|
||||
conn.commit()
|
||||
return deleted
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def share_grocery_list(list_id: int, shared_with_user_id: int, can_edit: bool = False) -> Dict[str, Any]:
|
||||
"""Share a grocery list with another user"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO grocery_list_shares (list_id, shared_with_user_id, can_edit)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (list_id, shared_with_user_id)
|
||||
DO UPDATE SET can_edit = EXCLUDED.can_edit, shared_at = CURRENT_TIMESTAMP
|
||||
RETURNING id, list_id, shared_with_user_id, can_edit, shared_at
|
||||
""",
|
||||
(list_id, shared_with_user_id, can_edit)
|
||||
)
|
||||
share = cur.fetchone()
|
||||
conn.commit()
|
||||
return dict(share)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def unshare_grocery_list(list_id: int, user_id: int) -> bool:
|
||||
"""Remove sharing access for a user"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"DELETE FROM grocery_list_shares WHERE list_id = %s AND shared_with_user_id = %s",
|
||||
(list_id, user_id)
|
||||
)
|
||||
deleted = cur.rowcount > 0
|
||||
conn.commit()
|
||||
return deleted
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_grocery_list_shares(list_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all users a grocery list is shared with"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT gls.id, gls.list_id, gls.shared_with_user_id, gls.can_edit, gls.shared_at,
|
||||
u.username, u.display_name, u.email
|
||||
FROM grocery_list_shares gls
|
||||
JOIN users u ON gls.shared_with_user_id = u.id
|
||||
WHERE gls.list_id = %s
|
||||
ORDER BY gls.shared_at DESC
|
||||
""",
|
||||
(list_id,)
|
||||
)
|
||||
shares = cur.fetchall()
|
||||
return [dict(row) for row in shares]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def search_users(query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Search users by username or display_name for autocomplete"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, username, display_name, email
|
||||
FROM users
|
||||
WHERE username ILIKE %s OR display_name ILIKE %s
|
||||
ORDER BY username
|
||||
LIMIT %s
|
||||
""",
|
||||
(f"%{query}%", f"%{query}%", limit)
|
||||
)
|
||||
users = cur.fetchall()
|
||||
return [dict(row) for row in users]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def toggle_grocery_list_pin(list_id: int, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Toggle pin status for a grocery list (owner only)"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
# Check if user is owner
|
||||
cur.execute(
|
||||
"SELECT id, is_pinned FROM grocery_lists WHERE id = %s AND owner_id = %s",
|
||||
(list_id, user_id)
|
||||
)
|
||||
result = cur.fetchone()
|
||||
if not result:
|
||||
return None
|
||||
|
||||
# Toggle pin status
|
||||
new_pin_status = not result["is_pinned"]
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE grocery_lists
|
||||
SET is_pinned = %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
RETURNING id, name, items, owner_id, is_pinned, created_at, updated_at
|
||||
""",
|
||||
(new_pin_status, list_id)
|
||||
)
|
||||
updated = cur.fetchone()
|
||||
conn.commit()
|
||||
return dict(updated) if updated else None
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
@ -1,380 +0,0 @@
|
||||
import os
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
"""Get database connection"""
|
||||
return psycopg2.connect(
|
||||
host=os.getenv("DB_HOST", "localhost"),
|
||||
port=int(os.getenv("DB_PORT", "5432")),
|
||||
database=os.getenv("DB_NAME", "recipes_db"),
|
||||
user=os.getenv("DB_USER", "recipes_user"),
|
||||
password=os.getenv("DB_PASSWORD", "recipes_password"),
|
||||
)
|
||||
|
||||
|
||||
# ============= Groups =============
|
||||
|
||||
def create_group(name: str, description: str, created_by: int, is_private: bool = False):
|
||||
"""Create a new group"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO groups (name, description, created_by, is_private)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id, name, description, created_by, is_private, created_at
|
||||
""",
|
||||
(name, description, created_by, is_private)
|
||||
)
|
||||
group = dict(cur.fetchone())
|
||||
|
||||
# Add creator as admin
|
||||
cur.execute(
|
||||
"INSERT INTO group_members (group_id, user_id, role) VALUES (%s, %s, 'admin')",
|
||||
(group["id"], created_by)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return group
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_group(group_id: int):
|
||||
"""Get group details"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT id, name, description, created_by, is_private, created_at FROM groups WHERE id = %s",
|
||||
(group_id,)
|
||||
)
|
||||
group = cur.fetchone()
|
||||
if not group:
|
||||
return None
|
||||
|
||||
# Get members
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT gm.role, gm.joined_at, u.id, u.username, u.display_name
|
||||
FROM group_members gm
|
||||
JOIN users u ON u.id = gm.user_id
|
||||
WHERE gm.group_id = %s
|
||||
ORDER BY gm.role, u.display_name
|
||||
""",
|
||||
(group_id,)
|
||||
)
|
||||
members = [dict(row) for row in cur.fetchall()]
|
||||
|
||||
result = dict(group)
|
||||
result["members"] = members
|
||||
result["member_count"] = len(members)
|
||||
return result
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_groups(user_id: int):
|
||||
"""Get all groups user is member of"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT g.id, g.name, g.description, g.is_private, g.created_at, gm.role,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count
|
||||
FROM groups g
|
||||
JOIN group_members gm ON g.id = gm.group_id
|
||||
WHERE gm.user_id = %s
|
||||
ORDER BY g.name
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def add_group_member(group_id: int, user_id: int, added_by: int):
|
||||
"""Add a member to a group"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
# Check if added_by is admin/moderator
|
||||
cur.execute(
|
||||
"SELECT role FROM group_members WHERE group_id = %s AND user_id = %s",
|
||||
(group_id, added_by)
|
||||
)
|
||||
adder = cur.fetchone()
|
||||
if not adder or adder["role"] not in ["admin", "moderator"]:
|
||||
return {"error": "Only admins and moderators can add members"}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO group_members (group_id, user_id, role)
|
||||
VALUES (%s, %s, 'member')
|
||||
ON CONFLICT (group_id, user_id) DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
(group_id, user_id)
|
||||
)
|
||||
result = cur.fetchone()
|
||||
conn.commit()
|
||||
|
||||
if result:
|
||||
return {"success": True}
|
||||
else:
|
||||
return {"error": "User is already a member"}
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def remove_group_member(group_id: int, user_id: int, removed_by: int):
|
||||
"""Remove a member from a group"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
# Check permissions
|
||||
cur.execute(
|
||||
"SELECT role FROM group_members WHERE group_id = %s AND user_id = %s",
|
||||
(group_id, removed_by)
|
||||
)
|
||||
remover = cur.fetchone()
|
||||
|
||||
# User can remove themselves, or admins/moderators can remove others
|
||||
if removed_by != user_id:
|
||||
if not remover or remover["role"] not in ["admin", "moderator"]:
|
||||
return {"error": "Only admins and moderators can remove members"}
|
||||
|
||||
cur.execute(
|
||||
"DELETE FROM group_members WHERE group_id = %s AND user_id = %s",
|
||||
(group_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============= Recipe Ratings & Comments =============
|
||||
|
||||
def add_or_update_rating(recipe_id: int, user_id: int, rating: int):
|
||||
"""Add or update a rating for a recipe"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO recipe_ratings (recipe_id, user_id, rating)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (recipe_id, user_id)
|
||||
DO UPDATE SET rating = EXCLUDED.rating, updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING id, recipe_id, user_id, rating, created_at, updated_at
|
||||
""",
|
||||
(recipe_id, user_id, rating)
|
||||
)
|
||||
result = cur.fetchone()
|
||||
conn.commit()
|
||||
return dict(result)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_recipe_rating_stats(recipe_id: int):
|
||||
"""Get rating statistics for a recipe"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) as rating_count,
|
||||
AVG(rating)::DECIMAL(3,2) as average_rating,
|
||||
COUNT(CASE WHEN rating = 5 THEN 1 END) as five_star,
|
||||
COUNT(CASE WHEN rating = 4 THEN 1 END) as four_star,
|
||||
COUNT(CASE WHEN rating = 3 THEN 1 END) as three_star,
|
||||
COUNT(CASE WHEN rating = 2 THEN 1 END) as two_star,
|
||||
COUNT(CASE WHEN rating = 1 THEN 1 END) as one_star
|
||||
FROM recipe_ratings
|
||||
WHERE recipe_id = %s
|
||||
""",
|
||||
(recipe_id,)
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_recipe_rating(recipe_id: int, user_id: int):
|
||||
"""Get user's rating for a recipe"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT rating FROM recipe_ratings WHERE recipe_id = %s AND user_id = %s",
|
||||
(recipe_id, user_id)
|
||||
)
|
||||
result = cur.fetchone()
|
||||
return dict(result) if result else None
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def add_comment(recipe_id: int, user_id: int, content: str, parent_comment_id: Optional[int] = None):
|
||||
"""Add a comment to a recipe"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO recipe_comments (recipe_id, user_id, content, parent_comment_id)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id, recipe_id, user_id, content, parent_comment_id, created_at
|
||||
""",
|
||||
(recipe_id, user_id, content, parent_comment_id)
|
||||
)
|
||||
result = cur.fetchone()
|
||||
conn.commit()
|
||||
return dict(result)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_recipe_comments(recipe_id: int):
|
||||
"""Get all comments for a recipe"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT c.id, c.content, c.parent_comment_id, c.created_at, c.updated_at,
|
||||
u.id AS user_id, u.username, u.display_name
|
||||
FROM recipe_comments c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.recipe_id = %s AND c.is_deleted = FALSE
|
||||
ORDER BY c.created_at ASC
|
||||
""",
|
||||
(recipe_id,)
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_comment(comment_id: int, user_id: int, content: str):
|
||||
"""Update a comment"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE recipe_comments
|
||||
SET content = %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s AND user_id = %s AND is_deleted = FALSE
|
||||
""",
|
||||
(content, comment_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_comment(comment_id: int, user_id: int):
|
||||
"""Soft delete a comment"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"UPDATE recipe_comments SET is_deleted = TRUE WHERE id = %s AND user_id = %s",
|
||||
(comment_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============= Recipe Shares to Groups =============
|
||||
|
||||
def share_recipe_to_group(recipe_id: int, group_id: int, user_id: int):
|
||||
"""Share a recipe to a group"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
# Check if user is member of group
|
||||
cur.execute(
|
||||
"SELECT 1 FROM group_members WHERE group_id = %s AND user_id = %s",
|
||||
(group_id, user_id)
|
||||
)
|
||||
if not cur.fetchone():
|
||||
return {"error": "Not a member of this group"}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO recipe_shares (recipe_id, group_id, shared_by)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (recipe_id, group_id) DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
(recipe_id, group_id, user_id)
|
||||
)
|
||||
result = cur.fetchone()
|
||||
conn.commit()
|
||||
|
||||
if result:
|
||||
return {"success": True}
|
||||
else:
|
||||
return {"error": "Recipe already shared to this group"}
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_group_recipes(group_id: int, user_id: int):
|
||||
"""Get all recipes shared to a group"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
# Verify user is member
|
||||
cur.execute(
|
||||
"SELECT 1 FROM group_members WHERE group_id = %s AND user_id = %s",
|
||||
(group_id, user_id)
|
||||
)
|
||||
if not cur.fetchone():
|
||||
return {"error": "Not a member of this group"}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.*, u.username AS owner_username, u.display_name AS owner_display_name,
|
||||
rs.shared_at, rs.shared_by,
|
||||
u2.username AS shared_by_username, u2.display_name AS shared_by_display_name
|
||||
FROM recipe_shares rs
|
||||
JOIN recipes r ON r.id = rs.recipe_id
|
||||
JOIN users u ON u.id = r.user_id
|
||||
JOIN users u2 ON u2.id = rs.shared_by
|
||||
WHERE rs.group_id = %s
|
||||
ORDER BY rs.shared_at DESC
|
||||
""",
|
||||
(group_id,)
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
970
backend/main.py
970
backend/main.py
File diff suppressed because it is too large
Load Diff
@ -1,124 +0,0 @@
|
||||
"""
|
||||
Database utilities for managing notifications.
|
||||
"""
|
||||
|
||||
from db_utils import get_conn
|
||||
|
||||
|
||||
def create_notification(user_id: int, type: str, message: str, related_id: int = None):
|
||||
"""Create a new notification for a user."""
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO notifications (user_id, type, message, related_id)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id, user_id, type, message, related_id, is_read, created_at
|
||||
""",
|
||||
(user_id, type, message, related_id)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"user_id": row["user_id"],
|
||||
"type": row["type"],
|
||||
"message": row["message"],
|
||||
"related_id": row["related_id"],
|
||||
"is_read": row["is_read"],
|
||||
"created_at": row["created_at"]
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def get_user_notifications(user_id: int, unread_only: bool = False):
|
||||
"""Get all notifications for a user."""
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
query = """
|
||||
SELECT id, user_id, type, message, related_id, is_read, created_at
|
||||
FROM notifications
|
||||
WHERE user_id = %s
|
||||
"""
|
||||
|
||||
if unread_only:
|
||||
query += " AND is_read = FALSE"
|
||||
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
cur.execute(query, (user_id,))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
notifications = []
|
||||
for row in rows:
|
||||
notifications.append({
|
||||
"id": row["id"],
|
||||
"user_id": row["user_id"],
|
||||
"type": row["type"],
|
||||
"message": row["message"],
|
||||
"related_id": row["related_id"],
|
||||
"is_read": row["is_read"],
|
||||
"created_at": row["created_at"]
|
||||
})
|
||||
|
||||
return notifications
|
||||
|
||||
|
||||
def mark_notification_as_read(notification_id: int, user_id: int):
|
||||
"""Mark a notification as read."""
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE notifications
|
||||
SET is_read = TRUE
|
||||
WHERE id = %s AND user_id = %s
|
||||
""",
|
||||
(notification_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
|
||||
def mark_all_notifications_as_read(user_id: int):
|
||||
"""Mark all notifications for a user as read."""
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE notifications
|
||||
SET is_read = TRUE
|
||||
WHERE user_id = %s AND is_read = FALSE
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
|
||||
def delete_notification(notification_id: int, user_id: int):
|
||||
"""Delete a notification."""
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM notifications
|
||||
WHERE id = %s AND user_id = %s
|
||||
""",
|
||||
(notification_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return True
|
||||
@ -1,33 +0,0 @@
|
||||
import os
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from starlette.config import Config
|
||||
|
||||
# Load config
|
||||
config = Config('.env')
|
||||
|
||||
# Initialize OAuth
|
||||
oauth = OAuth(config)
|
||||
|
||||
# Register Google OAuth
|
||||
oauth.register(
|
||||
name='google',
|
||||
client_id=os.getenv('GOOGLE_CLIENT_ID'),
|
||||
client_secret=os.getenv('GOOGLE_CLIENT_SECRET'),
|
||||
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
|
||||
client_kwargs={
|
||||
'scope': 'openid email profile'
|
||||
}
|
||||
)
|
||||
|
||||
# Register Microsoft Entra ID (Azure AD) OAuth
|
||||
# Use 'common' for multi-tenant + personal accounts, or 'consumers' for personal accounts only
|
||||
tenant_id = os.getenv('AZURE_TENANT_ID', 'common')
|
||||
oauth.register(
|
||||
name='azure',
|
||||
client_id=os.getenv('AZURE_CLIENT_ID'),
|
||||
client_secret=os.getenv('AZURE_CLIENT_SECRET'),
|
||||
server_metadata_url=f'https://login.microsoftonline.com/{tenant_id}/v2.0/.well-known/openid-configuration',
|
||||
client_kwargs={
|
||||
'scope': 'openid email profile'
|
||||
}
|
||||
)
|
||||
@ -2,21 +2,6 @@ fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.1
|
||||
|
||||
pydantic==2.7.4
|
||||
pydantic[email]==2.7.4
|
||||
python-dotenv==1.0.1
|
||||
|
||||
psycopg2-binary==2.9.9
|
||||
|
||||
# Authentication
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.9
|
||||
bcrypt==4.1.2
|
||||
|
||||
# Email
|
||||
aiosmtplib==3.0.2
|
||||
|
||||
# OAuth
|
||||
authlib==1.3.0
|
||||
httpx==0.27.0
|
||||
itsdangerous==2.1.2
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
import psycopg2
|
||||
import bcrypt
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# New password for admin
|
||||
new_password = "admin123" # Change this to whatever you want
|
||||
|
||||
# Hash the password
|
||||
salt = bcrypt.gensalt()
|
||||
password_hash = bcrypt.hashpw(new_password.encode('utf-8'), salt).decode('utf-8')
|
||||
|
||||
# Update in database
|
||||
conn = psycopg2.connect(os.getenv('DATABASE_URL'))
|
||||
cur = conn.cursor()
|
||||
|
||||
# Update admin password
|
||||
cur.execute(
|
||||
"UPDATE users SET password_hash = %s WHERE username = %s",
|
||||
(password_hash, 'admin')
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Verify
|
||||
cur.execute("SELECT username, email, is_admin FROM users WHERE username = 'admin'")
|
||||
user = cur.fetchone()
|
||||
if user:
|
||||
print(f"✓ Admin password updated successfully!")
|
||||
print(f" Username: {user[0]}")
|
||||
print(f" Email: {user[1]}")
|
||||
print(f" Is Admin: {user[2]}")
|
||||
print(f"\nYou can now login with:")
|
||||
print(f" Username: admin")
|
||||
print(f" Password: {new_password}")
|
||||
else:
|
||||
print("✗ Admin user not found!")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
@ -1 +0,0 @@
|
||||
# Router package initialization
|
||||
@ -1,88 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from auth_utils import get_current_user
|
||||
from chat_db_utils import (
|
||||
create_conversation,
|
||||
get_conversation,
|
||||
get_user_conversations,
|
||||
send_message,
|
||||
get_messages,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/conversations", tags=["chat"])
|
||||
|
||||
|
||||
class ConversationCreate(BaseModel):
|
||||
user_ids: List[int]
|
||||
is_group: bool = False
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_conversation_endpoint(
|
||||
data: ConversationCreate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new conversation (private or group chat)"""
|
||||
user_ids = data.user_ids
|
||||
if current_user["user_id"] not in user_ids:
|
||||
user_ids.append(current_user["user_id"])
|
||||
|
||||
return create_conversation(
|
||||
user_ids=user_ids,
|
||||
is_group=data.is_group,
|
||||
name=data.name,
|
||||
created_by=current_user["user_id"]
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_conversations_endpoint(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get all conversations for current user"""
|
||||
return get_user_conversations(current_user["user_id"])
|
||||
|
||||
|
||||
@router.get("/{conversation_id}")
|
||||
def get_conversation_endpoint(
|
||||
conversation_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get conversation details"""
|
||||
conversation = get_conversation(conversation_id)
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
return conversation
|
||||
|
||||
|
||||
@router.post("/{conversation_id}/messages")
|
||||
def send_message_endpoint(
|
||||
conversation_id: int,
|
||||
data: MessageCreate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Send a message in a conversation"""
|
||||
result = send_message(conversation_id, current_user["user_id"], data.content)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=403, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{conversation_id}/messages")
|
||||
def get_messages_endpoint(
|
||||
conversation_id: int,
|
||||
limit: int = Query(50, le=100),
|
||||
before_id: Optional[int] = None,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get messages from a conversation"""
|
||||
result = get_messages(conversation_id, current_user["user_id"], limit, before_id)
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
raise HTTPException(status_code=403, detail=result["error"])
|
||||
return result
|
||||
@ -1,152 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
from auth_utils import get_current_user
|
||||
from social_db_utils import (
|
||||
send_friend_request,
|
||||
accept_friend_request,
|
||||
reject_friend_request,
|
||||
get_friend_requests,
|
||||
get_friends,
|
||||
remove_friend,
|
||||
search_users,
|
||||
)
|
||||
from notification_db_utils import create_notification
|
||||
from user_db_utils import get_user_by_id
|
||||
|
||||
router = APIRouter(prefix="/friends", tags=["friends"])
|
||||
|
||||
|
||||
class FriendRequestModel(BaseModel):
|
||||
receiver_id: int
|
||||
|
||||
|
||||
@router.post("/request")
|
||||
def send_friend_request_endpoint(
|
||||
request: FriendRequestModel,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Send a friend request to another user"""
|
||||
result = send_friend_request(current_user["user_id"], request.receiver_id)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
# Create notification for receiver
|
||||
create_notification(
|
||||
user_id=request.receiver_id,
|
||||
type="friend_request",
|
||||
message=f"{current_user['display_name']} שלח לך בקשת חברות",
|
||||
related_id=result.get("id")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/requests")
|
||||
def get_friend_requests_endpoint(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get pending friend requests"""
|
||||
return get_friend_requests(current_user["user_id"])
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/accept")
|
||||
def accept_friend_request_endpoint(
|
||||
request_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Accept a friend request"""
|
||||
# Get request details before accepting
|
||||
from social_db_utils import get_db_connection
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT sender_id, receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'",
|
||||
(request_id,)
|
||||
)
|
||||
request_data = cur.fetchone()
|
||||
|
||||
# Verify current user is the receiver
|
||||
if not request_data:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
if request_data["receiver_id"] != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to accept this request")
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
result = accept_friend_request(request_id)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=404, detail=result["error"])
|
||||
|
||||
# Create notification for sender that their request was accepted
|
||||
if request_data:
|
||||
create_notification(
|
||||
user_id=request_data["sender_id"],
|
||||
type="friend_accepted",
|
||||
message=f"{current_user['display_name']} קיבל את בקשת החברות שלך",
|
||||
related_id=current_user["user_id"]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/reject")
|
||||
def reject_friend_request_endpoint(
|
||||
request_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Reject a friend request"""
|
||||
# Verify current user is the receiver
|
||||
from social_db_utils import get_db_connection
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'",
|
||||
(request_id,)
|
||||
)
|
||||
request_data = cur.fetchone()
|
||||
|
||||
if not request_data:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
if request_data["receiver_id"] != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to reject this request")
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return reject_friend_request(request_id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_friends_endpoint(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get list of user's friends"""
|
||||
return get_friends(current_user["user_id"])
|
||||
|
||||
|
||||
@router.delete("/{friend_id}")
|
||||
def remove_friend_endpoint(
|
||||
friend_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Remove a friend"""
|
||||
from fastapi.responses import Response
|
||||
remove_friend(current_user["user_id"], friend_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
def search_users_for_friends_endpoint(
|
||||
q: str = Query(..., min_length=1),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Search users to add as friends"""
|
||||
return search_users(q, current_user["user_id"])
|
||||
@ -1,163 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from auth_utils import get_current_user
|
||||
from groups_db_utils import (
|
||||
create_group,
|
||||
get_group,
|
||||
get_user_groups,
|
||||
add_group_member,
|
||||
remove_group_member,
|
||||
share_recipe_to_group,
|
||||
get_group_recipes,
|
||||
)
|
||||
from notification_db_utils import create_notification
|
||||
|
||||
router = APIRouter(prefix="/groups", tags=["groups"])
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
is_private: bool = False
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_group_endpoint(
|
||||
data: GroupCreate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new group"""
|
||||
return create_group(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
created_by=current_user["user_id"],
|
||||
is_private=data.is_private
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_user_groups_endpoint(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get all groups user is member of"""
|
||||
return get_user_groups(current_user["user_id"])
|
||||
|
||||
|
||||
@router.get("/{group_id}")
|
||||
def get_group_endpoint(
|
||||
group_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get group details"""
|
||||
group = get_group(group_id)
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
return group
|
||||
|
||||
|
||||
@router.post("/{group_id}/members/{user_id}")
|
||||
def add_group_member_endpoint(
|
||||
group_id: int,
|
||||
user_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Add a member to a group"""
|
||||
# Get group name
|
||||
group = get_group(group_id)
|
||||
|
||||
result = add_group_member(group_id, user_id, current_user["user_id"])
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=403, detail=result["error"])
|
||||
|
||||
# Notify the added user
|
||||
if group:
|
||||
create_notification(
|
||||
user_id=user_id,
|
||||
type="group_invite",
|
||||
message=f"{current_user['display_name']} הוסיף אותך לקבוצה '{group['name']}'" ,
|
||||
related_id=group_id
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{group_id}/members/{user_id}")
|
||||
def remove_group_member_endpoint(
|
||||
group_id: int,
|
||||
user_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Remove a member from a group"""
|
||||
result = remove_group_member(group_id, user_id, current_user["user_id"])
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=403, detail=result["error"])
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/{group_id}/recipes/{recipe_id}")
|
||||
def share_recipe_to_group_endpoint(
|
||||
group_id: int,
|
||||
recipe_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Share a recipe to a group"""
|
||||
from groups_db_utils import get_db_connection
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from db_utils import get_conn
|
||||
|
||||
# Get group members and names
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""SELECT gm.user_id, g.name as group_name
|
||||
FROM group_members gm
|
||||
JOIN groups g ON gm.group_id = g.id
|
||||
WHERE gm.group_id = %s AND gm.user_id != %s""",
|
||||
(group_id, current_user["user_id"])
|
||||
)
|
||||
members = cur.fetchall()
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Get recipe name
|
||||
recipe_conn = get_conn()
|
||||
recipe_cur = recipe_conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
recipe_cur.execute("SELECT name FROM recipes WHERE id = %s", (recipe_id,))
|
||||
recipe = recipe_cur.fetchone()
|
||||
finally:
|
||||
recipe_cur.close()
|
||||
recipe_conn.close()
|
||||
|
||||
result = share_recipe_to_group(recipe_id, group_id, current_user["user_id"])
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=403, detail=result["error"])
|
||||
|
||||
# Notify all group members except the sharer
|
||||
if members and recipe:
|
||||
group_name = members[0]["group_name"] if members else ""
|
||||
for member in members:
|
||||
create_notification(
|
||||
user_id=member["user_id"],
|
||||
type="recipe_shared",
|
||||
message=f"{current_user['display_name']} שיתף מתכון '{recipe['name']}' בקבוצה '{group_name}'",
|
||||
related_id=recipe_id
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{group_id}/recipes")
|
||||
def get_group_recipes_endpoint(
|
||||
group_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get all recipes shared to a group"""
|
||||
result = get_group_recipes(group_id, current_user["user_id"])
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
raise HTTPException(status_code=403, detail=result["error"])
|
||||
return result
|
||||
@ -1,91 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from auth_utils import get_current_user
|
||||
from groups_db_utils import (
|
||||
add_or_update_rating,
|
||||
get_recipe_rating_stats,
|
||||
get_user_recipe_rating,
|
||||
add_comment,
|
||||
get_recipe_comments,
|
||||
update_comment,
|
||||
delete_comment,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["ratings-comments"])
|
||||
|
||||
|
||||
class RatingCreate(BaseModel):
|
||||
rating: int
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
content: str
|
||||
parent_comment_id: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/recipes/{recipe_id}/rating")
|
||||
def rate_recipe_endpoint(
|
||||
recipe_id: int,
|
||||
data: RatingCreate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Add or update rating for a recipe"""
|
||||
if data.rating < 1 or data.rating > 5:
|
||||
raise HTTPException(status_code=400, detail="Rating must be between 1 and 5")
|
||||
return add_or_update_rating(recipe_id, current_user["user_id"], data.rating)
|
||||
|
||||
|
||||
@router.get("/recipes/{recipe_id}/rating/stats")
|
||||
def get_recipe_rating_stats_endpoint(recipe_id: int):
|
||||
"""Get rating statistics for a recipe"""
|
||||
return get_recipe_rating_stats(recipe_id)
|
||||
|
||||
|
||||
@router.get("/recipes/{recipe_id}/rating/mine")
|
||||
def get_my_recipe_rating_endpoint(
|
||||
recipe_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get current user's rating for a recipe"""
|
||||
rating = get_user_recipe_rating(recipe_id, current_user["user_id"])
|
||||
if not rating:
|
||||
return {"rating": None}
|
||||
return rating
|
||||
|
||||
|
||||
@router.post("/recipes/{recipe_id}/comments")
|
||||
def add_comment_endpoint(
|
||||
recipe_id: int,
|
||||
data: CommentCreate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Add a comment to a recipe"""
|
||||
return add_comment(recipe_id, current_user["user_id"], data.content, data.parent_comment_id)
|
||||
|
||||
|
||||
@router.get("/recipes/{recipe_id}/comments")
|
||||
def get_comments_endpoint(recipe_id: int):
|
||||
"""Get all comments for a recipe"""
|
||||
return get_recipe_comments(recipe_id)
|
||||
|
||||
|
||||
@router.patch("/comments/{comment_id}")
|
||||
def update_comment_endpoint(
|
||||
comment_id: int,
|
||||
data: CommentCreate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Update a comment"""
|
||||
return update_comment(comment_id, current_user["user_id"], data.content)
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}")
|
||||
def delete_comment_endpoint(
|
||||
comment_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a comment"""
|
||||
delete_comment(comment_id, current_user["user_id"])
|
||||
return Response(status_code=204)
|
||||
@ -1,32 +1,14 @@
|
||||
-- Create users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
display_name TEXT UNIQUE NOT NULL,
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
|
||||
|
||||
-- Create recipes table
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack
|
||||
time_minutes INTEGER NOT NULL,
|
||||
tags TEXT[] NOT NULL DEFAULT '{}', -- {"מהיר", "בריא"}
|
||||
ingredients TEXT[] NOT NULL DEFAULT '{}', -- {"ביצה", "עגבניה", "מלח"}
|
||||
steps TEXT[] NOT NULL DEFAULT '{}', -- {"לחתוך", "לבשל", ...}
|
||||
image TEXT, -- Base64-encoded image or image URL
|
||||
made_by TEXT, -- Person who created this recipe version
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- Recipe owner
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"]
|
||||
ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"]
|
||||
steps JSONB NOT NULL DEFAULT '[]', -- ["לחתוך", "לבשל", ...]
|
||||
image TEXT -- Base64-encoded image or image URL
|
||||
);
|
||||
|
||||
-- Optional: index for filters
|
||||
@ -39,90 +21,9 @@ CREATE INDEX IF NOT EXISTS idx_recipes_time_minutes
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_made_by
|
||||
ON recipes (made_by);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_user_id
|
||||
ON recipes (user_id);
|
||||
|
||||
-- Create grocery lists table
|
||||
CREATE TABLE IF NOT EXISTS grocery_lists (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
items TEXT[] NOT NULL DEFAULT '{}', -- Array of grocery items
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
is_pinned BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create grocery list shares table
|
||||
CREATE TABLE IF NOT EXISTS grocery_list_shares (
|
||||
id SERIAL PRIMARY KEY,
|
||||
list_id INTEGER NOT NULL REFERENCES grocery_lists(id) ON DELETE CASCADE,
|
||||
shared_with_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
can_edit BOOLEAN DEFAULT FALSE,
|
||||
shared_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(list_id, shared_with_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_grocery_lists_owner_id ON grocery_lists (owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_grocery_list_shares_list_id ON grocery_list_shares (list_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_grocery_list_shares_user_id ON grocery_list_shares (shared_with_user_id);
|
||||
|
||||
-- Create notifications table
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL, -- 'grocery_share', etc.
|
||||
message TEXT NOT NULL,
|
||||
related_id INTEGER, -- Related entity ID (e.g., list_id)
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications (is_read);
|
||||
|
||||
-- Create default admin user (password: admin123)
|
||||
-- Password hash generated with bcrypt for 'admin123'
|
||||
INSERT INTO users (username, email, password_hash, first_name, last_name, display_name, is_admin)
|
||||
VALUES ('admin', 'admin@myrecipes.local', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5lE7UGf3rCvHC', 'Admin', 'User', 'מנהל', TRUE)
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- Create demo recipes
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, made_by, user_id, visibility)
|
||||
VALUES
|
||||
(
|
||||
'שקשוקה ביתית',
|
||||
'breakfast',
|
||||
25,
|
||||
ARRAY['מהיר', 'בריא', 'צמחוני'],
|
||||
ARRAY['4 ביצים', '2 עגבניות גדולות', '1 בצל', '2 שיני שום', 'פלפל אדום', 'כוסברה', 'כמון', 'מלח ופלפל'],
|
||||
ARRAY['לחתוך את הבצל והשום דק', 'לחמם שמן בסיר ולהזהיב את הבצל', 'להוסיף עגבניות קצוצות ותבלינים', 'לבשל 15 דקות עד שמתעבה', 'לפתוח גומות ולשבור ביצים', 'לכסות ולבשל 5 דקות', 'לקשט בכוסברה ולהגיש עם לחם'],
|
||||
'מנהל',
|
||||
(SELECT id FROM users WHERE username = 'admin'),
|
||||
'public'
|
||||
),
|
||||
(
|
||||
'פסטה ברוטב שמנת ופטריות',
|
||||
'lunch',
|
||||
30,
|
||||
ARRAY['מהיר', 'מנת ערב', 'איטלקי'],
|
||||
ARRAY['500 גרם פסטה', '300 גרם פטריות', '200 מ"ל שמנת מתוקה', '2 שיני שום', 'פרמזן', 'חמאה', 'פטרוזיליה', 'מלח ופלפל'],
|
||||
ARRAY['להרתיח מים ולבשל את הפסטה לפי ההוראות', 'לחתוך פטריות ושום דק', 'לחמם חמאה ולטגן פטריות 5 דקות', 'להוסיף שום ולטגן דקה', 'להוסיף שמנת ופרמזן ולערבב', 'להוסיף את הפסטה המסוננת לרוטב', 'לערבב היטב ולהגיש עם פרמזן'],
|
||||
'מנהל',
|
||||
(SELECT id FROM users WHERE username = 'admin'),
|
||||
'public'
|
||||
),
|
||||
(
|
||||
'עוגת שוקולד פאדג׳',
|
||||
'snack',
|
||||
45,
|
||||
ARRAY['קינוח', 'שוקולד', 'מתוק'],
|
||||
ARRAY['200 גרם שוקולד מריר', '150 גרם חמאה', '3 ביצים', '1 כוס סוכר', '3/4 כוס קמח', 'אבקת אפייה', 'וניל'],
|
||||
ARRAY['לחמם תנור ל-180 מעלות', 'להמיס שוקולד וחמאה במיקרו', 'להקציף ביצים עם סוכר', 'להוסיף שוקולד מומס ולערבב', 'להוסיף קמח ואבקת אפייה', 'לשפוך לתבנית משומנת', 'לאפות 30 דקות', 'להוציא ולהגיש עם גלידה'],
|
||||
'מנהל',
|
||||
(SELECT id FROM users WHERE username = 'admin'),
|
||||
'public'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_tags_jsonb
|
||||
ON recipes USING GIN (tags);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_ingredients_jsonb
|
||||
ON recipes USING GIN (ingredients);
|
||||
|
||||
|
||||
@ -1,202 +0,0 @@
|
||||
import os
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from psycopg2 import errors
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
"""Get database connection"""
|
||||
return psycopg2.connect(
|
||||
host=os.getenv("DB_HOST", "localhost"),
|
||||
port=int(os.getenv("DB_PORT", "5432")),
|
||||
database=os.getenv("DB_NAME", "recipes_db"),
|
||||
user=os.getenv("DB_USER", "recipes_user"),
|
||||
password=os.getenv("DB_PASSWORD", "recipes_password"),
|
||||
)
|
||||
|
||||
|
||||
# ============= Friends System =============
|
||||
|
||||
def send_friend_request(sender_id: int, receiver_id: int):
|
||||
"""Send a friend request"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
# Check if already friends
|
||||
cur.execute(
|
||||
"SELECT 1 FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)",
|
||||
(sender_id, receiver_id, receiver_id, sender_id)
|
||||
)
|
||||
if cur.fetchone():
|
||||
return {"error": "Already friends"}
|
||||
|
||||
# Check if request already exists
|
||||
cur.execute(
|
||||
"SELECT * FROM friend_requests WHERE sender_id = %s AND receiver_id = %s AND status = 'pending'",
|
||||
(sender_id, receiver_id)
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
return dict(existing)
|
||||
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO friend_requests (sender_id, receiver_id)
|
||||
VALUES (%s, %s)
|
||||
RETURNING id, sender_id, receiver_id, status, created_at
|
||||
""",
|
||||
(sender_id, receiver_id)
|
||||
)
|
||||
request = cur.fetchone()
|
||||
conn.commit()
|
||||
return dict(request)
|
||||
except errors.UniqueViolation:
|
||||
# Request already exists, fetch and return it
|
||||
conn.rollback()
|
||||
cur.execute(
|
||||
"SELECT id, sender_id, receiver_id, status, created_at FROM friend_requests WHERE sender_id = %s AND receiver_id = %s",
|
||||
(sender_id, receiver_id)
|
||||
)
|
||||
existing_request = cur.fetchone()
|
||||
if existing_request:
|
||||
return dict(existing_request)
|
||||
return {"error": "Friend request already exists"}
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def accept_friend_request(request_id: int):
|
||||
"""Accept a friend request and create friendship"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
# Get request details
|
||||
cur.execute(
|
||||
"SELECT sender_id, receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'",
|
||||
(request_id,)
|
||||
)
|
||||
request = cur.fetchone()
|
||||
if not request:
|
||||
return {"error": "Request not found or already processed"}
|
||||
|
||||
sender_id = request["sender_id"]
|
||||
receiver_id = request["receiver_id"]
|
||||
|
||||
# Create bidirectional friendship
|
||||
cur.execute(
|
||||
"INSERT INTO friendships (user_id, friend_id) VALUES (%s, %s), (%s, %s) ON CONFLICT DO NOTHING",
|
||||
(sender_id, receiver_id, receiver_id, sender_id)
|
||||
)
|
||||
|
||||
# Update request status
|
||||
cur.execute(
|
||||
"UPDATE friend_requests SET status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(request_id,)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def reject_friend_request(request_id: int):
|
||||
"""Reject a friend request"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"UPDATE friend_requests SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = %s AND status = 'pending'",
|
||||
(request_id,)
|
||||
)
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_friend_requests(user_id: int):
|
||||
"""Get pending friend requests for a user"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT fr.id AS request_id, fr.sender_id, fr.receiver_id, fr.status, fr.created_at,
|
||||
u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email
|
||||
FROM friend_requests fr
|
||||
JOIN users u ON u.id = fr.sender_id
|
||||
WHERE fr.receiver_id = %s AND fr.status = 'pending'
|
||||
ORDER BY fr.created_at DESC
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_friends(user_id: int):
|
||||
"""Get list of user's friends"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT u.id, u.username, u.display_name, u.email, f.created_at AS friends_since
|
||||
FROM friendships f
|
||||
JOIN users u ON u.id = f.friend_id
|
||||
WHERE f.user_id = %s
|
||||
ORDER BY u.display_name
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def remove_friend(user_id: int, friend_id: int):
|
||||
"""Remove a friend"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"DELETE FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)",
|
||||
(user_id, friend_id, friend_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def search_users(query: str, current_user_id: int, limit: int = 20):
|
||||
"""Search for users by username or display name"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
search_pattern = f"%{query}%"
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT u.id, u.username, u.display_name, u.email,
|
||||
EXISTS(SELECT 1 FROM friendships WHERE user_id = %s AND friend_id = u.id) AS is_friend,
|
||||
EXISTS(SELECT 1 FROM friend_requests WHERE sender_id = %s AND receiver_id = u.id AND status = 'pending') AS request_sent
|
||||
FROM users u
|
||||
WHERE (u.username ILIKE %s OR u.display_name ILIKE %s) AND u.id != %s
|
||||
ORDER BY u.display_name
|
||||
LIMIT %s
|
||||
""",
|
||||
(current_user_id, current_user_id, search_pattern, search_pattern, current_user_id, limit)
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
@ -1,117 +0,0 @@
|
||||
import os
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
"""Get database connection"""
|
||||
return psycopg2.connect(
|
||||
host=os.getenv("DB_HOST", "localhost"),
|
||||
port=int(os.getenv("DB_PORT", "5432")),
|
||||
database=os.getenv("DB_NAME", "recipes_db"),
|
||||
user=os.getenv("DB_USER", "recipes_user"),
|
||||
password=os.getenv("DB_PASSWORD", "recipes_password"),
|
||||
)
|
||||
|
||||
|
||||
def create_user(username: str, email: str, password_hash: str, first_name: str = None, last_name: str = None, display_name: str = None, is_admin: bool = False, auth_provider: str = "local"):
|
||||
"""Create a new user"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
# Use display_name if provided, otherwise use username
|
||||
final_display_name = display_name if display_name else username
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO users (username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, username, email, first_name, last_name, display_name, is_admin, auth_provider, created_at
|
||||
""",
|
||||
(username, email, password_hash, first_name, last_name, final_display_name, is_admin, auth_provider)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
conn.commit()
|
||||
return dict(user)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_by_username(username: str):
|
||||
"""Get user by username"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider, created_at FROM users WHERE username = %s",
|
||||
(username,)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
return dict(user) if user else None
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_by_email(email: str):
|
||||
"""Get user by email"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider, created_at FROM users WHERE email = %s",
|
||||
(email,)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
return dict(user) if user else None
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_by_id(user_id: int):
|
||||
"""Get user by ID"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider, created_at FROM users WHERE id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
return dict(user) if user else None
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_by_display_name(display_name: str):
|
||||
"""Get user by display name"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT id, username, email, display_name, is_admin, created_at FROM users WHERE display_name = %s",
|
||||
(display_name,)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
return dict(user) if user else None
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_user_auth_provider(user_id: int, auth_provider: str):
|
||||
"""Update user's auth provider"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"UPDATE users SET auth_provider = %s WHERE id = %s",
|
||||
(auth_provider, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
178
demo-recipes.sql
178
demo-recipes.sql
@ -1,178 +0,0 @@
|
||||
-- Demo recipes for user dvir (id=3)
|
||||
|
||||
-- Recipe 1: שקשוקה
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'שקשוקה',
|
||||
'breakfast',
|
||||
25,
|
||||
'["מהיר", "בריא", "צמחוני"]'::jsonb,
|
||||
'["4 ביצים", "4 עגבניות", "1 בצל", "2 שיני שום", "פלפל חריף", "כמון", "מלח", "שמן זית"]'::jsonb,
|
||||
'[
|
||||
"לחתוך את הבצל והשום דק",
|
||||
"לחמם שמן בסיר ולטגן את הבצל עד שקוף",
|
||||
"להוסיף שום ופלפל חריף ולטגן דקה",
|
||||
"לקצוץ עגבניות ולהוסיף לסיר",
|
||||
"לתבל בכמון ומלח, לבשל 10 דקות",
|
||||
"לפתוח גומות ברוטב ולשבור ביצה בכל גומה",
|
||||
"לכסות ולבשל עד שהביצים מתקשות"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1525351484163-7529414344d8?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 2: פסטה ברוטב עגבניות
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'פסטה ברוטב עגבניות',
|
||||
'lunch',
|
||||
20,
|
||||
'["מהיר", "צמחוני", "ילדים אוהבים"]'::jsonb,
|
||||
'["500 גרם פסטה", "רסק עגבניות", "בזיליקום טרי", "3 שיני שום", "שמן זית", "מלח", "פלפל"]'::jsonb,
|
||||
'[
|
||||
"להרתיח מים מלוחים ולבשל את הפסטה לפי ההוראות",
|
||||
"בינתיים, לחמם שמן בסיר",
|
||||
"לטגן שום כתוש דקה",
|
||||
"להוסיף רסק עגבניות ולתבל",
|
||||
"לבשל על אש בינונית 10 דקות",
|
||||
"להוסיף בזיליקום קרוע",
|
||||
"לערבב את הפסטה המסוננת עם הרוטב"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 3: סלט ישראלי
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'סלט ישראלי',
|
||||
'snack',
|
||||
10,
|
||||
'["מהיר", "בריא", "טבעוני", "צמחוני"]'::jsonb,
|
||||
'["4 עגבניות", "2 מלפפונים", "1 בצל", "פטרוזיליה", "לימון", "שמן זית", "מלח"]'::jsonb,
|
||||
'[
|
||||
"לחתוך עגבניות ומלפפונים לקוביות קטנות",
|
||||
"לקצוץ בצל דק",
|
||||
"לקצוץ פטרוזיליה",
|
||||
"לערבב הכל בקערה",
|
||||
"להוסיף מיץ לימון ושמן זית",
|
||||
"לתבל במלח ולערבב היטב"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1512621776951-a57141f2eefd?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 4: חביתה עם ירקות
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'חביתה עם ירקות',
|
||||
'breakfast',
|
||||
15,
|
||||
'["מהיר", "בריא", "חלבוני", "צמחוני"]'::jsonb,
|
||||
'["3 ביצים", "1 בצל", "1 פלפל", "עגבניה", "גבינה צהובה", "מלח", "פלפל שחור", "שמן"]'::jsonb,
|
||||
'[
|
||||
"לקצוץ את הירקות לקוביות קטנות",
|
||||
"לטגן את הירקות בשמן עד שמתרככים",
|
||||
"להקציף את הביצים במזלג",
|
||||
"לשפוך את הביצים על הירקות",
|
||||
"לפזר גבינה קצוצה",
|
||||
"לבשל עד שהתחתית מוזהבת",
|
||||
"להפוך או לקפל לחצי ולהגיש"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1525351159099-122d10e7960e?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 5: עוף בתנור עם תפוחי אדמה
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'עוף בתנור עם תפוחי אדמה',
|
||||
'dinner',
|
||||
60,
|
||||
'["משפחתי", "חגיגי"]'::jsonb,
|
||||
'["1 עוף שלם", "1 ק״ג תפוחי אדמה", "פפריקה", "כורכום", "שום", "מלח", "פלפל", "שמן זית", "לימון"]'::jsonb,
|
||||
'[
|
||||
"לחמם תנור ל-200 מעלות",
|
||||
"לחתוך תפוחי אדמה לרבעים",
|
||||
"לשפשף את העוף בתבלינים, שמן ומיץ לימון",
|
||||
"לסדר תפוחי אדמה בתבנית",
|
||||
"להניח את העוף על התפוחי אדמה",
|
||||
"לאפות כשעה עד שהעוף מוזהב",
|
||||
"להוציא, לחתוך ולהגיש עם הירקות"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1598103442097-8b74394b95c6?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 6: סנדוויץ טונה
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'סנדוויץ טונה',
|
||||
'lunch',
|
||||
5,
|
||||
'["מהיר", "קר", "חלבוני"]'::jsonb,
|
||||
'["קופסת טונה", "2 פרוסות לחם", "מיונז", "חסה", "עגבניה", "מלפפון חמוץ", "מלח", "פלפל"]'::jsonb,
|
||||
'[
|
||||
"לסנן את הטונה",
|
||||
"לערבב את הטונה עם מיונז",
|
||||
"לתבל במלח ופלפל",
|
||||
"למרוח על פרוסת לחם",
|
||||
"להוסיף חסה, עגבניה ומלפפון",
|
||||
"לכסות בפרוסת לחם שנייה"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1528735602780-2552fd46c7af?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 7: בראוניז שוקולד
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'בראוניז שוקולד',
|
||||
'snack',
|
||||
35,
|
||||
'["קינוח", "שוקולד", "אפייה"]'::jsonb,
|
||||
'["200 גרם שוקולד מריר", "150 גרם חמאה", "3 ביצים", "כוס סוכר", "חצי כוס קמח", "אבקת קקאו", "וניל"]'::jsonb,
|
||||
'[
|
||||
"לחמם תנור ל-180 מעלות",
|
||||
"להמיס שוקולד וחמאה במיקרוגל",
|
||||
"להקציף ביצים וסוכר",
|
||||
"להוסיף את תערובת השוקולד",
|
||||
"להוסיף קמח וקקאו ולערבב",
|
||||
"לשפוך לתבנית משומנת",
|
||||
"לאפות 25 דקות",
|
||||
"להוציא ולהניח להתקרר לפני חיתוך"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1606313564200-e75d5e30476c?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 8: מרק עדשים
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'מרק עדשים',
|
||||
'dinner',
|
||||
40,
|
||||
'["בריא", "צמחוני", "טבעוני", "חם"]'::jsonb,
|
||||
'["2 כוסות עדשים כתומות", "בצל", "גזר", "3 שיני שום", "כמון", "כורכום", "מלח", "לימון"]'::jsonb,
|
||||
'[
|
||||
"לשטוף את העדשים",
|
||||
"לקצוץ בצל, גזר ושום",
|
||||
"לטגן את הבצל עד שקוף",
|
||||
"להוסיף שום ותבלינים",
|
||||
"להוסיף גזר ועדשים",
|
||||
"להוסיף 6 כוסות מים",
|
||||
"לבשל 30 דקות עד שהעדשים רכים",
|
||||
"לטחון חלק מהמרק לקבלת מרקם עבה",
|
||||
"להוסיף מיץ לימון לפני הגשה"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1547592166-23ac45744acd?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
@ -1,13 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="he" dir="rtl">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍽️</text></svg>">
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="google-site-verification" content="xzgN8wy4yNKqR0_qZZeUqj-wfnje0v7koYpUyU1ti2I" />
|
||||
<title>My Recipes | המתכונים שלי</title>
|
||||
<title>frontend</title>
|
||||
<!-- Load environment variables before app starts -->
|
||||
<script src="/env.js?v=20251219"></script>
|
||||
<script src="/env.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://my-recipes.dvirlabs.com/</loc>
|
||||
</url>
|
||||
</urlset>
|
||||
@ -32,68 +32,32 @@ body {
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.user-greeting-header {
|
||||
text-align: right;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.app-root {
|
||||
min-height: 100vh;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem;
|
||||
padding-top: 4rem; /* Add space for fixed theme toggle */
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-root {
|
||||
padding: 1.5rem;
|
||||
padding-top: 4.5rem;
|
||||
}
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
/* Top bar */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
background: linear-gradient(90deg, #020617, #020617f2);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
padding: 0.8rem 1rem;
|
||||
margin-bottom: 1.2rem;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.topbar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background: linear-gradient(90deg, #020617, #020617f2);
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
padding: 0.8rem 1.2rem;
|
||||
margin-bottom: 1.6rem;
|
||||
}
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.topbar-left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-emoji {
|
||||
@ -102,67 +66,12 @@ body {
|
||||
|
||||
.brand-title {
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.brand-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.brand-subtitle {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.topbar-actions {
|
||||
gap: 0.6rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-compact buttons */
|
||||
.btn-mobile-compact .btn-text-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-mobile-compact .btn-text-mobile {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.btn-mobile-compact .btn-text-desktop {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.btn-mobile-compact .btn-text-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.user-greeting {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(79, 70, 229, 0.1);
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
@ -174,125 +83,25 @@ body {
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.layout {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.pinned-lists-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.pinned-lists-sidebar {
|
||||
display: block;
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
top: 5rem;
|
||||
width: 280px;
|
||||
max-height: calc(100vh - 6rem);
|
||||
overflow-y: auto;
|
||||
z-index: 50;
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile pinned sidebar - slides in from left */
|
||||
@media (max-width: 959px) {
|
||||
.pinned-lists-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 90%;
|
||||
max-width: 360px;
|
||||
height: 100vh;
|
||||
background: var(--bg);
|
||||
z-index: 70;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
transition: left 0.3s ease;
|
||||
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.pinned-lists-sidebar.mobile-visible {
|
||||
display: block;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
z-index: 65;
|
||||
}
|
||||
|
||||
.close-sidebar-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: #5a4a2a;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.close-sidebar-btn:hover {
|
||||
color: #3a2a1a;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.content-wrapper {
|
||||
display: contents;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.sidebar-right,
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-right {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
/* Panels */
|
||||
|
||||
.panel {
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.7);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.panel {
|
||||
border-radius: 18px;
|
||||
padding: 1.1rem 1.2rem;
|
||||
}
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.7);
|
||||
}
|
||||
|
||||
.panel.secondary {
|
||||
@ -352,41 +161,21 @@ select {
|
||||
border: 1px solid rgba(148, 163, 184, 0.6);
|
||||
background: #020617;
|
||||
color: var(--text-main);
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
min-height: 44px; /* Better touch target for mobile */
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
input,
|
||||
select {
|
||||
padding: 0.4rem 0.65rem;
|
||||
min-height: auto;
|
||||
}
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
|
||||
.btn {
|
||||
border-radius: 999px;
|
||||
padding: 0.65rem 1rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
border: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.08s ease, box-shadow 0.08s ease,
|
||||
background-color 0.08s ease;
|
||||
min-height: 44px; /* Better touch target for mobile */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.btn {
|
||||
padding: 0.55rem 1.2rem;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.btn.full {
|
||||
@ -503,31 +292,31 @@ select {
|
||||
min-height: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
}
|
||||
|
||||
.recipe-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.recipe-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.3;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.recipe-subtitle {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.recipe-made-by {
|
||||
margin: 0.4rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
@ -535,34 +324,25 @@ select {
|
||||
.pill-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 0.35rem 0.75rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border: 1px solid rgba(148, 163, 184, 0.7);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Recipe Image */
|
||||
.recipe-image-container {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
border-radius: 14px;
|
||||
max-height: 250px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.2rem;
|
||||
margin-bottom: 0.8rem;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.recipe-image-container {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.recipe-image {
|
||||
@ -574,48 +354,33 @@ select {
|
||||
|
||||
.recipe-body {
|
||||
display: grid;
|
||||
gap: 1.2rem;
|
||||
gap: 0.8rem;
|
||||
flex: 1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
@media (min-width: 720px) {
|
||||
.recipe-body {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
}
|
||||
}
|
||||
|
||||
.recipe-column h3 {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.recipe-column ul,
|
||||
.recipe-column ol {
|
||||
margin: 0;
|
||||
padding-right: 1.2rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.recipe-column li {
|
||||
margin-bottom: 0.4rem;
|
||||
padding-right: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.recipe-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.8rem;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.recipe-actions {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
@ -666,22 +431,11 @@ select {
|
||||
}
|
||||
|
||||
.drawer {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
width: min(420px, 90vw);
|
||||
background: #020617;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
padding: 1rem;
|
||||
box-shadow: 18px 0 40px rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.drawer {
|
||||
width: min(420px, 90vw);
|
||||
padding: 1rem 1rem 1rem 1.2rem;
|
||||
}
|
||||
box-shadow: 18px 0 40px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
@ -692,20 +446,14 @@ select {
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 1rem;
|
||||
max-height: calc(100vh - 4rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 0.7rem;
|
||||
margin-top: 0.7rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@ -758,26 +506,17 @@ select {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 50;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
padding: 1.2rem;
|
||||
border-radius: 18px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.9);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.modal {
|
||||
border-radius: 18px;
|
||||
padding: 1.5rem;
|
||||
width: 90vw;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: 1rem;
|
||||
@ -785,26 +524,13 @@ select {
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.modal-header h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-bottom: 1.2rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.modal-body {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@ -905,27 +631,18 @@ select {
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 0.5rem;
|
||||
left: 0.5rem;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.toast-container {
|
||||
bottom: 5rem;
|
||||
right: 1.5rem;
|
||||
left: auto;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 0.9rem 1rem;
|
||||
padding: 1rem 1.2rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
@ -934,15 +651,8 @@ select {
|
||||
gap: 0.8rem;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
pointer-events: auto;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.toast {
|
||||
padding: 1rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
@ -976,16 +686,16 @@ select {
|
||||
/* Theme Toggle (fixed floating button) */
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 100;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--card);
|
||||
color: var(--text-main);
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -994,16 +704,6 @@ select {
|
||||
transition: all 180ms ease;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.theme-toggle {
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.4);
|
||||
@ -1013,90 +713,6 @@ select {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Pinned notes toggle button - mobile only - styled like a mini sticky note */
|
||||
.pinned-toggle-btn {
|
||||
position: fixed;
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
z-index: 100;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f5e6c8;
|
||||
background: linear-gradient(135deg, #fff9e6 0%, #fffaed 100%);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
box-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.1),
|
||||
0 4px 12px rgba(0, 0, 0, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
transition: all 180ms ease;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.pinned-toggle-btn::before {
|
||||
content: '📌';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: 8px;
|
||||
font-size: 1rem;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
.pinned-toggle-btn:hover {
|
||||
transform: rotate(0deg) scale(1.05);
|
||||
box-shadow:
|
||||
0 4px 8px rgba(0, 0, 0, 0.15),
|
||||
0 8px 20px rgba(0, 0, 0, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.pinned-toggle-btn:active {
|
||||
transform: rotate(0deg) scale(0.95);
|
||||
}
|
||||
|
||||
.note-icon-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.note-icon-lines span {
|
||||
height: 2px;
|
||||
background: rgba(90, 74, 42, 0.4);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.note-icon-lines span:nth-child(1) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.note-icon-lines span:nth-child(2) {
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
.note-icon-lines span:nth-child(3) {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
/* Mobile only utility class */
|
||||
.mobile-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.mobile-only {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Update body to apply bg properly in both themes */
|
||||
body {
|
||||
background: var(--bg);
|
||||
@ -1121,7 +737,7 @@ body {
|
||||
}
|
||||
|
||||
[data-theme="light"] body {
|
||||
background: linear-gradient(180deg, #ac8d75 0%, #ede9e5 100%);
|
||||
background: linear-gradient(180deg, #ac8d75 0%, #f6f8fa 100%);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
@ -1640,242 +1256,3 @@ html {
|
||||
[data-theme="light"] .recipe-list-image {
|
||||
background: rgba(229, 231, 235, 0.5);
|
||||
}
|
||||
|
||||
/* Auth Pages */
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: radial-gradient(circle at top, #0f172a 0, #020617 55%);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: #020617;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7);
|
||||
margin: 2rem auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Light mode auth styles */
|
||||
[data-theme="light"] .auth-container {
|
||||
background: linear-gradient(180deg, #ac8d75 0%, #ede9e5 100%);
|
||||
}
|
||||
|
||||
[data-theme="light"] .auth-card {
|
||||
background: #d1b29b;
|
||||
border: 1px solid rgba(107, 114, 128, 0.2);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Main Navigation Tabs */
|
||||
.main-navigation {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 2px solid var(--border-subtle);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 8px 8px 0 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
background: var(--card-soft);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
/* Grocery Lists specific styles */
|
||||
.grocery-lists-container {
|
||||
--panel-bg: var(--card);
|
||||
--hover-bg: var(--card-soft);
|
||||
--primary-color: var(--accent);
|
||||
--border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
[data-theme="light"] .grocery-lists-container {
|
||||
--panel-bg: #f9fafb;
|
||||
--hover-bg: #f3f4f6;
|
||||
--primary-color: #22c55e;
|
||||
--border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.grocery-lists-container input,
|
||||
.grocery-lists-container select,
|
||||
.grocery-lists-container textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-main);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
[data-theme="light"] .grocery-lists-container input,
|
||||
[data-theme="light"] .grocery-lists-container select,
|
||||
[data-theme="light"] .grocery-lists-container textarea {
|
||||
background: white;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn.primary:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn.secondary {
|
||||
background: var(--card-soft);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn.secondary:hover {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn.ghost:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn.danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn.danger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn.small {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn-icon:hover {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn-icon.delete {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.grocery-lists-container .btn-close:hover {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.grocery-lists-container .loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
@ -5,34 +5,12 @@ import TopBar from "./components/TopBar";
|
||||
import RecipeSearchList from "./components/RecipeSearchList";
|
||||
import RecipeDetails from "./components/RecipeDetails";
|
||||
import RecipeFormDrawer from "./components/RecipeFormDrawer";
|
||||
import GroceryLists from "./components/GroceryLists";
|
||||
import PinnedGroceryLists from "./components/PinnedGroceryLists";
|
||||
import Friends from "./components/Friends";
|
||||
import Chat from "./components/Chat";
|
||||
import Groups from "./components/Groups";
|
||||
import Modal from "./components/Modal";
|
||||
import ToastContainer from "./components/ToastContainer";
|
||||
import ThemeToggle from "./components/ThemeToggle";
|
||||
import Login from "./components/Login";
|
||||
import Register from "./components/Register";
|
||||
import ResetPassword from "./components/ResetPassword";
|
||||
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
||||
import { getToken, removeToken, getMe } from "./authApi";
|
||||
|
||||
function App() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
const [authView, setAuthView] = useState("login"); // "login" or "register"
|
||||
const [loadingAuth, setLoadingAuth] = useState(true);
|
||||
const [resetToken, setResetToken] = useState(null);
|
||||
const [currentView, setCurrentView] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem("currentView") || "recipes";
|
||||
} catch {
|
||||
return "recipes";
|
||||
}
|
||||
}); // "recipes" or "grocery-lists"
|
||||
|
||||
const [recipes, setRecipes] = useState([]);
|
||||
const [selectedRecipe, setSelectedRecipe] = useState(null);
|
||||
|
||||
@ -41,7 +19,7 @@ function App() {
|
||||
const [filterMealType, setFilterMealType] = useState("");
|
||||
const [filterMaxTime, setFilterMaxTime] = useState("");
|
||||
const [filterTags, setFilterTags] = useState([]);
|
||||
const [filterOwner, setFilterOwner] = useState("");
|
||||
const [filterMadeBy, setFilterMadeBy] = useState("");
|
||||
|
||||
// Random recipe filters
|
||||
const [mealTypeFilter, setMealTypeFilter] = useState("");
|
||||
@ -55,7 +33,6 @@ function App() {
|
||||
const [editingRecipe, setEditingRecipe] = useState(null);
|
||||
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
|
||||
const [logoutModal, setLogoutModal] = useState(false);
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [theme, setTheme] = useState(() => {
|
||||
try {
|
||||
@ -64,104 +41,7 @@ function App() {
|
||||
return "dark";
|
||||
}
|
||||
});
|
||||
const [showPinnedSidebar, setShowPinnedSidebar] = useState(false);
|
||||
|
||||
// Swipe gesture handling for mobile sidebar
|
||||
const [touchStart, setTouchStart] = useState(null);
|
||||
const [touchEnd, setTouchEnd] = useState(null);
|
||||
|
||||
// Minimum swipe distance (in px)
|
||||
const minSwipeDistance = 50;
|
||||
|
||||
const onTouchStart = (e) => {
|
||||
setTouchEnd(null);
|
||||
setTouchStart(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
const onTouchMove = (e) => {
|
||||
setTouchEnd(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (!touchStart || !touchEnd) return;
|
||||
|
||||
const distance = touchStart - touchEnd;
|
||||
const isLeftSwipe = distance > minSwipeDistance;
|
||||
|
||||
if (isLeftSwipe) {
|
||||
setShowPinnedSidebar(false);
|
||||
}
|
||||
|
||||
setTouchStart(null);
|
||||
setTouchEnd(null);
|
||||
};
|
||||
|
||||
// Check authentication on mount
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
// Check for reset token in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const resetTokenParam = urlParams.get('reset_token');
|
||||
if (resetTokenParam) {
|
||||
setResetToken(resetTokenParam);
|
||||
// Clean URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
setLoadingAuth(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
try {
|
||||
console.log("Checking authentication...");
|
||||
const userData = await getMe(token);
|
||||
console.log("Auth successful:", userData);
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
} catch (err) {
|
||||
console.error("Auth check error:", err);
|
||||
// Only remove token on authentication errors (401), not network errors
|
||||
if (err.status === 401 || err.message.includes('401')) {
|
||||
console.log("Token invalid or expired, logging out");
|
||||
removeToken();
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
} else if (err.status === 408 || err.name === 'AbortError') {
|
||||
// Timeout - assume not authenticated
|
||||
console.warn("Auth check timeout, removing token");
|
||||
removeToken();
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
} else {
|
||||
// Network error or server error - assume not authenticated to avoid being stuck
|
||||
console.warn("Auth check failed, removing token:", err.message);
|
||||
removeToken();
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
}
|
||||
} finally {
|
||||
// Always set loading to false, even if there was an error
|
||||
console.log("Setting loadingAuth to false");
|
||||
setLoadingAuth(false);
|
||||
}
|
||||
} else {
|
||||
console.log("No token found");
|
||||
setLoadingAuth(false);
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
// Save currentView to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("currentView", currentView);
|
||||
} catch (err) {
|
||||
console.error("Unable to save view", err);
|
||||
}
|
||||
}, [currentView]);
|
||||
|
||||
// Load recipes for everyone (readonly for non-authenticated)
|
||||
useEffect(() => {
|
||||
loadRecipes();
|
||||
}, []);
|
||||
@ -216,8 +96,8 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by made_by (username)
|
||||
if (filterOwner && (!recipe.made_by || recipe.made_by !== filterOwner)) {
|
||||
// Filter by made_by
|
||||
if (filterMadeBy && (!recipe.made_by || recipe.made_by !== filterMadeBy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -254,8 +134,7 @@ function App() {
|
||||
|
||||
const handleCreateRecipe = async (payload) => {
|
||||
try {
|
||||
const token = getToken();
|
||||
const created = await createRecipe(payload, token);
|
||||
const created = await createRecipe(payload);
|
||||
setDrawerOpen(false);
|
||||
setEditingRecipe(null);
|
||||
await loadRecipes();
|
||||
@ -274,8 +153,7 @@ function App() {
|
||||
|
||||
const handleUpdateRecipe = async (payload) => {
|
||||
try {
|
||||
const token = getToken();
|
||||
await updateRecipe(editingRecipe.id, payload, token);
|
||||
await updateRecipe(editingRecipe.id, payload);
|
||||
setDrawerOpen(false);
|
||||
setEditingRecipe(null);
|
||||
await loadRecipes();
|
||||
@ -299,8 +177,7 @@ function App() {
|
||||
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
||||
|
||||
try {
|
||||
const token = getToken();
|
||||
await deleteRecipe(recipeId, token);
|
||||
await deleteRecipe(recipeId);
|
||||
await loadRecipes();
|
||||
setSelectedRecipe(null);
|
||||
addToast("המתכון נמחק בהצלחה!", "success");
|
||||
@ -331,206 +208,12 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginSuccess = async () => {
|
||||
const token = getToken();
|
||||
const userData = await getMe(token);
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
await loadRecipes();
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setLogoutModal(true);
|
||||
};
|
||||
|
||||
const confirmLogout = () => {
|
||||
removeToken();
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
setRecipes([]);
|
||||
setSelectedRecipe(null);
|
||||
setLogoutModal(false);
|
||||
addToast('התנתקת בהצלחה', 'success');
|
||||
};
|
||||
|
||||
// Show loading state while checking auth
|
||||
if (loadingAuth) {
|
||||
return (
|
||||
<div className="app-root">
|
||||
<div style={{ textAlign: "center", padding: "3rem", color: "var(--text-muted)" }}>
|
||||
טוען...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show main app (readonly if not authenticated)
|
||||
return (
|
||||
<div className="app-root">
|
||||
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
|
||||
|
||||
{/* Pinned notes toggle button - only visible on recipes view for authenticated users */}
|
||||
{isAuthenticated && currentView === "recipes" && (
|
||||
<button
|
||||
className="pinned-toggle-btn mobile-only"
|
||||
onClick={() => setShowPinnedSidebar(!showPinnedSidebar)}
|
||||
aria-label="הצג תזכירים"
|
||||
title="תזכירים נעוצים"
|
||||
>
|
||||
<span className="note-icon-lines">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* User greeting above TopBar */}
|
||||
{isAuthenticated && user && (
|
||||
<div className="user-greeting-header">
|
||||
שלום, {user.display_name || user.username} 👋
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show login/register option in TopBar if not authenticated */}
|
||||
{!isAuthenticated ? (
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
<span className="logo-emoji" role="img" aria-label="plate">🍽</span>
|
||||
<div className="brand">
|
||||
<div className="brand-title">מה לבשל היום?</div>
|
||||
<div className="brand-subtitle">מנהל המתכונים האישי שלך</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||
<button className="btn ghost" onClick={() => setAuthView("login")}>
|
||||
התחבר
|
||||
</button>
|
||||
<button className="btn primary" onClick={() => setAuthView("register")}>
|
||||
הירשם
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
) : (
|
||||
<TopBar
|
||||
onAddClick={() => setDrawerOpen(true)}
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
onShowToast={addToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show auth modal if needed */}
|
||||
{!isAuthenticated && authView !== null && !resetToken && (
|
||||
<div className="drawer-backdrop" onClick={() => setAuthView(null)}>
|
||||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{authView === "login" ? (
|
||||
<Login
|
||||
onSuccess={handleLoginSuccess}
|
||||
onSwitchToRegister={() => setAuthView("register")}
|
||||
/>
|
||||
) : (
|
||||
<Register
|
||||
onSuccess={handleLoginSuccess}
|
||||
onSwitchToLogin={() => setAuthView("login")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show reset password if token present */}
|
||||
{!isAuthenticated && resetToken && (
|
||||
<div className="drawer-backdrop">
|
||||
<div className="auth-modal">
|
||||
<ResetPassword
|
||||
token={resetToken}
|
||||
onSuccess={() => {
|
||||
setResetToken(null);
|
||||
setAuthView("login");
|
||||
addToast("הסיסמה עודכנה בהצלחה! כעת תוכל להתחבר עם הסיסמה החדשה", "success");
|
||||
}}
|
||||
onBack={() => {
|
||||
setResetToken(null);
|
||||
setAuthView("login");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<nav className="main-navigation">
|
||||
<button
|
||||
className={`nav-tab ${currentView === "recipes" ? "active" : ""}`}
|
||||
onClick={() => setCurrentView("recipes")}
|
||||
>
|
||||
📖 מתכונים
|
||||
</button>
|
||||
<button
|
||||
className={`nav-tab ${currentView === "grocery-lists" ? "active" : ""}`}
|
||||
onClick={() => setCurrentView("grocery-lists")}
|
||||
>
|
||||
🛒 רשימות קניות
|
||||
</button>
|
||||
<button
|
||||
className={`nav-tab ${currentView === "friends" ? "active" : ""}`}
|
||||
onClick={() => setCurrentView("friends")}
|
||||
>
|
||||
👥 חברים
|
||||
</button>
|
||||
<button
|
||||
className={`nav-tab ${currentView === "chat" ? "active" : ""}`}
|
||||
onClick={() => setCurrentView("chat")}
|
||||
>
|
||||
💬 שיחות
|
||||
</button>
|
||||
<button
|
||||
className={`nav-tab ${currentView === "groups" ? "active" : ""}`}
|
||||
onClick={() => setCurrentView("groups")}
|
||||
>
|
||||
👨👩👧👦 קבוצות
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
<TopBar onAddClick={() => setDrawerOpen(true)} />
|
||||
|
||||
<main className="layout">
|
||||
{currentView === "grocery-lists" ? (
|
||||
<GroceryLists user={user} onShowToast={addToast} />
|
||||
) : currentView === "friends" ? (
|
||||
<Friends showToast={addToast} />
|
||||
) : currentView === "chat" ? (
|
||||
<Chat showToast={addToast} />
|
||||
) : currentView === "groups" ? (
|
||||
<Groups showToast={addToast} onRecipeSelect={setSelectedRecipe} />
|
||||
) : (
|
||||
<>
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<aside
|
||||
className={`pinned-lists-sidebar ${showPinnedSidebar ? 'mobile-visible' : ''}`}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<button
|
||||
className="close-sidebar-btn mobile-only"
|
||||
onClick={() => setShowPinnedSidebar(false)}
|
||||
aria-label="סגור תזכירים"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<PinnedGroceryLists onShowToast={addToast} />
|
||||
</aside>
|
||||
{showPinnedSidebar && (
|
||||
<div
|
||||
className="sidebar-backdrop mobile-only"
|
||||
onClick={() => setShowPinnedSidebar(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<section className="content-wrapper">
|
||||
<section className="sidebar">
|
||||
<RecipeSearchList
|
||||
allRecipes={recipes}
|
||||
@ -545,27 +228,15 @@ function App() {
|
||||
onMaxTimeChange={setFilterMaxTime}
|
||||
filterTags={filterTags}
|
||||
onTagsChange={setFilterTags}
|
||||
filterOwner={filterOwner}
|
||||
onOwnerChange={setFilterOwner}
|
||||
filterMadeBy={filterMadeBy}
|
||||
onMadeByChange={setFilterMadeBy}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{/* Recipe Details Card */}
|
||||
<RecipeDetails
|
||||
recipe={selectedRecipe}
|
||||
onEditClick={handleEditRecipe}
|
||||
onShowDeleteModal={handleShowDeleteModal}
|
||||
isAuthenticated={isAuthenticated}
|
||||
currentUser={user}
|
||||
showToast={addToast}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="sidebar-right">
|
||||
{/* Random Recipe Suggester - Right Side */}
|
||||
{/* Random Recipe Suggester - Top Left */}
|
||||
<section className="panel filter-panel">
|
||||
<h3>חיפוש מתכון רנדומלי</h3>
|
||||
<div className="panel-grid">
|
||||
@ -579,7 +250,7 @@ function App() {
|
||||
<option value="breakfast">בוקר</option>
|
||||
<option value="lunch">צהריים</option>
|
||||
<option value="dinner">ערב</option>
|
||||
<option value="snack">קינוחים</option>
|
||||
<option value="snack">נשנוש</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -612,13 +283,16 @@ function App() {
|
||||
{loadingRandom ? "מחפש מתכון..." : "תן לי רעיון לארוחה 🎲"}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Recipe Details Card */}
|
||||
<RecipeDetails
|
||||
recipe={selectedRecipe}
|
||||
onEditClick={handleEditRecipe}
|
||||
onShowDeleteModal={handleShowDeleteModal}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{isAuthenticated && (
|
||||
<RecipeFormDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
@ -627,9 +301,7 @@ function App() {
|
||||
}}
|
||||
onSubmit={handleFormSubmit}
|
||||
editingRecipe={editingRecipe}
|
||||
currentUser={user}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={deleteModal.isOpen}
|
||||
@ -642,17 +314,6 @@ function App() {
|
||||
onCancel={handleCancelDelete}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={logoutModal}
|
||||
title="התנתקות"
|
||||
message="האם אתה בטוח שברצונך להתנתק?"
|
||||
confirmText="התנתק"
|
||||
cancelText="ביטול"
|
||||
isDangerous={false}
|
||||
onConfirm={confirmLogout}
|
||||
onCancel={() => setLogoutModal(false)}
|
||||
/>
|
||||
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// Get API base from injected env.js or fallback to /api relative path
|
||||
export const getApiBase = () => {
|
||||
const getApiBase = () => {
|
||||
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
|
||||
return window.__ENV__.API_BASE;
|
||||
}
|
||||
@ -37,14 +37,10 @@ export async function getRandomRecipe(filters) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createRecipe(recipe, token) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
export async function createRecipe(recipe) {
|
||||
const res = await fetch(`${API_BASE}/recipes`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(recipe),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@ -53,14 +49,10 @@ export async function createRecipe(recipe, token) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateRecipe(id, payload, token) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
export async function updateRecipe(id, payload) {
|
||||
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@ -69,14 +61,9 @@ export async function updateRecipe(id, payload, token) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteRecipe(id, token) {
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
export async function deleteRecipe(id) {
|
||||
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
throw new Error("Failed to delete recipe");
|
||||
|
||||
@ -1,134 +0,0 @@
|
||||
// Get API base from injected env.js or fallback to /api relative path
|
||||
const getApiBase = () => {
|
||||
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
|
||||
return window.__ENV__.API_BASE;
|
||||
}
|
||||
return "/api";
|
||||
};
|
||||
|
||||
const API_BASE = getApiBase();
|
||||
|
||||
export async function register(username, email, password, firstName, lastName, displayName) {
|
||||
const res = await fetch(`${API_BASE}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
display_name: displayName
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.detail || "Failed to register");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function login(username, password) {
|
||||
let lastError;
|
||||
const maxRetries = 2;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.detail || "Failed to login");
|
||||
}
|
||||
return res.json();
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < maxRetries - 1) {
|
||||
// Wait before retry (100ms)
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function getMe(token) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = new Error("Failed to get user info");
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
return res.json();
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === 'AbortError') {
|
||||
const timeoutError = new Error("Request timeout");
|
||||
timeoutError.status = 408;
|
||||
throw timeoutError;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestPasswordChangeCode(token) {
|
||||
const res = await fetch(`${API_BASE}/auth/request-password-change-code`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.detail || "Failed to send verification code");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function changePassword(verificationCode, currentPassword, newPassword, token) {
|
||||
const res = await fetch(`${API_BASE}/auth/change-password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
verification_code: verificationCode,
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.detail || "Failed to change password");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Auth helpers
|
||||
export function saveToken(token) {
|
||||
localStorage.setItem("auth_token", token);
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem("auth_token");
|
||||
}
|
||||
|
||||
export function removeToken() {
|
||||
localStorage.removeItem("auth_token");
|
||||
}
|
||||
@ -1,176 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { changePassword, requestPasswordChangeCode } from "../authApi";
|
||||
|
||||
export default function ChangePassword({ token, onClose, onSuccess }) {
|
||||
const [step, setStep] = useState(1); // 1: request code, 2: enter code & passwords
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
|
||||
const handleRequestCode = async () => {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await requestPasswordChangeCode(token);
|
||||
setCodeSent(true);
|
||||
setStep(2);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Validation
|
||||
if (!verificationCode || !currentPassword || !newPassword || !confirmPassword) {
|
||||
setError("נא למלא את כל השדות");
|
||||
return;
|
||||
}
|
||||
|
||||
if (verificationCode.length !== 6) {
|
||||
setError("קוד האימות חייב להכיל 6 ספרות");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("הסיסמאות החדשות אינן תואמות");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError("הסיסמה חייבת להכיל לפחות 6 תווים");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await changePassword(verificationCode, currentPassword, newPassword, token);
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>שינוי סיסמה</h2>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<p style={{ marginBottom: "1rem", color: "var(--text-muted)" }}>
|
||||
קוד אימות יישלח לכתובת המייל שלך. הקוד תקף ל-10 דקות.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary full"
|
||||
onClick={handleRequestCode}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "שולח..." : "שלח קוד אימות"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{codeSent && (
|
||||
<div style={{
|
||||
padding: "0.75rem",
|
||||
background: "rgba(34, 197, 94, 0.1)",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1rem",
|
||||
color: "var(--accent)"
|
||||
}}>
|
||||
✓ קוד אימות נשלח לכתובת המייל שלך
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>קוד אימות (6 ספרות)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
placeholder="123456"
|
||||
maxLength={6}
|
||||
style={{ fontSize: "1.2rem", letterSpacing: "0.3rem", textAlign: "center" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>סיסמה נוכחית</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>סיסמה חדשה</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>אימות סיסמה חדשה</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
ביטול
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "משנה..." : "שמור סיסמה"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,490 +0,0 @@
|
||||
.chat-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 200px);
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.chat-loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.chat-sidebar {
|
||||
width: 320px;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
border-right: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.chat-sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.chat-sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-new-chat {
|
||||
padding: 0.5rem 1rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-new-chat:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-subtle) transparent;
|
||||
}
|
||||
|
||||
.conversations-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.conversations-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.conversations-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.no-conversations {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: var(--card-hover);
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: linear-gradient(90deg, rgba(102, 126, 234, 0.1) 0%, transparent 100%);
|
||||
border-right: 3px solid #667eea;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.conversation-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.conversation-preview {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Main Chat Area */
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--card);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.chat-header h3 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.no-selection {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
background: var(--bg);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-selection p {
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
background: var(--bg);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-subtle) transparent;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 70%;
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.message.mine {
|
||||
align-self: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message.theirs {
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-sender {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 0.875rem 1.125rem;
|
||||
border-radius: 18px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message.mine .message-bubble {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
|
||||
.message.theirs .message-bubble {
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-bottom-left-radius: 6px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Message Input */
|
||||
.message-input-form {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--card);
|
||||
box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.message-input-form input {
|
||||
flex: 1;
|
||||
padding: 0.875rem 1.125rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 24px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.message-input-form input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.message-input-form button {
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.message-input-form button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.message-input-form button:disabled {
|
||||
background: var(--border-subtle);
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* New Chat Form */
|
||||
.new-chat-form {
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.new-chat-form h3 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.friends-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
background: var(--card);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-subtle) transparent;
|
||||
}
|
||||
|
||||
.friends-selection::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.friends-selection::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.friends-selection::-webkit-scrollbar-thumb {
|
||||
background: var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.friend-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.friend-checkbox:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.friend-checkbox input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
accent-color: #667eea;
|
||||
}
|
||||
|
||||
.group-name-input {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.group-name-input label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.group-name-input input {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.125rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.group-name-input input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.new-chat-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
flex: 1;
|
||||
padding: 0.875rem;
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(17, 153, 142, 0.3);
|
||||
}
|
||||
|
||||
.btn-create:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(17, 153, 142, 0.4);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
flex: 1;
|
||||
padding: 0.875rem;
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
height: calc(100vh - 150px);
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.message-input-form {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.message-input-form button {
|
||||
padding: 0.875rem 1.25rem;
|
||||
}
|
||||
|
||||
.new-chat-form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chat-sidebar {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 90%;
|
||||
}
|
||||
}
|
||||
@ -1,268 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
getConversations,
|
||||
createConversation,
|
||||
getMessages,
|
||||
sendMessage,
|
||||
getFriends,
|
||||
} from "../socialApi";
|
||||
import "./Chat.css";
|
||||
|
||||
export default function Chat({ showToast }) {
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [selectedConversation, setSelectedConversation] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showNewChat, setShowNewChat] = useState(false);
|
||||
const [friends, setFriends] = useState([]);
|
||||
const [selectedFriends, setSelectedFriends] = useState([]);
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedConversation) {
|
||||
loadMessages(selectedConversation.conversation_id);
|
||||
const interval = setInterval(() => {
|
||||
loadMessages(selectedConversation.conversation_id, true);
|
||||
}, 3000); // Poll for new messages every 3 seconds
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [selectedConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
function scrollToBottom() {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
async function loadConversations() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getConversations();
|
||||
setConversations(data);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(conversationId, silent = false) {
|
||||
try {
|
||||
const data = await getMessages(conversationId);
|
||||
setMessages(data);
|
||||
} catch (error) {
|
||||
if (!silent) showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendMessage(e) {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim() || !selectedConversation) return;
|
||||
|
||||
try {
|
||||
await sendMessage(selectedConversation.conversation_id, newMessage);
|
||||
setNewMessage("");
|
||||
await loadMessages(selectedConversation.conversation_id);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartNewChat() {
|
||||
try {
|
||||
const friendsData = await getFriends();
|
||||
setFriends(friendsData);
|
||||
setSelectedFriends([]);
|
||||
setGroupName("");
|
||||
setShowNewChat(true);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFriendSelection(friendId) {
|
||||
setSelectedFriends((prev) =>
|
||||
prev.includes(friendId) ? prev.filter((id) => id !== friendId) : [...prev, friendId]
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCreateConversation() {
|
||||
if (selectedFriends.length === 0) {
|
||||
showToast("בחר לפחות חבר אחד", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const isGroup = selectedFriends.length > 1;
|
||||
|
||||
// Only validate group name if it's actually a group chat
|
||||
if (isGroup && !groupName.trim()) {
|
||||
showToast("הכנס שם קבוצה", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Creating conversation with friends:", selectedFriends);
|
||||
console.log("Is group:", isGroup);
|
||||
console.log("Group name:", isGroup ? groupName : "(private chat)");
|
||||
|
||||
const conversation = await createConversation(
|
||||
selectedFriends,
|
||||
isGroup,
|
||||
isGroup ? groupName : null
|
||||
);
|
||||
showToast("השיחה נוצרה בהצלחה!", "success");
|
||||
setShowNewChat(false);
|
||||
setSelectedFriends([]);
|
||||
setGroupName("");
|
||||
await loadConversations();
|
||||
setSelectedConversation(conversation);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="chat-loading">טוען שיחות...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-container">
|
||||
<div className="chat-sidebar">
|
||||
<div className="chat-sidebar-header">
|
||||
<h2>הודעות</h2>
|
||||
<button onClick={handleStartNewChat} className="btn-new-chat">
|
||||
+ חדש
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="conversations-list">
|
||||
{conversations.length === 0 ? (
|
||||
<p className="no-conversations">אין שיחות עדיין</p>
|
||||
) : (
|
||||
conversations.map((conv) => (
|
||||
<div
|
||||
key={conv.conversation_id}
|
||||
className={`conversation-item ${
|
||||
selectedConversation?.conversation_id === conv.conversation_id ? "active" : ""
|
||||
}`}
|
||||
onClick={() => setSelectedConversation(conv)}
|
||||
>
|
||||
<div className="conversation-name">
|
||||
{conv.name || conv.other_member_name || "Conversation"}
|
||||
</div>
|
||||
{conv.last_message && (
|
||||
<div className="conversation-preview">{conv.last_message}</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-main">
|
||||
{showNewChat ? (
|
||||
<div className="new-chat-form">
|
||||
<h3>שיחה חדשה</h3>
|
||||
<div className="friends-selection">
|
||||
{friends.length === 0 ? (
|
||||
<p>אין חברים לשוחח איתם. הוסף חברים תחילה!</p>
|
||||
) : (
|
||||
friends.map((friend) => (
|
||||
<label key={friend.id} className="friend-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFriends.includes(friend.id)}
|
||||
onChange={() => toggleFriendSelection(friend.id)}
|
||||
/>
|
||||
<span>{friend.username || friend.email}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedFriends.length > 1 && (
|
||||
<div className="group-name-input">
|
||||
<label>שם קבוצה:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
placeholder="הכנס שם קבוצה..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="new-chat-actions">
|
||||
<button onClick={handleCreateConversation} className="btn-create">
|
||||
צור
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNewChat(false);
|
||||
setSelectedFriends([]);
|
||||
setGroupName("");
|
||||
}}
|
||||
className="btn-cancel"
|
||||
>
|
||||
ביטול
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedConversation ? (
|
||||
<>
|
||||
<div className="chat-header">
|
||||
<h3>{selectedConversation.name || selectedConversation.other_member_name || "שיחה"}</h3>
|
||||
</div>
|
||||
|
||||
<div className="messages-container">
|
||||
{messages.length === 0 ? (
|
||||
<p className="no-messages">אין הודעות עדיין. התחל את השיחה!</p>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div key={msg.message_id} className={`message ${msg.is_mine ? "mine" : "theirs"}`}>
|
||||
{!msg.is_mine && (
|
||||
<div className="message-sender">{msg.sender_username || msg.sender_email}</div>
|
||||
)}
|
||||
<div className="message-bubble">{msg.content}</div>
|
||||
<div className="message-time">
|
||||
{new Date(msg.created_at).toLocaleTimeString('he-IL', {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSendMessage} className="message-input-form">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder="הקלד הודעה..."
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={!newMessage.trim()}>
|
||||
שלח
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="no-selection">
|
||||
<p>בחר שיחה או התחל שיחה חדשה</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { getApiBase } from "../api";
|
||||
|
||||
function ForgotPassword({ onBack }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setMessage("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/forgot-password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMessage(data.message);
|
||||
setEmail("");
|
||||
} else {
|
||||
setError(data.detail || "שגיאה בשליחת הבקשה");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("שגיאה בשליחת הבקשה");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">שכחת סיסמה?</h1>
|
||||
<p className="auth-subtitle">
|
||||
הזן את כתובת המייל שלך ונשלח לך קישור לאיפוס הסיסמה
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{message && (
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
background: "var(--success-bg, #dcfce7)",
|
||||
border: "1px solid var(--success-border, #22c55e)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--success-text, #166534)",
|
||||
marginBottom: "1rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>כתובת מייל</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="הזן כתובת מייל"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary full-width"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "שולח..." : "שלח קישור לאיפוס"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-footer">
|
||||
<p>
|
||||
נזכרת בסיסמה?{" "}
|
||||
<button className="link-btn" onClick={onBack}>
|
||||
חזור להתחברות
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ForgotPassword;
|
||||
@ -1,205 +0,0 @@
|
||||
.friends-container {
|
||||
max-width: 800px;
|
||||
margin: 2rem auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.friends-container h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.friends-loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.friends-search {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.friends-search-input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.friends-search-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.friends-search-btn:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.friends-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.friends-tabs button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: #666;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.friends-tabs button:hover {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.friends-tabs button.active {
|
||||
color: #007bff;
|
||||
border-bottom-color: #007bff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.friends-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.friends-empty {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.friend-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.friend-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.friend-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.friend-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.friend-email {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.friend-since {
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.friend-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.friend-btn-add,
|
||||
.friend-btn-accept {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.friend-btn-add:hover,
|
||||
.friend-btn-accept:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
.friend-btn-remove,
|
||||
.friend-btn-reject {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.friend-btn-remove:hover,
|
||||
.friend-btn-reject:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.friend-status {
|
||||
padding: 0.5rem 1rem;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.request-card {
|
||||
background-color: #f8f9fa;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.friends-search-input {
|
||||
background-color: #333;
|
||||
color: white;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.friends-tabs {
|
||||
border-bottom-color: #555;
|
||||
}
|
||||
|
||||
.friend-card {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.request-card {
|
||||
background-color: #1a3a52;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.friend-email {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.friend-since {
|
||||
color: #888;
|
||||
}
|
||||
}
|
||||
@ -1,242 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
getFriends,
|
||||
getFriendRequests,
|
||||
acceptFriendRequest,
|
||||
rejectFriendRequest,
|
||||
sendFriendRequest,
|
||||
removeFriend,
|
||||
searchUsers,
|
||||
} from "../socialApi";
|
||||
import "./Friends.css";
|
||||
|
||||
export default function Friends({ showToast }) {
|
||||
const [friends, setFriends] = useState([]);
|
||||
const [requests, setRequests] = useState([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState("friends"); // friends | requests | search
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [friendsData, requestsData] = await Promise.all([
|
||||
getFriends(),
|
||||
getFriendRequests(),
|
||||
]);
|
||||
setFriends(friendsData);
|
||||
setRequests(requestsData);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch(e) {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
try {
|
||||
const results = await searchUsers(searchQuery);
|
||||
setSearchResults(results);
|
||||
setActiveTab("search");
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendRequest(userId) {
|
||||
try {
|
||||
await sendFriendRequest(userId);
|
||||
showToast("Friend request sent!", "success");
|
||||
// Update search results to reflect sent request
|
||||
setSearchResults(
|
||||
searchResults.map((user) =>
|
||||
user.id === userId ? { ...user, request_sent: true } : user
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAcceptRequest(requestId) {
|
||||
try {
|
||||
await acceptFriendRequest(requestId);
|
||||
showToast("Friend request accepted!", "success");
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectRequest(requestId) {
|
||||
try {
|
||||
await rejectFriendRequest(requestId);
|
||||
showToast("Friend request rejected", "info");
|
||||
setRequests(requests.filter((req) => req.request_id !== requestId));
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveFriend(friendId) {
|
||||
if (!confirm("Remove this friend?")) return;
|
||||
|
||||
try {
|
||||
await removeFriend(friendId);
|
||||
showToast("Friend removed", "info");
|
||||
setFriends(friends.filter((friend) => friend.user_id !== friendId));
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="friends-loading">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="friends-container">
|
||||
<h2>Friends</h2>
|
||||
|
||||
{/* Search Bar */}
|
||||
<form onSubmit={handleSearch} className="friends-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users by email or username..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="friends-search-input"
|
||||
/>
|
||||
<button type="submit" className="friends-search-btn">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="friends-tabs">
|
||||
<button
|
||||
className={activeTab === "friends" ? "active" : ""}
|
||||
onClick={() => setActiveTab("friends")}
|
||||
>
|
||||
Friends ({friends.length})
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === "requests" ? "active" : ""}
|
||||
onClick={() => setActiveTab("requests")}
|
||||
>
|
||||
Requests ({requests.length})
|
||||
</button>
|
||||
{searchResults.length > 0 && (
|
||||
<button
|
||||
className={activeTab === "search" ? "active" : ""}
|
||||
onClick={() => setActiveTab("search")}
|
||||
>
|
||||
Search Results
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Friends List */}
|
||||
{activeTab === "friends" && (
|
||||
<div className="friends-list">
|
||||
{friends.length === 0 ? (
|
||||
<p className="friends-empty">No friends yet. Search for users to add!</p>
|
||||
) : (
|
||||
friends.map((friend) => (
|
||||
<div key={friend.id} className="friend-card">
|
||||
<div className="friend-info">
|
||||
<div className="friend-name">{friend.username || friend.email}</div>
|
||||
<div className="friend-email">{friend.email}</div>
|
||||
<div className="friend-since">
|
||||
Friends since {new Date(friend.friends_since).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveFriend(friend.id)}
|
||||
className="friend-btn-remove"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Friend Requests */}
|
||||
{activeTab === "requests" && (
|
||||
<div className="friends-list">
|
||||
{requests.length === 0 ? (
|
||||
<p className="friends-empty">No pending friend requests</p>
|
||||
) : (
|
||||
requests.map((request) => (
|
||||
<div key={request.request_id} className="friend-card request-card">
|
||||
<div className="friend-info">
|
||||
<div className="friend-name">
|
||||
{request.sender_username || request.sender_email}
|
||||
</div>
|
||||
<div className="friend-email">{request.sender_email}</div>
|
||||
<div className="friend-since">
|
||||
Sent {new Date(request.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="friend-actions">
|
||||
<button
|
||||
onClick={() => handleAcceptRequest(request.request_id)}
|
||||
className="friend-btn-accept"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRejectRequest(request.request_id)}
|
||||
className="friend-btn-reject"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Results */}
|
||||
{activeTab === "search" && (
|
||||
<div className="friends-list">
|
||||
{searchResults.length === 0 ? (
|
||||
<p className="friends-empty">No users found</p>
|
||||
) : (
|
||||
searchResults.map((user) => (
|
||||
<div key={user.id} className="friend-card">
|
||||
<div className="friend-info">
|
||||
<div className="friend-name">{user.username || user.email}</div>
|
||||
<div className="friend-email">{user.email}</div>
|
||||
</div>
|
||||
{user.is_friend ? (
|
||||
<span className="friend-status">Friends</span>
|
||||
) : user.request_sent ? (
|
||||
<span className="friend-status">Request Sent</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSendRequest(user.id)}
|
||||
className="friend-btn-add"
|
||||
>
|
||||
Add Friend
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,542 +0,0 @@
|
||||
.groups-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 200px);
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.groups-loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.groups-sidebar {
|
||||
width: 300px;
|
||||
border-right: 1px solid #ddd;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.groups-sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.groups-sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-new-group {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-new-group:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
.groups-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.no-groups {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.group-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.group-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.group-item.active {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 3px solid #28a745;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.group-stats {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Main Area */
|
||||
.groups-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.no-selection {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 1.1rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-selection p {
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Group Header */
|
||||
.group-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #ddd;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.group-header h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.group-description {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.group-tabs {
|
||||
display: flex;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.group-tabs button {
|
||||
padding: 1rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: #666;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.group-tabs button:hover {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.group-tabs button.active {
|
||||
color: #28a745;
|
||||
border-bottom-color: #28a745;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.group-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.content-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.btn-share,
|
||||
.btn-add-member {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-share:hover,
|
||||
.btn-add-member:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Recipes Grid */
|
||||
.recipes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.recipe-card-mini {
|
||||
padding: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.recipe-card-mini:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.recipe-card-mini .recipe-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.recipe-card-mini .recipe-meta {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Members List */
|
||||
.members-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: #ffc107;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-remove-member {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-remove-member:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
/* Create Group Form */
|
||||
.create-group-form {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.create-group-form h3 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-field input[type="text"],
|
||||
.form-field textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.checkbox-field label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-field input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-create:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
/* Modals */
|
||||
.share-recipe-modal,
|
||||
.add-member-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.recipes-selection,
|
||||
.friends-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recipe-option,
|
||||
.friend-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.recipe-option:hover,
|
||||
.friend-option:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.recipe-option button,
|
||||
.friend-option button {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.recipe-option button:hover,
|
||||
.friend-option button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.groups-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
height: calc(100vh - 150px);
|
||||
}
|
||||
|
||||
.groups-sidebar {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.recipes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.groups-container {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.groups-sidebar {
|
||||
border-right-color: #444;
|
||||
}
|
||||
|
||||
.groups-sidebar-header {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
.group-item {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
.group-item:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.group-item.active {
|
||||
background-color: #1a3a52;
|
||||
}
|
||||
|
||||
.group-stats {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
background-color: #333;
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
.group-description {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.group-tabs {
|
||||
border-bottom-color: #555;
|
||||
}
|
||||
|
||||
.recipe-card-mini,
|
||||
.member-item {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.recipe-card-mini .recipe-meta {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #2a2a2a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.recipe-option,
|
||||
.friend-option {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.recipe-option:hover,
|
||||
.friend-option:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.form-field input[type="text"],
|
||||
.form-field textarea {
|
||||
background-color: #333;
|
||||
color: white;
|
||||
border-color: #555;
|
||||
}
|
||||
}
|
||||
@ -1,378 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
getGroups,
|
||||
createGroup,
|
||||
getGroup,
|
||||
addGroupMember,
|
||||
removeGroupMember,
|
||||
shareRecipeToGroup,
|
||||
getGroupRecipes,
|
||||
getFriends,
|
||||
} from "../socialApi";
|
||||
import { getRecipes } from "../api";
|
||||
import "./Groups.css";
|
||||
|
||||
export default function Groups({ showToast, onRecipeSelect }) {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [selectedGroup, setSelectedGroup] = useState(null);
|
||||
const [groupDetails, setGroupDetails] = useState(null);
|
||||
const [groupRecipes, setGroupRecipes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState("groups"); // groups | create | members | recipes
|
||||
|
||||
// Create group form
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupDescription, setNewGroupDescription] = useState("");
|
||||
const [isPrivate, setIsPrivate] = useState(true);
|
||||
|
||||
// Add member
|
||||
const [friends, setFriends] = useState([]);
|
||||
const [showAddMember, setShowAddMember] = useState(false);
|
||||
|
||||
// Share recipe
|
||||
const [myRecipes, setMyRecipes] = useState([]);
|
||||
const [showShareRecipe, setShowShareRecipe] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedGroup) {
|
||||
loadGroupDetails();
|
||||
loadGroupRecipes();
|
||||
}
|
||||
}, [selectedGroup]);
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getGroups();
|
||||
setGroups(data);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroupDetails() {
|
||||
try {
|
||||
const data = await getGroup(selectedGroup.group_id);
|
||||
setGroupDetails(data);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroupRecipes() {
|
||||
try {
|
||||
const data = await getGroupRecipes(selectedGroup.group_id);
|
||||
setGroupRecipes(data);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateGroup(e) {
|
||||
e.preventDefault();
|
||||
if (!newGroupName.trim()) return;
|
||||
|
||||
try {
|
||||
await createGroup(newGroupName, newGroupDescription, isPrivate);
|
||||
showToast("הקבוצה נוצרה!", "success");
|
||||
setNewGroupName("");
|
||||
setNewGroupDescription("");
|
||||
setIsPrivate(true);
|
||||
setActiveTab("groups");
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShowAddMember() {
|
||||
try {
|
||||
const friendsData = await getFriends();
|
||||
setFriends(friendsData);
|
||||
setShowAddMember(true);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddMember(friendId) {
|
||||
try {
|
||||
await addGroupMember(selectedGroup.group_id, friendId);
|
||||
showToast("החבר נוסף!", "success");
|
||||
setShowAddMember(false);
|
||||
await loadGroupDetails();
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveMember(userId) {
|
||||
if (!confirm("להסיר את החבר הזה?")) return;
|
||||
|
||||
try {
|
||||
await removeGroupMember(selectedGroup.group_id, userId);
|
||||
showToast("החבר הוסר", "info");
|
||||
await loadGroupDetails();
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShowShareRecipe() {
|
||||
try {
|
||||
const recipes = await getRecipes();
|
||||
setMyRecipes(recipes);
|
||||
setShowShareRecipe(true);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShareRecipe(recipeId) {
|
||||
try {
|
||||
await shareRecipeToGroup(selectedGroup.group_id, recipeId);
|
||||
showToast("המתכון שותף!", "success");
|
||||
setShowShareRecipe(false);
|
||||
await loadGroupRecipes();
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="groups-loading">טוען קבוצות...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="groups-container">
|
||||
<div className="groups-sidebar">
|
||||
<div className="groups-sidebar-header">
|
||||
<h2>קבוצות מתכונים</h2>
|
||||
<button onClick={() => setActiveTab("create")} className="btn-new-group">
|
||||
+ חדש
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="groups-list">
|
||||
{groups.length === 0 ? (
|
||||
<p className="no-groups">אין קבוצות עדיין. צור אחת!</p>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<div
|
||||
key={group.group_id}
|
||||
className={`group-item ${selectedGroup?.group_id === group.group_id ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setSelectedGroup(group);
|
||||
setActiveTab("recipes");
|
||||
}}
|
||||
>
|
||||
<div className="group-name">
|
||||
{group.is_private ? "🔒 " : "🌐 "}
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="group-stats">
|
||||
{group.member_count} חברים · {group.recipe_count || 0} מתכונים
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="groups-main">
|
||||
{activeTab === "create" ? (
|
||||
<div className="create-group-form">
|
||||
<h3>צור קבוצה חדשה</h3>
|
||||
<form onSubmit={handleCreateGroup}>
|
||||
<div className="form-field">
|
||||
<label>שם הקבוצה *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder="מתכוני משפחה, חברים טבעונים וכו'"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>תיאור</label>
|
||||
<textarea
|
||||
value={newGroupDescription}
|
||||
onChange={(e) => setNewGroupDescription(e.target.value)}
|
||||
placeholder="על מה הקבוצה הזאת?"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field checkbox-field">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPrivate}
|
||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||
/>
|
||||
<span>קבוצה פרטית (בהזמנה בלבד)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-create">
|
||||
צור קבוצה
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("groups")}
|
||||
className="btn-cancel"
|
||||
>
|
||||
ביטול
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : selectedGroup ? (
|
||||
<>
|
||||
<div className="group-header">
|
||||
<div>
|
||||
<h3>
|
||||
{selectedGroup.is_private ? "🔒 " : "🌐 "}
|
||||
{selectedGroup.name}
|
||||
</h3>
|
||||
{groupDetails?.description && (
|
||||
<p className="group-description">{groupDetails.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group-tabs">
|
||||
<button
|
||||
className={activeTab === "recipes" ? "active" : ""}
|
||||
onClick={() => setActiveTab("recipes")}
|
||||
>
|
||||
מתכונים ({groupRecipes.length})
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === "members" ? "active" : ""}
|
||||
onClick={() => setActiveTab("members")}
|
||||
>
|
||||
חברים ({groupDetails?.members?.length || 0})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "recipes" && (
|
||||
<div className="group-content">
|
||||
<div className="content-header">
|
||||
<h4>מתכונים משותפים</h4>
|
||||
{groupDetails?.is_admin && (
|
||||
<button onClick={handleShowShareRecipe} className="btn-share">
|
||||
+ שתף מתכון
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showShareRecipe && (
|
||||
<div className="share-recipe-modal">
|
||||
<div className="modal-content">
|
||||
<h4>שתף מתכון לקבוצה</h4>
|
||||
<div className="recipes-selection">
|
||||
{myRecipes.map((recipe) => (
|
||||
<div key={recipe.id} className="recipe-option">
|
||||
<span>{recipe.name}</span>
|
||||
<button onClick={() => handleShareRecipe(recipe.id)}>שתף</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setShowShareRecipe(false)} className="btn-close">
|
||||
ביטול
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="recipes-grid">
|
||||
{groupRecipes.length === 0 ? (
|
||||
<p className="empty-state">עדיין לא שותפו מתכונים</p>
|
||||
) : (
|
||||
groupRecipes.map((recipe) => (
|
||||
<div
|
||||
key={recipe.recipe_id}
|
||||
className="recipe-card-mini"
|
||||
onClick={() => onRecipeSelect?.(recipe)}
|
||||
>
|
||||
<div className="recipe-name">{recipe.recipe_name}</div>
|
||||
<div className="recipe-meta">
|
||||
שותף על ידי {recipe.shared_by_username || recipe.shared_by_email}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "members" && (
|
||||
<div className="group-content">
|
||||
<div className="content-header">
|
||||
<h4>חברים</h4>
|
||||
{groupDetails?.is_admin && (
|
||||
<button onClick={handleShowAddMember} className="btn-add-member">
|
||||
+ הוסף חבר
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddMember && (
|
||||
<div className="add-member-modal">
|
||||
<div className="modal-content">
|
||||
<h4>הוסף חבר</h4>
|
||||
<div className="friends-selection">
|
||||
{friends.map((friend) => (
|
||||
<div key={friend.user_id} className="friend-option">
|
||||
<span>{friend.username || friend.email}</span>
|
||||
<button onClick={() => handleAddMember(friend.user_id)}>הוסף</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setShowAddMember(false)} className="btn-close">
|
||||
ביטול
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="members-list">
|
||||
{groupDetails?.members?.map((member) => (
|
||||
<div key={member.user_id} className="member-item">
|
||||
<div className="member-info">
|
||||
<div className="member-name">{member.username || member.email}</div>
|
||||
{member.is_admin && <span className="admin-badge">מנהל</span>}
|
||||
</div>
|
||||
{groupDetails.is_admin && !member.is_admin && (
|
||||
<button
|
||||
onClick={() => handleRemoveMember(member.user_id)}
|
||||
className="btn-remove-member"
|
||||
>
|
||||
הסר
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="no-selection">
|
||||
<p>בחר קבוצה או צור קבוצה חדשה</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,181 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { login, saveToken } from "../authApi";
|
||||
import ForgotPassword from "./ForgotPassword";
|
||||
|
||||
function Login({ onSuccess, onSwitchToRegister }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
||||
|
||||
// Check for token in URL (from Google OAuth redirect)
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const token = urlParams.get('token');
|
||||
if (token) {
|
||||
saveToken(token);
|
||||
// Clean URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
onSuccess();
|
||||
}
|
||||
}, [onSuccess]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const data = await login(username, password);
|
||||
saveToken(data.access_token);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
const apiBase = window.__ENV__?.API_BASE || "http://localhost:8000";
|
||||
window.location.href = `${apiBase}/auth/google/login`;
|
||||
};
|
||||
|
||||
const handleAzureLogin = () => {
|
||||
const apiBase = window.__ENV__?.API_BASE || "http://localhost:8000";
|
||||
window.location.href = `${apiBase}/auth/azure/login`;
|
||||
};
|
||||
|
||||
if (showForgotPassword) {
|
||||
return <ForgotPassword onBack={() => setShowForgotPassword(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">התחברות</h1>
|
||||
<p className="auth-subtitle">ברוכים השבים למתכונים שלכם</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="field">
|
||||
<label>שם משתמש</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
placeholder="הזן שם משתמש"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>סיסמה</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="הזן סיסמה"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn primary full-width" disabled={loading}>
|
||||
{loading ? "מתחבר..." : "התחבר"}
|
||||
</button>
|
||||
|
||||
<div style={{ textAlign: "center", marginTop: "0.75rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}
|
||||
onClick={() => setShowForgotPassword(true)}
|
||||
>
|
||||
שכחת סיסמה?
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style={{
|
||||
margin: "1rem 0",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
position: "relative"
|
||||
}}>
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderTop: "1px solid var(--border-subtle)",
|
||||
zIndex: 0
|
||||
}}></div>
|
||||
<span style={{
|
||||
background: "var(--card)",
|
||||
padding: "0 1rem",
|
||||
position: "relative",
|
||||
zIndex: 1
|
||||
}}>או</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleLogin}
|
||||
className="btn ghost full-width"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.5rem",
|
||||
border: "1px solid var(--border-subtle)"
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18">
|
||||
<path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"/>
|
||||
<path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/>
|
||||
<path fill="#FBBC05" d="M3.964 10.707c-.18-.54-.282-1.117-.282-1.707 0-.593.102-1.17.282-1.709V4.958H.957C.347 6.173 0 7.548 0 9c0 1.452.348 2.827.957 4.042l3.007-2.335z"/>
|
||||
<path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/>
|
||||
</svg>
|
||||
המשך עם Google
|
||||
</button>
|
||||
|
||||
{/* <button
|
||||
type="button"
|
||||
onClick={handleAzureLogin}
|
||||
className="btn ghost full-width"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.5rem",
|
||||
border: "1px solid var(--border-subtle)",
|
||||
marginTop: "0.5rem"
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 23 23">
|
||||
<path fill="#f25022" d="M1 1h10v10H1z"/>
|
||||
<path fill="#00a4ef" d="M12 1h10v10H12z"/>
|
||||
<path fill="#7fba00" d="M1 12h10v10H1z"/>
|
||||
<path fill="#ffb900" d="M12 12h10v10H12z"/>
|
||||
</svg>
|
||||
המשך עם Microsoft
|
||||
</button> */}
|
||||
|
||||
<div className="auth-footer">
|
||||
<p>
|
||||
עדיין אין לך חשבון?{" "}
|
||||
<button className="link-btn" onClick={onSwitchToRegister}>
|
||||
הירשם עכשיו
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
@ -1,221 +0,0 @@
|
||||
.notification-bell-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-bell-btn {
|
||||
position: relative;
|
||||
background: var(--card-soft);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
font-size: 1.25rem;
|
||||
transition: all 0.2s;
|
||||
line-height: 1;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.notification-bell-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 12px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.notification-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
width: 420px;
|
||||
max-height: 550px;
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
opacity: 0.98;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--card-soft);
|
||||
}
|
||||
|
||||
.notification-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
overflow-y: auto;
|
||||
max-height: 450px;
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar-track {
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: transparent;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.notification-item.unread::before {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.notification-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.notification-item.unread:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification-item.read .notification-message {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.btn-icon-small {
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.4rem 0.65rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
line-height: 1;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.btn-icon-small:hover {
|
||||
background: var(--hover-bg);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-icon-small:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-icon-small.delete {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-icon-small.delete:hover {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
@ -1,209 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
getNotifications,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
deleteNotification,
|
||||
} from "../notificationApi";
|
||||
import "./NotificationBell.css";
|
||||
|
||||
function NotificationBell({ onShowToast }) {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [processingIds, setProcessingIds] = useState(new Set());
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications();
|
||||
// Poll for new notifications every 30 seconds
|
||||
const interval = setInterval(loadNotifications, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Close dropdown when clicking outside
|
||||
function handleClickOutside(event) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (showDropdown) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [showDropdown]);
|
||||
|
||||
const loadNotifications = async () => {
|
||||
try {
|
||||
const data = await getNotifications();
|
||||
setNotifications(data);
|
||||
setUnreadCount(data.filter((n) => !n.is_read).length);
|
||||
} catch (error) {
|
||||
// If unauthorized (401), user is not logged in - don't show errors
|
||||
if (error.message.includes("401") || error.message.includes("Unauthorized") || error.message.includes("User not found")) {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
// Catch network errors (fetch failed)
|
||||
if (error.message.includes("Failed to fetch") || error.message.includes("NetworkError") || error.message.includes("fetch")) {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
// Silent fail for other polling errors
|
||||
console.error("Failed to load notifications", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAsRead = async (notificationId) => {
|
||||
// Prevent duplicate calls
|
||||
if (processingIds.has(notificationId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessingIds(new Set(processingIds).add(notificationId));
|
||||
|
||||
try {
|
||||
await markNotificationAsRead(notificationId);
|
||||
setNotifications(
|
||||
notifications.map((n) =>
|
||||
n.id === notificationId ? { ...n, is_read: true } : n
|
||||
)
|
||||
);
|
||||
setUnreadCount(Math.max(0, unreadCount - 1));
|
||||
} catch (error) {
|
||||
console.error("Error marking notification as read:", error);
|
||||
const errorMessage = error.message.includes("Network error")
|
||||
? "שגיאת רשת: לא ניתן להתחבר לשרת"
|
||||
: error.message.includes("Failed to fetch")
|
||||
? "שגיאה בסימון ההתראה - בדוק את החיבור לאינטרנט"
|
||||
: error.message;
|
||||
onShowToast?.(errorMessage, "error");
|
||||
} finally {
|
||||
setProcessingIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(notificationId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
try {
|
||||
await markAllNotificationsAsRead();
|
||||
setNotifications(notifications.map((n) => ({ ...n, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
onShowToast?.("כל ההתראות סומנו כנקראו", "success");
|
||||
} catch (error) {
|
||||
onShowToast?.(error.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (notificationId) => {
|
||||
try {
|
||||
await deleteNotification(notificationId);
|
||||
const notification = notifications.find((n) => n.id === notificationId);
|
||||
setNotifications(notifications.filter((n) => n.id !== notificationId));
|
||||
if (notification && !notification.is_read) {
|
||||
setUnreadCount(Math.max(0, unreadCount - 1));
|
||||
}
|
||||
} catch (error) {
|
||||
onShowToast?.(error.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return "עכשיו";
|
||||
if (minutes < 60) return `לפני ${minutes} דקות`;
|
||||
if (hours < 24) return `לפני ${hours} שעות`;
|
||||
return `לפני ${days} ימים`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="notification-bell-container" ref={dropdownRef}>
|
||||
<button
|
||||
className="notification-bell-btn"
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
title="התראות"
|
||||
>
|
||||
🔔
|
||||
{unreadCount > 0 && (
|
||||
<span className="notification-badge">{unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="notification-dropdown">
|
||||
<div className="notification-header">
|
||||
<h3>התראות</h3>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={handleMarkAllAsRead}
|
||||
>
|
||||
סמן הכל כנקרא
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="notification-list">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="notification-empty">אין התראות חדשות</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`notification-item ${
|
||||
notification.is_read ? "read" : "unread"
|
||||
}`}
|
||||
>
|
||||
<div className="notification-content">
|
||||
<p className="notification-message">
|
||||
{notification.message}
|
||||
</p>
|
||||
<span className="notification-time">
|
||||
{formatTime(notification.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="notification-actions">
|
||||
{!notification.is_read && (
|
||||
<button
|
||||
className="btn-icon-small"
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
disabled={processingIds.has(notification.id)}
|
||||
title="סמן כנקרא"
|
||||
>
|
||||
{processingIds.has(notification.id) ? "..." : "✓"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn-icon-small delete"
|
||||
onClick={() => handleDelete(notification.id)}
|
||||
title="מחק"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationBell;
|
||||
@ -1,222 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getGroceryLists, updateGroceryList } from "../groceryApi";
|
||||
|
||||
function PinnedGroceryLists({ onShowToast }) {
|
||||
const [pinnedLists, setPinnedLists] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPinnedLists();
|
||||
}, []);
|
||||
|
||||
const loadPinnedLists = async () => {
|
||||
try {
|
||||
const allLists = await getGroceryLists();
|
||||
const pinned = allLists.filter((list) => list.is_pinned);
|
||||
setPinnedLists(pinned);
|
||||
} catch (error) {
|
||||
console.error("Failed to load pinned lists", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleItem = async (listId, itemIndex) => {
|
||||
const list = pinnedLists.find((l) => l.id === listId);
|
||||
if (!list || !list.can_edit) return;
|
||||
|
||||
const updatedItems = [...list.items];
|
||||
const item = updatedItems[itemIndex];
|
||||
|
||||
if (item.startsWith("✓ ")) {
|
||||
updatedItems[itemIndex] = item.substring(2);
|
||||
} else {
|
||||
updatedItems[itemIndex] = "✓ " + item;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateGroceryList(listId, { items: updatedItems });
|
||||
setPinnedLists(
|
||||
pinnedLists.map((l) =>
|
||||
l.id === listId ? { ...l, items: updatedItems } : l
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
onShowToast?.(error.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
if (pinnedLists.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pinned-grocery-lists">
|
||||
{pinnedLists.map((list) => (
|
||||
<div key={list.id} className="pinned-note">
|
||||
<div className="pin-icon">📌</div>
|
||||
<h3 className="note-title">{list.name}</h3>
|
||||
<ul className="note-items">
|
||||
{list.items.length === 0 ? (
|
||||
<li className="empty-note">הרשימה ריקה</li>
|
||||
) : (
|
||||
list.items.map((item, index) => {
|
||||
const isChecked = item.startsWith("✓ ");
|
||||
const itemText = isChecked ? item.substring(2) : item;
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className={`note-item ${isChecked ? "checked" : ""}`}
|
||||
onClick={() =>
|
||||
list.can_edit && handleToggleItem(list.id, index)
|
||||
}
|
||||
style={{ cursor: list.can_edit ? "pointer" : "default" }}
|
||||
>
|
||||
<span className="checkbox">{isChecked ? "☑" : "☐"}</span>
|
||||
<span className="item-text">{itemText}</span>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<style jsx>{`
|
||||
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;700&display=swap');
|
||||
|
||||
.pinned-grocery-lists {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.pinned-note {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #fff9e6 0%, #fffaed 100%);
|
||||
padding: 2rem 1.5rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.1),
|
||||
0 4px 12px rgba(0, 0, 0, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid #f5e6c8;
|
||||
transform: rotate(-1deg);
|
||||
transition: all 0.3s ease;
|
||||
font-family: 'Caveat', cursive;
|
||||
}
|
||||
|
||||
.pinned-note:nth-child(even) {
|
||||
transform: rotate(1deg);
|
||||
background: linear-gradient(135deg, #fff5e1 0%, #fff9eb 100%);
|
||||
}
|
||||
|
||||
.pinned-note:hover {
|
||||
transform: rotate(0deg) scale(1.02);
|
||||
box-shadow:
|
||||
0 4px 8px rgba(0, 0, 0, 0.15),
|
||||
0 8px 20px rgba(0, 0, 0, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.pin-icon {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: 20px;
|
||||
font-size: 2rem;
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.2));
|
||||
transform: rotate(25deg);
|
||||
}
|
||||
|
||||
.note-title {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.8rem;
|
||||
color: #5a4a2a;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
border-bottom: 2px solid rgba(90, 74, 42, 0.2);
|
||||
padding-bottom: 0.5rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.note-items {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-note {
|
||||
text-align: center;
|
||||
color: #9a8a6a;
|
||||
font-size: 1.3rem;
|
||||
padding: 1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
font-size: 1.4rem;
|
||||
color: #4a3a1a;
|
||||
line-height: 1.6;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
color: #2a1a0a;
|
||||
}
|
||||
|
||||
.note-item.checked .item-text {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.item-text {
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Paper texture overlay */
|
||||
.pinned-note::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 31px,
|
||||
rgba(90, 74, 42, 0.03) 31px,
|
||||
rgba(90, 74, 42, 0.03) 32px
|
||||
);
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Light mode specific adjustments */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.pinned-note {
|
||||
background: linear-gradient(135deg, #fffbf0 0%, #fffef8 100%);
|
||||
border-color: #f0e0c0;
|
||||
}
|
||||
|
||||
.pinned-note:nth-child(even) {
|
||||
background: linear-gradient(135deg, #fff8e8 0%, #fffcf3 100%);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PinnedGroceryLists;
|
||||
@ -1,302 +0,0 @@
|
||||
.ratings-comments-container {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 2px solid #eee;
|
||||
}
|
||||
|
||||
.ratings-section,
|
||||
.comments-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.ratings-section h3,
|
||||
.comments-section h3 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.ratings-loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Ratings */
|
||||
.rating-display {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.avg-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.rating-number {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.stars-display {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stars-display .star {
|
||||
font-size: 1.5rem;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.stars-display .star.filled {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.rating-count {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.no-ratings {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.my-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stars-input {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stars-input .star {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 2rem;
|
||||
color: #ddd;
|
||||
transition: color 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stars-input .star.filled {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.stars-input .star:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Comments */
|
||||
.comment-form {
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.reply-indicator {
|
||||
padding: 0.5rem;
|
||||
background-color: #e7f3ff;
|
||||
border-left: 3px solid #007bff;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reply-indicator button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #007bff;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.comment-form textarea {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.comment-form button[type="submit"] {
|
||||
align-self: flex-end;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.comment-form button[type="submit"]:hover:not(:disabled) {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.comment-form button[type="submit"]:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.comments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.no-comments {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.comment {
|
||||
padding: 1rem;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.comment.reply {
|
||||
margin-left: 2rem;
|
||||
background-color: #f8f9fa;
|
||||
border-left: 3px solid #007bff;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.comment-date {
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.comment-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #007bff;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.comment-actions button:hover {
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
.comment-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.comment-edit-form textarea {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.comment-edit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.comment-edit-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.comment-edit-actions button:first-child {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.comment-edit-actions button:first-child:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
.comment-edit-actions button:last-child {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.comment-edit-actions button:last-child:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ratings-comments-container {
|
||||
border-top-color: #555;
|
||||
}
|
||||
|
||||
.my-rating {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.comment-form textarea {
|
||||
background-color: #333;
|
||||
color: white;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.comment {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.comment.reply {
|
||||
background-color: #1a3a52;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.comment-edit-form textarea {
|
||||
background-color: #333;
|
||||
color: white;
|
||||
border-color: #555;
|
||||
}
|
||||
}
|
||||
@ -1,267 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
rateRecipe,
|
||||
getRecipeRatingStats,
|
||||
getMyRecipeRating,
|
||||
addComment,
|
||||
getRecipeComments,
|
||||
updateComment,
|
||||
deleteComment,
|
||||
} from "../socialApi";
|
||||
import "./RatingsComments.css";
|
||||
|
||||
export default function RatingsComments({ recipeId, isAuthenticated, showToast }) {
|
||||
const [ratingStats, setRatingStats] = useState(null);
|
||||
const [myRating, setMyRating] = useState(0);
|
||||
const [hoverRating, setHoverRating] = useState(0);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [replyTo, setReplyTo] = useState(null);
|
||||
const [editingComment, setEditingComment] = useState(null);
|
||||
const [editContent, setEditContent] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [recipeId]);
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const statsPromise = getRecipeRatingStats(recipeId);
|
||||
const commentsPromise = getRecipeComments(recipeId);
|
||||
|
||||
const [stats, commentsData] = await Promise.all([statsPromise, commentsPromise]);
|
||||
|
||||
setRatingStats(stats);
|
||||
setComments(commentsData);
|
||||
|
||||
if (isAuthenticated) {
|
||||
try {
|
||||
const myRatingData = await getMyRecipeRating(recipeId);
|
||||
setMyRating(myRatingData.rating || 0);
|
||||
} catch (error) {
|
||||
// User hasn't rated yet
|
||||
setMyRating(0);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRate(rating) {
|
||||
if (!isAuthenticated) {
|
||||
showToast("נא להתחבר כדי לדרג", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rateRecipe(recipeId, rating);
|
||||
setMyRating(rating);
|
||||
showToast("דירוג נשלח בהצלחה!", "success");
|
||||
// Reload stats
|
||||
const stats = await getRecipeRatingStats(recipeId);
|
||||
setRatingStats(stats);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddComment(e) {
|
||||
e.preventDefault();
|
||||
if (!isAuthenticated) {
|
||||
showToast("נא להתחבר כדי להגיב", "error");
|
||||
return;
|
||||
}
|
||||
if (!newComment.trim()) return;
|
||||
|
||||
try {
|
||||
await addComment(recipeId, newComment, replyTo);
|
||||
setNewComment("");
|
||||
setReplyTo(null);
|
||||
showToast("תגובה נוספה!", "success");
|
||||
const commentsData = await getRecipeComments(recipeId);
|
||||
setComments(commentsData);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditComment(commentId, content) {
|
||||
try {
|
||||
await updateComment(commentId, content);
|
||||
setEditingComment(null);
|
||||
setEditContent("");
|
||||
showToast("תגובה עודכנה!", "success");
|
||||
const commentsData = await getRecipeComments(recipeId);
|
||||
setComments(commentsData);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteComment(commentId) {
|
||||
if (!confirm("למחוק תגובה זו?")) return;
|
||||
|
||||
try {
|
||||
await deleteComment(commentId);
|
||||
showToast("תגובה נמחקה", "info");
|
||||
const commentsData = await getRecipeComments(recipeId);
|
||||
setComments(commentsData);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing(comment) {
|
||||
setEditingComment(comment.comment_id);
|
||||
setEditContent(comment.content);
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
setEditingComment(null);
|
||||
setEditContent("");
|
||||
}
|
||||
|
||||
function startReply(commentId) {
|
||||
setReplyTo(commentId);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="ratings-loading">טוען...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ratings-comments-container">
|
||||
{/* Ratings Section */}
|
||||
<div className="ratings-section">
|
||||
<h3>דירוג</h3>
|
||||
<div className="rating-display">
|
||||
{ratingStats && ratingStats.total_ratings > 0 ? (
|
||||
<>
|
||||
<div className="avg-rating">
|
||||
<span className="rating-number">{ratingStats.average_rating.toFixed(1)}</span>
|
||||
<div className="stars-display">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<span
|
||||
key={star}
|
||||
className={star <= Math.round(ratingStats.average_rating) ? "star filled" : "star"}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="rating-count">({ratingStats.total_ratings} דירוגים)</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="no-ratings">אין דירוגים עדיין</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAuthenticated && (
|
||||
<div className="my-rating">
|
||||
<span>הדירוג שלך:</span>
|
||||
<div className="stars-input">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
className={star <= (hoverRating || myRating) ? "star filled" : "star"}
|
||||
onMouseEnter={() => setHoverRating(star)}
|
||||
onMouseLeave={() => setHoverRating(0)}
|
||||
onClick={() => handleRate(star)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments Section */}
|
||||
<div className="comments-section">
|
||||
<h3>תגובות ({comments.length})</h3>
|
||||
|
||||
{isAuthenticated && (
|
||||
<form onSubmit={handleAddComment} className="comment-form">
|
||||
{replyTo && (
|
||||
<div className="reply-indicator">
|
||||
משיב לתגובה...{" "}
|
||||
<button type="button" onClick={() => setReplyTo(null)}>
|
||||
ביטול
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="הוסף תגובה..."
|
||||
rows="3"
|
||||
/>
|
||||
<button type="submit" disabled={!newComment.trim()}>
|
||||
פרסם תגובה
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="comments-list">
|
||||
{comments.length === 0 ? (
|
||||
<p className="no-comments">אין תגובות עדיין. היה הראשון!</p>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<div
|
||||
key={comment.comment_id}
|
||||
className={`comment ${comment.parent_comment_id ? "reply" : ""}`}
|
||||
>
|
||||
<div className="comment-header">
|
||||
<span className="comment-author">{comment.author_username || comment.author_email}</span>
|
||||
<span className="comment-date">
|
||||
{new Date(comment.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editingComment === comment.comment_id ? (
|
||||
<div className="comment-edit-form">
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
rows="3"
|
||||
/>
|
||||
<div className="comment-edit-actions">
|
||||
<button onClick={() => handleEditComment(comment.comment_id, editContent)}>
|
||||
שמור
|
||||
</button>
|
||||
<button onClick={cancelEditing}>ביטול</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="comment-content">{comment.content}</div>
|
||||
<div className="comment-actions">
|
||||
{isAuthenticated && !comment.parent_comment_id && (
|
||||
<button onClick={() => startReply(comment.comment_id)}>השב</button>
|
||||
)}
|
||||
{isAuthenticated && comment.is_author && (
|
||||
<>
|
||||
<button onClick={() => startEditing(comment)}>ערוך</button>
|
||||
<button onClick={() => handleDeleteComment(comment.comment_id)}>
|
||||
מחק
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import placeholderImage from "../assets/placeholder.svg";
|
||||
import RatingsComments from "./RatingsComments";
|
||||
|
||||
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal, isAuthenticated, currentUser, showToast }) {
|
||||
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) {
|
||||
if (!recipe) {
|
||||
return (
|
||||
<section className="panel placeholder">
|
||||
@ -14,15 +13,6 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal,
|
||||
onShowDeleteModal(recipe.id, recipe.name);
|
||||
};
|
||||
|
||||
// Debug ownership check
|
||||
console.log('Recipe ownership check:', {
|
||||
recipeUserId: recipe.user_id,
|
||||
recipeUserIdType: typeof recipe.user_id,
|
||||
currentUserId: currentUser?.id,
|
||||
currentUserIdType: typeof currentUser?.id,
|
||||
isEqual: recipe.user_id === currentUser?.id
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="panel recipe-card">
|
||||
{/* Recipe Image */}
|
||||
@ -36,8 +26,8 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal,
|
||||
<p className="recipe-subtitle">
|
||||
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
|
||||
</p>
|
||||
{(recipe.owner_display_name || recipe.made_by) && (
|
||||
<h4 className="recipe-made-by">המתכון של: {recipe.owner_display_name || recipe.made_by}</h4>
|
||||
{recipe.made_by && (
|
||||
<h4 className="recipe-made-by">המתכון של: {recipe.made_by}</h4>
|
||||
)}
|
||||
</div>
|
||||
<div className="pill-row">
|
||||
@ -76,7 +66,6 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal,
|
||||
</footer>
|
||||
)}
|
||||
|
||||
{isAuthenticated && currentUser && (Number(recipe.user_id) === Number(currentUser.id) || currentUser.is_admin) && (
|
||||
<div className="recipe-actions">
|
||||
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
|
||||
✏️ ערוך
|
||||
@ -85,14 +74,6 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal,
|
||||
🗑 מחק
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ratings and Comments */}
|
||||
<RatingsComments
|
||||
recipeId={recipe.id}
|
||||
isAuthenticated={isAuthenticated}
|
||||
showToast={showToast}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -106,7 +87,7 @@ function translateMealType(type) {
|
||||
case "dinner":
|
||||
return "ערב";
|
||||
case "snack":
|
||||
return "קינוחים";
|
||||
return "נשנוש";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
|
||||
@ -148,7 +148,7 @@ function translateMealType(type) {
|
||||
case "dinner":
|
||||
return "ערב";
|
||||
case "snack":
|
||||
return "קינוחים";
|
||||
return "נשנוש";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
|
||||
@ -1,20 +1,16 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, currentUser = null }) {
|
||||
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
const [name, setName] = useState("");
|
||||
const [mealType, setMealType] = useState("lunch");
|
||||
const [timeMinutes, setTimeMinutes] = useState(15);
|
||||
const [madeBy, setMadeBy] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [image, setImage] = useState("");
|
||||
const [visibility, setVisibility] = useState("public");
|
||||
|
||||
const [ingredients, setIngredients] = useState([""]);
|
||||
const [steps, setSteps] = useState([""]);
|
||||
|
||||
const lastIngredientRef = useRef(null);
|
||||
const lastStepRef = useRef(null);
|
||||
|
||||
const isEditMode = !!editingRecipe;
|
||||
|
||||
useEffect(() => {
|
||||
@ -24,33 +20,27 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
setMealType(editingRecipe.meal_type || "lunch");
|
||||
setTimeMinutes(editingRecipe.time_minutes || 15);
|
||||
setMadeBy(editingRecipe.made_by || "");
|
||||
setTags((editingRecipe.tags || []).join(" "));
|
||||
setTags((editingRecipe.tags || []).join(", "));
|
||||
setImage(editingRecipe.image || "");
|
||||
setVisibility(editingRecipe.visibility || "public");
|
||||
setIngredients(editingRecipe.ingredients || [""]);
|
||||
setSteps(editingRecipe.steps || [""]);
|
||||
} else {
|
||||
setName("");
|
||||
setMealType("lunch");
|
||||
setTimeMinutes(15);
|
||||
setMadeBy(currentUser?.username || "");
|
||||
setMadeBy("");
|
||||
setTags("");
|
||||
setImage("");
|
||||
setVisibility("public");
|
||||
setIngredients([""]);
|
||||
setSteps([""]);
|
||||
}
|
||||
}
|
||||
}, [open, editingRecipe, isEditMode, currentUser]);
|
||||
}, [open, editingRecipe, isEditMode]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleAddIngredient = () => {
|
||||
setIngredients((prev) => [...prev, ""]);
|
||||
setTimeout(() => {
|
||||
lastIngredientRef.current?.focus();
|
||||
lastIngredientRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleChangeIngredient = (idx, value) => {
|
||||
@ -63,10 +53,6 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
|
||||
const handleAddStep = () => {
|
||||
setSteps((prev) => [...prev, ""]);
|
||||
setTimeout(() => {
|
||||
lastStepRef.current?.focus();
|
||||
lastStepRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleChangeStep = (idx, value) => {
|
||||
@ -98,7 +84,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
const cleanIngredients = ingredients.map((s) => s.trim()).filter(Boolean);
|
||||
const cleanSteps = steps.map((s) => s.trim()).filter(Boolean);
|
||||
const tagsArr = tags
|
||||
.split(" ")
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
@ -109,10 +95,12 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
tags: tagsArr,
|
||||
ingredients: cleanIngredients,
|
||||
steps: cleanSteps,
|
||||
made_by: madeBy.trim() || currentUser?.username || "",
|
||||
visibility: visibility,
|
||||
};
|
||||
|
||||
if (madeBy.trim()) {
|
||||
payload.made_by = madeBy.trim();
|
||||
}
|
||||
|
||||
if (image) {
|
||||
payload.image = image;
|
||||
}
|
||||
@ -148,20 +136,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
<option value="breakfast">בוקר</option>
|
||||
<option value="lunch">צהריים</option>
|
||||
<option value="dinner">ערב</option>
|
||||
<option value="snack">קינוחים</option>
|
||||
<option value="snack">נשנוש</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>מי יכול לראות?</label>
|
||||
<select value={visibility} onChange={(e) => setVisibility(e.target.value)}>
|
||||
<option value="public">ציבורי - כולם</option>
|
||||
<option value="friends">חברים בלבד</option>
|
||||
<option value="private">פרטי - רק אני</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>זמן הכנה (דקות)</label>
|
||||
<input
|
||||
@ -172,6 +150,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>המתכון של:</label>
|
||||
@ -212,11 +191,11 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>תגיות (מופרד ברווחים)</label>
|
||||
<label>תגיות (מופרד בפסיקים)</label>
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="מהיר טבעוני משפחתי..."
|
||||
placeholder="מהיר, טבעוני, משפחתי..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -226,7 +205,6 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
{ingredients.map((val, idx) => (
|
||||
<div key={idx} className="dynamic-row">
|
||||
<input
|
||||
ref={idx === ingredients.length - 1 ? lastIngredientRef : null}
|
||||
value={val}
|
||||
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
|
||||
placeholder="למשל: 2 ביצים"
|
||||
@ -256,7 +234,6 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
{steps.map((val, idx) => (
|
||||
<div key={idx} className="dynamic-row">
|
||||
<input
|
||||
ref={idx === steps.length - 1 ? lastStepRef : null}
|
||||
value={val}
|
||||
onChange={(e) => handleChangeStep(idx, e.target.value)}
|
||||
placeholder="למשל: לחמם את התנור ל־180 מעלות"
|
||||
|
||||
@ -42,7 +42,7 @@ function translateMealType(type) {
|
||||
case "dinner":
|
||||
return "ערב";
|
||||
case "snack":
|
||||
return "קינוחים";
|
||||
return "נשנוש";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
|
||||
@ -14,8 +14,8 @@ function RecipeSearchList({
|
||||
onMaxTimeChange,
|
||||
filterTags,
|
||||
onTagsChange,
|
||||
filterOwner,
|
||||
onOwnerChange,
|
||||
filterMadeBy,
|
||||
onMadeByChange,
|
||||
}) {
|
||||
const [expandFilters, setExpandFilters] = useState(false);
|
||||
|
||||
@ -27,14 +27,8 @@ function RecipeSearchList({
|
||||
// Extract unique meal types from ALL recipes (not filtered)
|
||||
const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort();
|
||||
|
||||
// Extract unique made_by (username) from ALL recipes and map to display names
|
||||
const madeByMap = new Map();
|
||||
allRecipes.forEach((r) => {
|
||||
if (r.made_by && r.owner_display_name) {
|
||||
madeByMap.set(r.made_by, r.owner_display_name);
|
||||
}
|
||||
});
|
||||
const allMadeBy = Array.from(madeByMap.keys()).sort();
|
||||
// Extract unique made_by from ALL recipes (not filtered)
|
||||
const allMadeBy = Array.from(new Set(allRecipes.map((r) => r.made_by).filter(Boolean))).sort();
|
||||
|
||||
// Extract max time for slider from ALL recipes (not filtered) - add 10 to make it comfortable to select max time recipes
|
||||
const maxTimeInRecipes = Math.max(...allRecipes.map((r) => r.time_minutes), 0);
|
||||
@ -53,10 +47,10 @@ function RecipeSearchList({
|
||||
onMealTypeChange("");
|
||||
onMaxTimeChange("");
|
||||
onTagsChange([]);
|
||||
onOwnerChange("");
|
||||
onMadeByChange("");
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterOwner;
|
||||
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterMadeBy;
|
||||
|
||||
return (
|
||||
<section className="panel secondary recipe-search-list">
|
||||
@ -171,18 +165,18 @@ function RecipeSearchList({
|
||||
<label className="filter-label">המתכונים של:</label>
|
||||
<div className="filter-options">
|
||||
<button
|
||||
className={`filter-btn ${filterOwner === "" ? "active" : ""}`}
|
||||
onClick={() => onOwnerChange("")}
|
||||
className={`filter-btn ${filterMadeBy === "" ? "active" : ""}`}
|
||||
onClick={() => onMadeByChange("")}
|
||||
>
|
||||
הכל
|
||||
</button>
|
||||
{allMadeBy.map((madeBy) => (
|
||||
{allMadeBy.map((person) => (
|
||||
<button
|
||||
key={madeBy}
|
||||
className={`filter-btn ${filterOwner === madeBy ? "active" : ""}`}
|
||||
onClick={() => onOwnerChange(madeBy)}
|
||||
key={person}
|
||||
className={`filter-btn ${filterMadeBy === person ? "active" : ""}`}
|
||||
onClick={() => onMadeByChange(person)}
|
||||
>
|
||||
{madeByMap.get(madeBy) || madeBy}
|
||||
{person}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -245,7 +239,7 @@ function translateMealType(type) {
|
||||
case "dinner":
|
||||
return "ערב";
|
||||
case "snack":
|
||||
return "קינוחים";
|
||||
return "נשנוש";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
|
||||
@ -1,164 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { register, login, saveToken } from "../authApi";
|
||||
|
||||
function Register({ onSuccess, onSwitchToLogin }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Validation
|
||||
if (password !== confirmPassword) {
|
||||
setError("הסיסמאות אינן תואמות");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("הסיסמה חייבת להכיל לפחות 6 תווים");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!displayName.trim()) {
|
||||
setError("שם תצוגה הוא שדה חובה");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Register the user
|
||||
await register(username, email, password, firstName, lastName, displayName);
|
||||
|
||||
// Automatically login after successful registration
|
||||
const response = await login(username, password);
|
||||
saveToken(response.access_token);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">הרשמה</h1>
|
||||
<p className="auth-subtitle">צור חשבון חדש והתחל לנהל את המתכונים שלך</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="field">
|
||||
<label>שם פרטי</label>
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder="שם פרטי (אופציונלי)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>שם משפחה</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
placeholder="שם משפחה (אופציונלי)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>שם תצוגה *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
required
|
||||
placeholder="איך תרצה שיופיע שמך?"
|
||||
minLength={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>שם משתמש * (אנגלית בלבד)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
placeholder="username (English only)"
|
||||
autoComplete="username"
|
||||
minLength={3}
|
||||
pattern="[a-zA-Z0-9_-]+"
|
||||
title="שם משתמש יכול להכיל רק אותיות באנגלית, מספרים, _ ו-"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>אימייל *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>סיסמה *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="בחר סיסמה חזקה"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>אימות סיסמה *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder="הזן סיסמה שוב"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn primary full-width" disabled={loading}>
|
||||
{loading ? "נרשם..." : "הירשם"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-footer">
|
||||
<p>
|
||||
כבר יש לך חשבון?{" "}
|
||||
<button className="link-btn" onClick={onSwitchToLogin}>
|
||||
התחבר
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Register;
|
||||
@ -1,108 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getApiBase } from "../api";
|
||||
|
||||
function ResetPassword({ token, onSuccess, onBack }) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("הסיסמאות אינן תואמות");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("הסיסמה חייבת להכיל לפחות 6 תווים");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/reset-password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: token,
|
||||
new_password: password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
onSuccess();
|
||||
} else {
|
||||
setError(data.detail || "שגיאה באיפוס הסיסמה");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("שגיאה באיפוס הסיסמה");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">איפוס סיסמה</h1>
|
||||
<p className="auth-subtitle">הזן את הסיסמה החדשה שלך</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="field">
|
||||
<label>סיסמה חדשה</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="הזן סיסמה חדשה"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>אימות סיסמה</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder="הזן שוב את הסיסמה"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary full-width"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "מאפס..." : "איפוס סיסמה"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-footer">
|
||||
<p>
|
||||
<button className="link-btn" onClick={onBack}>
|
||||
חזור להתחברות
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResetPassword;
|
||||
@ -1,6 +1,4 @@
|
||||
import NotificationBell from "./NotificationBell";
|
||||
|
||||
function TopBar({ onAddClick, user, onLogout, onShowToast }) {
|
||||
function TopBar({ onAddClick }) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
@ -13,20 +11,10 @@ function TopBar({ onAddClick, user, onLogout, onShowToast }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
{user && <NotificationBell onShowToast={onShowToast} />}
|
||||
{user && (
|
||||
<button className="btn primary btn-mobile-compact" onClick={onAddClick}>
|
||||
<span className="btn-text-desktop">+ מתכון חדש</span>
|
||||
<span className="btn-text-mobile">+</span>
|
||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||
<button className="btn primary" onClick={onAddClick}>
|
||||
+ מתכון חדש
|
||||
</button>
|
||||
)}
|
||||
{onLogout && (
|
||||
<button className="btn ghost btn-mobile-compact" onClick={onLogout}>
|
||||
<span className="btn-text-desktop">יציאה</span>
|
||||
<span className="btn-text-mobile">↩️</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@ -1,131 +0,0 @@
|
||||
const API_URL = window.__ENV__?.API_BASE || window.ENV?.VITE_API_URL || "http://192.168.1.100:8000";
|
||||
|
||||
// Get auth token from localStorage
|
||||
const getAuthHeaders = () => {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
};
|
||||
};
|
||||
|
||||
// Get all grocery lists
|
||||
export const getGroceryLists = async () => {
|
||||
const res = await fetch(`${API_URL}/grocery-lists`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch grocery lists");
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// Create a new grocery list
|
||||
export const createGroceryList = async (data) => {
|
||||
const res = await fetch(`${API_URL}/grocery-lists`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.detail || "Failed to create grocery list");
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// Get a specific grocery list
|
||||
export const getGroceryList = async (id) => {
|
||||
const res = await fetch(`${API_URL}/grocery-lists/${id}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch grocery list");
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// Update a grocery list
|
||||
export const updateGroceryList = async (id, data) => {
|
||||
const res = await fetch(`${API_URL}/grocery-lists/${id}`, {
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.detail || "Failed to update grocery list");
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// Delete a grocery list
|
||||
export const deleteGroceryList = async (id) => {
|
||||
const res = await fetch(`${API_URL}/grocery-lists/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.detail || "Failed to delete grocery list");
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle pin status for a grocery list
|
||||
export const togglePinGroceryList = async (id) => {
|
||||
const res = await fetch(`${API_URL}/grocery-lists/${id}/pin`, {
|
||||
method: "PATCH",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let errorMessage = "Failed to toggle pin status";
|
||||
try {
|
||||
const error = await res.json();
|
||||
errorMessage = error.detail || errorMessage;
|
||||
} catch (e) {
|
||||
errorMessage = `HTTP ${res.status}: ${res.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// Share a grocery list
|
||||
export const shareGroceryList = async (listId, data) => {
|
||||
const res = await fetch(`${API_URL}/grocery-lists/${listId}/share`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.detail || "Failed to share grocery list");
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// Get grocery list shares
|
||||
export const getGroceryListShares = async (listId) => {
|
||||
const res = await fetch(`${API_URL}/grocery-lists/${listId}/shares`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch shares");
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// Unshare a grocery list
|
||||
export const unshareGroceryList = async (listId, userId) => {
|
||||
const res = await fetch(`${API_URL}/grocery-lists/${listId}/shares/${userId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.detail || "Failed to unshare grocery list");
|
||||
}
|
||||
};
|
||||
|
||||
// Search users
|
||||
export const searchUsers = async (query) => {
|
||||
const res = await fetch(`${API_URL}/users/search?q=${encodeURIComponent(query)}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to search users");
|
||||
return res.json();
|
||||
};
|
||||
@ -1,77 +0,0 @@
|
||||
const API_BASE_URL = window.__ENV__?.API_BASE || window._env_?.VITE_API_URL || import.meta.env.VITE_API_URL || "http://192.168.1.100:8000";
|
||||
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getNotifications(unreadOnly = false) {
|
||||
const url = `${API_BASE_URL}/notifications${unreadOnly ? '?unread_only=true' : ''}`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const errorData = await response.json().catch(() => ({ detail: "Failed to fetch notifications" }));
|
||||
throw new Error(errorData.detail || "Failed to fetch notifications");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function markNotificationAsRead(notificationId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}/read`, {
|
||||
method: "PATCH",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: "Failed to mark notification as read" }));
|
||||
throw new Error(errorData.detail || "Failed to mark notification as read");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
// If it's a network error (fetch failed), throw a more specific error
|
||||
if (error.message === "Failed to fetch" || error.name === "TypeError") {
|
||||
throw new Error("Network error: Unable to connect to server");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAllNotificationsAsRead() {
|
||||
const response = await fetch(`${API_BASE_URL}/notifications/read-all`, {
|
||||
method: "PATCH",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: "Failed to mark all notifications as read" }));
|
||||
throw new Error(errorData.detail || "Failed to mark all notifications as read");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteNotification(notificationId) {
|
||||
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: "Failed to delete notification" }));
|
||||
throw new Error(errorData.detail || "Failed to delete notification");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@ -1,312 +0,0 @@
|
||||
import { getApiBase } from "./api";
|
||||
import { getToken } from "./authApi";
|
||||
|
||||
const API_BASE = getApiBase();
|
||||
|
||||
// ============= Friends API =============
|
||||
|
||||
export async function sendFriendRequest(receiverId) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/friends/request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ receiver_id: receiverId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.detail || "Failed to send friend request");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getFriendRequests() {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/friends/requests`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch friend requests");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function acceptFriendRequest(requestId) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/friends/requests/${requestId}/accept`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to accept friend request");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function rejectFriendRequest(requestId) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/friends/requests/${requestId}/reject`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to reject friend request");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getFriends() {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/friends`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch friends");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function removeFriend(friendId) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/friends/${friendId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to remove friend");
|
||||
}
|
||||
|
||||
export async function searchUsers(query) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/friends/search?q=${encodeURIComponent(query)}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to search users");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ============= Chat API =============
|
||||
|
||||
export async function getConversations() {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/conversations`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch conversations");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createConversation(userIds, isGroup = false, name = null) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/conversations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ user_ids: userIds, is_group: isGroup, name }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
console.error("Create conversation error:", error);
|
||||
throw new Error(error.detail || JSON.stringify(error) || "Failed to create conversation");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getMessages(conversationId, limit = 50, beforeId = null) {
|
||||
const token = getToken();
|
||||
let url = `${API_BASE}/conversations/${conversationId}/messages?limit=${limit}`;
|
||||
if (beforeId) url += `&before_id=${beforeId}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch messages");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function sendMessage(conversationId, content) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/conversations/${conversationId}/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to send message");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ============= Groups API =============
|
||||
|
||||
export async function getGroups() {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/groups`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch groups");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createGroup(name, description = "", isPrivate = false) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/groups`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ name, description, is_private: isPrivate }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create group");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getGroup(groupId) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/groups/${groupId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch group");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function addGroupMember(groupId, userId) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/groups/${groupId}/members/${userId}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to add member");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function removeGroupMember(groupId, userId) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/groups/${groupId}/members/${userId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to remove member");
|
||||
}
|
||||
|
||||
export async function shareRecipeToGroup(groupId, recipeId) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/groups/${groupId}/recipes/${recipeId}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to share recipe");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getGroupRecipes(groupId) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/groups/${groupId}/recipes`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch group recipes");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ============= Ratings & Comments API =============
|
||||
|
||||
export async function rateRecipe(recipeId, rating) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/recipes/${recipeId}/rating`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ rating }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to rate recipe");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getRecipeRatingStats(recipeId) {
|
||||
const res = await fetch(`${API_BASE}/recipes/${recipeId}/rating/stats`);
|
||||
if (!res.ok) throw new Error("Failed to fetch rating stats");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getMyRecipeRating(recipeId) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/recipes/${recipeId}/rating/mine`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch my rating");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function addComment(recipeId, content, parentCommentId = null) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/recipes/${recipeId}/comments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ content, parent_comment_id: parentCommentId }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to add comment");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getRecipeComments(recipeId) {
|
||||
const res = await fetch(`${API_BASE}/recipes/${recipeId}/comments`);
|
||||
if (!res.ok) throw new Error("Failed to fetch comments");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateComment(commentId, content) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/comments/${commentId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update comment");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteComment(commentId) {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}/comments/${commentId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete comment");
|
||||
}
|
||||
@ -5,14 +5,4 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
assetsInclude: ['**/*.svg'],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user