Complete test version
@ -5,8 +5,9 @@ class User:
|
||||
|
||||
TABLE_NAME = "users"
|
||||
|
||||
def __init__(self, id, email, hashed_password, created_at=None, updated_at=None):
|
||||
def __init__(self, id, username, email, hashed_password, created_at=None, updated_at=None):
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.email = email
|
||||
self.hashed_password = hashed_password
|
||||
self.created_at = created_at or datetime.utcnow()
|
||||
@ -15,6 +16,7 @@ class User:
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"username": self.username,
|
||||
"email": self.email,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
|
||||
@ -43,3 +43,17 @@ def send_message(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
@router.post("/conversations/{conversation_id}/mark-read")
|
||||
def mark_messages_as_read(
|
||||
conversation_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Mark all unread messages in a conversation as read"""
|
||||
try:
|
||||
return ChatService.mark_messages_as_read(current_user["user_id"], conversation_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
@ -19,7 +19,30 @@ def like_user(
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
@router.delete("/{liked_user_id}")
|
||||
def unlike_user(
|
||||
liked_user_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Unlike another user"""
|
||||
return LikeService.unlike_user(current_user["user_id"], liked_user_id)
|
||||
|
||||
@router.get("/received/list")
|
||||
def get_likes_received(current_user: dict = Depends(get_current_user)):
|
||||
"""Get all likes received (unmatched)"""
|
||||
return LikeService.get_likes_received(current_user["user_id"])
|
||||
|
||||
@router.get("/matches/list")
|
||||
def get_matches(current_user: dict = Depends(get_current_user)):
|
||||
"""Get all matches"""
|
||||
return LikeService.get_matches(current_user["user_id"])
|
||||
|
||||
@router.post("/received/acknowledge")
|
||||
def acknowledge_likes(current_user: dict = Depends(get_current_user)):
|
||||
"""Mark all received likes as acknowledged"""
|
||||
return LikeService.acknowledge_likes(current_user["user_id"])
|
||||
|
||||
@router.get("/sent/list")
|
||||
def get_liked_profiles(current_user: dict = Depends(get_current_user)):
|
||||
"""Get all profiles that current user has liked"""
|
||||
return {"liked_ids": LikeService.get_liked_profiles(current_user["user_id"])}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
email: EmailStr
|
||||
password: str
|
||||
password: str = Field(..., min_length=6)
|
||||
display_name: str
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
email_or_username: str
|
||||
password: str
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from .message import MessageResponse
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
@ -8,5 +8,7 @@ class ConversationResponse(BaseModel):
|
||||
user_id_2: int
|
||||
other_user_display_name: str
|
||||
other_user_id: int
|
||||
other_user_photo: Optional[str] = None
|
||||
latest_message: str = ""
|
||||
unread_count: int = 0
|
||||
created_at: str
|
||||
|
||||
@ -17,11 +17,16 @@ class AuthService:
|
||||
if cur.fetchone():
|
||||
raise ValueError("Email already registered")
|
||||
|
||||
# Check if username already exists
|
||||
cur.execute("SELECT id FROM users WHERE username = %s", (user_data.username,))
|
||||
if cur.fetchone():
|
||||
raise ValueError("Username already taken")
|
||||
|
||||
# Hash password and create user
|
||||
hashed_pwd = hash_password(user_data.password)
|
||||
cur.execute(
|
||||
"INSERT INTO users (email, hashed_password) VALUES (%s, %s) RETURNING id",
|
||||
(user_data.email, hashed_pwd)
|
||||
"INSERT INTO users (username, email, hashed_password) VALUES (%s, %s, %s) RETURNING id",
|
||||
(user_data.username, user_data.email, hashed_pwd)
|
||||
)
|
||||
user_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
@ -41,15 +46,20 @@ class AuthService:
|
||||
|
||||
@staticmethod
|
||||
def login(user_data: UserLogin) -> TokenResponse:
|
||||
"""Authenticate user and return token"""
|
||||
"""Authenticate user and return token - accepts email or username"""
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, hashed_password FROM users WHERE email = %s", (user_data.email,))
|
||||
# Try to find user by email first, then by username
|
||||
cur.execute(
|
||||
"SELECT id, hashed_password, email FROM users WHERE email = %s OR username = %s",
|
||||
(user_data.email_or_username, user_data.email_or_username)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row or not verify_password(user_data.password, row[1]):
|
||||
raise ValueError("Invalid email or password")
|
||||
raise ValueError("Invalid email, username or password")
|
||||
|
||||
user_id = row[0]
|
||||
token = create_access_token(user_id, user_data.email)
|
||||
email = row[2]
|
||||
token = create_access_token(user_id, email)
|
||||
return TokenResponse(access_token=token, token_type="bearer", user_id=user_id)
|
||||
|
||||
@ -62,10 +62,19 @@ class ChatService:
|
||||
conv_id, user_1, user_2, created_at, updated_at = row
|
||||
other_user_id = user_2 if user_1 == user_id else user_1
|
||||
|
||||
# Get other user's display name
|
||||
cur.execute("SELECT display_name FROM profiles WHERE user_id = %s", (other_user_id,))
|
||||
# Get other user's display name and photo
|
||||
cur.execute(
|
||||
"""SELECT p.display_name, ph.file_path
|
||||
FROM profiles p
|
||||
LEFT JOIN photos ph ON p.id = ph.profile_id
|
||||
WHERE p.user_id = %s
|
||||
ORDER BY ph.display_order
|
||||
LIMIT 1""",
|
||||
(other_user_id,)
|
||||
)
|
||||
profile_row = cur.fetchone()
|
||||
other_user_name = profile_row[0] if profile_row else "Unknown"
|
||||
other_user_photo = profile_row[1] if profile_row and profile_row[1] else None
|
||||
|
||||
# Get latest message
|
||||
cur.execute(
|
||||
@ -75,13 +84,22 @@ class ChatService:
|
||||
msg_row = cur.fetchone()
|
||||
latest_msg = msg_row[0] if msg_row else ""
|
||||
|
||||
# Get unread message count (messages not read by current user and not sent by them)
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM messages WHERE conversation_id = %s AND sender_id != %s AND read_at IS NULL",
|
||||
(conv_id, user_id)
|
||||
)
|
||||
unread_count = cur.fetchone()[0]
|
||||
|
||||
conversations.append(ConversationResponse(
|
||||
id=conv_id,
|
||||
user_id_1=user_1,
|
||||
user_id_2=user_2,
|
||||
other_user_id=other_user_id,
|
||||
other_user_display_name=other_user_name,
|
||||
other_user_photo=other_user_photo,
|
||||
latest_message=latest_msg,
|
||||
unread_count=unread_count,
|
||||
created_at=created_at.isoformat()
|
||||
))
|
||||
|
||||
@ -123,3 +141,31 @@ class ChatService:
|
||||
))
|
||||
|
||||
return list(reversed(messages)) # Return in chronological order
|
||||
|
||||
@staticmethod
|
||||
def mark_messages_as_read(user_id: int, conversation_id: int) -> dict:
|
||||
"""Mark all unread messages in a conversation as read"""
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
# Verify user is in this conversation
|
||||
cur.execute(
|
||||
"SELECT user_id_1, user_id_2 FROM conversations WHERE id = %s",
|
||||
(conversation_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row or (user_id != row[0] and user_id != row[1]):
|
||||
raise ValueError("Not authorized to view this conversation")
|
||||
|
||||
# Mark all unread messages from other user as read
|
||||
cur.execute(
|
||||
"""UPDATE messages
|
||||
SET read_at = CURRENT_TIMESTAMP
|
||||
WHERE conversation_id = %s
|
||||
AND sender_id != %s
|
||||
AND read_at IS NULL""",
|
||||
(conversation_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"message": "Messages marked as read"}
|
||||
|
||||
@ -44,6 +44,21 @@ class LikeService:
|
||||
|
||||
return LikeResponse(id=like_id, liker_id=liker_id, liked_id=liked_id, is_match=is_match)
|
||||
|
||||
@staticmethod
|
||||
def unlike_user(liker_id: int, liked_id: int) -> dict:
|
||||
"""User A unlikes User B"""
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
# Delete the like
|
||||
cur.execute(
|
||||
"DELETE FROM likes WHERE liker_id = %s AND liked_id = %s",
|
||||
(liker_id, liked_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"message": "Like removed"}
|
||||
|
||||
@staticmethod
|
||||
def get_matches(user_id: int) -> list:
|
||||
"""Get all users that match with this user"""
|
||||
@ -68,11 +83,75 @@ class LikeService:
|
||||
matches = []
|
||||
for match_id in match_ids:
|
||||
cur.execute(
|
||||
"SELECT id, display_name FROM profiles WHERE user_id = %s",
|
||||
"""SELECT p.id, p.display_name, ph.file_path
|
||||
FROM profiles p
|
||||
LEFT JOIN photos ph ON p.id = ph.profile_id AND ph.display_order = 1
|
||||
WHERE p.user_id = %s""",
|
||||
(match_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
matches.append({"user_id": match_id, "display_name": row[1]})
|
||||
matches.append({
|
||||
"user_id": match_id,
|
||||
"display_name": row[1],
|
||||
"photo": row[2] if row[2] else None
|
||||
})
|
||||
|
||||
return matches
|
||||
|
||||
@staticmethod
|
||||
def get_likes_received(user_id: int) -> list:
|
||||
"""Get count of likes received from other users (not matched yet and not acknowledged)"""
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
# Get likes where this user is the liked_id and not yet acknowledged
|
||||
cur.execute(
|
||||
"""SELECT l.liker_id, p.display_name, l.created_at
|
||||
FROM likes l
|
||||
JOIN profiles p ON l.liker_id = p.user_id
|
||||
WHERE l.liked_id = %s
|
||||
AND l.acknowledged_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM likes l2
|
||||
WHERE l2.liker_id = %s AND l2.liked_id = l.liker_id
|
||||
)
|
||||
ORDER BY l.created_at DESC""",
|
||||
(user_id, user_id)
|
||||
)
|
||||
|
||||
likes = []
|
||||
for row in cur.fetchall():
|
||||
likes.append({"user_id": row[0], "display_name": row[1]})
|
||||
|
||||
return likes
|
||||
|
||||
@staticmethod
|
||||
def acknowledge_likes(user_id: int) -> dict:
|
||||
"""Mark all received likes as acknowledged by this user"""
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
# Update all unacknowledged likes where user is the liked_id
|
||||
cur.execute(
|
||||
"""UPDATE likes SET acknowledged_at = CURRENT_TIMESTAMP
|
||||
WHERE liked_id = %s AND acknowledged_at IS NULL""",
|
||||
(user_id,)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"message": "Likes acknowledged"}
|
||||
|
||||
@staticmethod
|
||||
def get_liked_profiles(user_id: int) -> list:
|
||||
"""Get all profiles that this user has liked (sent likes to)"""
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""SELECT DISTINCT liked_id
|
||||
FROM likes
|
||||
WHERE liker_id = %s""",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
liked_ids = [row[0] for row in cur.fetchall()]
|
||||
return liked_ids
|
||||
|
||||
@ -50,7 +50,8 @@ class ProfileService:
|
||||
return None
|
||||
|
||||
profile_id = row[0]
|
||||
interests = json.loads(row[7]) if row[7] else []
|
||||
# PostgreSQL JSONB returns as list/dict, not string
|
||||
interests = row[7] if isinstance(row[7], list) else (json.loads(row[7]) if row[7] else [])
|
||||
|
||||
# Fetch photos
|
||||
cur.execute("SELECT id, file_path FROM photos WHERE profile_id = %s ORDER BY display_order", (profile_id,))
|
||||
@ -85,7 +86,8 @@ class ProfileService:
|
||||
profiles = []
|
||||
for row in cur.fetchall():
|
||||
profile_id = row[0]
|
||||
interests = json.loads(row[7]) if row[7] else []
|
||||
# PostgreSQL JSONB returns as list/dict, not string
|
||||
interests = row[7] if isinstance(row[7], list) else (json.loads(row[7]) if row[7] else [])
|
||||
|
||||
# Fetch photos
|
||||
cur.execute(
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn==0.24.0
|
||||
psycopg2-binary==2.9.9
|
||||
passlib==1.7.4
|
||||
bcrypt==3.2.0
|
||||
passlib==1.7.4.1
|
||||
bcrypt==4.1.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
python-multipart==0.0.6
|
||||
alembic==1.13.1
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
email-validator==2.1.0
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/src/assets/icons/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dating App</title>
|
||||
</head>
|
||||
|
||||
@ -3,6 +3,26 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.greeting-banner {
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.greeting-banner h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.greeting-banner p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
@ -25,11 +45,65 @@
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: 'Poppins', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.nav-profile-pic {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--accent-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.nav-profile-pic:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.nav-profile-pic-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--bg-primary);
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
border: 2px solid var(--accent-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-profile-pic-placeholder:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.nav-links button {
|
||||
@ -42,13 +116,142 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-links button:hover {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Icon button styling */
|
||||
.nav-icon-btn {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--text-primary) !important;
|
||||
padding: 0.5rem !important;
|
||||
font-size: 1.5rem !important;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50% !important;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-icon-btn:hover {
|
||||
background: rgba(0, 212, 255, 0.1) !important;
|
||||
transform: scale(1.15);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.nav-icon-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nav-icon-btn.rotating {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
margin-right: 23px;
|
||||
}
|
||||
|
||||
.nav-icon-btn svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.nav-logout-btn {
|
||||
font-size: 1.8rem !important;
|
||||
width: 50px !important;
|
||||
height: 50px !important;
|
||||
margin-left: auto;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.logout-icon {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.logout-text {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-logout-btn:hover .logout-icon {
|
||||
filter: invert(20%) brightness(1.4) hue-rotate(320deg);
|
||||
}
|
||||
.chat-icon {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.discover-icon {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.matches-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.like-icon {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
.nav-logout-btn:hover {
|
||||
background: rgba(255, 71, 87, 0.15) !important;
|
||||
color: #ff4757;
|
||||
transform: scale(1.2) !important;
|
||||
box-shadow: 0 0 20px rgba(255, 71, 87, 0.4);
|
||||
}
|
||||
|
||||
.nav-btn-with-badge {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
border: 2px solid var(--bg-secondary);
|
||||
box-shadow: 0 4px 12px rgba(255, 71, 87, 0.4);
|
||||
white-space: nowrap;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.notification-badge.likes-badge {
|
||||
background: linear-gradient(135deg, #ff4757, #ff006e);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
|
||||
@ -5,53 +5,233 @@ import ProfileEditor from './pages/ProfileEditor'
|
||||
import Discover from './pages/Discover'
|
||||
import Matches from './pages/Matches'
|
||||
import Chat from './pages/Chat'
|
||||
import Modal from './components/Modal'
|
||||
import LikesModal from './components/LikesModal'
|
||||
import ProfilePhotoModal from './components/ProfilePhotoModal'
|
||||
import { likeAPI, chatAPI, profileAPI, API_BASE_URL } from './api'
|
||||
import logo from './assets/icons/logo.png'
|
||||
import logoutIcon from './assets/icons/logout.svg'
|
||||
import discoverIcon from './assets/icons/discover.svg'
|
||||
import chatIcon from './assets/icons/chat.svg'
|
||||
import matchesIcon from './assets/icons/matches.svg'
|
||||
import likeIcon from './assets/icons/likes.svg'
|
||||
import lightModeIcon from './assets/icons/lightMode.svg'
|
||||
import darkModeIcon from './assets/icons/darkMode.svg'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [currentPage, setCurrentPage] = useState('login')
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [notificationData, setNotificationData] = useState({ unreadMessages: 0, likesReceived: 0 })
|
||||
const [showLogoutModal, setShowLogoutModal] = useState(false)
|
||||
const [showLikesModal, setShowLikesModal] = useState(false)
|
||||
const [showProfilePhotoModal, setShowProfilePhotoModal] = useState(false)
|
||||
const [userProfile, setUserProfile] = useState(null)
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
const saved = localStorage.getItem('darkMode')
|
||||
const isDark = saved === null ? true : saved === 'true'
|
||||
// Set theme immediately on init
|
||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light')
|
||||
return isDark
|
||||
})
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
const savedPage = localStorage.getItem('currentPage')
|
||||
if (token) {
|
||||
setIsAuthenticated(true)
|
||||
setCurrentPage('discover')
|
||||
setCurrentPage(savedPage || 'discover')
|
||||
loadUserProfile()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Set theme whenever isDarkMode changes
|
||||
document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light')
|
||||
localStorage.setItem('darkMode', isDarkMode ? 'true' : 'false')
|
||||
}, [isDarkMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && currentPage !== 'login' && currentPage !== 'register') {
|
||||
localStorage.setItem('currentPage', currentPage)
|
||||
}
|
||||
}, [currentPage, isAuthenticated])
|
||||
|
||||
const loadUserProfile = async () => {
|
||||
try {
|
||||
const response = await profileAPI.getMyProfile()
|
||||
setUserProfile(response.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load user profile', err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadNotifications()
|
||||
// Refresh notifications every 5 seconds
|
||||
const interval = setInterval(loadNotifications, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
const loadNotifications = async () => {
|
||||
try {
|
||||
const [messagesRes, likesRes] = await Promise.all([
|
||||
chatAPI.getConversations(),
|
||||
likeAPI.getLikesReceived(),
|
||||
])
|
||||
|
||||
const unreadMessages = messagesRes.data?.reduce((sum, conv) => sum + (conv.unread_count || 0), 0) || 0
|
||||
const likesReceived = likesRes.data?.length || 0
|
||||
|
||||
setNotificationData({
|
||||
unreadMessages,
|
||||
likesReceived,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to load notifications', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
setIsAuthenticated(true)
|
||||
setCurrentPage('discover')
|
||||
loadUserProfile()
|
||||
}
|
||||
|
||||
const handleRegisterSuccess = () => {
|
||||
setIsAuthenticated(true)
|
||||
setCurrentPage('profile-editor')
|
||||
loadUserProfile()
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
const confirmLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user_id')
|
||||
setIsAuthenticated(false)
|
||||
setCurrentPage('login')
|
||||
setShowLogoutModal(false)
|
||||
setUserProfile(null)
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
await Promise.all([loadNotifications(), loadUserProfile()])
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
setIsDarkMode(!isDarkMode)
|
||||
}
|
||||
|
||||
const userPhoto = userProfile?.photos && userProfile.photos.length > 0
|
||||
? `${API_BASE_URL}/media/${userProfile.photos[0].file_path}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{isAuthenticated && (
|
||||
<nav className="navbar">
|
||||
<div className="nav-brand">Dating App</div>
|
||||
<div className="nav-brand">
|
||||
<img src={logo} alt="Dating App Logo" className="nav-logo" />
|
||||
<span>Dating App</span>
|
||||
</div>
|
||||
<div className="nav-links">
|
||||
<button onClick={() => setCurrentPage('discover')}>Discover</button>
|
||||
<button onClick={() => setCurrentPage('matches')}>Matches</button>
|
||||
<button onClick={() => setCurrentPage('chat')}>Chat</button>
|
||||
<button onClick={() => setCurrentPage('profile-editor')}>Profile</button>
|
||||
<button onClick={handleLogout}>Logout</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage('discover')}
|
||||
className="nav-icon-btn"
|
||||
title="Discover profiles"
|
||||
>
|
||||
<img src={discoverIcon} alt="Discover" className="discover-icon" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage('matches')}
|
||||
className="nav-icon-btn"
|
||||
title="Your matches"
|
||||
>
|
||||
<img src={matchesIcon} alt="Matches" className="matches-icon" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage('chat')}
|
||||
className="nav-icon-btn nav-btn-with-badge"
|
||||
title="Chat"
|
||||
>
|
||||
<img src={chatIcon} alt="Chat" className="chat-icon" />
|
||||
{notificationData.unreadMessages > 0 && (
|
||||
<span className="notification-badge" title="Unread messages">
|
||||
{notificationData.unreadMessages}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowLikesModal(true)}
|
||||
className="nav-icon-btn nav-btn-with-badge"
|
||||
title="Likes received"
|
||||
>
|
||||
<img src={likeIcon} alt="Likes" className="like-icon" />
|
||||
{notificationData.likesReceived > 0 && (
|
||||
<span className="notification-badge likes-badge" title="New likes">
|
||||
{notificationData.likesReceived}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="nav-profile" onClick={() => setShowProfilePhotoModal(true)}>
|
||||
{userPhoto ? (
|
||||
<img
|
||||
src={userPhoto}
|
||||
alt="Your profile"
|
||||
className="nav-profile-pic"
|
||||
title="Click to update your profile photo"
|
||||
/>
|
||||
) : (
|
||||
<div className="nav-profile-pic-placeholder">
|
||||
{userProfile?.display_name?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className={`nav-icon-btn ${isRefreshing ? 'rotating' : ''}`}
|
||||
title="Refresh"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<polyline points="1 20 1 14 7 14"></polyline>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36M20.49 15a9 9 0 0 1-14.85 3.36"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="nav-icon-btn"
|
||||
title={isDarkMode ? 'Light mode' : 'Dark mode'}
|
||||
>
|
||||
<img src={isDarkMode ? lightModeIcon : darkModeIcon} alt="Theme" className="theme-icon" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowLogoutModal(true)}
|
||||
className="nav-icon-btn nav-logout-btn"
|
||||
title="Logout"
|
||||
>
|
||||
<img src={logoutIcon} alt="Logout" className="logout-icon" />
|
||||
<span className="logout-text">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<main className="main-content">
|
||||
{isAuthenticated && currentPage === 'discover' && userProfile && (
|
||||
<div className="greeting-banner">
|
||||
<h2>Welcome back, {userProfile.display_name}! 👋</h2>
|
||||
<p>Ready to find your perfect match?</p>
|
||||
</div>
|
||||
)}
|
||||
{currentPage === 'login' && (
|
||||
<Login onLoginSuccess={handleLoginSuccess} onRegisterClick={() => setCurrentPage('register')} />
|
||||
)}
|
||||
@ -59,10 +239,35 @@ function App() {
|
||||
<Register onRegisterSuccess={handleRegisterSuccess} onLoginClick={() => setCurrentPage('login')} />
|
||||
)}
|
||||
{isAuthenticated && currentPage === 'profile-editor' && <ProfileEditor />}
|
||||
{isAuthenticated && currentPage === 'discover' && <Discover />}
|
||||
{isAuthenticated && currentPage === 'matches' && <Matches />}
|
||||
{isAuthenticated && currentPage === 'chat' && <Chat />}
|
||||
{isAuthenticated && currentPage === 'discover' && <Discover onNotificationsUpdate={loadNotifications} onNavigateToChat={() => setCurrentPage('chat')} />}
|
||||
{isAuthenticated && currentPage === 'matches' && <Matches onNavigateToChat={() => setCurrentPage('chat')} />}
|
||||
{isAuthenticated && currentPage === 'chat' && <Chat onNotificationsUpdate={loadNotifications} />}
|
||||
</main>
|
||||
|
||||
<Modal
|
||||
isOpen={showLogoutModal}
|
||||
title="Confirm Logout"
|
||||
message="Are you sure you want to logout? You can login again anytime."
|
||||
confirmText="Yes, Logout"
|
||||
cancelText="Cancel"
|
||||
isDangerous={true}
|
||||
onConfirm={confirmLogout}
|
||||
onCancel={() => setShowLogoutModal(false)}
|
||||
/>
|
||||
|
||||
<LikesModal
|
||||
isOpen={showLikesModal}
|
||||
onClose={() => setShowLikesModal(false)}
|
||||
onLikesUpdate={loadNotifications}
|
||||
/>
|
||||
|
||||
<ProfilePhotoModal
|
||||
isOpen={showProfilePhotoModal}
|
||||
onClose={() => setShowProfilePhotoModal(false)}
|
||||
currentPhoto={userPhoto?.replace(`${API_BASE_URL}/media/`, '')}
|
||||
onPhotoUpdate={() => loadUserProfile()}
|
||||
onEditProfile={() => setCurrentPage('profile-editor')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -37,10 +37,10 @@ api.interceptors.response.use(
|
||||
|
||||
// Auth endpoints
|
||||
export const authAPI = {
|
||||
register: (email, password, displayName) =>
|
||||
api.post('/auth/register', { email, password, display_name: displayName }),
|
||||
login: (email, password) =>
|
||||
api.post('/auth/login', { email, password }),
|
||||
register: (username, email, password, displayName) =>
|
||||
api.post('/auth/register', { username, email, password, display_name: displayName }),
|
||||
login: (emailOrUsername, password) =>
|
||||
api.post('/auth/login', { email_or_username: emailOrUsername, password }),
|
||||
getCurrentUser: () =>
|
||||
api.get('/auth/me'),
|
||||
}
|
||||
@ -76,8 +76,16 @@ export const photoAPI = {
|
||||
export const likeAPI = {
|
||||
likeUser: (userId) =>
|
||||
api.post(`/likes/${userId}`),
|
||||
unlikeUser: (userId) =>
|
||||
api.delete(`/likes/${userId}`),
|
||||
getMatches: () =>
|
||||
api.get('/likes/matches/list'),
|
||||
getLikesReceived: () =>
|
||||
api.get('/likes/received/list'),
|
||||
getLikedProfiles: () =>
|
||||
api.get('/likes/sent/list'),
|
||||
acknowledgeLikes: () =>
|
||||
api.post('/likes/received/acknowledge'),
|
||||
}
|
||||
|
||||
// Chat endpoints
|
||||
@ -88,6 +96,8 @@ export const chatAPI = {
|
||||
api.get(`/chat/conversations/${conversationId}/messages`, { params: { limit } }),
|
||||
sendMessage: (conversationId, content) =>
|
||||
api.post(`/chat/conversations/${conversationId}/messages`, { content }),
|
||||
markAsRead: (conversationId) =>
|
||||
api.post(`/chat/conversations/${conversationId}/mark-read`),
|
||||
}
|
||||
|
||||
export { API_BASE_URL }
|
||||
|
||||
4
frontend/src/assets/icons/chat.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="800px" height="800px" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 13.5997 2.37562 15.1116 3.04346 16.4525C3.22094 16.8088 3.28001 17.2161 3.17712 17.6006L2.58151 19.8267C2.32295 20.793 3.20701 21.677 4.17335 21.4185L6.39939 20.8229C6.78393 20.72 7.19121 20.7791 7.54753 20.9565C8.88837 21.6244 10.4003 22 12 22Z" stroke="#e36567" stroke-width="1.5" fill="#e6b2e6"/>
|
||||
<path opacity="0.5" d="M10.0286 14.9426L9.54259 15.5139L10.0286 14.9426ZM12 9.50069L11.4641 10.0254C11.6052 10.1695 11.7983 10.2507 12 10.2507C12.2017 10.2507 12.3948 10.1695 12.5359 10.0254L12 9.50069ZM13.9714 14.9426L13.4855 14.3714L13.9714 14.9426ZM12 15.9939L12 15.2439H12L12 15.9939ZM10.5145 14.3714C9.93292 13.8766 9.34909 13.3035 8.91635 12.7109C8.47476 12.1061 8.25 11.562 8.25 11.1093H6.75C6.75 12.025 7.18465 12.8829 7.70492 13.5955C8.23403 14.3201 8.91448 14.9795 9.54259 15.5139L10.5145 14.3714ZM8.25 11.1093C8.25 10.0016 8.74454 9.41826 9.25333 9.22801C9.77052 9.03463 10.5951 9.13785 11.4641 10.0254L12.5359 8.97598C11.38 7.79544 9.95456 7.36438 8.72797 7.82302C7.49299 8.28481 6.75 9.53986 6.75 11.1093H8.25ZM14.4574 15.5139C15.0855 14.9795 15.766 14.3201 16.2951 13.5955C16.8154 12.8829 17.25 12.025 17.25 11.1093H15.75C15.75 11.562 15.5252 12.1062 15.0837 12.7109C14.6509 13.3036 14.0671 13.8766 13.4855 14.3714L14.4574 15.5139ZM17.25 11.1093C17.25 9.53985 16.507 8.2848 15.272 7.82302C14.0454 7.36438 12.62 7.79544 11.4641 8.97598L12.5359 10.0254C13.4049 9.13785 14.2295 9.03463 14.7467 9.22801C15.2555 9.41826 15.75 10.0016 15.75 11.1093H17.25ZM9.54259 15.5139C10.3221 16.177 10.9428 16.7439 12 16.7439L12 15.2439C11.586 15.2439 11.3828 15.11 10.5145 14.3714L9.54259 15.5139ZM13.4855 14.3714C12.6172 15.11 12.414 15.2439 12 15.2439L12 16.7439C13.0572 16.7439 13.6779 16.177 14.4574 15.5139L13.4855 14.3714Z" fill="#e36567"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
2
frontend/src/assets/icons/darkMode.svg
Normal file
@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" ?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 35 35" data-name="Layer 2" id="Layer_2" xmlns="http://www.w3.org/2000/svg"><path d="M18.44,34.68a18.22,18.22,0,0,1-2.94-.24,18.18,18.18,0,0,1-15-20.86A18.06,18.06,0,0,1,9.59.63,2.42,2.42,0,0,1,12.2.79a2.39,2.39,0,0,1,1,2.41L11.9,3.1l1.23.22A15.66,15.66,0,0,0,23.34,21h0a15.82,15.82,0,0,0,8.47.53A2.44,2.44,0,0,1,34.47,25,18.18,18.18,0,0,1,18.44,34.68ZM10.67,2.89a15.67,15.67,0,0,0-5,22.77A15.66,15.66,0,0,0,32.18,24a18.49,18.49,0,0,1-9.65-.64A18.18,18.18,0,0,1,10.67,2.89Z"/></svg>
|
||||
|
After Width: | Height: | Size: 646 B |
16
frontend/src/assets/icons/discover.svg
Normal file
@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<!-- SEARCH: ring -->
|
||||
<g id="search-ring" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="10.5" cy="10.5" r="6" fill="#e91e63"/>
|
||||
</g>
|
||||
|
||||
<!-- SEARCH: handle -->
|
||||
<g id="search-handle" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 15l4 4" fill="#e91e63"/>
|
||||
</g>
|
||||
|
||||
<!-- HEART -->
|
||||
<g id="heart" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.5 13.3l-1.8-1.8c-.7-.7-.7-1.7 0-2.4.7-.7 1.8-.7 2.5 0 .7-.7 1.8-.7 2.5 0 .7.7.7 1.7 0 2.4z" fill="#e91e63"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 721 B |
23
frontend/src/assets/icons/lightMode.svg
Normal file
@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="800px" height="800px" viewBox="0 0 24 24" fill="none">
|
||||
|
||||
<g clip-path="url(#a)" stroke="#fafa11" stroke-width="1.5" stroke-miterlimit="10">
|
||||
|
||||
<path d="M5 12H1M23 12h-4M7.05 7.05 4.222 4.222M19.778 19.778 16.95 16.95M7.05 16.95l-2.828 2.828M19.778 4.222 16.95 7.05" stroke-linecap="round" fill="#fafa11"/>
|
||||
|
||||
<path d="M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" fill="#fafa11" fill-opacity=".16"/>
|
||||
|
||||
<path d="M12 19v4M12 1v4" stroke-linecap="round" fill="#fafa11"/>
|
||||
|
||||
</g>
|
||||
|
||||
<defs fill="#fafa11">
|
||||
|
||||
<clipPath id="a" fill="#fafa11">
|
||||
|
||||
<path fill="#dbd90c" d="M0 0h24v24H0z"/>
|
||||
|
||||
</clipPath>
|
||||
|
||||
</defs>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 634 B |
5
frontend/src/assets/icons/like.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<g stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20l-6.5-6.5c-1.8-1.8-1.8-4.6 0-6.4 1.8-1.8 4.6-1.8 6.4 0 1.8-1.8 4.6-1.8 6.4 0 1.8 1.8 1.8 4.6 0 6.4z" fill="#db170f"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 354 B |
10
frontend/src/assets/icons/likes.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="-18.4 0 110.516 110.516" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group_1122" data-name="Group 1122" transform="translate(-1785.023 -13880.168)">
|
||||
<path id="Path_849" data-name="Path 849" d="M-3338.974,2073.806l-8.418-10.145-4.857-11.333v-11.656h-7.446l-11.224,8.1-6.584,12.519-.971,9.713-8.2-2.914-2.7,12.844,24.175,7.77,28.709-5.072-2.483-9.821" transform="translate(5186 11845)" fill="#f63832" fill-rule="evenodd"/>
|
||||
<path id="Path_850" data-name="Path 850" d="M-3361.314,2067.546l-10.361,12.867,7.556,6.991,9.928-6.991-7.123-12.867" transform="translate(5186 11845)" fill="#f1ca25" fill-rule="evenodd"/>
|
||||
<path id="Path_851" data-name="Path 851" d="M-3331.79,2089.3l-10.192-8.118-9.881-.765-12.446,5.939-2.451-1.7-9.05-4.242-8.482.479-11.123,6.5-3.3,10.364.944,9.994,10.554,15.552,10,10.93,13.76,9.143,7.352-6.034,17.815-15.743,8.769-20.36-2.267-11.947" transform="translate(5186 11845)" fill="#ef204d" fill-rule="evenodd"/>
|
||||
<path id="Path_852" data-name="Path 852" d="M-3348.687,2128.358a119.257,119.257,0,0,1-15.432,11.9,119.076,119.076,0,0,1-15.434-11.9c-7.669-7.051-16.816-17.767-16.816-28.741a16.787,16.787,0,0,1,4.95-11.943,16.778,16.778,0,0,1,11.944-4.948,16.765,16.765,0,0,1,12.063,5.069l3.293,3.36,3.291-3.36a16.765,16.765,0,0,1,12.064-5.069,16.772,16.772,0,0,1,11.942,4.948,16.784,16.784,0,0,1,4.952,11.943C-3331.87,2110.591-3341.018,2121.307-3348.687,2128.358Zm-7.959-48.744a21.535,21.535,0,0,0-7.473,4.957,21.8,21.8,0,0,0-4.5-3.514,25.312,25.312,0,0,1,6.794-9.848A45.738,45.738,0,0,0-3356.646,2079.614Zm-27.912-9.485a33.246,33.246,0,0,1,4.2,2.165l3.608,2.476V2070.4c0-7.889,1.88-13.98,5.741-18.621,3.453-4.152,8.674-7.4,15.892-9.888a33.373,33.373,0,0,0-.1,6.1,33.532,33.532,0,0,0,9.271,20.688c3.676,3.918,6.044,7.9,7.062,11.837a21.425,21.425,0,0,0-9.878-2.4,22.178,22.178,0,0,0-2.938.2,41.03,41.03,0,0,1-7.1-11.72l-1.1-3.107-2.539,2.1c-5.177,4.3-8.621,8.742-10.4,13.572a21.439,21.439,0,0,0-6.64-1.048,21.5,21.5,0,0,0-6.975,1.156A30.16,30.16,0,0,1-3384.558,2070.129Zm50.856,14.153a22.658,22.658,0,0,0-.489-3.967c-1.079-5.032-3.905-10.007-8.405-14.79a28.693,28.693,0,0,1-8.013-17.685,26.105,26.105,0,0,1,.647-8.618l1.242-4.054-4.075,1.167c-18.316,5.231-27.22,14.644-28.418,30.267-1.753-.837-3.423-1.531-3.722-1.656l-2.092-.867-.9,2.077a34.644,34.644,0,0,0-3.145,15.36,21.484,21.484,0,0,0-9.9,18.1c0,24.57,36.858,46.067,36.858,46.067s36.855-21.5,36.855-46.067a21.427,21.427,0,0,0-6.438-15.335" transform="translate(5186 11845)" fill="#070505" fill-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/src/assets/icons/logo.png
Normal file
|
After Width: | Height: | Size: 394 KiB |
9
frontend/src/assets/icons/logout.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="800px" width="800px" version="1.1" id="Layer_1" viewBox="0 0 392.663 392.663" xml:space="preserve">
|
||||
<path style="fill:#ffffff;" d="M34.958,365.511l93.091-19.329c5.042-1.099,8.663-5.56,8.663-10.667V57.018 c0-5.172-3.62-9.632-8.663-10.667L34.958,27.022c-0.776-0.129-1.552-0.259-2.263-0.259c-5.947,0-10.796,4.848-10.796,10.925v317.156 C21.834,361.632,28.234,366.869,34.958,365.511z"/>
|
||||
<polygon style="fill:#56ACE0;" points="43.62,51.071 114.925,65.939 114.925,326.594 43.62,341.398 "/>
|
||||
<g>
|
||||
<path style="fill:#194f82;" d="M94.691,165.107c-6.012,0-10.925,4.848-10.925,10.925v40.404c0,6.012,4.848,10.925,10.925,10.925 c6.077,0,10.925-4.848,10.925-10.925v-40.404C105.616,170.085,100.703,165.107,94.691,165.107z"/>
|
||||
<path style="fill:#194f82;" d="M389.414,194.004l-48.356-48.356c-4.267-4.267-11.119-4.267-15.451,0 c-4.267,4.267-4.267,11.119,0,15.451l29.737,29.737h-76.606V32.711C278.739,14.675,264.065,0,246.028,0H32.76 C14.723,0,0.048,14.675,0.048,32.711v5.042v317.156v5.042c0,18.036,14.675,32.711,32.711,32.711h213.333 c18.036,0,32.711-14.675,32.711-32.711V212.622h76.541l-29.737,29.737c-4.267,4.267-4.267,11.119,0,15.451 c5.301,4.331,10.537,4.202,15.451,0l48.356-48.356C393.681,205.123,393.681,198.271,389.414,194.004z M21.834,354.844V37.689 c0-6.012,4.848-10.925,10.925-10.925c0.776,0,95.354,19.523,95.354,19.523c5.042,1.099,8.663,5.56,8.663,10.667v278.497 c0,5.172-3.62,9.632-8.663,10.667l-93.091,19.329C28.234,366.869,21.834,361.632,21.834,354.844z M256.954,359.822 c0,6.012-4.848,10.925-10.925,10.925h-128.97l15.451-3.168c15.063-3.168,26.053-16.614,26.053-32.065V57.018 c0-15.451-10.99-28.897-26.053-32l-15.451-3.232h129.034c6.012,0,10.925,4.848,10.925,10.925v327.111H256.954z"/>
|
||||
</g>
|
||||
<path style="fill:#db130c;" d="M246.093,21.786H117.059l15.451,3.232c15.063,3.168,26.053,16.614,26.053,32v278.497 c0,15.451-10.99,28.897-26.053,32.065l-15.386,3.168h128.97c6.012,0,10.925-4.848,10.925-10.925V32.711 C256.954,26.699,252.105,21.786,246.093,21.786z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
10
frontend/src/assets/icons/matches.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" transform="matrix(1, 0, 0, 1, 0, 0)">
|
||||
<g stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- back heart -->
|
||||
<path d="M9.2 6.4c-1.2-1.1-3.2-1.1-4.4.2-1.2 1.3-1.1 3.4.3 4.8l4.1 4.1 4.1-4.1c1.4-1.4 1.5-3.5.3-4.8-1.2-1.3-3.2-1.3-4.4-.2z" opacity="0.85" fill="#f253c0"/>
|
||||
<!-- front heart -->
|
||||
<path d="M14.8 8.5c-1.2-1.1-3.2-1.1-4.4.2-1.2 1.3-1.1 3.4.3 4.8l3.4 3.4 3.4-3.4c1.4-1.4 1.5-3.5.3-4.8-1.2-1.3-3.2-1.3-4.4-.2z" fill="#f253c0"/>
|
||||
<!-- small link heart -->
|
||||
<path d="M6.5 14.9c-.8-.7-2.1-.7-2.9.1-.8.9-.8 2.2.2 3.2l2.2 2.2 2.2-2.2c1-1 .9-2.3.2-3.2-.8-.9-2.1-.9-2.9-.1z" opacity="0.9" fill="#f253c0"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 753 B |
5
frontend/src/assets/icons/notLike.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<g stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20l-6.5-6.5c-1.8-1.8-1.8-4.6 0-6.4 1.8-1.8 4.6-1.8 6.4 0 1.8-1.8 4.6-1.8 6.4 0 1.8 1.8 1.8 4.6 0 6.4z" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 354 B |
12
frontend/src/assets/icons/profile.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<g stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- outer circle -->
|
||||
<path d="M12 21a9 9 0 1 0-9-9 9 9 0 0 0 9 9z"/>
|
||||
<!-- head -->
|
||||
<path d="M12 12.2a3.1 3.1 0 1 0-3.1-3.1 3.1 3.1 0 0 0 3.1 3.1z"/>
|
||||
<!-- shoulders -->
|
||||
<path d="M6.9 19c1.3-2.2 3-3.2 5.1-3.2s3.8 1 5.1 3.2"/>
|
||||
<!-- small heart badge -->
|
||||
<path d="M18.6 14.5c-.6-.6-1.6-.6-2.2.1-.6.7-.6 1.7.1 2.4l1.2 1.2 1.2-1.2c.7-.7.7-1.7.1-2.4-.6-.7-1.6-.7-2.2-.1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 581 B |
148
frontend/src/components/LikesModal.jsx
Normal file
@ -0,0 +1,148 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { likeAPI, profileAPI, API_BASE_URL } from '../api'
|
||||
import likeIcon from '../assets/icons/like.svg'
|
||||
import notLikeIcon from '../assets/icons/notLike.svg'
|
||||
import '../styles/likes-modal.css'
|
||||
|
||||
export default function LikesModal({ isOpen, onClose, onLikesUpdate }) {
|
||||
const [likes, setLikes] = useState([])
|
||||
const [profiles, setProfiles] = useState({})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [likedProfiles, setLikedProfiles] = useState(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadLikesReceived()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const loadLikesReceived = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const response = await likeAPI.getLikesReceived()
|
||||
setLikes(response.data || [])
|
||||
|
||||
// Fetch profile details for each liker
|
||||
const profilesData = {}
|
||||
for (const like of response.data || []) {
|
||||
try {
|
||||
const profileRes = await profileAPI.getProfile(like.user_id)
|
||||
profilesData[like.user_id] = profileRes.data
|
||||
} catch (err) {
|
||||
console.error(`Failed to load profile for user ${like.user_id}`)
|
||||
}
|
||||
}
|
||||
setProfiles(profilesData)
|
||||
|
||||
// Always update parent to sync notification count
|
||||
if (onLikesUpdate) {
|
||||
onLikesUpdate()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load likes', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseModal = async () => {
|
||||
// Mark all viewed likes as acknowledged in database
|
||||
try {
|
||||
await likeAPI.acknowledgeLikes()
|
||||
} catch (err) {
|
||||
console.error('Failed to acknowledge likes', err)
|
||||
}
|
||||
|
||||
// Update parent notification count when closing
|
||||
if (onLikesUpdate) {
|
||||
onLikesUpdate()
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleLike = async (userId) => {
|
||||
try {
|
||||
await likeAPI.likeUser(userId)
|
||||
setLikedProfiles(prev => new Set(prev).add(userId))
|
||||
// Remove from likes list after a short delay
|
||||
setTimeout(() => {
|
||||
setLikes(prev => prev.filter(like => like.user_id !== userId))
|
||||
}, 300)
|
||||
if (onLikesUpdate) {
|
||||
onLikesUpdate()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to like profile', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePass = (userId) => {
|
||||
setLikes(prev => prev.filter(like => like.user_id !== userId))
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="likes-modal-overlay" onClick={handleCloseModal}>
|
||||
<div className="likes-modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="likes-modal-header">
|
||||
<h2>❤️ New Likes</h2>
|
||||
<button className="likes-modal-close" onClick={handleCloseModal}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="likes-modal-body">
|
||||
{isLoading ? (
|
||||
<p className="loading">Loading...</p>
|
||||
) : likes.length === 0 ? (
|
||||
<p className="no-likes">No new likes yet</p>
|
||||
) : (
|
||||
<div className="likes-list">
|
||||
{likes.map((like) => {
|
||||
const profile = profiles[like.user_id]
|
||||
return (
|
||||
<div key={like.user_id} className="like-item">
|
||||
<div className="like-item-photo">
|
||||
{profile?.photos && profile.photos.length > 0 ? (
|
||||
<img
|
||||
src={`${API_BASE_URL}/media/${profile.photos[0].file_path}`}
|
||||
alt={profile?.display_name}
|
||||
/>
|
||||
) : (
|
||||
<div className="like-item-placeholder">
|
||||
{profile?.display_name?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="like-item-info">
|
||||
<h4>{profile?.display_name}, {profile?.age}</h4>
|
||||
<p>{profile?.location}</p>
|
||||
</div>
|
||||
<div className="like-item-actions">
|
||||
<button
|
||||
className="like-item-pass"
|
||||
onClick={() => handlePass(like.user_id)}
|
||||
title="Skip"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`like-item-like ${likedProfiles.has(like.user_id) ? 'already-liked' : ''}`}
|
||||
onClick={() => handleLike(like.user_id)}
|
||||
title={likedProfiles.has(like.user_id) ? 'Liked' : 'Like'}
|
||||
>
|
||||
<img src={likedProfiles.has(like.user_id) ? likeIcon : notLikeIcon} alt="Like" className="like-button-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
frontend/src/components/Modal.jsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import '../styles/modal.css'
|
||||
|
||||
export default function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = 'Confirm', cancelText = 'Cancel', isDangerous = false }) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="modal-close" onClick={onCancel}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="modal-btn modal-btn-cancel" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
className={`modal-btn modal-btn-confirm ${isDangerous ? 'modal-btn-danger' : ''}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
93
frontend/src/components/ProfilePhotoModal.jsx
Normal file
@ -0,0 +1,93 @@
|
||||
import React, { useState } from 'react'
|
||||
import { photoAPI, API_BASE_URL } from '../api'
|
||||
import '../styles/profilePhotoModal.css'
|
||||
|
||||
export default function ProfilePhotoModal({ isOpen, onClose, currentPhoto, onPhotoUpdate, onEditProfile }) {
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const handlePhotoUpload = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
setIsUploading(true)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
try {
|
||||
const response = await photoAPI.uploadPhoto(file)
|
||||
setSuccess('Photo updated successfully')
|
||||
setTimeout(() => {
|
||||
if (onPhotoUpdate) {
|
||||
onPhotoUpdate(response.data)
|
||||
}
|
||||
onClose()
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
setError('Failed to upload photo. Please try again.')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="profile-photo-modal-overlay" onClick={onClose}>
|
||||
<div className="profile-photo-modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="profile-photo-modal-header">
|
||||
<h2>Update Profile Photo</h2>
|
||||
<button className="profile-photo-modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="profile-photo-modal-body">
|
||||
{currentPhoto && (
|
||||
<div className="current-photo">
|
||||
<p className="label">Current Photo:</p>
|
||||
<img
|
||||
src={`${API_BASE_URL}/media/${currentPhoto}`}
|
||||
alt="Current profile"
|
||||
className="current-photo-img"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="photo-upload-section">
|
||||
<p className="label">Upload New Photo:</p>
|
||||
<label htmlFor="photo-file-input" className="upload-button">
|
||||
📸 Choose Photo
|
||||
</label>
|
||||
<input
|
||||
id="photo-file-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handlePhotoUpload}
|
||||
disabled={isUploading}
|
||||
className="file-input"
|
||||
/>
|
||||
{isUploading && <p className="uploading">Uploading...</p>}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{success && <div className="success-message">{success}</div>}
|
||||
|
||||
<p className="info-text">
|
||||
💡 Your profile photo will be displayed on your profile and in the discover section.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose()
|
||||
if (onEditProfile) onEditProfile()
|
||||
}}
|
||||
className="edit-profile-btn"
|
||||
>
|
||||
✏️ Edit Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -19,9 +19,39 @@
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #0f0f1e;
|
||||
--bg-secondary: #1a1a2e;
|
||||
--bg-tertiary: #16213e;
|
||||
--accent-primary: #00d4ff;
|
||||
--accent-secondary: #ff006e;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #b0b0b0;
|
||||
--border-color: #2a2a3e;
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f5f5f5;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #f0f0f0;
|
||||
--accent-primary: #6366f1;
|
||||
--accent-secondary: #ec4899;
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--border-color: #e5e7eb;
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
|
||||
'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
@ -30,6 +60,12 @@ body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Poppins', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
button {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { chatAPI } from '../api'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { chatAPI, API_BASE_URL } from '../api'
|
||||
import '../styles/chat.css'
|
||||
|
||||
export default function Chat({ conversationId }) {
|
||||
export default function Chat({ conversationId, onNotificationsUpdate }) {
|
||||
const [conversations, setConversations] = useState([])
|
||||
const [selectedConversation, setSelectedConversation] = useState(conversationId || null)
|
||||
const [messages, setMessages] = useState([])
|
||||
@ -10,6 +10,7 @@ export default function Chat({ conversationId }) {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const messageInputRef = useRef(null)
|
||||
|
||||
const currentUserId = localStorage.getItem('user_id')
|
||||
|
||||
@ -31,6 +32,10 @@ export default function Chat({ conversationId }) {
|
||||
setIsLoading(true)
|
||||
const response = await chatAPI.getConversations()
|
||||
setConversations(response.data || [])
|
||||
// Notify parent about notifications update
|
||||
if (onNotificationsUpdate) {
|
||||
onNotificationsUpdate()
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load conversations')
|
||||
} finally {
|
||||
@ -44,6 +49,23 @@ export default function Chat({ conversationId }) {
|
||||
try {
|
||||
const response = await chatAPI.getMessages(selectedConversation)
|
||||
setMessages(response.data || [])
|
||||
|
||||
// Mark messages as read
|
||||
await chatAPI.markAsRead(selectedConversation)
|
||||
|
||||
// Update unread_count to 0 for this conversation immediately
|
||||
setConversations(prev =>
|
||||
prev.map(conv =>
|
||||
conv.id === selectedConversation
|
||||
? { ...conv, unread_count: 0 }
|
||||
: conv
|
||||
)
|
||||
)
|
||||
|
||||
// Update parent notifications
|
||||
if (onNotificationsUpdate) {
|
||||
onNotificationsUpdate()
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load messages')
|
||||
}
|
||||
@ -58,6 +80,8 @@ export default function Chat({ conversationId }) {
|
||||
const response = await chatAPI.sendMessage(selectedConversation, newMessage)
|
||||
setMessages((prev) => [...prev, response.data])
|
||||
setNewMessage('')
|
||||
// Auto-focus back to input field after sending
|
||||
setTimeout(() => messageInputRef.current?.focus(), 0)
|
||||
} catch (err) {
|
||||
setError('Failed to send message')
|
||||
} finally {
|
||||
@ -92,8 +116,26 @@ export default function Chat({ conversationId }) {
|
||||
className={`conversation-item ${selectedConversation === conv.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedConversation(conv.id)}
|
||||
>
|
||||
<h4>{conv.other_user_display_name}</h4>
|
||||
<p className="latest-msg">{conv.latest_message || 'No messages yet'}</p>
|
||||
<div className="conversation-header">
|
||||
{conv.other_user_photo ? (
|
||||
<img
|
||||
src={`${API_BASE_URL}/media/${conv.other_user_photo}`}
|
||||
alt={conv.other_user_display_name}
|
||||
className="conversation-avatar"
|
||||
/>
|
||||
) : (
|
||||
<div className="conversation-avatar-placeholder">
|
||||
{conv.other_user_display_name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="conversation-info">
|
||||
<h4>{conv.other_user_display_name}</h4>
|
||||
<p className="latest-msg">{conv.latest_message || 'No messages yet'}</p>
|
||||
</div>
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="unread-badge">{conv.unread_count}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -123,11 +165,13 @@ export default function Chat({ conversationId }) {
|
||||
|
||||
<form onSubmit={handleSendMessage} className="message-form">
|
||||
<input
|
||||
ref={messageInputRef}
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
disabled={isSending}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={isSending || !newMessage.trim()}>
|
||||
Send
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { profileAPI, likeAPI, API_BASE_URL } from '../api'
|
||||
import Modal from '../components/Modal'
|
||||
import likeIcon from '../assets/icons/like.svg'
|
||||
import notLikeIcon from '../assets/icons/notLike.svg'
|
||||
import '../styles/discover.css'
|
||||
|
||||
export default function Discover() {
|
||||
export default function Discover({ onNotificationsUpdate, onNavigateToChat }) {
|
||||
const [profiles, setProfiles] = useState([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [likedProfiles, setLikedProfiles] = useState(new Set())
|
||||
const [showMatchModal, setShowMatchModal] = useState(false)
|
||||
const [matchName, setMatchName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadProfiles()
|
||||
@ -17,6 +23,16 @@ export default function Discover() {
|
||||
setIsLoading(true)
|
||||
const response = await profileAPI.discoverProfiles()
|
||||
setProfiles(response.data || [])
|
||||
|
||||
// Load all profiles this user has already liked
|
||||
const likedResponse = await likeAPI.getLikedProfiles()
|
||||
const likedIds = new Set(likedResponse.data?.liked_ids || [])
|
||||
setLikedProfiles(likedIds)
|
||||
|
||||
// Notify parent about notifications update
|
||||
if (onNotificationsUpdate) {
|
||||
onNotificationsUpdate()
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load profiles')
|
||||
} finally {
|
||||
@ -28,14 +44,32 @@ export default function Discover() {
|
||||
if (currentIndex >= profiles.length) return
|
||||
|
||||
const profile = profiles[currentIndex]
|
||||
const isAlreadyLiked = likedProfiles.has(profile.id)
|
||||
|
||||
try {
|
||||
const response = await likeAPI.likeUser(profile.id)
|
||||
if (response.data.is_match) {
|
||||
alert(`It's a match with ${profile.display_name}!`)
|
||||
if (isAlreadyLiked) {
|
||||
// Unlike
|
||||
await likeAPI.unlikeUser(profile.id)
|
||||
setLikedProfiles(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(profile.id)
|
||||
return newSet
|
||||
})
|
||||
} else {
|
||||
// Like
|
||||
const response = await likeAPI.likeUser(profile.id)
|
||||
setLikedProfiles(prev => new Set(prev).add(profile.id))
|
||||
if (response.data.is_match) {
|
||||
setMatchName(profile.display_name)
|
||||
setShowMatchModal(true)
|
||||
}
|
||||
}
|
||||
// Update notifications
|
||||
if (onNotificationsUpdate) {
|
||||
onNotificationsUpdate()
|
||||
}
|
||||
nextProfile()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to like profile')
|
||||
setError(err.response?.data?.detail || 'Failed to update like')
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,12 +77,12 @@ export default function Discover() {
|
||||
nextProfile()
|
||||
}
|
||||
|
||||
const previousProfile = () => {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : profiles.length - 1))
|
||||
}
|
||||
|
||||
const nextProfile = () => {
|
||||
if (currentIndex < profiles.length - 1) {
|
||||
setCurrentIndex((prev) => prev + 1)
|
||||
} else {
|
||||
setError('No more profiles to discover')
|
||||
}
|
||||
setCurrentIndex((prev) => (prev < profiles.length - 1 ? prev + 1 : 0))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@ -68,6 +102,17 @@ export default function Discover() {
|
||||
}
|
||||
|
||||
const profile = profiles[currentIndex]
|
||||
const isAlreadyLiked = likedProfiles.has(profile.id)
|
||||
|
||||
// Get current + next 2 profiles with infinite loop wrapping
|
||||
const getCardAtIndex = (idx) => {
|
||||
return profiles[(currentIndex + idx) % profiles.length]
|
||||
}
|
||||
const cardsToShow = [
|
||||
getCardAtIndex(0),
|
||||
getCardAtIndex(1),
|
||||
getCardAtIndex(2)
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="discover">
|
||||
@ -75,44 +120,95 @@ export default function Discover() {
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="card-container">
|
||||
<div className="profile-card">
|
||||
{profile.photos && profile.photos.length > 0 ? (
|
||||
<img src={`${API_BASE_URL}/media/${profile.photos[0].file_path}`} alt={profile.display_name} />
|
||||
) : (
|
||||
<div className="no-photo">No photo</div>
|
||||
)}
|
||||
<button
|
||||
className="nav-arrow nav-arrow-left"
|
||||
onClick={previousProfile}
|
||||
title="Previous profile"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
|
||||
<div className="card-info">
|
||||
<h2>
|
||||
{profile.display_name}, {profile.age}
|
||||
</h2>
|
||||
<p className="location">{profile.location}</p>
|
||||
{profile.bio && <p className="bio">{profile.bio}</p>}
|
||||
{profile.interests && profile.interests.length > 0 && (
|
||||
<div className="interests">
|
||||
{profile.interests.map((interest) => (
|
||||
<span key={interest} className="interest-tag">
|
||||
{interest}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{cardsToShow.map((prof, idx) => {
|
||||
const isCurrentLiked = likedProfiles.has(prof.id)
|
||||
return (
|
||||
<div key={prof.id} className="profile-card" style={{zIndex: 10 - idx}}>
|
||||
{idx === 0 && isCurrentLiked && <div className="liked-badge">❤️ Liked</div>}
|
||||
|
||||
<div className="card-actions">
|
||||
<button className="pass-btn" onClick={handlePass}>
|
||||
Pass
|
||||
</button>
|
||||
<button className="like-btn" onClick={handleLike}>
|
||||
♥ Like
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{prof.photos && prof.photos.length > 0 ? (
|
||||
<img src={`${API_BASE_URL}/media/${prof.photos[0].file_path}`} alt={prof.display_name} />
|
||||
) : (
|
||||
<div className="no-photo">No photo</div>
|
||||
)}
|
||||
|
||||
{idx === 0 && (
|
||||
<>
|
||||
<div className="card-info">
|
||||
<h2>
|
||||
{prof.display_name}, {prof.age}
|
||||
</h2>
|
||||
<p className="location">{prof.location}</p>
|
||||
{prof.bio && <p className="bio">{prof.bio}</p>}
|
||||
{prof.interests && prof.interests.length > 0 && (
|
||||
<div className="interests">
|
||||
{prof.interests.map((interest) => (
|
||||
<span key={interest} className="interest-tag">
|
||||
{interest}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
<button className="pass-btn" onClick={handlePass} title="Pass">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`like-btn ${isCurrentLiked ? 'already-liked' : ''}`}
|
||||
onClick={handleLike}
|
||||
title={isCurrentLiked ? 'Unlike' : 'Like'}
|
||||
>
|
||||
<img src={isCurrentLiked ? likeIcon : notLikeIcon} alt="Like" className="like-button-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
className="nav-arrow nav-arrow-right"
|
||||
onClick={nextProfile}
|
||||
title="Next profile"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="progress">
|
||||
{/* <div className="profile-counter">
|
||||
Profile {currentIndex + 1} of {profiles.length}
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<Modal
|
||||
isOpen={showMatchModal}
|
||||
title="It's a Match! 🎉"
|
||||
message={`You and ${matchName} liked each other! Start chatting now.`}
|
||||
confirmText="Go to Chat"
|
||||
cancelText="Keep Discovering"
|
||||
onConfirm={() => {
|
||||
setShowMatchModal(false)
|
||||
if (onNavigateToChat) {
|
||||
onNavigateToChat()
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowMatchModal(false)
|
||||
nextProfile()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
127
frontend/src/pages/LikesReceived.jsx
Normal file
@ -0,0 +1,127 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { likeAPI, profileAPI, API_BASE_URL } from '../api'
|
||||
import '../styles/likes-received.css'
|
||||
|
||||
export default function LikesReceived({ onNotificationsUpdate }) {
|
||||
const [likes, setLikes] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [profiles, setProfiles] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
loadLikesReceived()
|
||||
}, [])
|
||||
|
||||
const loadLikesReceived = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const response = await likeAPI.getLikesReceived()
|
||||
setLikes(response.data || [])
|
||||
|
||||
// Fetch profile details for each liker
|
||||
const profilesData = {}
|
||||
for (const like of response.data || []) {
|
||||
try {
|
||||
const profileRes = await profileAPI.getProfile(like.user_id)
|
||||
profilesData[like.user_id] = profileRes.data
|
||||
} catch (err) {
|
||||
console.error(`Failed to load profile for user ${like.user_id}`)
|
||||
}
|
||||
}
|
||||
setProfiles(profilesData)
|
||||
|
||||
// Notify parent about notifications update
|
||||
if (onNotificationsUpdate) {
|
||||
onNotificationsUpdate()
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load likes')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLike = async (userId) => {
|
||||
try {
|
||||
await likeAPI.likeUser(userId)
|
||||
// Remove from likes list after liking back
|
||||
setLikes(prev => prev.filter(like => like.user_id !== userId))
|
||||
if (onNotificationsUpdate) {
|
||||
onNotificationsUpdate()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to like profile')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePass = (userId) => {
|
||||
// Remove from list without liking
|
||||
setLikes(prev => prev.filter(like => like.user_id !== userId))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="likes-received">Loading likes...</div>
|
||||
}
|
||||
|
||||
if (likes.length === 0) {
|
||||
return (
|
||||
<div className="likes-received">
|
||||
<div className="empty-state">
|
||||
<h2>No likes yet</h2>
|
||||
<p>Keep discovering to get more likes!</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="likes-received">
|
||||
<h1>People Who Liked You ❤️</h1>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="likes-grid">
|
||||
{likes.map((like) => {
|
||||
const profile = profiles[like.user_id]
|
||||
return (
|
||||
<div key={like.user_id} className="like-card">
|
||||
<div className="like-card-image">
|
||||
{profile?.photos && profile.photos.length > 0 ? (
|
||||
<img
|
||||
src={`${API_BASE_URL}/media/${profile.photos[0].file_path}`}
|
||||
alt={profile.display_name}
|
||||
/>
|
||||
) : (
|
||||
<div className="no-photo">No photo</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="like-card-info">
|
||||
<h3>{profile?.display_name}, {profile?.age}</h3>
|
||||
<p className="location">{profile?.location}</p>
|
||||
{profile?.bio && <p className="bio">{profile.bio}</p>}
|
||||
{profile?.interests && profile.interests.length > 0 && (
|
||||
<div className="interests">
|
||||
{profile.interests.map((interest) => (
|
||||
<span key={interest} className="interest-tag">
|
||||
{interest}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="like-card-actions">
|
||||
<button className="pass-btn" onClick={() => handlePass(like.user_id)}>
|
||||
Pass
|
||||
</button>
|
||||
<button className="like-back-btn" onClick={() => handleLike(like.user_id)}>
|
||||
❤️ Like Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -3,7 +3,7 @@ import { authAPI } from '../api'
|
||||
import '../styles/auth.css'
|
||||
|
||||
export default function Login({ onLoginSuccess, onRegisterClick }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailOrUsername, setEmailOrUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@ -14,7 +14,7 @@ export default function Login({ onLoginSuccess, onRegisterClick }) {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await authAPI.login(email, password)
|
||||
const response = await authAPI.login(emailOrUsername, password)
|
||||
const { access_token, user_id } = response.data
|
||||
|
||||
// Store token and user ID
|
||||
@ -37,10 +37,10 @@ export default function Login({ onLoginSuccess, onRegisterClick }) {
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<form onSubmit={handleLogin}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="text"
|
||||
placeholder="Email or Username"
|
||||
value={emailOrUsername}
|
||||
onChange={(e) => setEmailOrUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { likeAPI } from '../api'
|
||||
import { likeAPI, API_BASE_URL } from '../api'
|
||||
import '../styles/matches.css'
|
||||
|
||||
export default function Matches() {
|
||||
export default function Matches({ onNavigateToChat }) {
|
||||
const [matches, setMatches] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@ -23,8 +23,8 @@ export default function Matches() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartChat = (userId) => {
|
||||
window.location.href = `/chat?user_id=${userId}`
|
||||
const handleStartChat = () => {
|
||||
onNavigateToChat()
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@ -51,8 +51,15 @@ export default function Matches() {
|
||||
<div className="matches-grid">
|
||||
{matches.map((match) => (
|
||||
<div key={match.user_id} className="match-card">
|
||||
{match.photo && (
|
||||
<img
|
||||
src={`${API_BASE_URL}/media/${match.photo}`}
|
||||
alt={match.display_name}
|
||||
className="match-card-photo"
|
||||
/>
|
||||
)}
|
||||
<h3>{match.display_name}</h3>
|
||||
<button onClick={() => handleStartChat(match.user_id)}>
|
||||
<button onClick={handleStartChat}>
|
||||
Message
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -125,10 +125,9 @@ export default function ProfileEditor() {
|
||||
<div className="form-group">
|
||||
<label>Gender</label>
|
||||
<select name="gender" value={profile.gender} onChange={handleInputChange}>
|
||||
<option value="">Select...</option>
|
||||
<option value="">Select Gender</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -182,12 +181,39 @@ export default function ProfileEditor() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="photo-section-inline">
|
||||
<h3>Photos</h3>
|
||||
<div className="photo-upload">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handlePhotoUpload}
|
||||
id="photo-input"
|
||||
/>
|
||||
<label htmlFor="photo-input">Upload Photo</label>
|
||||
</div>
|
||||
<div className="photos-grid">
|
||||
{photos.map((photo) => (
|
||||
<div key={photo.id} className="photo-card">
|
||||
<img src={`${API_BASE_URL}/media/${photo.file_path}`} alt="Profile" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeletePhoto(photo.id)}
|
||||
className="delete-btn"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Saving...' : 'Save Profile'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="photo-section">
|
||||
<div className="photo-section" style={{display: 'none'}}>
|
||||
<h2>Photos</h2>
|
||||
<div className="photo-upload">
|
||||
<input
|
||||
|
||||
@ -3,6 +3,7 @@ import { authAPI } from '../api'
|
||||
import '../styles/auth.css'
|
||||
|
||||
export default function Register({ onRegisterSuccess, onLoginClick }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
@ -15,21 +16,16 @@ export default function Register({ onRegisterSuccess, onLoginClick }) {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
console.log('Attempting to register:', { email, displayName })
|
||||
const response = await authAPI.register(email, password, displayName)
|
||||
console.log('Register response:', response.data)
|
||||
const response = await authAPI.register(username, email, password, displayName)
|
||||
const { access_token, user_id } = response.data
|
||||
|
||||
// Store token and user ID
|
||||
localStorage.setItem('token', access_token)
|
||||
localStorage.setItem('user_id', user_id)
|
||||
|
||||
console.log('Registration successful, calling onRegisterSuccess')
|
||||
onRegisterSuccess()
|
||||
} catch (err) {
|
||||
console.error('Registration error:', err)
|
||||
const errorMsg = err.response?.data?.detail || err.message || 'Registration failed'
|
||||
console.error('Error message:', errorMsg)
|
||||
setError(errorMsg)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@ -43,6 +39,13 @@ export default function Register({ onRegisterSuccess, onLoginClick }) {
|
||||
<h2>Register</h2>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<form onSubmit={handleRegister}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
|
||||
@ -27,9 +27,10 @@
|
||||
|
||||
.conversations-list h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Poppins', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
@ -54,9 +55,57 @@
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.conversation-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.conversation-avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--bg-primary);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-item h4 {
|
||||
margin: 0;
|
||||
margin-bottom: 0.6rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
|
||||
@ -1,54 +1,111 @@
|
||||
.discover {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.discover h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 2.5rem;
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-family: 'Poppins', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.card-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
perspective: 1000px;
|
||||
position: relative;
|
||||
min-height: 900px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-lg);
|
||||
/* box-shadow: var(--shadow-lg); */
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
transform: scale(1);
|
||||
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
animation: cardEnter 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.profile-card:hover {
|
||||
transform: scale(1.02);
|
||||
@keyframes cardEnter {
|
||||
from {
|
||||
opacity: 0.5;
|
||||
transform: translateX(100px) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-card:first-of-type {
|
||||
opacity: 1;
|
||||
transform: translateY(80px) !important;
|
||||
}
|
||||
|
||||
.profile-card:nth-child(n+2) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Next cards stacking to the left - only stack forward */
|
||||
.profile-card:nth-child(2) {
|
||||
z-index: 5;
|
||||
transform: translateY(0px) translateX(-30px) scale(0.97);
|
||||
}
|
||||
|
||||
.profile-card:nth-child(3) {
|
||||
z-index: 4;
|
||||
transform: translateY(0px) translateX(-55px) scale(0.94);
|
||||
}
|
||||
|
||||
.profile-card:first-of-type:hover {
|
||||
transform: translateY(80px) scale(1.02) !important;
|
||||
box-shadow: 0 20px 40px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.profile-card:first-of-type img {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.profile-card:first-of-type:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.profile-card img {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
aspect-ratio: 1 / 1.125;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.3s ease;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.profile-card:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.profile-card:not(:first-of-type):hover {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.profile-card:not(:first-of-type):hover img {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.no-photo {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
@ -58,6 +115,7 @@
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.2rem;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
@ -124,6 +182,10 @@
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pass-btn {
|
||||
@ -137,6 +199,10 @@
|
||||
border-color: var(--text-secondary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.pass-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.like-btn {
|
||||
@ -146,6 +212,12 @@
|
||||
|
||||
.like-btn:hover {
|
||||
background-color: #c82333;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.like-btn .like-button-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
@ -158,3 +230,78 @@
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.card-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.nav-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 100;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-arrow:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
transform: translateY(-50%) scale(1.15);
|
||||
}
|
||||
|
||||
.nav-arrow:disabled {
|
||||
background: rgba(100, 100, 100, 0.3);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.nav-arrow-left {
|
||||
left: -120px;
|
||||
}
|
||||
|
||||
.nav-arrow-right {
|
||||
right: -120px;
|
||||
}
|
||||
|
||||
.liked-badge {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 71, 87, 0.95);
|
||||
color: white;
|
||||
padding: 0.8rem 1.2rem;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
box-shadow: 0 4px 12px rgba(255, 71, 87, 0.4);
|
||||
z-index: 9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* .profile-counter {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
margin-top: 1rem;
|
||||
font-weight: 500;
|
||||
} */
|
||||
|
||||
.like-btn:disabled {
|
||||
background: rgba(0, 212, 255, 0.3);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
258
frontend/src/styles/likes-modal.css
Normal file
@ -0,0 +1,258 @@
|
||||
.likes-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.likes-modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
border: 1px solid var(--border-color);
|
||||
animation: slideUp 0.3s ease;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.likes-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.likes-modal-header h2 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.likes-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.likes-modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.likes-modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.likes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.like-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
align-items: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.like-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.like-item:hover {
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
}
|
||||
|
||||
.like-item-photo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.like-item-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.like-item-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--bg-primary);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.like-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.like-item-info h4 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.like-item-info p {
|
||||
margin: 0.25rem 0 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.like-item-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.like-item-pass,
|
||||
.like-item-like {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.like-item-pass {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1.5px solid var(--border-color);
|
||||
}
|
||||
|
||||
.like-item-pass:hover {
|
||||
background: rgba(255, 71, 87, 0.1);
|
||||
color: #ff4757;
|
||||
border-color: #ff4757;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(255, 71, 87, 0.2);
|
||||
}
|
||||
|
||||
.like-item-like {
|
||||
background: linear-gradient(135deg, var(--accent-secondary), #ff1493);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(255, 71, 87, 0.3);
|
||||
}
|
||||
|
||||
.like-item-like:hover {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 8px 20px rgba(255, 71, 87, 0.4);
|
||||
}
|
||||
|
||||
.like-button-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.no-likes {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.likes-modal-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.likes-modal-body::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.likes-modal-body::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.likes-modal-body::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.likes-modal-content {
|
||||
max-width: 100%;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.like-item {
|
||||
padding: 0.8rem;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.like-item-photo {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.like-item-pass,
|
||||
.like-item-like {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
191
frontend/src/styles/likes-received.css
Normal file
@ -0,0 +1,191 @@
|
||||
.likes-received {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.likes-received h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 2.2rem;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.likes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.like-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.like-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 212, 255, 0.2);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.like-card-image {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--bg-tertiary), var(--bg-secondary));
|
||||
}
|
||||
|
||||
.like-card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.like-card:hover .like-card-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.no-photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.like-card-info {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.like-card-info h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.like-card-info .location {
|
||||
margin: 0;
|
||||
color: var(--accent-primary);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.like-card-info .bio {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.interests {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.interest-tag {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-primary);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 16px;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--accent-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.like-card-actions {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.like-card-actions button {
|
||||
flex: 1;
|
||||
padding: 0.8rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pass-btn {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1.5px solid var(--border-color);
|
||||
}
|
||||
|
||||
.pass-btn:hover {
|
||||
background: var(--border-color);
|
||||
border-color: var(--text-secondary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.like-back-btn {
|
||||
background: linear-gradient(135deg, var(--accent-secondary), #ff1493);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.like-back-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(255, 71, 87, 0.3);
|
||||
}
|
||||
|
||||
.likes-received .empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.likes-received .empty-state h2 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.likes-received .empty-state p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.likes-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.like-card-image {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.like-card-info {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.like-card-actions {
|
||||
padding: 0.8rem 1rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
}
|
||||
@ -6,11 +6,14 @@
|
||||
.matches h1 {
|
||||
margin-bottom: 2rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 2.5rem;
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-family: 'Poppins', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.matches-grid {
|
||||
@ -36,16 +39,16 @@
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.match-card img {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
.match-card-photo {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 1rem;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.match-card:hover img {
|
||||
.match-card:hover .match-card-photo {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
|
||||
137
frontend/src/styles/modal.css
Normal file
@ -0,0 +1,137 @@
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
border: 1px solid var(--border-color);
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.modal-body p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
padding: 0.8rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.modal-btn-cancel {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1.5px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-btn-cancel:hover {
|
||||
background: var(--border-color);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-btn-confirm {
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.modal-btn-confirm:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.modal-btn-danger {
|
||||
background: linear-gradient(135deg, #ff4757, #ff006e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-btn-danger:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(255, 71, 87, 0.4);
|
||||
}
|
||||
@ -29,6 +29,15 @@
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.photo-section-inline {
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
margin-bottom: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.8rem;
|
||||
}
|
||||
@ -69,6 +78,27 @@
|
||||
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Dark mode styling for select dropdown */
|
||||
.form-group select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2300d4ff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.7rem center;
|
||||
background-size: 1.5em 1.5em;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.form-group select option {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group select option:checked {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.interest-input {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
@ -159,8 +189,6 @@
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.photo-section h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
208
frontend/src/styles/profilePhotoModal.css
Normal file
@ -0,0 +1,208 @@
|
||||
.profile-photo-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.profile-photo-modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid var(--border-color);
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-photo-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-photo-modal-header h2 {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.profile-photo-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.profile-photo-modal-close:hover {
|
||||
color: var(--accent-primary);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.profile-photo-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.current-photo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.current-photo .label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.current-photo-img {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
height: 300px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--border-color);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.photo-upload-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
border: 2px dashed var(--accent-primary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.photo-upload-section .label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
padding: 1rem 1.5rem;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upload-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.uploading {
|
||||
color: var(--accent-primary);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff4757;
|
||||
background: rgba(255, 71, 87, 0.1);
|
||||
padding: 0.8rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid rgba(255, 71, 87, 0.3);
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: #2ed573;
|
||||
background: rgba(46, 213, 115, 0.1);
|
||||
padding: 0.8rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid rgba(46, 213, 115, 0.3);
|
||||
}
|
||||
|
||||
.edit-profile-btn {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.edit-profile-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
BIN
media/044658cc-2296-4b29-96bd-ec1a4662d47e.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
media/2e7f5465-6468-452a-a936-fcafbc84f76e.jpg
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
media/bb349aaf-371b-434c-b4a6-3d7bc6584a63.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
media/cbb9ce4a-fb91-43f6-b3e3-aca6bb1a0ee8.jpg
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
media/cc7aa0fd-03a3-4a9d-90df-8194f4c64cd3.jpg
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
media/de475af6-8e8a-45ea-be3d-d1b62cfea5d6.jpg
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
media/fd058086-a2e0-4c66-a8b4-dc9f0b765b06.jpg
Normal file
|
After Width: | Height: | Size: 221 KiB |
26
migration_add_likes_acknowledged.sql
Normal file
@ -0,0 +1,26 @@
|
||||
-- ============================================================================
|
||||
-- MIGRATION: Add acknowledged tracking to likes
|
||||
-- ============================================================================
|
||||
-- Run this migration to track when users have viewed received likes
|
||||
-- Date: 2025-12-17
|
||||
-- ============================================================================
|
||||
|
||||
-- Add acknowledged_at column to likes table if it doesn't exist
|
||||
ALTER TABLE likes ADD COLUMN IF NOT EXISTS acknowledged_at TIMESTAMP DEFAULT NULL;
|
||||
|
||||
-- Create index on acknowledged_at for faster queries
|
||||
CREATE INDEX IF NOT EXISTS idx_likes_acknowledged_at ON likes(acknowledged_at);
|
||||
|
||||
-- ============================================================================
|
||||
-- VERIFICATION QUERIES (Run these to verify the changes)
|
||||
-- ============================================================================
|
||||
-- Check if acknowledged_at column exists:
|
||||
-- SELECT column_name, data_type FROM information_schema.columns
|
||||
-- WHERE table_name='likes' AND column_name='acknowledged_at';
|
||||
|
||||
-- Count acknowledged vs unacknowledged likes:
|
||||
-- SELECT
|
||||
-- COUNT(*) as total_likes,
|
||||
-- COUNT(CASE WHEN acknowledged_at IS NOT NULL THEN 1 END) as acknowledged_likes,
|
||||
-- COUNT(CASE WHEN acknowledged_at IS NULL THEN 1 END) as new_likes
|
||||
-- FROM likes;
|
||||
33
migration_add_read_tracking.sql
Normal file
@ -0,0 +1,33 @@
|
||||
-- ============================================================================
|
||||
-- MIGRATION: Add message read tracking
|
||||
-- ============================================================================
|
||||
-- Run this migration to add the read_at field to messages table
|
||||
-- This enables tracking of read/unread messages for notifications
|
||||
-- Date: 2025-12-17
|
||||
-- ============================================================================
|
||||
|
||||
-- Add read_at column to messages table if it doesn't exist
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS read_at TIMESTAMP DEFAULT NULL;
|
||||
|
||||
-- Create index on read_at for faster unread message queries
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_read_at ON messages(read_at);
|
||||
|
||||
-- Create index for unread message queries (conversation + read status + sender)
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(conversation_id, read_at, sender_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- VERIFICATION QUERIES (Run these to verify the changes)
|
||||
-- ============================================================================
|
||||
-- Check if read_at column exists:
|
||||
-- SELECT column_name, data_type FROM information_schema.columns
|
||||
-- WHERE table_name='messages' AND column_name='read_at';
|
||||
|
||||
-- Count messages with and without read_at:
|
||||
-- SELECT
|
||||
-- COUNT(*) as total_messages,
|
||||
-- COUNT(CASE WHEN read_at IS NOT NULL THEN 1 END) as read_messages,
|
||||
-- COUNT(CASE WHEN read_at IS NULL THEN 1 END) as unread_messages
|
||||
-- FROM messages;
|
||||
|
||||
-- Check indexes were created:
|
||||
-- SELECT indexname FROM pg_indexes WHERE tablename='messages';
|
||||
129
schema.sql
@ -16,17 +16,21 @@ EXCEPTION WHEN DUPLICATE_OBJECT THEN
|
||||
END
|
||||
$do$;
|
||||
|
||||
-- Grant privileges to user before database creation
|
||||
ALTER DEFAULT PRIVILEGES GRANT ALL ON TABLES TO dating_app_user;
|
||||
ALTER DEFAULT PRIVILEGES GRANT ALL ON SEQUENCES TO dating_app_user;
|
||||
-- Grant all privileges on database to dating_app_user
|
||||
GRANT ALL PRIVILEGES ON DATABASE dating_app TO dating_app_user;
|
||||
|
||||
-- Create the database owned by dating_app_user
|
||||
CREATE DATABASE dating_app OWNER dating_app_user;
|
||||
-- Grant all privileges on all tables in public schema to dating_app_user
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO dating_app_user;
|
||||
|
||||
-- Grant connection privileges
|
||||
GRANT CONNECT ON DATABASE dating_app TO dating_app_user;
|
||||
GRANT USAGE ON SCHEMA public TO dating_app_user;
|
||||
GRANT CREATE ON SCHEMA public TO dating_app_user;
|
||||
-- Grant all privileges on all sequences in public schema to dating_app_user
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO dating_app_user;
|
||||
|
||||
-- Grant all privileges on schema itself
|
||||
GRANT ALL PRIVILEGES ON SCHEMA public TO dating_app_user;
|
||||
|
||||
-- Set default privileges for future tables
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO dating_app_user;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO dating_app_user;
|
||||
|
||||
-- ============================================================================
|
||||
-- TABLE: USERS
|
||||
@ -34,6 +38,7 @@ GRANT CREATE ON SCHEMA public TO dating_app_user;
|
||||
-- Stores user account information
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
hashed_password VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
@ -41,6 +46,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
|
||||
-- ============================================================================
|
||||
-- TABLE: PROFILES
|
||||
@ -120,6 +126,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
conversation_id INTEGER NOT NULL,
|
||||
sender_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
read_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@ -133,23 +140,23 @@ CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
|
||||
-- ============================================================================
|
||||
|
||||
-- Test user 1: Alice (Password hash for 'password123')
|
||||
INSERT INTO users (email, hashed_password)
|
||||
VALUES ('alice@example.com', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5YmMxSUmGEJiq')
|
||||
INSERT INTO users (username, email, hashed_password)
|
||||
VALUES ('alice', 'alice@example.com', '$2b$12$u9bdKguk7ROP404cOx9FIuyKZoc3DK6el3y5muR8ayUnHG1bJewh2')
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
-- Test user 2: Bob
|
||||
INSERT INTO users (email, hashed_password)
|
||||
VALUES ('bob@example.com', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5YmMxSUmGEJiq')
|
||||
INSERT INTO users (username, email, hashed_password)
|
||||
VALUES ('bob', 'bob@example.com', '$2b$12$u9bdKguk7ROP404cOx9FIuyKZoc3DK6el3y5muR8ayUnHG1bJewh2')
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
-- Test user 3: Charlie
|
||||
INSERT INTO users (email, hashed_password)
|
||||
VALUES ('charlie@example.com', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5YmMxSUmGEJiq')
|
||||
INSERT INTO users (username, email, hashed_password)
|
||||
VALUES ('charlie', 'charlie@example.com', '$2b$12$u9bdKguk7ROP404cOx9FIuyKZoc3DK6el3y5muR8ayUnHG1bJewh2')
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
-- Test user 4: Diana
|
||||
INSERT INTO users (email, hashed_password)
|
||||
VALUES ('diana@example.com', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5YmMxSUmGEJiq')
|
||||
INSERT INTO users (username, email, hashed_password)
|
||||
VALUES ('diana', 'diana@example.com', '$2b$12$u9bdKguk7ROP404cOx9FIuyKZoc3DK6el3y5muR8ayUnHG1bJewh2')
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
@ -208,75 +215,6 @@ VALUES (
|
||||
)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- SAMPLE LIKES (Optional - Uncomment to create test likes)
|
||||
-- ============================================================================
|
||||
|
||||
-- Alice likes Bob
|
||||
INSERT INTO likes (liker_id, liked_id)
|
||||
SELECT (SELECT id FROM users WHERE email = 'alice@example.com'),
|
||||
(SELECT id FROM users WHERE email = 'bob@example.com')
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM likes
|
||||
WHERE liker_id = (SELECT id FROM users WHERE email = 'alice@example.com')
|
||||
AND liked_id = (SELECT id FROM users WHERE email = 'bob@example.com')
|
||||
);
|
||||
|
||||
-- Bob likes Alice (MATCH!)
|
||||
INSERT INTO likes (liker_id, liked_id)
|
||||
SELECT (SELECT id FROM users WHERE email = 'bob@example.com'),
|
||||
(SELECT id FROM users WHERE email = 'alice@example.com')
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM likes
|
||||
WHERE liker_id = (SELECT id FROM users WHERE email = 'bob@example.com')
|
||||
AND liked_id = (SELECT id FROM users WHERE email = 'alice@example.com')
|
||||
);
|
||||
|
||||
-- Charlie likes Diana
|
||||
INSERT INTO likes (liker_id, liked_id)
|
||||
SELECT (SELECT id FROM users WHERE email = 'charlie@example.com'),
|
||||
(SELECT id FROM users WHERE email = 'diana@example.com')
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM likes
|
||||
WHERE liker_id = (SELECT id FROM users WHERE email = 'charlie@example.com')
|
||||
AND liked_id = (SELECT id FROM users WHERE email = 'diana@example.com')
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- SAMPLE CONVERSATION (Optional - Uncomment for test chat)
|
||||
-- ============================================================================
|
||||
|
||||
-- Create conversation between Alice and Bob (they matched!)
|
||||
INSERT INTO conversations (user_id_1, user_id_2)
|
||||
SELECT (SELECT id FROM users WHERE email = 'alice@example.com'),
|
||||
(SELECT id FROM users WHERE email = 'bob@example.com')
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM conversations
|
||||
WHERE (user_id_1 = (SELECT id FROM users WHERE email = 'alice@example.com')
|
||||
AND user_id_2 = (SELECT id FROM users WHERE email = 'bob@example.com'))
|
||||
);
|
||||
|
||||
-- Sample messages in conversation
|
||||
INSERT INTO messages (conversation_id, sender_id, content)
|
||||
SELECT c.id,
|
||||
(SELECT id FROM users WHERE email = 'alice@example.com'),
|
||||
'Hi Bob! Love your photography page.'
|
||||
FROM conversations c
|
||||
WHERE c.user_id_1 = (SELECT id FROM users WHERE email = 'alice@example.com')
|
||||
AND c.user_id_2 = (SELECT id FROM users WHERE email = 'bob@example.com')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM messages WHERE conversation_id = c.id
|
||||
);
|
||||
|
||||
INSERT INTO messages (conversation_id, sender_id, content)
|
||||
SELECT c.id,
|
||||
(SELECT id FROM users WHERE email = 'bob@example.com'),
|
||||
'Thanks Alice! Would love to grab coffee sometime?'
|
||||
FROM conversations c
|
||||
WHERE c.user_id_1 = (SELECT id FROM users WHERE email = 'alice@example.com')
|
||||
AND c.user_id_2 = (SELECT id FROM users WHERE email = 'bob@example.com')
|
||||
AND (SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) < 2;
|
||||
|
||||
-- ============================================================================
|
||||
-- VERIFICATION QUERIES
|
||||
-- ============================================================================
|
||||
@ -293,7 +231,7 @@ AND (SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) < 2;
|
||||
-- ============================================================================
|
||||
--
|
||||
-- Password hashes used in sample data:
|
||||
-- - Hash: $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5YmMxSUmGEJiq
|
||||
-- - Hash: $2b$12$u9bdKguk7ROP404cOx9FIuyKZoc3DK6el3y5muR8ayUnHG1bJewh2
|
||||
-- - Password: 'password123'
|
||||
--
|
||||
-- To generate your own bcrypt hash, use Python:
|
||||
@ -315,3 +253,20 @@ AND (SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) < 2;
|
||||
-- Password: (set when installing PostgreSQL)
|
||||
--
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- MIGRATIONS (Run these if tables already exist and need updates)
|
||||
-- ============================================================================
|
||||
-- Uncomment and run the following lines to update existing database:
|
||||
|
||||
-- Add username column to users table if it doesn't already exist:
|
||||
-- ALTER TABLE users ADD COLUMN username VARCHAR(255) UNIQUE;
|
||||
|
||||
-- Add index on username for faster lookups:
|
||||
-- CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
|
||||
-- Update dating_app_user permissions:
|
||||
-- GRANT ALL PRIVILEGES ON DATABASE dating_app TO dating_app_user;
|
||||
-- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO dating_app_user;
|
||||
-- GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO dating_app_user;
|
||||
-- GRANT ALL PRIVILEGES ON SCHEMA public TO dating_app_user;
|
||||
|
||||