Compare commits
33 Commits
master
...
social-net
| Author | SHA1 | Date | |
|---|---|---|---|
| c3782810bf | |||
| 9b95ba95b8 | |||
| 3270788902 | |||
| 70f8ce1a6b | |||
| 1d04352ed7 | |||
| 2fcbcaa302 | |||
| a78053474b | |||
| ae9349ca7e | |||
| 0afe014947 | |||
|
|
1da5dc0a30 | ||
| ba7d0c9121 | |||
|
|
b70411e1f1 | ||
| 01369a743d | |||
| 7c6703354e | |||
| 080977cdb7 | |||
| df7510da2e | |||
| e1515442f4 | |||
|
|
6d5b8f2314 | ||
|
|
5841e7b9d4 | ||
|
|
b2877877dd | ||
|
|
0f3aa43b89 | ||
|
|
e0b3102007 | ||
|
|
a5d87b8e25 | ||
|
|
22639a489a | ||
| 8d81d16682 | |||
| fa5ba578bb | |||
| 53ca792988 | |||
| e160357256 | |||
| c912663c3d | |||
| 66d2aa0a66 | |||
| 1d33e52100 | |||
| b35100c92f | |||
| 81acc68aaa |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
my-recipes/
|
||||||
|
my-recipes-chart/
|
||||||
@ -63,7 +63,7 @@ steps:
|
|||||||
- |
|
- |
|
||||||
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||||
echo "💡 Setting frontend tag to: $TAG"
|
echo "💡 Setting frontend tag to: $TAG"
|
||||||
yq -i ".frontend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
yq -i ".frontend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||||
git add 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 commit -m "frontend: update tag to $TAG" || echo "No changes"
|
||||||
git push origin HEAD
|
git push origin HEAD
|
||||||
@ -93,7 +93,7 @@ steps:
|
|||||||
- |
|
- |
|
||||||
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||||
echo "💡 Setting backend tag to: $TAG"
|
echo "💡 Setting backend tag to: $TAG"
|
||||||
yq -i ".backend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
yq -i ".backend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||||
git add 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 commit -m "backend: update tag to $TAG" || echo "No changes"
|
||||||
git push origin HEAD
|
git push origin HEAD
|
||||||
|
|||||||
19
backend/.env
19
backend/.env
@ -4,3 +4,22 @@ DB_USER=recipes_user
|
|||||||
DB_NAME=recipes_db
|
DB_NAME=recipes_db
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
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
|
||||||
28
backend/.env.local
Normal file
28
backend/.env.local
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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
Normal file
1
backend/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
21
backend/MIGRATION_INSTRUCTIONS.md
Normal file
21
backend/MIGRATION_INSTRUCTIONS.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# 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.
|
||||||
BIN
backend/__pycache__/auth_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/auth_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/auth_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/auth_utils.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/__pycache__/email_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/email_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/grocery_db_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/grocery_db_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/grocery_db_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/grocery_db_utils.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/__pycache__/notification_db_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/notification_db_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/notification_db_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/notification_db_utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/oauth_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/oauth_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/user_db_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/user_db_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/user_db_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/user_db_utils.cpython-313.pyc
Normal file
Binary file not shown.
30
backend/add_auth_provider_column.sql
Normal file
30
backend/add_auth_provider_column.sql
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
-- 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;
|
||||||
5
backend/add_is_pinned_column.sql
Normal file
5
backend/add_is_pinned_column.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- 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
|
||||||
127
backend/add_social_features.sql
Normal file
127
backend/add_social_features.sql
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
-- Add social networking features to recipes database
|
||||||
|
|
||||||
|
-- Add visibility column to recipes table
|
||||||
|
ALTER TABLE recipes
|
||||||
|
ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) DEFAULT 'public' CHECK (visibility IN ('public', 'private', 'friends', 'groups'));
|
||||||
|
|
||||||
|
-- Create friendships table
|
||||||
|
CREATE TABLE IF NOT EXISTS friendships (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
friend_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, friend_id),
|
||||||
|
CHECK (user_id != friend_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create friend_requests table
|
||||||
|
CREATE TABLE IF NOT EXISTS friend_requests (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
receiver_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(sender_id, receiver_id),
|
||||||
|
CHECK (sender_id != receiver_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create conversations table
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100),
|
||||||
|
is_group BOOLEAN DEFAULT FALSE,
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create conversation_members table
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(conversation_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create messages table
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
edited_at TIMESTAMP,
|
||||||
|
is_deleted BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create recipe_ratings table
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_ratings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(recipe_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create recipe_comments table
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_comments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
parent_comment_id INTEGER REFERENCES recipe_comments(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_deleted BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create groups table
|
||||||
|
CREATE TABLE IF NOT EXISTS groups (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
is_private BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create group_members table
|
||||||
|
CREATE TABLE IF NOT EXISTS group_members (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(20) DEFAULT 'member' CHECK (role IN ('admin', 'moderator', 'member')),
|
||||||
|
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(group_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create recipe_shares table (for sharing recipes to specific groups)
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_shares (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
shared_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(recipe_id, group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_friendships_user_id ON friendships(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_friendships_friend_id ON friendships(friend_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_friend_requests_receiver ON friend_requests(receiver_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_friend_requests_sender ON friend_requests(sender_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversation_members_user ON conversation_members(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipe_ratings_recipe ON recipe_ratings(recipe_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipe_comments_recipe ON recipe_comments(recipe_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipe_shares_group ON recipe_shares(group_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipes_visibility ON recipes(visibility);
|
||||||
97
backend/auth_utils.py
Normal file
97
backend/auth_utils.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
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
|
||||||
239
backend/chat_db_utils.py
Normal file
239
backend/chat_db_utils.py
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
"""Get database connection"""
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=os.getenv("DB_HOST", "localhost"),
|
||||||
|
port=int(os.getenv("DB_PORT", "5432")),
|
||||||
|
database=os.getenv("DB_NAME", "recipes_db"),
|
||||||
|
user=os.getenv("DB_USER", "recipes_user"),
|
||||||
|
password=os.getenv("DB_PASSWORD", "recipes_password"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============= Conversations & Messages =============
|
||||||
|
|
||||||
|
def create_conversation(user_ids: List[int], is_group: bool = False, name: Optional[str] = None, created_by: int = None):
|
||||||
|
"""Create a new conversation"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# For private chats, check if conversation already exists
|
||||||
|
if not is_group and len(user_ids) == 2:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT c.id FROM conversations c
|
||||||
|
JOIN conversation_members cm1 ON c.id = cm1.conversation_id
|
||||||
|
JOIN conversation_members cm2 ON c.id = cm2.conversation_id
|
||||||
|
WHERE c.is_group = FALSE
|
||||||
|
AND cm1.user_id = %s AND cm2.user_id = %s
|
||||||
|
""",
|
||||||
|
(user_ids[0], user_ids[1])
|
||||||
|
)
|
||||||
|
existing = cur.fetchone()
|
||||||
|
if existing:
|
||||||
|
return get_conversation(existing["id"])
|
||||||
|
|
||||||
|
# Create conversation
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO conversations (name, is_group, created_by)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
RETURNING id, name, is_group, created_by, created_at
|
||||||
|
""",
|
||||||
|
(name, is_group, created_by)
|
||||||
|
)
|
||||||
|
conversation = dict(cur.fetchone())
|
||||||
|
conversation_id = conversation["id"]
|
||||||
|
|
||||||
|
# Add members
|
||||||
|
for user_id in user_ids:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO conversation_members (conversation_id, user_id) VALUES (%s, %s)",
|
||||||
|
(conversation_id, user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Return conversation with conversation_id field
|
||||||
|
conversation["conversation_id"] = conversation["id"]
|
||||||
|
return conversation
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversation(conversation_id: int):
|
||||||
|
"""Get conversation details"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, name, is_group, created_by, created_at FROM conversations WHERE id = %s",
|
||||||
|
(conversation_id,)
|
||||||
|
)
|
||||||
|
conversation = cur.fetchone()
|
||||||
|
if not conversation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get members
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.id, u.username, u.display_name
|
||||||
|
FROM conversation_members cm
|
||||||
|
JOIN users u ON u.id = cm.user_id
|
||||||
|
WHERE cm.conversation_id = %s
|
||||||
|
""",
|
||||||
|
(conversation_id,)
|
||||||
|
)
|
||||||
|
members = [dict(row) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
result = dict(conversation)
|
||||||
|
result["members"] = members
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_conversations(user_id: int):
|
||||||
|
"""Get all conversations for a user"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT c.id AS conversation_id, c.name, c.is_group, c.created_at,
|
||||||
|
(SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id AND m.created_at > cm.last_read_at) AS unread_count,
|
||||||
|
(SELECT m.content FROM messages m WHERE m.conversation_id = c.id ORDER BY m.created_at DESC LIMIT 1) AS last_message,
|
||||||
|
(SELECT m.created_at FROM messages m WHERE m.conversation_id = c.id ORDER BY m.created_at DESC LIMIT 1) AS last_message_at
|
||||||
|
FROM conversations c
|
||||||
|
JOIN conversation_members cm ON c.id = cm.conversation_id
|
||||||
|
WHERE cm.user_id = %s
|
||||||
|
ORDER BY last_message_at DESC NULLS LAST, c.created_at DESC
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
conversations = [dict(row) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
# Get members for each conversation and add other_member_name for private chats
|
||||||
|
for conv in conversations:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.id, u.username, u.display_name, u.email
|
||||||
|
FROM conversation_members cm
|
||||||
|
JOIN users u ON u.id = cm.user_id
|
||||||
|
WHERE cm.conversation_id = %s AND u.id != %s
|
||||||
|
""",
|
||||||
|
(conv["conversation_id"], user_id)
|
||||||
|
)
|
||||||
|
members = [dict(row) for row in cur.fetchall()]
|
||||||
|
conv["members"] = members
|
||||||
|
|
||||||
|
# For private chats, add other_member_name
|
||||||
|
if not conv["is_group"] and len(members) > 0:
|
||||||
|
conv["other_member_name"] = members[0].get("display_name") or members[0].get("username") or members[0].get("email")
|
||||||
|
|
||||||
|
return conversations
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def send_message(conversation_id: int, sender_id: int, content: str):
|
||||||
|
"""Send a message in a conversation"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Verify user is member of conversation
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM conversation_members WHERE conversation_id = %s AND user_id = %s",
|
||||||
|
(conversation_id, sender_id)
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
return {"error": "Not a member of this conversation"}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO messages (conversation_id, sender_id, content)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
RETURNING id, conversation_id, sender_id, content, created_at
|
||||||
|
""",
|
||||||
|
(conversation_id, sender_id, content)
|
||||||
|
)
|
||||||
|
message = cur.fetchone()
|
||||||
|
|
||||||
|
# Update conversation updated_at
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||||
|
(conversation_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return dict(message)
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_messages(conversation_id: int, user_id: int, limit: int = 50, before_id: Optional[int] = None):
|
||||||
|
"""Get messages from a conversation"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Verify user is member
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM conversation_members WHERE conversation_id = %s AND user_id = %s",
|
||||||
|
(conversation_id, user_id)
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
return {"error": "Not a member of this conversation"}
|
||||||
|
|
||||||
|
# Get messages
|
||||||
|
if before_id:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT m.id AS message_id, m.sender_id, m.content, m.created_at, m.edited_at,
|
||||||
|
u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email,
|
||||||
|
CASE WHEN m.sender_id = %s THEN TRUE ELSE FALSE END AS is_mine
|
||||||
|
FROM messages m
|
||||||
|
JOIN users u ON u.id = m.sender_id
|
||||||
|
WHERE m.conversation_id = %s AND m.is_deleted = FALSE AND m.id < %s
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(user_id, conversation_id, before_id, limit)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT m.id AS message_id, m.sender_id, m.content, m.created_at, m.edited_at,
|
||||||
|
u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email,
|
||||||
|
CASE WHEN m.sender_id = %s THEN TRUE ELSE FALSE END AS is_mine
|
||||||
|
FROM messages m
|
||||||
|
JOIN users u ON u.id = m.sender_id
|
||||||
|
WHERE m.conversation_id = %s AND m.is_deleted = FALSE
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(user_id, conversation_id, limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [dict(row) for row in cur.fetchall()]
|
||||||
|
messages.reverse() # Return in chronological order
|
||||||
|
|
||||||
|
# Mark as read
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE conversation_members SET last_read_at = CURRENT_TIMESTAMP WHERE conversation_id = %s AND user_id = %s",
|
||||||
|
(conversation_id, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return messages
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
@ -48,18 +48,43 @@ def get_conn():
|
|||||||
return psycopg2.connect(dsn, cursor_factory=RealDictCursor)
|
return psycopg2.connect(dsn, cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
|
||||||
def list_recipes_db() -> List[Dict[str, Any]]:
|
def list_recipes_db(user_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""List recipes visible to the user. If user_id is None, only show public recipes."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
if user_id is None:
|
||||||
|
# Not authenticated - only public recipes
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, name, meal_type, time_minutes,
|
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
||||||
tags, ingredients, steps, image, made_by
|
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
||||||
FROM recipes
|
r.visibility, u.display_name as owner_display_name
|
||||||
ORDER BY id
|
FROM recipes r
|
||||||
|
LEFT JOIN users u ON r.user_id = u.id
|
||||||
|
WHERE r.visibility = 'public'
|
||||||
|
ORDER BY r.id
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# Authenticated - show public, own recipes, friends' recipes
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
||||||
|
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
||||||
|
r.visibility, u.display_name as owner_display_name
|
||||||
|
FROM recipes r
|
||||||
|
LEFT JOIN users u ON r.user_id = u.id
|
||||||
|
WHERE r.visibility = 'public'
|
||||||
|
OR r.user_id = %s
|
||||||
|
OR (r.visibility = 'friends' AND EXISTS (
|
||||||
|
SELECT 1 FROM friendships f
|
||||||
|
WHERE f.user_id = %s AND f.friend_id = r.user_id
|
||||||
|
))
|
||||||
|
ORDER BY r.id
|
||||||
|
""",
|
||||||
|
(user_id, user_id)
|
||||||
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
return rows
|
return rows
|
||||||
finally:
|
finally:
|
||||||
@ -68,7 +93,7 @@ def list_recipes_db() -> List[Dict[str, Any]]:
|
|||||||
def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
עדכון מתכון קיים לפי id.
|
עדכון מתכון קיים לפי id.
|
||||||
recipe_data: name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
recipe_data: name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, visibility
|
||||||
"""
|
"""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
@ -83,9 +108,10 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
|
|||||||
ingredients = %s,
|
ingredients = %s,
|
||||||
steps = %s,
|
steps = %s,
|
||||||
image = %s,
|
image = %s,
|
||||||
made_by = %s
|
made_by = %s,
|
||||||
|
visibility = %s
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id, visibility
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
recipe_data["name"],
|
recipe_data["name"],
|
||||||
@ -96,6 +122,7 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
|
|||||||
json.dumps(recipe_data.get("steps", [])),
|
json.dumps(recipe_data.get("steps", [])),
|
||||||
recipe_data.get("image"),
|
recipe_data.get("image"),
|
||||||
recipe_data.get("made_by"),
|
recipe_data.get("made_by"),
|
||||||
|
recipe_data.get("visibility", "public"),
|
||||||
recipe_id,
|
recipe_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -133,9 +160,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by)
|
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)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id, visibility
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
recipe_data["name"],
|
recipe_data["name"],
|
||||||
@ -146,6 +173,8 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
json.dumps(recipe_data.get("steps", [])),
|
json.dumps(recipe_data.get("steps", [])),
|
||||||
recipe_data.get("image"),
|
recipe_data.get("image"),
|
||||||
recipe_data.get("made_by"),
|
recipe_data.get("made_by"),
|
||||||
|
recipe_data.get("user_id"),
|
||||||
|
recipe_data.get("visibility", "public"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@ -158,23 +187,40 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
def get_recipes_by_filters_db(
|
def get_recipes_by_filters_db(
|
||||||
meal_type: Optional[str],
|
meal_type: Optional[str],
|
||||||
max_time: Optional[int],
|
max_time: Optional[int],
|
||||||
|
user_id: Optional[int] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
SELECT id, name, meal_type, time_minutes,
|
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
||||||
tags, ingredients, steps, image, made_by
|
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
||||||
FROM recipes
|
r.visibility, u.display_name as owner_display_name
|
||||||
|
FROM recipes r
|
||||||
|
LEFT JOIN users u ON r.user_id = u.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
"""
|
"""
|
||||||
params: List = []
|
params: List = []
|
||||||
|
|
||||||
|
# Visibility filter
|
||||||
|
if user_id is None:
|
||||||
|
query += " AND r.visibility = 'public'"
|
||||||
|
else:
|
||||||
|
query += """ AND (
|
||||||
|
r.visibility = 'public'
|
||||||
|
OR r.user_id = %s
|
||||||
|
OR (r.visibility = 'friends' AND EXISTS (
|
||||||
|
SELECT 1 FROM friendships f
|
||||||
|
WHERE f.user_id = %s AND f.friend_id = r.user_id
|
||||||
|
))
|
||||||
|
)"""
|
||||||
|
params.extend([user_id, user_id])
|
||||||
|
|
||||||
if meal_type:
|
if meal_type:
|
||||||
query += " AND meal_type = %s"
|
query += " AND r.meal_type = %s"
|
||||||
params.append(meal_type.lower())
|
params.append(meal_type.lower())
|
||||||
|
|
||||||
if max_time:
|
if max_time:
|
||||||
query += " AND time_minutes <= %s"
|
query += " AND r.time_minutes <= %s"
|
||||||
params.append(max_time)
|
params.append(max_time)
|
||||||
|
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|||||||
211
backend/email_utils.py
Normal file
211
backend/email_utils.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
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]
|
||||||
253
backend/grocery_db_utils.py
Normal file
253
backend/grocery_db_utils.py
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
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()
|
||||||
380
backend/groups_db_utils.py
Normal file
380
backend/groups_db_utils.py
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
"""Get database connection"""
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=os.getenv("DB_HOST", "localhost"),
|
||||||
|
port=int(os.getenv("DB_PORT", "5432")),
|
||||||
|
database=os.getenv("DB_NAME", "recipes_db"),
|
||||||
|
user=os.getenv("DB_USER", "recipes_user"),
|
||||||
|
password=os.getenv("DB_PASSWORD", "recipes_password"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============= Groups =============
|
||||||
|
|
||||||
|
def create_group(name: str, description: str, created_by: int, is_private: bool = False):
|
||||||
|
"""Create a new group"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO groups (name, description, created_by, is_private)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING id, name, description, created_by, is_private, created_at
|
||||||
|
""",
|
||||||
|
(name, description, created_by, is_private)
|
||||||
|
)
|
||||||
|
group = dict(cur.fetchone())
|
||||||
|
|
||||||
|
# Add creator as admin
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO group_members (group_id, user_id, role) VALUES (%s, %s, 'admin')",
|
||||||
|
(group["id"], created_by)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return group
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_group(group_id: int):
|
||||||
|
"""Get group details"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, name, description, created_by, is_private, created_at FROM groups WHERE id = %s",
|
||||||
|
(group_id,)
|
||||||
|
)
|
||||||
|
group = cur.fetchone()
|
||||||
|
if not group:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get members
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT gm.role, gm.joined_at, u.id, u.username, u.display_name
|
||||||
|
FROM group_members gm
|
||||||
|
JOIN users u ON u.id = gm.user_id
|
||||||
|
WHERE gm.group_id = %s
|
||||||
|
ORDER BY gm.role, u.display_name
|
||||||
|
""",
|
||||||
|
(group_id,)
|
||||||
|
)
|
||||||
|
members = [dict(row) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
result = dict(group)
|
||||||
|
result["members"] = members
|
||||||
|
result["member_count"] = len(members)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_groups(user_id: int):
|
||||||
|
"""Get all groups user is member of"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT g.id, g.name, g.description, g.is_private, g.created_at, gm.role,
|
||||||
|
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count
|
||||||
|
FROM groups g
|
||||||
|
JOIN group_members gm ON g.id = gm.group_id
|
||||||
|
WHERE gm.user_id = %s
|
||||||
|
ORDER BY g.name
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def add_group_member(group_id: int, user_id: int, added_by: int):
|
||||||
|
"""Add a member to a group"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Check if added_by is admin/moderator
|
||||||
|
cur.execute(
|
||||||
|
"SELECT role FROM group_members WHERE group_id = %s AND user_id = %s",
|
||||||
|
(group_id, added_by)
|
||||||
|
)
|
||||||
|
adder = cur.fetchone()
|
||||||
|
if not adder or adder["role"] not in ["admin", "moderator"]:
|
||||||
|
return {"error": "Only admins and moderators can add members"}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO group_members (group_id, user_id, role)
|
||||||
|
VALUES (%s, %s, 'member')
|
||||||
|
ON CONFLICT (group_id, user_id) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(group_id, user_id)
|
||||||
|
)
|
||||||
|
result = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return {"success": True}
|
||||||
|
else:
|
||||||
|
return {"error": "User is already a member"}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def remove_group_member(group_id: int, user_id: int, removed_by: int):
|
||||||
|
"""Remove a member from a group"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Check permissions
|
||||||
|
cur.execute(
|
||||||
|
"SELECT role FROM group_members WHERE group_id = %s AND user_id = %s",
|
||||||
|
(group_id, removed_by)
|
||||||
|
)
|
||||||
|
remover = cur.fetchone()
|
||||||
|
|
||||||
|
# User can remove themselves, or admins/moderators can remove others
|
||||||
|
if removed_by != user_id:
|
||||||
|
if not remover or remover["role"] not in ["admin", "moderator"]:
|
||||||
|
return {"error": "Only admins and moderators can remove members"}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM group_members WHERE group_id = %s AND user_id = %s",
|
||||||
|
(group_id, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"success": True}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============= Recipe Ratings & Comments =============
|
||||||
|
|
||||||
|
def add_or_update_rating(recipe_id: int, user_id: int, rating: int):
|
||||||
|
"""Add or update a rating for a recipe"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO recipe_ratings (recipe_id, user_id, rating)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (recipe_id, user_id)
|
||||||
|
DO UPDATE SET rating = EXCLUDED.rating, updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id, recipe_id, user_id, rating, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(recipe_id, user_id, rating)
|
||||||
|
)
|
||||||
|
result = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(result)
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_recipe_rating_stats(recipe_id: int):
|
||||||
|
"""Get rating statistics for a recipe"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as rating_count,
|
||||||
|
AVG(rating)::DECIMAL(3,2) as average_rating,
|
||||||
|
COUNT(CASE WHEN rating = 5 THEN 1 END) as five_star,
|
||||||
|
COUNT(CASE WHEN rating = 4 THEN 1 END) as four_star,
|
||||||
|
COUNT(CASE WHEN rating = 3 THEN 1 END) as three_star,
|
||||||
|
COUNT(CASE WHEN rating = 2 THEN 1 END) as two_star,
|
||||||
|
COUNT(CASE WHEN rating = 1 THEN 1 END) as one_star
|
||||||
|
FROM recipe_ratings
|
||||||
|
WHERE recipe_id = %s
|
||||||
|
""",
|
||||||
|
(recipe_id,)
|
||||||
|
)
|
||||||
|
return dict(cur.fetchone())
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_recipe_rating(recipe_id: int, user_id: int):
|
||||||
|
"""Get user's rating for a recipe"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT rating FROM recipe_ratings WHERE recipe_id = %s AND user_id = %s",
|
||||||
|
(recipe_id, user_id)
|
||||||
|
)
|
||||||
|
result = cur.fetchone()
|
||||||
|
return dict(result) if result else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def add_comment(recipe_id: int, user_id: int, content: str, parent_comment_id: Optional[int] = None):
|
||||||
|
"""Add a comment to a recipe"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO recipe_comments (recipe_id, user_id, content, parent_comment_id)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING id, recipe_id, user_id, content, parent_comment_id, created_at
|
||||||
|
""",
|
||||||
|
(recipe_id, user_id, content, parent_comment_id)
|
||||||
|
)
|
||||||
|
result = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(result)
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_recipe_comments(recipe_id: int):
|
||||||
|
"""Get all comments for a recipe"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT c.id, c.content, c.parent_comment_id, c.created_at, c.updated_at,
|
||||||
|
u.id AS user_id, u.username, u.display_name
|
||||||
|
FROM recipe_comments c
|
||||||
|
JOIN users u ON u.id = c.user_id
|
||||||
|
WHERE c.recipe_id = %s AND c.is_deleted = FALSE
|
||||||
|
ORDER BY c.created_at ASC
|
||||||
|
""",
|
||||||
|
(recipe_id,)
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_comment(comment_id: int, user_id: int, content: str):
|
||||||
|
"""Update a comment"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE recipe_comments
|
||||||
|
SET content = %s, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s AND user_id = %s AND is_deleted = FALSE
|
||||||
|
""",
|
||||||
|
(content, comment_id, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"success": True}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_comment(comment_id: int, user_id: int):
|
||||||
|
"""Soft delete a comment"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE recipe_comments SET is_deleted = TRUE WHERE id = %s AND user_id = %s",
|
||||||
|
(comment_id, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"success": True}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============= Recipe Shares to Groups =============
|
||||||
|
|
||||||
|
def share_recipe_to_group(recipe_id: int, group_id: int, user_id: int):
|
||||||
|
"""Share a recipe to a group"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Check if user is member of group
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM group_members WHERE group_id = %s AND user_id = %s",
|
||||||
|
(group_id, user_id)
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
return {"error": "Not a member of this group"}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO recipe_shares (recipe_id, group_id, shared_by)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (recipe_id, group_id) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(recipe_id, group_id, user_id)
|
||||||
|
)
|
||||||
|
result = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return {"success": True}
|
||||||
|
else:
|
||||||
|
return {"error": "Recipe already shared to this group"}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_group_recipes(group_id: int, user_id: int):
|
||||||
|
"""Get all recipes shared to a group"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Verify user is member
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM group_members WHERE group_id = %s AND user_id = %s",
|
||||||
|
(group_id, user_id)
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
return {"error": "Not a member of this group"}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT r.*, u.username AS owner_username, u.display_name AS owner_display_name,
|
||||||
|
rs.shared_at, rs.shared_by,
|
||||||
|
u2.username AS shared_by_username, u2.display_name AS shared_by_display_name
|
||||||
|
FROM recipe_shares rs
|
||||||
|
JOIN recipes r ON r.id = rs.recipe_id
|
||||||
|
JOIN users u ON u.id = r.user_id
|
||||||
|
JOIN users u2 ON u2.id = rs.shared_by
|
||||||
|
WHERE rs.group_id = %s
|
||||||
|
ORDER BY rs.shared_at DESC
|
||||||
|
""",
|
||||||
|
(group_id,)
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
970
backend/main.py
970
backend/main.py
File diff suppressed because it is too large
Load Diff
124
backend/notification_db_utils.py
Normal file
124
backend/notification_db_utils.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
33
backend/oauth_utils.py
Normal file
33
backend/oauth_utils.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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,6 +2,21 @@ fastapi==0.115.0
|
|||||||
uvicorn[standard]==0.30.1
|
uvicorn[standard]==0.30.1
|
||||||
|
|
||||||
pydantic==2.7.4
|
pydantic==2.7.4
|
||||||
|
pydantic[email]==2.7.4
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
|
|
||||||
psycopg2-binary==2.9.9
|
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
|
||||||
|
|||||||
41
backend/reset_admin_password.py
Normal file
41
backend/reset_admin_password.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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
backend/routers/__init__.py
Normal file
1
backend/routers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Router package initialization
|
||||||
88
backend/routers/chat.py
Normal file
88
backend/routers/chat.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
from auth_utils import get_current_user
|
||||||
|
from chat_db_utils import (
|
||||||
|
create_conversation,
|
||||||
|
get_conversation,
|
||||||
|
get_user_conversations,
|
||||||
|
send_message,
|
||||||
|
get_messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/conversations", tags=["chat"])
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationCreate(BaseModel):
|
||||||
|
user_ids: List[int]
|
||||||
|
is_group: bool = False
|
||||||
|
name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCreate(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_conversation_endpoint(
|
||||||
|
data: ConversationCreate,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Create a new conversation (private or group chat)"""
|
||||||
|
user_ids = data.user_ids
|
||||||
|
if current_user["user_id"] not in user_ids:
|
||||||
|
user_ids.append(current_user["user_id"])
|
||||||
|
|
||||||
|
return create_conversation(
|
||||||
|
user_ids=user_ids,
|
||||||
|
is_group=data.is_group,
|
||||||
|
name=data.name,
|
||||||
|
created_by=current_user["user_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_conversations_endpoint(
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get all conversations for current user"""
|
||||||
|
return get_user_conversations(current_user["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{conversation_id}")
|
||||||
|
def get_conversation_endpoint(
|
||||||
|
conversation_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get conversation details"""
|
||||||
|
conversation = get_conversation(conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{conversation_id}/messages")
|
||||||
|
def send_message_endpoint(
|
||||||
|
conversation_id: int,
|
||||||
|
data: MessageCreate,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Send a message in a conversation"""
|
||||||
|
result = send_message(conversation_id, current_user["user_id"], data.content)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=403, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{conversation_id}/messages")
|
||||||
|
def get_messages_endpoint(
|
||||||
|
conversation_id: int,
|
||||||
|
limit: int = Query(50, le=100),
|
||||||
|
before_id: Optional[int] = None,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get messages from a conversation"""
|
||||||
|
result = get_messages(conversation_id, current_user["user_id"], limit, before_id)
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
raise HTTPException(status_code=403, detail=result["error"])
|
||||||
|
return result
|
||||||
152
backend/routers/friends.py
Normal file
152
backend/routers/friends.py
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List
|
||||||
|
from auth_utils import get_current_user
|
||||||
|
from social_db_utils import (
|
||||||
|
send_friend_request,
|
||||||
|
accept_friend_request,
|
||||||
|
reject_friend_request,
|
||||||
|
get_friend_requests,
|
||||||
|
get_friends,
|
||||||
|
remove_friend,
|
||||||
|
search_users,
|
||||||
|
)
|
||||||
|
from notification_db_utils import create_notification
|
||||||
|
from user_db_utils import get_user_by_id
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/friends", tags=["friends"])
|
||||||
|
|
||||||
|
|
||||||
|
class FriendRequestModel(BaseModel):
|
||||||
|
receiver_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/request")
|
||||||
|
def send_friend_request_endpoint(
|
||||||
|
request: FriendRequestModel,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Send a friend request to another user"""
|
||||||
|
result = send_friend_request(current_user["user_id"], request.receiver_id)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
|
|
||||||
|
# Create notification for receiver
|
||||||
|
create_notification(
|
||||||
|
user_id=request.receiver_id,
|
||||||
|
type="friend_request",
|
||||||
|
message=f"{current_user['display_name']} שלח לך בקשת חברות",
|
||||||
|
related_id=result.get("id")
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests")
|
||||||
|
def get_friend_requests_endpoint(
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get pending friend requests"""
|
||||||
|
return get_friend_requests(current_user["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/requests/{request_id}/accept")
|
||||||
|
def accept_friend_request_endpoint(
|
||||||
|
request_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Accept a friend request"""
|
||||||
|
# Get request details before accepting
|
||||||
|
from social_db_utils import get_db_connection
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT sender_id, receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'",
|
||||||
|
(request_id,)
|
||||||
|
)
|
||||||
|
request_data = cur.fetchone()
|
||||||
|
|
||||||
|
# Verify current user is the receiver
|
||||||
|
if not request_data:
|
||||||
|
raise HTTPException(status_code=404, detail="Request not found")
|
||||||
|
if request_data["receiver_id"] != current_user["user_id"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to accept this request")
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
result = accept_friend_request(request_id)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=404, detail=result["error"])
|
||||||
|
|
||||||
|
# Create notification for sender that their request was accepted
|
||||||
|
if request_data:
|
||||||
|
create_notification(
|
||||||
|
user_id=request_data["sender_id"],
|
||||||
|
type="friend_accepted",
|
||||||
|
message=f"{current_user['display_name']} קיבל את בקשת החברות שלך",
|
||||||
|
related_id=current_user["user_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/requests/{request_id}/reject")
|
||||||
|
def reject_friend_request_endpoint(
|
||||||
|
request_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Reject a friend request"""
|
||||||
|
# Verify current user is the receiver
|
||||||
|
from social_db_utils import get_db_connection
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'",
|
||||||
|
(request_id,)
|
||||||
|
)
|
||||||
|
request_data = cur.fetchone()
|
||||||
|
|
||||||
|
if not request_data:
|
||||||
|
raise HTTPException(status_code=404, detail="Request not found")
|
||||||
|
if request_data["receiver_id"] != current_user["user_id"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to reject this request")
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return reject_friend_request(request_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_friends_endpoint(
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get list of user's friends"""
|
||||||
|
return get_friends(current_user["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{friend_id}")
|
||||||
|
def remove_friend_endpoint(
|
||||||
|
friend_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Remove a friend"""
|
||||||
|
from fastapi.responses import Response
|
||||||
|
remove_friend(current_user["user_id"], friend_id)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search")
|
||||||
|
def search_users_for_friends_endpoint(
|
||||||
|
q: str = Query(..., min_length=1),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Search users to add as friends"""
|
||||||
|
return search_users(q, current_user["user_id"])
|
||||||
163
backend/routers/groups.py
Normal file
163
backend/routers/groups.py
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from auth_utils import get_current_user
|
||||||
|
from groups_db_utils import (
|
||||||
|
create_group,
|
||||||
|
get_group,
|
||||||
|
get_user_groups,
|
||||||
|
add_group_member,
|
||||||
|
remove_group_member,
|
||||||
|
share_recipe_to_group,
|
||||||
|
get_group_recipes,
|
||||||
|
)
|
||||||
|
from notification_db_utils import create_notification
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/groups", tags=["groups"])
|
||||||
|
|
||||||
|
|
||||||
|
class GroupCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
is_private: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_group_endpoint(
|
||||||
|
data: GroupCreate,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Create a new group"""
|
||||||
|
return create_group(
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
created_by=current_user["user_id"],
|
||||||
|
is_private=data.is_private
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_user_groups_endpoint(
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get all groups user is member of"""
|
||||||
|
return get_user_groups(current_user["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{group_id}")
|
||||||
|
def get_group_endpoint(
|
||||||
|
group_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get group details"""
|
||||||
|
group = get_group(group_id)
|
||||||
|
if not group:
|
||||||
|
raise HTTPException(status_code=404, detail="Group not found")
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{group_id}/members/{user_id}")
|
||||||
|
def add_group_member_endpoint(
|
||||||
|
group_id: int,
|
||||||
|
user_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Add a member to a group"""
|
||||||
|
# Get group name
|
||||||
|
group = get_group(group_id)
|
||||||
|
|
||||||
|
result = add_group_member(group_id, user_id, current_user["user_id"])
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=403, detail=result["error"])
|
||||||
|
|
||||||
|
# Notify the added user
|
||||||
|
if group:
|
||||||
|
create_notification(
|
||||||
|
user_id=user_id,
|
||||||
|
type="group_invite",
|
||||||
|
message=f"{current_user['display_name']} הוסיף אותך לקבוצה '{group['name']}'" ,
|
||||||
|
related_id=group_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{group_id}/members/{user_id}")
|
||||||
|
def remove_group_member_endpoint(
|
||||||
|
group_id: int,
|
||||||
|
user_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Remove a member from a group"""
|
||||||
|
result = remove_group_member(group_id, user_id, current_user["user_id"])
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=403, detail=result["error"])
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{group_id}/recipes/{recipe_id}")
|
||||||
|
def share_recipe_to_group_endpoint(
|
||||||
|
group_id: int,
|
||||||
|
recipe_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Share a recipe to a group"""
|
||||||
|
from groups_db_utils import get_db_connection
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from db_utils import get_conn
|
||||||
|
|
||||||
|
# Get group members and names
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT gm.user_id, g.name as group_name
|
||||||
|
FROM group_members gm
|
||||||
|
JOIN groups g ON gm.group_id = g.id
|
||||||
|
WHERE gm.group_id = %s AND gm.user_id != %s""",
|
||||||
|
(group_id, current_user["user_id"])
|
||||||
|
)
|
||||||
|
members = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Get recipe name
|
||||||
|
recipe_conn = get_conn()
|
||||||
|
recipe_cur = recipe_conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
recipe_cur.execute("SELECT name FROM recipes WHERE id = %s", (recipe_id,))
|
||||||
|
recipe = recipe_cur.fetchone()
|
||||||
|
finally:
|
||||||
|
recipe_cur.close()
|
||||||
|
recipe_conn.close()
|
||||||
|
|
||||||
|
result = share_recipe_to_group(recipe_id, group_id, current_user["user_id"])
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=403, detail=result["error"])
|
||||||
|
|
||||||
|
# Notify all group members except the sharer
|
||||||
|
if members and recipe:
|
||||||
|
group_name = members[0]["group_name"] if members else ""
|
||||||
|
for member in members:
|
||||||
|
create_notification(
|
||||||
|
user_id=member["user_id"],
|
||||||
|
type="recipe_shared",
|
||||||
|
message=f"{current_user['display_name']} שיתף מתכון '{recipe['name']}' בקבוצה '{group_name}'",
|
||||||
|
related_id=recipe_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{group_id}/recipes")
|
||||||
|
def get_group_recipes_endpoint(
|
||||||
|
group_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get all recipes shared to a group"""
|
||||||
|
result = get_group_recipes(group_id, current_user["user_id"])
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
raise HTTPException(status_code=403, detail=result["error"])
|
||||||
|
return result
|
||||||
91
backend/routers/ratings_comments.py
Normal file
91
backend/routers/ratings_comments.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from auth_utils import get_current_user
|
||||||
|
from groups_db_utils import (
|
||||||
|
add_or_update_rating,
|
||||||
|
get_recipe_rating_stats,
|
||||||
|
get_user_recipe_rating,
|
||||||
|
add_comment,
|
||||||
|
get_recipe_comments,
|
||||||
|
update_comment,
|
||||||
|
delete_comment,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["ratings-comments"])
|
||||||
|
|
||||||
|
|
||||||
|
class RatingCreate(BaseModel):
|
||||||
|
rating: int
|
||||||
|
|
||||||
|
|
||||||
|
class CommentCreate(BaseModel):
|
||||||
|
content: str
|
||||||
|
parent_comment_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recipes/{recipe_id}/rating")
|
||||||
|
def rate_recipe_endpoint(
|
||||||
|
recipe_id: int,
|
||||||
|
data: RatingCreate,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Add or update rating for a recipe"""
|
||||||
|
if data.rating < 1 or data.rating > 5:
|
||||||
|
raise HTTPException(status_code=400, detail="Rating must be between 1 and 5")
|
||||||
|
return add_or_update_rating(recipe_id, current_user["user_id"], data.rating)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recipes/{recipe_id}/rating/stats")
|
||||||
|
def get_recipe_rating_stats_endpoint(recipe_id: int):
|
||||||
|
"""Get rating statistics for a recipe"""
|
||||||
|
return get_recipe_rating_stats(recipe_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recipes/{recipe_id}/rating/mine")
|
||||||
|
def get_my_recipe_rating_endpoint(
|
||||||
|
recipe_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get current user's rating for a recipe"""
|
||||||
|
rating = get_user_recipe_rating(recipe_id, current_user["user_id"])
|
||||||
|
if not rating:
|
||||||
|
return {"rating": None}
|
||||||
|
return rating
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recipes/{recipe_id}/comments")
|
||||||
|
def add_comment_endpoint(
|
||||||
|
recipe_id: int,
|
||||||
|
data: CommentCreate,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Add a comment to a recipe"""
|
||||||
|
return add_comment(recipe_id, current_user["user_id"], data.content, data.parent_comment_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recipes/{recipe_id}/comments")
|
||||||
|
def get_comments_endpoint(recipe_id: int):
|
||||||
|
"""Get all comments for a recipe"""
|
||||||
|
return get_recipe_comments(recipe_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/comments/{comment_id}")
|
||||||
|
def update_comment_endpoint(
|
||||||
|
comment_id: int,
|
||||||
|
data: CommentCreate,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Update a comment"""
|
||||||
|
return update_comment(comment_id, current_user["user_id"], data.content)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/comments/{comment_id}")
|
||||||
|
def delete_comment_endpoint(
|
||||||
|
comment_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Delete a comment"""
|
||||||
|
delete_comment(comment_id, current_user["user_id"])
|
||||||
|
return Response(status_code=204)
|
||||||
@ -1,14 +1,32 @@
|
|||||||
|
-- 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 recipes table
|
||||||
CREATE TABLE IF NOT EXISTS recipes (
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack
|
meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack
|
||||||
time_minutes INTEGER NOT NULL,
|
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
|
made_by TEXT, -- Person who created this recipe version
|
||||||
tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"]
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- Recipe owner
|
||||||
ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"]
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
steps JSONB NOT NULL DEFAULT '[]', -- ["לחתוך", "לבשל", ...]
|
|
||||||
image TEXT -- Base64-encoded image or image URL
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Optional: index for filters
|
-- Optional: index for filters
|
||||||
@ -21,9 +39,90 @@ CREATE INDEX IF NOT EXISTS idx_recipes_time_minutes
|
|||||||
CREATE INDEX IF NOT EXISTS idx_recipes_made_by
|
CREATE INDEX IF NOT EXISTS idx_recipes_made_by
|
||||||
ON recipes (made_by);
|
ON recipes (made_by);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipes_tags_jsonb
|
CREATE INDEX IF NOT EXISTS idx_recipes_user_id
|
||||||
ON recipes USING GIN (tags);
|
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_ingredients_jsonb
|
|
||||||
ON recipes USING GIN (ingredients);
|
|
||||||
|
|
||||||
|
|||||||
202
backend/social_db_utils.py
Normal file
202
backend/social_db_utils.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from psycopg2 import errors
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
"""Get database connection"""
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=os.getenv("DB_HOST", "localhost"),
|
||||||
|
port=int(os.getenv("DB_PORT", "5432")),
|
||||||
|
database=os.getenv("DB_NAME", "recipes_db"),
|
||||||
|
user=os.getenv("DB_USER", "recipes_user"),
|
||||||
|
password=os.getenv("DB_PASSWORD", "recipes_password"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============= Friends System =============
|
||||||
|
|
||||||
|
def send_friend_request(sender_id: int, receiver_id: int):
|
||||||
|
"""Send a friend request"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Check if already friends
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)",
|
||||||
|
(sender_id, receiver_id, receiver_id, sender_id)
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
return {"error": "Already friends"}
|
||||||
|
|
||||||
|
# Check if request already exists
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * FROM friend_requests WHERE sender_id = %s AND receiver_id = %s AND status = 'pending'",
|
||||||
|
(sender_id, receiver_id)
|
||||||
|
)
|
||||||
|
existing = cur.fetchone()
|
||||||
|
if existing:
|
||||||
|
return dict(existing)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO friend_requests (sender_id, receiver_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
RETURNING id, sender_id, receiver_id, status, created_at
|
||||||
|
""",
|
||||||
|
(sender_id, receiver_id)
|
||||||
|
)
|
||||||
|
request = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(request)
|
||||||
|
except errors.UniqueViolation:
|
||||||
|
# Request already exists, fetch and return it
|
||||||
|
conn.rollback()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, sender_id, receiver_id, status, created_at FROM friend_requests WHERE sender_id = %s AND receiver_id = %s",
|
||||||
|
(sender_id, receiver_id)
|
||||||
|
)
|
||||||
|
existing_request = cur.fetchone()
|
||||||
|
if existing_request:
|
||||||
|
return dict(existing_request)
|
||||||
|
return {"error": "Friend request already exists"}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def accept_friend_request(request_id: int):
|
||||||
|
"""Accept a friend request and create friendship"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Get request details
|
||||||
|
cur.execute(
|
||||||
|
"SELECT sender_id, receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'",
|
||||||
|
(request_id,)
|
||||||
|
)
|
||||||
|
request = cur.fetchone()
|
||||||
|
if not request:
|
||||||
|
return {"error": "Request not found or already processed"}
|
||||||
|
|
||||||
|
sender_id = request["sender_id"]
|
||||||
|
receiver_id = request["receiver_id"]
|
||||||
|
|
||||||
|
# Create bidirectional friendship
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO friendships (user_id, friend_id) VALUES (%s, %s), (%s, %s) ON CONFLICT DO NOTHING",
|
||||||
|
(sender_id, receiver_id, receiver_id, sender_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update request status
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE friend_requests SET status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||||
|
(request_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return {"success": True}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def reject_friend_request(request_id: int):
|
||||||
|
"""Reject a friend request"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE friend_requests SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = %s AND status = 'pending'",
|
||||||
|
(request_id,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"success": True}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_friend_requests(user_id: int):
|
||||||
|
"""Get pending friend requests for a user"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT fr.id AS request_id, fr.sender_id, fr.receiver_id, fr.status, fr.created_at,
|
||||||
|
u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email
|
||||||
|
FROM friend_requests fr
|
||||||
|
JOIN users u ON u.id = fr.sender_id
|
||||||
|
WHERE fr.receiver_id = %s AND fr.status = 'pending'
|
||||||
|
ORDER BY fr.created_at DESC
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_friends(user_id: int):
|
||||||
|
"""Get list of user's friends"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.id, u.username, u.display_name, u.email, f.created_at AS friends_since
|
||||||
|
FROM friendships f
|
||||||
|
JOIN users u ON u.id = f.friend_id
|
||||||
|
WHERE f.user_id = %s
|
||||||
|
ORDER BY u.display_name
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def remove_friend(user_id: int, friend_id: int):
|
||||||
|
"""Remove a friend"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)",
|
||||||
|
(user_id, friend_id, friend_id, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"success": True}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def search_users(query: str, current_user_id: int, limit: int = 20):
|
||||||
|
"""Search for users by username or display name"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
search_pattern = f"%{query}%"
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.id, u.username, u.display_name, u.email,
|
||||||
|
EXISTS(SELECT 1 FROM friendships WHERE user_id = %s AND friend_id = u.id) AS is_friend,
|
||||||
|
EXISTS(SELECT 1 FROM friend_requests WHERE sender_id = %s AND receiver_id = u.id AND status = 'pending') AS request_sent
|
||||||
|
FROM users u
|
||||||
|
WHERE (u.username ILIKE %s OR u.display_name ILIKE %s) AND u.id != %s
|
||||||
|
ORDER BY u.display_name
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(current_user_id, current_user_id, search_pattern, search_pattern, current_user_id, limit)
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
117
backend/user_db_utils.py
Normal file
117
backend/user_db_utils.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
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
Normal file
178
demo-recipes.sql
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
-- 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,12 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="he" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<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>">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<meta name="google-site-verification" content="xzgN8wy4yNKqR0_qZZeUqj-wfnje0v7koYpUyU1ti2I" />
|
||||||
|
<title>My Recipes | המתכונים שלי</title>
|
||||||
<!-- Load environment variables before app starts -->
|
<!-- Load environment variables before app starts -->
|
||||||
<script src="/env.js"></script>
|
<script src="/env.js?v=20251219"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
6
frontend/public/sitemap.xml
Normal file
6
frontend/public/sitemap.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?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,32 +32,68 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: flex-start;
|
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 {
|
.app-root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.5rem;
|
padding: 0.75rem;
|
||||||
|
padding-top: 4rem; /* Add space for fixed theme toggle */
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.app-root {
|
||||||
|
padding: 1.5rem;
|
||||||
|
padding-top: 4.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Top bar */
|
/* Top bar */
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
background: linear-gradient(90deg, #020617, #020617f2);
|
background: linear-gradient(90deg, #020617, #020617f2);
|
||||||
border-radius: 999px;
|
border-radius: 18px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
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;
|
||||||
|
border-radius: 999px;
|
||||||
padding: 0.8rem 1.2rem;
|
padding: 0.8rem 1.2rem;
|
||||||
margin-bottom: 1.6rem;
|
margin-bottom: 1.6rem;
|
||||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.9);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-left {
|
.topbar-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.topbar-left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-emoji {
|
.logo-emoji {
|
||||||
@ -66,14 +102,69 @@ body {
|
|||||||
|
|
||||||
.brand-title {
|
.brand-title {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.brand-title {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-subtitle {
|
.brand-subtitle {
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.brand-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.topbar-actions {
|
||||||
|
gap: 0.6rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile-compact buttons */
|
||||||
|
.btn-mobile-compact .btn-text-desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mobile-compact .btn-text-mobile {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.btn-mobile-compact .btn-text-desktop {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mobile-compact .btn-text-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-greeting {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(79, 70, 229, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
@ -83,27 +174,127 @@ body {
|
|||||||
|
|
||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
.layout {
|
.layout {
|
||||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar,
|
.sidebar,
|
||||||
|
.sidebar-right,
|
||||||
.content {
|
.content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-right {
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
/* Panels */
|
/* Panels */
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-radius: 18px;
|
border-radius: 16px;
|
||||||
padding: 1.1rem 1.2rem;
|
padding: 0.9rem 1rem;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.7);
|
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.panel {
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1.1rem 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.panel.secondary {
|
.panel.secondary {
|
||||||
background: var(--card-soft);
|
background: var(--card-soft);
|
||||||
}
|
}
|
||||||
@ -161,21 +352,41 @@ select {
|
|||||||
border: 1px solid rgba(148, 163, 184, 0.6);
|
border: 1px solid rgba(148, 163, 184, 0.6);
|
||||||
background: #020617;
|
background: #020617;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
padding: 0.4rem 0.65rem;
|
padding: 0.55rem 0.75rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
min-height: 44px; /* Better touch target for mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
padding: 0.4rem 0.65rem;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.55rem 1.2rem;
|
padding: 0.65rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.08s ease, box-shadow 0.08s ease,
|
transition: transform 0.08s ease, box-shadow 0.08s ease,
|
||||||
background-color 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 {
|
.btn.full {
|
||||||
@ -292,31 +503,31 @@ select {
|
|||||||
min-height: 260px;
|
min-height: 260px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-header {
|
.recipe-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.8rem;
|
gap: 1rem;
|
||||||
margin-bottom: 0.8rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-header h2 {
|
.recipe-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.3rem;
|
font-size: 1.6rem;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-subtitle {
|
.recipe-subtitle {
|
||||||
margin: 0.2rem 0 0;
|
margin: 0.3rem 0 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-made-by {
|
.recipe-made-by {
|
||||||
margin: 0.3rem 0 0;
|
margin: 0.4rem 0 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.85rem;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@ -324,25 +535,34 @@ select {
|
|||||||
.pill-row {
|
.pill-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.3rem;
|
gap: 0.4rem;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.35rem 0.75rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(15, 23, 42, 0.95);
|
background: rgba(15, 23, 42, 0.95);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.7);
|
border: 1px solid rgba(148, 163, 184, 0.7);
|
||||||
font-size: 0.78rem;
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Recipe Image */
|
/* Recipe Image */
|
||||||
.recipe-image-container {
|
.recipe-image-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 250px;
|
height: 280px;
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 0.8rem;
|
margin-bottom: 1.2rem;
|
||||||
background: rgba(15, 23, 42, 0.5);
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.recipe-image-container {
|
||||||
|
height: 320px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-image {
|
.recipe-image {
|
||||||
@ -354,33 +574,48 @@ select {
|
|||||||
|
|
||||||
.recipe-body {
|
.recipe-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.8rem;
|
gap: 1.2rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 720px) {
|
@media (min-width: 600px) {
|
||||||
.recipe-body {
|
.recipe-body {
|
||||||
grid-template-columns: 1fr 1.2fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-column h3 {
|
.recipe-column h3 {
|
||||||
margin: 0 0 0.3rem;
|
margin: 0 0 0.6rem;
|
||||||
font-size: 0.95rem;
|
font-size: 1.1rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-column ul,
|
.recipe-column ul,
|
||||||
.recipe-column ol {
|
.recipe-column ol {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-right: 1rem;
|
padding-right: 1.2rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-column li {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-actions {
|
.recipe-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.5rem;
|
||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.recipe-actions {
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags {
|
.tags {
|
||||||
@ -431,11 +666,22 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drawer {
|
.drawer {
|
||||||
width: min(420px, 90vw);
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
background: #020617;
|
background: #020617;
|
||||||
border-left: 1px solid var(--border-subtle);
|
border-left: 1px solid var(--border-subtle);
|
||||||
padding: 1rem 1rem 1rem 1.2rem;
|
padding: 1rem;
|
||||||
box-shadow: 18px 0 40px rgba(0, 0, 0, 0.7);
|
box-shadow: 18px 0 40px rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.drawer {
|
||||||
|
width: min(420px, 90vw);
|
||||||
|
padding: 1rem 1rem 1rem 1.2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-header {
|
.drawer-header {
|
||||||
@ -446,14 +692,20 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drawer-body {
|
.drawer-body {
|
||||||
max-height: calc(100vh - 4rem);
|
flex: 1;
|
||||||
overflow: auto;
|
overflow-y: auto;
|
||||||
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-footer {
|
.drawer-footer {
|
||||||
margin-top: 0.7rem;
|
margin-top: auto;
|
||||||
|
padding-top: 0.7rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
@ -506,16 +758,25 @@ select {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-radius: 18px;
|
border-radius: 16px;
|
||||||
padding: 1.5rem;
|
padding: 1.2rem;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.9);
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.9);
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.modal {
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1.5rem;
|
||||||
width: 90vw;
|
width: 90vw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@ -524,13 +785,26 @@ select {
|
|||||||
|
|
||||||
.modal-header h2 {
|
.modal-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.modal-header h2 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
margin-bottom: 1.2rem;
|
margin-bottom: 1.2rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.modal-body {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
@ -631,18 +905,27 @@ select {
|
|||||||
|
|
||||||
.toast-container {
|
.toast-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1.5rem;
|
bottom: 1rem;
|
||||||
right: 1.5rem;
|
right: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
max-width: 400px;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.toast-container {
|
||||||
|
bottom: 5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
left: auto;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
padding: 1rem 1.2rem;
|
padding: 0.9rem 1rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
@ -651,7 +934,14 @@ select {
|
|||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
animation: slideIn 0.3s ease-out;
|
animation: slideIn 0.3s ease-out;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.toast {
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast.success {
|
.toast.success {
|
||||||
@ -686,16 +976,16 @@ select {
|
|||||||
/* Theme Toggle (fixed floating button) */
|
/* Theme Toggle (fixed floating button) */
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 1.5rem;
|
top: 0.75rem;
|
||||||
right: 1.5rem;
|
right: 0.75rem;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
width: 3rem;
|
width: 2.75rem;
|
||||||
height: 3rem;
|
height: 2.75rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
font-size: 1.2rem;
|
font-size: 1.1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -704,6 +994,16 @@ select {
|
|||||||
transition: all 180ms ease;
|
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 {
|
.theme-toggle:hover {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.4);
|
||||||
@ -713,6 +1013,90 @@ select {
|
|||||||
transform: scale(0.95);
|
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 */
|
/* Update body to apply bg properly in both themes */
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@ -737,7 +1121,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] body {
|
[data-theme="light"] body {
|
||||||
background: linear-gradient(180deg, #ac8d75 0%, #f6f8fa 100%);
|
background: linear-gradient(180deg, #ac8d75 0%, #ede9e5 100%);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1256,3 +1640,242 @@ html {
|
|||||||
[data-theme="light"] .recipe-list-image {
|
[data-theme="light"] .recipe-list-image {
|
||||||
background: rgba(229, 231, 235, 0.5);
|
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,12 +5,34 @@ import TopBar from "./components/TopBar";
|
|||||||
import RecipeSearchList from "./components/RecipeSearchList";
|
import RecipeSearchList from "./components/RecipeSearchList";
|
||||||
import RecipeDetails from "./components/RecipeDetails";
|
import RecipeDetails from "./components/RecipeDetails";
|
||||||
import RecipeFormDrawer from "./components/RecipeFormDrawer";
|
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 Modal from "./components/Modal";
|
||||||
import ToastContainer from "./components/ToastContainer";
|
import ToastContainer from "./components/ToastContainer";
|
||||||
import ThemeToggle from "./components/ThemeToggle";
|
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 { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
||||||
|
import { getToken, removeToken, getMe } from "./authApi";
|
||||||
|
|
||||||
function App() {
|
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 [recipes, setRecipes] = useState([]);
|
||||||
const [selectedRecipe, setSelectedRecipe] = useState(null);
|
const [selectedRecipe, setSelectedRecipe] = useState(null);
|
||||||
|
|
||||||
@ -19,7 +41,7 @@ function App() {
|
|||||||
const [filterMealType, setFilterMealType] = useState("");
|
const [filterMealType, setFilterMealType] = useState("");
|
||||||
const [filterMaxTime, setFilterMaxTime] = useState("");
|
const [filterMaxTime, setFilterMaxTime] = useState("");
|
||||||
const [filterTags, setFilterTags] = useState([]);
|
const [filterTags, setFilterTags] = useState([]);
|
||||||
const [filterMadeBy, setFilterMadeBy] = useState("");
|
const [filterOwner, setFilterOwner] = useState("");
|
||||||
|
|
||||||
// Random recipe filters
|
// Random recipe filters
|
||||||
const [mealTypeFilter, setMealTypeFilter] = useState("");
|
const [mealTypeFilter, setMealTypeFilter] = useState("");
|
||||||
@ -33,6 +55,7 @@ function App() {
|
|||||||
const [editingRecipe, setEditingRecipe] = useState(null);
|
const [editingRecipe, setEditingRecipe] = useState(null);
|
||||||
|
|
||||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
|
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
|
||||||
|
const [logoutModal, setLogoutModal] = useState(false);
|
||||||
const [toasts, setToasts] = useState([]);
|
const [toasts, setToasts] = useState([]);
|
||||||
const [theme, setTheme] = useState(() => {
|
const [theme, setTheme] = useState(() => {
|
||||||
try {
|
try {
|
||||||
@ -41,7 +64,104 @@ function App() {
|
|||||||
return "dark";
|
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(() => {
|
useEffect(() => {
|
||||||
loadRecipes();
|
loadRecipes();
|
||||||
}, []);
|
}, []);
|
||||||
@ -96,8 +216,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by made_by
|
// Filter by made_by (username)
|
||||||
if (filterMadeBy && (!recipe.made_by || recipe.made_by !== filterMadeBy)) {
|
if (filterOwner && (!recipe.made_by || recipe.made_by !== filterOwner)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,7 +254,8 @@ function App() {
|
|||||||
|
|
||||||
const handleCreateRecipe = async (payload) => {
|
const handleCreateRecipe = async (payload) => {
|
||||||
try {
|
try {
|
||||||
const created = await createRecipe(payload);
|
const token = getToken();
|
||||||
|
const created = await createRecipe(payload, token);
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
setEditingRecipe(null);
|
setEditingRecipe(null);
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
@ -153,7 +274,8 @@ function App() {
|
|||||||
|
|
||||||
const handleUpdateRecipe = async (payload) => {
|
const handleUpdateRecipe = async (payload) => {
|
||||||
try {
|
try {
|
||||||
await updateRecipe(editingRecipe.id, payload);
|
const token = getToken();
|
||||||
|
await updateRecipe(editingRecipe.id, payload, token);
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
setEditingRecipe(null);
|
setEditingRecipe(null);
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
@ -177,7 +299,8 @@ function App() {
|
|||||||
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteRecipe(recipeId);
|
const token = getToken();
|
||||||
|
await deleteRecipe(recipeId, token);
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
setSelectedRecipe(null);
|
setSelectedRecipe(null);
|
||||||
addToast("המתכון נמחק בהצלחה!", "success");
|
addToast("המתכון נמחק בהצלחה!", "success");
|
||||||
@ -208,12 +331,206 @@ 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 (
|
return (
|
||||||
<div className="app-root">
|
<div className="app-root">
|
||||||
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
|
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
|
||||||
<TopBar onAddClick={() => setDrawerOpen(true)} />
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="layout">
|
<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">
|
<section className="sidebar">
|
||||||
<RecipeSearchList
|
<RecipeSearchList
|
||||||
allRecipes={recipes}
|
allRecipes={recipes}
|
||||||
@ -228,15 +545,27 @@ function App() {
|
|||||||
onMaxTimeChange={setFilterMaxTime}
|
onMaxTimeChange={setFilterMaxTime}
|
||||||
filterTags={filterTags}
|
filterTags={filterTags}
|
||||||
onTagsChange={setFilterTags}
|
onTagsChange={setFilterTags}
|
||||||
filterMadeBy={filterMadeBy}
|
filterOwner={filterOwner}
|
||||||
onMadeByChange={setFilterMadeBy}
|
onOwnerChange={setFilterOwner}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="content">
|
<section className="content">
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
{/* Random Recipe Suggester - Top Left */}
|
{/* Recipe Details Card */}
|
||||||
|
<RecipeDetails
|
||||||
|
recipe={selectedRecipe}
|
||||||
|
onEditClick={handleEditRecipe}
|
||||||
|
onShowDeleteModal={handleShowDeleteModal}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
currentUser={user}
|
||||||
|
showToast={addToast}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="sidebar-right">
|
||||||
|
{/* Random Recipe Suggester - Right Side */}
|
||||||
<section className="panel filter-panel">
|
<section className="panel filter-panel">
|
||||||
<h3>חיפוש מתכון רנדומלי</h3>
|
<h3>חיפוש מתכון רנדומלי</h3>
|
||||||
<div className="panel-grid">
|
<div className="panel-grid">
|
||||||
@ -250,7 +579,7 @@ function App() {
|
|||||||
<option value="breakfast">בוקר</option>
|
<option value="breakfast">בוקר</option>
|
||||||
<option value="lunch">צהריים</option>
|
<option value="lunch">צהריים</option>
|
||||||
<option value="dinner">ערב</option>
|
<option value="dinner">ערב</option>
|
||||||
<option value="snack">נשנוש</option>
|
<option value="snack">קינוחים</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -283,16 +612,13 @@ function App() {
|
|||||||
{loadingRandom ? "מחפש מתכון..." : "תן לי רעיון לארוחה 🎲"}
|
{loadingRandom ? "מחפש מתכון..." : "תן לי רעיון לארוחה 🎲"}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Recipe Details Card */}
|
|
||||||
<RecipeDetails
|
|
||||||
recipe={selectedRecipe}
|
|
||||||
onEditClick={handleEditRecipe}
|
|
||||||
onShowDeleteModal={handleShowDeleteModal}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
<RecipeFormDrawer
|
<RecipeFormDrawer
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@ -301,7 +627,9 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
editingRecipe={editingRecipe}
|
editingRecipe={editingRecipe}
|
||||||
|
currentUser={user}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={deleteModal.isOpen}
|
isOpen={deleteModal.isOpen}
|
||||||
@ -314,6 +642,17 @@ function App() {
|
|||||||
onCancel={handleCancelDelete}
|
onCancel={handleCancelDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={logoutModal}
|
||||||
|
title="התנתקות"
|
||||||
|
message="האם אתה בטוח שברצונך להתנתק?"
|
||||||
|
confirmText="התנתק"
|
||||||
|
cancelText="ביטול"
|
||||||
|
isDangerous={false}
|
||||||
|
onConfirm={confirmLogout}
|
||||||
|
onCancel={() => setLogoutModal(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// Get API base from injected env.js or fallback to /api relative path
|
// Get API base from injected env.js or fallback to /api relative path
|
||||||
const getApiBase = () => {
|
export const getApiBase = () => {
|
||||||
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
|
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
|
||||||
return window.__ENV__.API_BASE;
|
return window.__ENV__.API_BASE;
|
||||||
}
|
}
|
||||||
@ -37,10 +37,14 @@ export async function getRandomRecipe(filters) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRecipe(recipe) {
|
export async function createRecipe(recipe, token) {
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE}/recipes`, {
|
const res = await fetch(`${API_BASE}/recipes`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
body: JSON.stringify(recipe),
|
body: JSON.stringify(recipe),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -49,10 +53,14 @@ export async function createRecipe(recipe) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRecipe(id, payload) {
|
export async function updateRecipe(id, payload, token) {
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -61,9 +69,14 @@ export async function updateRecipe(id, payload) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRecipe(id) {
|
export async function deleteRecipe(id, token) {
|
||||||
|
const headers = {};
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
if (!res.ok && res.status !== 204) {
|
if (!res.ok && res.status !== 204) {
|
||||||
throw new Error("Failed to delete recipe");
|
throw new Error("Failed to delete recipe");
|
||||||
|
|||||||
134
frontend/src/authApi.js
Normal file
134
frontend/src/authApi.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
// 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");
|
||||||
|
}
|
||||||
176
frontend/src/components/ChangePassword.jsx
Normal file
176
frontend/src/components/ChangePassword.jsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
490
frontend/src/components/Chat.css
Normal file
490
frontend/src/components/Chat.css
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
.chat-container {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 200px);
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.chat-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
border-left: 1px solid var(--border-subtle);
|
||||||
|
border-right: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-chat {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-chat:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-subtle) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-subtle);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-conversations {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item:hover {
|
||||||
|
background-color: var(--card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item.active {
|
||||||
|
background: linear-gradient(90deg, rgba(102, 126, 234, 0.1) 0%, transparent 100%);
|
||||||
|
border-right: 3px solid #667eea;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-preview {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Chat Area */
|
||||||
|
.chat-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--card);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-selection {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-selection p {
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.messages-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: var(--bg);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-subtle) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-subtle);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-messages {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 70%;
|
||||||
|
animation: slideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.mine {
|
||||||
|
align-self: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.theirs {
|
||||||
|
align-self: flex-end;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-sender {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble {
|
||||||
|
padding: 0.875rem 1.125rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.mine .message-bubble {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.theirs .message-bubble {
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-bottom-left-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message Input */
|
||||||
|
.message-input-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
background: var(--card);
|
||||||
|
box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input-form input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.875rem 1.125rem;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 24px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input-form button {
|
||||||
|
padding: 0.875rem 2rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input-form button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input-form button:disabled {
|
||||||
|
background: var(--border-subtle);
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* New Chat Form */
|
||||||
|
.new-chat-form {
|
||||||
|
padding: 2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-form h3 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-selection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-subtle) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-selection::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-selection::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-selection::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-subtle);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-checkbox:hover {
|
||||||
|
background: var(--card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-checkbox input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name-input {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name-input label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name-input input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1.125rem;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name-input input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-create {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 2px 8px rgba(17, 153, 142, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-create:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(17, 153, 142, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: var(--card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chat-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
height: calc(100vh - 150px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input-form {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input-form button {
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-form {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.chat-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-main {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
268
frontend/src/components/Chat.jsx
Normal file
268
frontend/src/components/Chat.jsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
getConversations,
|
||||||
|
createConversation,
|
||||||
|
getMessages,
|
||||||
|
sendMessage,
|
||||||
|
getFriends,
|
||||||
|
} from "../socialApi";
|
||||||
|
import "./Chat.css";
|
||||||
|
|
||||||
|
export default function Chat({ showToast }) {
|
||||||
|
const [conversations, setConversations] = useState([]);
|
||||||
|
const [selectedConversation, setSelectedConversation] = useState(null);
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [newMessage, setNewMessage] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showNewChat, setShowNewChat] = useState(false);
|
||||||
|
const [friends, setFriends] = useState([]);
|
||||||
|
const [selectedFriends, setSelectedFriends] = useState([]);
|
||||||
|
const [groupName, setGroupName] = useState("");
|
||||||
|
const messagesEndRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConversations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedConversation) {
|
||||||
|
loadMessages(selectedConversation.conversation_id);
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadMessages(selectedConversation.conversation_id, true);
|
||||||
|
}, 3000); // Poll for new messages every 3 seconds
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [selectedConversation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConversations() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getConversations();
|
||||||
|
setConversations(data);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages(conversationId, silent = false) {
|
||||||
|
try {
|
||||||
|
const data = await getMessages(conversationId);
|
||||||
|
setMessages(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendMessage(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newMessage.trim() || !selectedConversation) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendMessage(selectedConversation.conversation_id, newMessage);
|
||||||
|
setNewMessage("");
|
||||||
|
await loadMessages(selectedConversation.conversation_id);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStartNewChat() {
|
||||||
|
try {
|
||||||
|
const friendsData = await getFriends();
|
||||||
|
setFriends(friendsData);
|
||||||
|
setSelectedFriends([]);
|
||||||
|
setGroupName("");
|
||||||
|
setShowNewChat(true);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFriendSelection(friendId) {
|
||||||
|
setSelectedFriends((prev) =>
|
||||||
|
prev.includes(friendId) ? prev.filter((id) => id !== friendId) : [...prev, friendId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateConversation() {
|
||||||
|
if (selectedFriends.length === 0) {
|
||||||
|
showToast("בחר לפחות חבר אחד", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGroup = selectedFriends.length > 1;
|
||||||
|
|
||||||
|
// Only validate group name if it's actually a group chat
|
||||||
|
if (isGroup && !groupName.trim()) {
|
||||||
|
showToast("הכנס שם קבוצה", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Creating conversation with friends:", selectedFriends);
|
||||||
|
console.log("Is group:", isGroup);
|
||||||
|
console.log("Group name:", isGroup ? groupName : "(private chat)");
|
||||||
|
|
||||||
|
const conversation = await createConversation(
|
||||||
|
selectedFriends,
|
||||||
|
isGroup,
|
||||||
|
isGroup ? groupName : null
|
||||||
|
);
|
||||||
|
showToast("השיחה נוצרה בהצלחה!", "success");
|
||||||
|
setShowNewChat(false);
|
||||||
|
setSelectedFriends([]);
|
||||||
|
setGroupName("");
|
||||||
|
await loadConversations();
|
||||||
|
setSelectedConversation(conversation);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="chat-loading">טוען שיחות...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-container">
|
||||||
|
<div className="chat-sidebar">
|
||||||
|
<div className="chat-sidebar-header">
|
||||||
|
<h2>הודעות</h2>
|
||||||
|
<button onClick={handleStartNewChat} className="btn-new-chat">
|
||||||
|
+ חדש
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="conversations-list">
|
||||||
|
{conversations.length === 0 ? (
|
||||||
|
<p className="no-conversations">אין שיחות עדיין</p>
|
||||||
|
) : (
|
||||||
|
conversations.map((conv) => (
|
||||||
|
<div
|
||||||
|
key={conv.conversation_id}
|
||||||
|
className={`conversation-item ${
|
||||||
|
selectedConversation?.conversation_id === conv.conversation_id ? "active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedConversation(conv)}
|
||||||
|
>
|
||||||
|
<div className="conversation-name">
|
||||||
|
{conv.name || conv.other_member_name || "Conversation"}
|
||||||
|
</div>
|
||||||
|
{conv.last_message && (
|
||||||
|
<div className="conversation-preview">{conv.last_message}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-main">
|
||||||
|
{showNewChat ? (
|
||||||
|
<div className="new-chat-form">
|
||||||
|
<h3>שיחה חדשה</h3>
|
||||||
|
<div className="friends-selection">
|
||||||
|
{friends.length === 0 ? (
|
||||||
|
<p>אין חברים לשוחח איתם. הוסף חברים תחילה!</p>
|
||||||
|
) : (
|
||||||
|
friends.map((friend) => (
|
||||||
|
<label key={friend.id} className="friend-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedFriends.includes(friend.id)}
|
||||||
|
onChange={() => toggleFriendSelection(friend.id)}
|
||||||
|
/>
|
||||||
|
<span>{friend.username || friend.email}</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFriends.length > 1 && (
|
||||||
|
<div className="group-name-input">
|
||||||
|
<label>שם קבוצה:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={groupName}
|
||||||
|
onChange={(e) => setGroupName(e.target.value)}
|
||||||
|
placeholder="הכנס שם קבוצה..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="new-chat-actions">
|
||||||
|
<button onClick={handleCreateConversation} className="btn-create">
|
||||||
|
צור
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewChat(false);
|
||||||
|
setSelectedFriends([]);
|
||||||
|
setGroupName("");
|
||||||
|
}}
|
||||||
|
className="btn-cancel"
|
||||||
|
>
|
||||||
|
ביטול
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : selectedConversation ? (
|
||||||
|
<>
|
||||||
|
<div className="chat-header">
|
||||||
|
<h3>{selectedConversation.name || selectedConversation.other_member_name || "שיחה"}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="messages-container">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<p className="no-messages">אין הודעות עדיין. התחל את השיחה!</p>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => (
|
||||||
|
<div key={msg.message_id} className={`message ${msg.is_mine ? "mine" : "theirs"}`}>
|
||||||
|
{!msg.is_mine && (
|
||||||
|
<div className="message-sender">{msg.sender_username || msg.sender_email}</div>
|
||||||
|
)}
|
||||||
|
<div className="message-bubble">{msg.content}</div>
|
||||||
|
<div className="message-time">
|
||||||
|
{new Date(msg.created_at).toLocaleTimeString('he-IL', {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSendMessage} className="message-input-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMessage}
|
||||||
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
|
placeholder="הקלד הודעה..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={!newMessage.trim()}>
|
||||||
|
שלח
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="no-selection">
|
||||||
|
<p>בחר שיחה או התחל שיחה חדשה</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
frontend/src/components/ForgotPassword.jsx
Normal file
100
frontend/src/components/ForgotPassword.jsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
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;
|
||||||
205
frontend/src/components/Friends.css
Normal file
205
frontend/src/components/Friends.css
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
.friends-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-container h2 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-search {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-search-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-search-btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-search-btn:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-tabs button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #666;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-tabs button:hover {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-tabs button.active {
|
||||||
|
color: #007bff;
|
||||||
|
border-bottom-color: #007bff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: white;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-email {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-since {
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-btn-add,
|
||||||
|
.friend-btn-accept {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-btn-add:hover,
|
||||||
|
.friend-btn-accept:hover {
|
||||||
|
background-color: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-btn-remove,
|
||||||
|
.friend-btn-reject {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-btn-remove:hover,
|
||||||
|
.friend-btn-reject:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-status {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.friends-search-input {
|
||||||
|
background-color: #333;
|
||||||
|
color: white;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-tabs {
|
||||||
|
border-bottom-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card {
|
||||||
|
background-color: #1a3a52;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-email {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-since {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
}
|
||||||
242
frontend/src/components/Friends.jsx
Normal file
242
frontend/src/components/Friends.jsx
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
getFriends,
|
||||||
|
getFriendRequests,
|
||||||
|
acceptFriendRequest,
|
||||||
|
rejectFriendRequest,
|
||||||
|
sendFriendRequest,
|
||||||
|
removeFriend,
|
||||||
|
searchUsers,
|
||||||
|
} from "../socialApi";
|
||||||
|
import "./Friends.css";
|
||||||
|
|
||||||
|
export default function Friends({ showToast }) {
|
||||||
|
const [friends, setFriends] = useState([]);
|
||||||
|
const [requests, setRequests] = useState([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState("friends"); // friends | requests | search
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [friendsData, requestsData] = await Promise.all([
|
||||||
|
getFriends(),
|
||||||
|
getFriendRequests(),
|
||||||
|
]);
|
||||||
|
setFriends(friendsData);
|
||||||
|
setRequests(requestsData);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!searchQuery.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await searchUsers(searchQuery);
|
||||||
|
setSearchResults(results);
|
||||||
|
setActiveTab("search");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendRequest(userId) {
|
||||||
|
try {
|
||||||
|
await sendFriendRequest(userId);
|
||||||
|
showToast("Friend request sent!", "success");
|
||||||
|
// Update search results to reflect sent request
|
||||||
|
setSearchResults(
|
||||||
|
searchResults.map((user) =>
|
||||||
|
user.id === userId ? { ...user, request_sent: true } : user
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAcceptRequest(requestId) {
|
||||||
|
try {
|
||||||
|
await acceptFriendRequest(requestId);
|
||||||
|
showToast("Friend request accepted!", "success");
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRejectRequest(requestId) {
|
||||||
|
try {
|
||||||
|
await rejectFriendRequest(requestId);
|
||||||
|
showToast("Friend request rejected", "info");
|
||||||
|
setRequests(requests.filter((req) => req.request_id !== requestId));
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveFriend(friendId) {
|
||||||
|
if (!confirm("Remove this friend?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeFriend(friendId);
|
||||||
|
showToast("Friend removed", "info");
|
||||||
|
setFriends(friends.filter((friend) => friend.user_id !== friendId));
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="friends-loading">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="friends-container">
|
||||||
|
<h2>Friends</h2>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<form onSubmit={handleSearch} className="friends-search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search users by email or username..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="friends-search-input"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="friends-search-btn">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="friends-tabs">
|
||||||
|
<button
|
||||||
|
className={activeTab === "friends" ? "active" : ""}
|
||||||
|
onClick={() => setActiveTab("friends")}
|
||||||
|
>
|
||||||
|
Friends ({friends.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={activeTab === "requests" ? "active" : ""}
|
||||||
|
onClick={() => setActiveTab("requests")}
|
||||||
|
>
|
||||||
|
Requests ({requests.length})
|
||||||
|
</button>
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<button
|
||||||
|
className={activeTab === "search" ? "active" : ""}
|
||||||
|
onClick={() => setActiveTab("search")}
|
||||||
|
>
|
||||||
|
Search Results
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Friends List */}
|
||||||
|
{activeTab === "friends" && (
|
||||||
|
<div className="friends-list">
|
||||||
|
{friends.length === 0 ? (
|
||||||
|
<p className="friends-empty">No friends yet. Search for users to add!</p>
|
||||||
|
) : (
|
||||||
|
friends.map((friend) => (
|
||||||
|
<div key={friend.id} className="friend-card">
|
||||||
|
<div className="friend-info">
|
||||||
|
<div className="friend-name">{friend.username || friend.email}</div>
|
||||||
|
<div className="friend-email">{friend.email}</div>
|
||||||
|
<div className="friend-since">
|
||||||
|
Friends since {new Date(friend.friends_since).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveFriend(friend.id)}
|
||||||
|
className="friend-btn-remove"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Friend Requests */}
|
||||||
|
{activeTab === "requests" && (
|
||||||
|
<div className="friends-list">
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<p className="friends-empty">No pending friend requests</p>
|
||||||
|
) : (
|
||||||
|
requests.map((request) => (
|
||||||
|
<div key={request.request_id} className="friend-card request-card">
|
||||||
|
<div className="friend-info">
|
||||||
|
<div className="friend-name">
|
||||||
|
{request.sender_username || request.sender_email}
|
||||||
|
</div>
|
||||||
|
<div className="friend-email">{request.sender_email}</div>
|
||||||
|
<div className="friend-since">
|
||||||
|
Sent {new Date(request.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="friend-actions">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAcceptRequest(request.request_id)}
|
||||||
|
className="friend-btn-accept"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRejectRequest(request.request_id)}
|
||||||
|
className="friend-btn-reject"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Results */}
|
||||||
|
{activeTab === "search" && (
|
||||||
|
<div className="friends-list">
|
||||||
|
{searchResults.length === 0 ? (
|
||||||
|
<p className="friends-empty">No users found</p>
|
||||||
|
) : (
|
||||||
|
searchResults.map((user) => (
|
||||||
|
<div key={user.id} className="friend-card">
|
||||||
|
<div className="friend-info">
|
||||||
|
<div className="friend-name">{user.username || user.email}</div>
|
||||||
|
<div className="friend-email">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
{user.is_friend ? (
|
||||||
|
<span className="friend-status">Friends</span>
|
||||||
|
) : user.request_sent ? (
|
||||||
|
<span className="friend-status">Request Sent</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSendRequest(user.id)}
|
||||||
|
className="friend-btn-add"
|
||||||
|
>
|
||||||
|
Add Friend
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1305
frontend/src/components/GroceryLists.jsx
Normal file
1305
frontend/src/components/GroceryLists.jsx
Normal file
File diff suppressed because it is too large
Load Diff
542
frontend/src/components/Groups.css
Normal file
542
frontend/src/components/Groups.css
Normal file
@ -0,0 +1,542 @@
|
|||||||
|
.groups-container {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 200px);
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.groups-sidebar {
|
||||||
|
width: 300px;
|
||||||
|
border-right: 1px solid #ddd;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-sidebar-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-group {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-group:hover {
|
||||||
|
background-color: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-groups {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item.active {
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border-left: 3px solid #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Area */
|
||||||
|
.groups-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-selection {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-selection p {
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group Header */
|
||||||
|
.group-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-description {
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.group-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-tabs button {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #666;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-tabs button:hover {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-tabs button.active {
|
||||||
|
color: #28a745;
|
||||||
|
border-bottom-color: #28a745;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.group-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-share,
|
||||||
|
.btn-add-member {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-share:hover,
|
||||||
|
.btn-add-member:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipes Grid */
|
||||||
|
.recipes-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card-mini {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card-mini:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card-mini .recipe-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card-mini .recipe-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Members List */
|
||||||
|
.members-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-member {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-member:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create Group Form */
|
||||||
|
.create-group-form {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-group-form h3 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input[type="text"],
|
||||||
|
.form-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-create {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-create:hover {
|
||||||
|
background-color: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
.share-recipe-modal,
|
||||||
|
.add-member-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-selection,
|
||||||
|
.friends-selection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-option,
|
||||||
|
.friend-option {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-option:hover,
|
||||||
|
.friend-option:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-option button,
|
||||||
|
.friend-option button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-option button:hover,
|
||||||
|
.friend-option button:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.groups-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
height: calc(100vh - 150px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-sidebar {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.groups-container {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-sidebar {
|
||||||
|
border-right-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-sidebar-header {
|
||||||
|
border-bottom-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item {
|
||||||
|
border-bottom-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item:hover {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item.active {
|
||||||
|
background-color: #1a3a52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-stats {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
background-color: #333;
|
||||||
|
border-bottom-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-description {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-tabs {
|
||||||
|
border-bottom-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card-mini,
|
||||||
|
.member-item {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card-mini .recipe-meta {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-option,
|
||||||
|
.friend-option {
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-option:hover,
|
||||||
|
.friend-option:hover {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input[type="text"],
|
||||||
|
.form-field textarea {
|
||||||
|
background-color: #333;
|
||||||
|
color: white;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
378
frontend/src/components/Groups.jsx
Normal file
378
frontend/src/components/Groups.jsx
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
getGroups,
|
||||||
|
createGroup,
|
||||||
|
getGroup,
|
||||||
|
addGroupMember,
|
||||||
|
removeGroupMember,
|
||||||
|
shareRecipeToGroup,
|
||||||
|
getGroupRecipes,
|
||||||
|
getFriends,
|
||||||
|
} from "../socialApi";
|
||||||
|
import { getRecipes } from "../api";
|
||||||
|
import "./Groups.css";
|
||||||
|
|
||||||
|
export default function Groups({ showToast, onRecipeSelect }) {
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState(null);
|
||||||
|
const [groupDetails, setGroupDetails] = useState(null);
|
||||||
|
const [groupRecipes, setGroupRecipes] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState("groups"); // groups | create | members | recipes
|
||||||
|
|
||||||
|
// Create group form
|
||||||
|
const [newGroupName, setNewGroupName] = useState("");
|
||||||
|
const [newGroupDescription, setNewGroupDescription] = useState("");
|
||||||
|
const [isPrivate, setIsPrivate] = useState(true);
|
||||||
|
|
||||||
|
// Add member
|
||||||
|
const [friends, setFriends] = useState([]);
|
||||||
|
const [showAddMember, setShowAddMember] = useState(false);
|
||||||
|
|
||||||
|
// Share recipe
|
||||||
|
const [myRecipes, setMyRecipes] = useState([]);
|
||||||
|
const [showShareRecipe, setShowShareRecipe] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGroups();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedGroup) {
|
||||||
|
loadGroupDetails();
|
||||||
|
loadGroupRecipes();
|
||||||
|
}
|
||||||
|
}, [selectedGroup]);
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getGroups();
|
||||||
|
setGroups(data);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroupDetails() {
|
||||||
|
try {
|
||||||
|
const data = await getGroup(selectedGroup.group_id);
|
||||||
|
setGroupDetails(data);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroupRecipes() {
|
||||||
|
try {
|
||||||
|
const data = await getGroupRecipes(selectedGroup.group_id);
|
||||||
|
setGroupRecipes(data);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateGroup(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newGroupName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createGroup(newGroupName, newGroupDescription, isPrivate);
|
||||||
|
showToast("הקבוצה נוצרה!", "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>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
frontend/src/components/Login.jsx
Normal file
181
frontend/src/components/Login.jsx
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
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;
|
||||||
221
frontend/src/components/NotificationBell.css
Normal file
221
frontend/src/components/NotificationBell.css
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
.notification-bell-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell-btn {
|
||||||
|
position: relative;
|
||||||
|
background: var(--card-soft);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell-btn:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 10px);
|
||||||
|
right: 0;
|
||||||
|
width: 420px;
|
||||||
|
max-height: 550px;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0.98;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--card-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list::-webkit-scrollbar-track {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.unread::before {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.unread {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.unread:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.read .notification-message {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-time {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-small {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 0.4rem 0.65rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-small:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-small:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-small.delete {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-small.delete:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
209
frontend/src/components/NotificationBell.jsx
Normal file
209
frontend/src/components/NotificationBell.jsx
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
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;
|
||||||
222
frontend/src/components/PinnedGroceryLists.jsx
Normal file
222
frontend/src/components/PinnedGroceryLists.jsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
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;
|
||||||
302
frontend/src/components/RatingsComments.css
Normal file
302
frontend/src/components/RatingsComments.css
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
.ratings-comments-container {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 2px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ratings-section,
|
||||||
|
.comments-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ratings-section h3,
|
||||||
|
.comments-section h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ratings-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ratings */
|
||||||
|
.rating-display {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avg-rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-number {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars-display {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars-display .star {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars-display .star.filled {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-count {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-ratings {
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars-input .star {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #ddd;
|
||||||
|
transition: color 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars-input .star.filled {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars-input .star:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comments */
|
||||||
|
.comment-form {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-indicator {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border-left: 3px solid #007bff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-indicator button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #007bff;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form button[type="submit"] {
|
||||||
|
align-self: flex-end;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form button[type="submit"]:hover:not(:disabled) {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form button[type="submit"]:disabled {
|
||||||
|
background-color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-comments {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment.reply {
|
||||||
|
margin-left: 2rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-left: 3px solid #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #007bff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions button:hover {
|
||||||
|
color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edit-form textarea {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edit-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edit-actions button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edit-actions button:first-child {
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edit-actions button:first-child:hover {
|
||||||
|
background-color: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edit-actions button:last-child {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edit-actions button:last-child:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.ratings-comments-container {
|
||||||
|
border-top-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-rating {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea {
|
||||||
|
background-color: #333;
|
||||||
|
color: white;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment.reply {
|
||||||
|
background-color: #1a3a52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edit-form textarea {
|
||||||
|
background-color: #333;
|
||||||
|
color: white;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
267
frontend/src/components/RatingsComments.jsx
Normal file
267
frontend/src/components/RatingsComments.jsx
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
rateRecipe,
|
||||||
|
getRecipeRatingStats,
|
||||||
|
getMyRecipeRating,
|
||||||
|
addComment,
|
||||||
|
getRecipeComments,
|
||||||
|
updateComment,
|
||||||
|
deleteComment,
|
||||||
|
} from "../socialApi";
|
||||||
|
import "./RatingsComments.css";
|
||||||
|
|
||||||
|
export default function RatingsComments({ recipeId, isAuthenticated, showToast }) {
|
||||||
|
const [ratingStats, setRatingStats] = useState(null);
|
||||||
|
const [myRating, setMyRating] = useState(0);
|
||||||
|
const [hoverRating, setHoverRating] = useState(0);
|
||||||
|
const [comments, setComments] = useState([]);
|
||||||
|
const [newComment, setNewComment] = useState("");
|
||||||
|
const [replyTo, setReplyTo] = useState(null);
|
||||||
|
const [editingComment, setEditingComment] = useState(null);
|
||||||
|
const [editContent, setEditContent] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [recipeId]);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const statsPromise = getRecipeRatingStats(recipeId);
|
||||||
|
const commentsPromise = getRecipeComments(recipeId);
|
||||||
|
|
||||||
|
const [stats, commentsData] = await Promise.all([statsPromise, commentsPromise]);
|
||||||
|
|
||||||
|
setRatingStats(stats);
|
||||||
|
setComments(commentsData);
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
try {
|
||||||
|
const myRatingData = await getMyRecipeRating(recipeId);
|
||||||
|
setMyRating(myRatingData.rating || 0);
|
||||||
|
} catch (error) {
|
||||||
|
// User hasn't rated yet
|
||||||
|
setMyRating(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRate(rating) {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
showToast("נא להתחבר כדי לדרג", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rateRecipe(recipeId, rating);
|
||||||
|
setMyRating(rating);
|
||||||
|
showToast("דירוג נשלח בהצלחה!", "success");
|
||||||
|
// Reload stats
|
||||||
|
const stats = await getRecipeRatingStats(recipeId);
|
||||||
|
setRatingStats(stats);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddComment(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
showToast("נא להתחבר כדי להגיב", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newComment.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addComment(recipeId, newComment, replyTo);
|
||||||
|
setNewComment("");
|
||||||
|
setReplyTo(null);
|
||||||
|
showToast("תגובה נוספה!", "success");
|
||||||
|
const commentsData = await getRecipeComments(recipeId);
|
||||||
|
setComments(commentsData);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditComment(commentId, content) {
|
||||||
|
try {
|
||||||
|
await updateComment(commentId, content);
|
||||||
|
setEditingComment(null);
|
||||||
|
setEditContent("");
|
||||||
|
showToast("תגובה עודכנה!", "success");
|
||||||
|
const commentsData = await getRecipeComments(recipeId);
|
||||||
|
setComments(commentsData);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteComment(commentId) {
|
||||||
|
if (!confirm("למחוק תגובה זו?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteComment(commentId);
|
||||||
|
showToast("תגובה נמחקה", "info");
|
||||||
|
const commentsData = await getRecipeComments(recipeId);
|
||||||
|
setComments(commentsData);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditing(comment) {
|
||||||
|
setEditingComment(comment.comment_id);
|
||||||
|
setEditContent(comment.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditing() {
|
||||||
|
setEditingComment(null);
|
||||||
|
setEditContent("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReply(commentId) {
|
||||||
|
setReplyTo(commentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="ratings-loading">טוען...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ratings-comments-container">
|
||||||
|
{/* Ratings Section */}
|
||||||
|
<div className="ratings-section">
|
||||||
|
<h3>דירוג</h3>
|
||||||
|
<div className="rating-display">
|
||||||
|
{ratingStats && ratingStats.total_ratings > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="avg-rating">
|
||||||
|
<span className="rating-number">{ratingStats.average_rating.toFixed(1)}</span>
|
||||||
|
<div className="stars-display">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<span
|
||||||
|
key={star}
|
||||||
|
className={star <= Math.round(ratingStats.average_rating) ? "star filled" : "star"}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="rating-count">({ratingStats.total_ratings} דירוגים)</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="no-ratings">אין דירוגים עדיין</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
|
<div className="my-rating">
|
||||||
|
<span>הדירוג שלך:</span>
|
||||||
|
<div className="stars-input">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
className={star <= (hoverRating || myRating) ? "star filled" : "star"}
|
||||||
|
onMouseEnter={() => setHoverRating(star)}
|
||||||
|
onMouseLeave={() => setHoverRating(0)}
|
||||||
|
onClick={() => handleRate(star)}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments Section */}
|
||||||
|
<div className="comments-section">
|
||||||
|
<h3>תגובות ({comments.length})</h3>
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
|
<form onSubmit={handleAddComment} className="comment-form">
|
||||||
|
{replyTo && (
|
||||||
|
<div className="reply-indicator">
|
||||||
|
משיב לתגובה...{" "}
|
||||||
|
<button type="button" onClick={() => setReplyTo(null)}>
|
||||||
|
ביטול
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
placeholder="הוסף תגובה..."
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={!newComment.trim()}>
|
||||||
|
פרסם תגובה
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="comments-list">
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<p className="no-comments">אין תגובות עדיין. היה הראשון!</p>
|
||||||
|
) : (
|
||||||
|
comments.map((comment) => (
|
||||||
|
<div
|
||||||
|
key={comment.comment_id}
|
||||||
|
className={`comment ${comment.parent_comment_id ? "reply" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="comment-header">
|
||||||
|
<span className="comment-author">{comment.author_username || comment.author_email}</span>
|
||||||
|
<span className="comment-date">
|
||||||
|
{new Date(comment.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingComment === comment.comment_id ? (
|
||||||
|
<div className="comment-edit-form">
|
||||||
|
<textarea
|
||||||
|
value={editContent}
|
||||||
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<div className="comment-edit-actions">
|
||||||
|
<button onClick={() => handleEditComment(comment.comment_id, editContent)}>
|
||||||
|
שמור
|
||||||
|
</button>
|
||||||
|
<button onClick={cancelEditing}>ביטול</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="comment-content">{comment.content}</div>
|
||||||
|
<div className="comment-actions">
|
||||||
|
{isAuthenticated && !comment.parent_comment_id && (
|
||||||
|
<button onClick={() => startReply(comment.comment_id)}>השב</button>
|
||||||
|
)}
|
||||||
|
{isAuthenticated && comment.is_author && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => startEditing(comment)}>ערוך</button>
|
||||||
|
<button onClick={() => handleDeleteComment(comment.comment_id)}>
|
||||||
|
מחק
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import placeholderImage from "../assets/placeholder.svg";
|
import placeholderImage from "../assets/placeholder.svg";
|
||||||
|
import RatingsComments from "./RatingsComments";
|
||||||
|
|
||||||
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) {
|
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal, isAuthenticated, currentUser, showToast }) {
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
return (
|
return (
|
||||||
<section className="panel placeholder">
|
<section className="panel placeholder">
|
||||||
@ -13,6 +14,15 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
|||||||
onShowDeleteModal(recipe.id, recipe.name);
|
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 (
|
return (
|
||||||
<section className="panel recipe-card">
|
<section className="panel recipe-card">
|
||||||
{/* Recipe Image */}
|
{/* Recipe Image */}
|
||||||
@ -26,8 +36,8 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
|||||||
<p className="recipe-subtitle">
|
<p className="recipe-subtitle">
|
||||||
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
|
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
|
||||||
</p>
|
</p>
|
||||||
{recipe.made_by && (
|
{(recipe.owner_display_name || recipe.made_by) && (
|
||||||
<h4 className="recipe-made-by">המתכון של: {recipe.made_by}</h4>
|
<h4 className="recipe-made-by">המתכון של: {recipe.owner_display_name || recipe.made_by}</h4>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pill-row">
|
<div className="pill-row">
|
||||||
@ -66,6 +76,7 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
|||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAuthenticated && currentUser && (Number(recipe.user_id) === Number(currentUser.id) || currentUser.is_admin) && (
|
||||||
<div className="recipe-actions">
|
<div className="recipe-actions">
|
||||||
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
|
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
|
||||||
✏️ ערוך
|
✏️ ערוך
|
||||||
@ -74,6 +85,14 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
|||||||
🗑 מחק
|
🗑 מחק
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ratings and Comments */}
|
||||||
|
<RatingsComments
|
||||||
|
recipeId={recipe.id}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
showToast={showToast}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -87,7 +106,7 @@ function translateMealType(type) {
|
|||||||
case "dinner":
|
case "dinner":
|
||||||
return "ערב";
|
return "ערב";
|
||||||
case "snack":
|
case "snack":
|
||||||
return "נשנוש";
|
return "קינוחים";
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -148,7 +148,7 @@ function translateMealType(type) {
|
|||||||
case "dinner":
|
case "dinner":
|
||||||
return "ערב";
|
return "ערב";
|
||||||
case "snack":
|
case "snack":
|
||||||
return "נשנוש";
|
return "קינוחים";
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,20 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
|
||||||
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, currentUser = null }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [mealType, setMealType] = useState("lunch");
|
const [mealType, setMealType] = useState("lunch");
|
||||||
const [timeMinutes, setTimeMinutes] = useState(15);
|
const [timeMinutes, setTimeMinutes] = useState(15);
|
||||||
const [madeBy, setMadeBy] = useState("");
|
const [madeBy, setMadeBy] = useState("");
|
||||||
const [tags, setTags] = useState("");
|
const [tags, setTags] = useState("");
|
||||||
const [image, setImage] = useState("");
|
const [image, setImage] = useState("");
|
||||||
|
const [visibility, setVisibility] = useState("public");
|
||||||
|
|
||||||
const [ingredients, setIngredients] = useState([""]);
|
const [ingredients, setIngredients] = useState([""]);
|
||||||
const [steps, setSteps] = useState([""]);
|
const [steps, setSteps] = useState([""]);
|
||||||
|
|
||||||
|
const lastIngredientRef = useRef(null);
|
||||||
|
const lastStepRef = useRef(null);
|
||||||
|
|
||||||
const isEditMode = !!editingRecipe;
|
const isEditMode = !!editingRecipe;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -20,27 +24,33 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
setMealType(editingRecipe.meal_type || "lunch");
|
setMealType(editingRecipe.meal_type || "lunch");
|
||||||
setTimeMinutes(editingRecipe.time_minutes || 15);
|
setTimeMinutes(editingRecipe.time_minutes || 15);
|
||||||
setMadeBy(editingRecipe.made_by || "");
|
setMadeBy(editingRecipe.made_by || "");
|
||||||
setTags((editingRecipe.tags || []).join(", "));
|
setTags((editingRecipe.tags || []).join(" "));
|
||||||
setImage(editingRecipe.image || "");
|
setImage(editingRecipe.image || "");
|
||||||
|
setVisibility(editingRecipe.visibility || "public");
|
||||||
setIngredients(editingRecipe.ingredients || [""]);
|
setIngredients(editingRecipe.ingredients || [""]);
|
||||||
setSteps(editingRecipe.steps || [""]);
|
setSteps(editingRecipe.steps || [""]);
|
||||||
} else {
|
} else {
|
||||||
setName("");
|
setName("");
|
||||||
setMealType("lunch");
|
setMealType("lunch");
|
||||||
setTimeMinutes(15);
|
setTimeMinutes(15);
|
||||||
setMadeBy("");
|
setMadeBy(currentUser?.username || "");
|
||||||
setTags("");
|
setTags("");
|
||||||
setImage("");
|
setImage("");
|
||||||
|
setVisibility("public");
|
||||||
setIngredients([""]);
|
setIngredients([""]);
|
||||||
setSteps([""]);
|
setSteps([""]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, editingRecipe, isEditMode]);
|
}, [open, editingRecipe, isEditMode, currentUser]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const handleAddIngredient = () => {
|
const handleAddIngredient = () => {
|
||||||
setIngredients((prev) => [...prev, ""]);
|
setIngredients((prev) => [...prev, ""]);
|
||||||
|
setTimeout(() => {
|
||||||
|
lastIngredientRef.current?.focus();
|
||||||
|
lastIngredientRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeIngredient = (idx, value) => {
|
const handleChangeIngredient = (idx, value) => {
|
||||||
@ -53,6 +63,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
|
|
||||||
const handleAddStep = () => {
|
const handleAddStep = () => {
|
||||||
setSteps((prev) => [...prev, ""]);
|
setSteps((prev) => [...prev, ""]);
|
||||||
|
setTimeout(() => {
|
||||||
|
lastStepRef.current?.focus();
|
||||||
|
lastStepRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeStep = (idx, value) => {
|
const handleChangeStep = (idx, value) => {
|
||||||
@ -84,7 +98,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
const cleanIngredients = ingredients.map((s) => s.trim()).filter(Boolean);
|
const cleanIngredients = ingredients.map((s) => s.trim()).filter(Boolean);
|
||||||
const cleanSteps = steps.map((s) => s.trim()).filter(Boolean);
|
const cleanSteps = steps.map((s) => s.trim()).filter(Boolean);
|
||||||
const tagsArr = tags
|
const tagsArr = tags
|
||||||
.split(",")
|
.split(" ")
|
||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
@ -95,12 +109,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
tags: tagsArr,
|
tags: tagsArr,
|
||||||
ingredients: cleanIngredients,
|
ingredients: cleanIngredients,
|
||||||
steps: cleanSteps,
|
steps: cleanSteps,
|
||||||
|
made_by: madeBy.trim() || currentUser?.username || "",
|
||||||
|
visibility: visibility,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (madeBy.trim()) {
|
|
||||||
payload.made_by = madeBy.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (image) {
|
if (image) {
|
||||||
payload.image = image;
|
payload.image = image;
|
||||||
}
|
}
|
||||||
@ -136,10 +148,20 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
<option value="breakfast">בוקר</option>
|
<option value="breakfast">בוקר</option>
|
||||||
<option value="lunch">צהריים</option>
|
<option value="lunch">צהריים</option>
|
||||||
<option value="dinner">ערב</option>
|
<option value="dinner">ערב</option>
|
||||||
<option value="snack">נשנוש</option>
|
<option value="snack">קינוחים</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="field">
|
||||||
<label>זמן הכנה (דקות)</label>
|
<label>זמן הכנה (דקות)</label>
|
||||||
<input
|
<input
|
||||||
@ -150,7 +172,6 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>המתכון של:</label>
|
<label>המתכון של:</label>
|
||||||
@ -191,11 +212,11 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>תגיות (מופרד בפסיקים)</label>
|
<label>תגיות (מופרד ברווחים)</label>
|
||||||
<input
|
<input
|
||||||
value={tags}
|
value={tags}
|
||||||
onChange={(e) => setTags(e.target.value)}
|
onChange={(e) => setTags(e.target.value)}
|
||||||
placeholder="מהיר, טבעוני, משפחתי..."
|
placeholder="מהיר טבעוני משפחתי..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -205,6 +226,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
{ingredients.map((val, idx) => (
|
{ingredients.map((val, idx) => (
|
||||||
<div key={idx} className="dynamic-row">
|
<div key={idx} className="dynamic-row">
|
||||||
<input
|
<input
|
||||||
|
ref={idx === ingredients.length - 1 ? lastIngredientRef : null}
|
||||||
value={val}
|
value={val}
|
||||||
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
|
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
|
||||||
placeholder="למשל: 2 ביצים"
|
placeholder="למשל: 2 ביצים"
|
||||||
@ -234,6 +256,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
{steps.map((val, idx) => (
|
{steps.map((val, idx) => (
|
||||||
<div key={idx} className="dynamic-row">
|
<div key={idx} className="dynamic-row">
|
||||||
<input
|
<input
|
||||||
|
ref={idx === steps.length - 1 ? lastStepRef : null}
|
||||||
value={val}
|
value={val}
|
||||||
onChange={(e) => handleChangeStep(idx, e.target.value)}
|
onChange={(e) => handleChangeStep(idx, e.target.value)}
|
||||||
placeholder="למשל: לחמם את התנור ל־180 מעלות"
|
placeholder="למשל: לחמם את התנור ל־180 מעלות"
|
||||||
|
|||||||
@ -42,7 +42,7 @@ function translateMealType(type) {
|
|||||||
case "dinner":
|
case "dinner":
|
||||||
return "ערב";
|
return "ערב";
|
||||||
case "snack":
|
case "snack":
|
||||||
return "נשנוש";
|
return "קינוחים";
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,8 +14,8 @@ function RecipeSearchList({
|
|||||||
onMaxTimeChange,
|
onMaxTimeChange,
|
||||||
filterTags,
|
filterTags,
|
||||||
onTagsChange,
|
onTagsChange,
|
||||||
filterMadeBy,
|
filterOwner,
|
||||||
onMadeByChange,
|
onOwnerChange,
|
||||||
}) {
|
}) {
|
||||||
const [expandFilters, setExpandFilters] = useState(false);
|
const [expandFilters, setExpandFilters] = useState(false);
|
||||||
|
|
||||||
@ -27,8 +27,14 @@ function RecipeSearchList({
|
|||||||
// Extract unique meal types from ALL recipes (not filtered)
|
// Extract unique meal types from ALL recipes (not filtered)
|
||||||
const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort();
|
const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort();
|
||||||
|
|
||||||
// Extract unique made_by from ALL recipes (not filtered)
|
// Extract unique made_by (username) from ALL recipes and map to display names
|
||||||
const allMadeBy = Array.from(new Set(allRecipes.map((r) => r.made_by).filter(Boolean))).sort();
|
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 max time for slider from ALL recipes (not filtered) - add 10 to make it comfortable to select max time recipes
|
// 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);
|
const maxTimeInRecipes = Math.max(...allRecipes.map((r) => r.time_minutes), 0);
|
||||||
@ -47,10 +53,10 @@ function RecipeSearchList({
|
|||||||
onMealTypeChange("");
|
onMealTypeChange("");
|
||||||
onMaxTimeChange("");
|
onMaxTimeChange("");
|
||||||
onTagsChange([]);
|
onTagsChange([]);
|
||||||
onMadeByChange("");
|
onOwnerChange("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterMadeBy;
|
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterOwner;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel secondary recipe-search-list">
|
<section className="panel secondary recipe-search-list">
|
||||||
@ -165,18 +171,18 @@ function RecipeSearchList({
|
|||||||
<label className="filter-label">המתכונים של:</label>
|
<label className="filter-label">המתכונים של:</label>
|
||||||
<div className="filter-options">
|
<div className="filter-options">
|
||||||
<button
|
<button
|
||||||
className={`filter-btn ${filterMadeBy === "" ? "active" : ""}`}
|
className={`filter-btn ${filterOwner === "" ? "active" : ""}`}
|
||||||
onClick={() => onMadeByChange("")}
|
onClick={() => onOwnerChange("")}
|
||||||
>
|
>
|
||||||
הכל
|
הכל
|
||||||
</button>
|
</button>
|
||||||
{allMadeBy.map((person) => (
|
{allMadeBy.map((madeBy) => (
|
||||||
<button
|
<button
|
||||||
key={person}
|
key={madeBy}
|
||||||
className={`filter-btn ${filterMadeBy === person ? "active" : ""}`}
|
className={`filter-btn ${filterOwner === madeBy ? "active" : ""}`}
|
||||||
onClick={() => onMadeByChange(person)}
|
onClick={() => onOwnerChange(madeBy)}
|
||||||
>
|
>
|
||||||
{person}
|
{madeByMap.get(madeBy) || madeBy}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -239,7 +245,7 @@ function translateMealType(type) {
|
|||||||
case "dinner":
|
case "dinner":
|
||||||
return "ערב";
|
return "ערב";
|
||||||
case "snack":
|
case "snack":
|
||||||
return "נשנוש";
|
return "קינוחים";
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|||||||
164
frontend/src/components/Register.jsx
Normal file
164
frontend/src/components/Register.jsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
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;
|
||||||
108
frontend/src/components/ResetPassword.jsx
Normal file
108
frontend/src/components/ResetPassword.jsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
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,4 +1,6 @@
|
|||||||
function TopBar({ onAddClick }) {
|
import NotificationBell from "./NotificationBell";
|
||||||
|
|
||||||
|
function TopBar({ onAddClick, user, onLogout, onShowToast }) {
|
||||||
return (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
@ -11,10 +13,20 @@ function TopBar({ onAddClick }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
<div className="topbar-actions">
|
||||||
<button className="btn primary" onClick={onAddClick}>
|
{user && <NotificationBell onShowToast={onShowToast} />}
|
||||||
+ מתכון חדש
|
{user && (
|
||||||
|
<button className="btn primary btn-mobile-compact" onClick={onAddClick}>
|
||||||
|
<span className="btn-text-desktop">+ מתכון חדש</span>
|
||||||
|
<span className="btn-text-mobile">+</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
131
frontend/src/groceryApi.js
Normal file
131
frontend/src/groceryApi.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
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();
|
||||||
|
};
|
||||||
77
frontend/src/notificationApi.js
Normal file
77
frontend/src/notificationApi.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
312
frontend/src/socialApi.js
Normal file
312
frontend/src/socialApi.js
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
import { getApiBase } from "./api";
|
||||||
|
import { getToken } from "./authApi";
|
||||||
|
|
||||||
|
const API_BASE = getApiBase();
|
||||||
|
|
||||||
|
// ============= Friends API =============
|
||||||
|
|
||||||
|
export async function sendFriendRequest(receiverId) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/friends/request`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ receiver_id: receiverId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to send friend request");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFriendRequests() {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/friends/requests`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch friend requests");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptFriendRequest(requestId) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/friends/requests/${requestId}/accept`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to accept friend request");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectFriendRequest(requestId) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/friends/requests/${requestId}/reject`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to reject friend request");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFriends() {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/friends`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch friends");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFriend(friendId) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/friends/${friendId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to remove friend");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchUsers(query) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/friends/search?q=${encodeURIComponent(query)}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to search users");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Chat API =============
|
||||||
|
|
||||||
|
export async function getConversations() {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/conversations`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch conversations");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConversation(userIds, isGroup = false, name = null) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/conversations`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ user_ids: userIds, is_group: isGroup, name }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
console.error("Create conversation error:", error);
|
||||||
|
throw new Error(error.detail || JSON.stringify(error) || "Failed to create conversation");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMessages(conversationId, limit = 50, beforeId = null) {
|
||||||
|
const token = getToken();
|
||||||
|
let url = `${API_BASE}/conversations/${conversationId}/messages?limit=${limit}`;
|
||||||
|
if (beforeId) url += `&before_id=${beforeId}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch messages");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessage(conversationId, content) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/conversations/${conversationId}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to send message");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Groups API =============
|
||||||
|
|
||||||
|
export async function getGroups() {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/groups`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch groups");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGroup(name, description = "", isPrivate = false) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/groups`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name, description, is_private: isPrivate }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to create group");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGroup(groupId) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/groups/${groupId}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch group");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addGroupMember(groupId, userId) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/groups/${groupId}/members/${userId}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to add member");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeGroupMember(groupId, userId) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/groups/${groupId}/members/${userId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to remove member");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shareRecipeToGroup(groupId, recipeId) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/groups/${groupId}/recipes/${recipeId}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to share recipe");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGroupRecipes(groupId) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/groups/${groupId}/recipes`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch group recipes");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Ratings & Comments API =============
|
||||||
|
|
||||||
|
export async function rateRecipe(recipeId, rating) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/recipes/${recipeId}/rating`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ rating }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to rate recipe");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecipeRatingStats(recipeId) {
|
||||||
|
const res = await fetch(`${API_BASE}/recipes/${recipeId}/rating/stats`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch rating stats");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMyRecipeRating(recipeId) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/recipes/${recipeId}/rating/mine`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch my rating");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addComment(recipeId, content, parentCommentId = null) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/recipes/${recipeId}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content, parent_comment_id: parentCommentId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to add comment");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecipeComments(recipeId) {
|
||||||
|
const res = await fetch(`${API_BASE}/recipes/${recipeId}/comments`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch comments");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateComment(commentId, content) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/comments/${commentId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update comment");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteComment(commentId) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/comments/${commentId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to delete comment");
|
||||||
|
}
|
||||||
@ -5,4 +5,14 @@ import react from '@vitejs/plugin-react'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
assetsInclude: ['**/*.svg'],
|
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