Complete test version

This commit is contained in:
dvirlabs 2025-12-17 06:20:46 +02:00
parent a9546d5283
commit 97a07b6a33
54 changed files with 2551 additions and 210 deletions

View File

@ -5,8 +5,9 @@ class User:
TABLE_NAME = "users" 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.id = id
self.username = username
self.email = email self.email = email
self.hashed_password = hashed_password self.hashed_password = hashed_password
self.created_at = created_at or datetime.utcnow() self.created_at = created_at or datetime.utcnow()
@ -15,6 +16,7 @@ class User:
def to_dict(self): def to_dict(self):
return { return {
"id": self.id, "id": self.id,
"username": self.username,
"email": self.email, "email": self.email,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,

View File

@ -43,3 +43,17 @@ def send_message(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=str(e) 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)
)

View File

@ -19,7 +19,30 @@ def like_user(
detail=str(e) 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") @router.get("/matches/list")
def get_matches(current_user: dict = Depends(get_current_user)): def get_matches(current_user: dict = Depends(get_current_user)):
"""Get all matches""" """Get all matches"""
return LikeService.get_matches(current_user["user_id"]) 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"])}

View File

@ -1,12 +1,14 @@
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr, Field
from typing import Optional
class UserRegister(BaseModel): class UserRegister(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
email: EmailStr email: EmailStr
password: str password: str = Field(..., min_length=6)
display_name: str display_name: str
class UserLogin(BaseModel): class UserLogin(BaseModel):
email: EmailStr email_or_username: str
password: str password: str
class TokenResponse(BaseModel): class TokenResponse(BaseModel):

View File

@ -1,5 +1,5 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List, Optional
from .message import MessageResponse from .message import MessageResponse
class ConversationResponse(BaseModel): class ConversationResponse(BaseModel):
@ -8,5 +8,7 @@ class ConversationResponse(BaseModel):
user_id_2: int user_id_2: int
other_user_display_name: str other_user_display_name: str
other_user_id: int other_user_id: int
other_user_photo: Optional[str] = None
latest_message: str = "" latest_message: str = ""
unread_count: int = 0
created_at: str created_at: str

View File

@ -17,11 +17,16 @@ class AuthService:
if cur.fetchone(): if cur.fetchone():
raise ValueError("Email already registered") 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 # Hash password and create user
hashed_pwd = hash_password(user_data.password) hashed_pwd = hash_password(user_data.password)
cur.execute( cur.execute(
"INSERT INTO users (email, hashed_password) VALUES (%s, %s) RETURNING id", "INSERT INTO users (username, email, hashed_password) VALUES (%s, %s, %s) RETURNING id",
(user_data.email, hashed_pwd) (user_data.username, user_data.email, hashed_pwd)
) )
user_id = cur.fetchone()[0] user_id = cur.fetchone()[0]
conn.commit() conn.commit()
@ -41,15 +46,20 @@ class AuthService:
@staticmethod @staticmethod
def login(user_data: UserLogin) -> TokenResponse: 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: with get_db_connection() as conn:
cur = conn.cursor() 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() row = cur.fetchone()
if not row or not verify_password(user_data.password, row[1]): 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] 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) return TokenResponse(access_token=token, token_type="bearer", user_id=user_id)

View File

@ -62,10 +62,19 @@ class ChatService:
conv_id, user_1, user_2, created_at, updated_at = row conv_id, user_1, user_2, created_at, updated_at = row
other_user_id = user_2 if user_1 == user_id else user_1 other_user_id = user_2 if user_1 == user_id else user_1
# Get other user's display name # Get other user's display name and photo
cur.execute("SELECT display_name FROM profiles WHERE user_id = %s", (other_user_id,)) 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() profile_row = cur.fetchone()
other_user_name = profile_row[0] if profile_row else "Unknown" 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 # Get latest message
cur.execute( cur.execute(
@ -75,13 +84,22 @@ class ChatService:
msg_row = cur.fetchone() msg_row = cur.fetchone()
latest_msg = msg_row[0] if msg_row else "" 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( conversations.append(ConversationResponse(
id=conv_id, id=conv_id,
user_id_1=user_1, user_id_1=user_1,
user_id_2=user_2, user_id_2=user_2,
other_user_id=other_user_id, other_user_id=other_user_id,
other_user_display_name=other_user_name, other_user_display_name=other_user_name,
other_user_photo=other_user_photo,
latest_message=latest_msg, latest_message=latest_msg,
unread_count=unread_count,
created_at=created_at.isoformat() created_at=created_at.isoformat()
)) ))
@ -123,3 +141,31 @@ class ChatService:
)) ))
return list(reversed(messages)) # Return in chronological order 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"}

View File

@ -44,6 +44,21 @@ class LikeService:
return LikeResponse(id=like_id, liker_id=liker_id, liked_id=liked_id, is_match=is_match) 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 @staticmethod
def get_matches(user_id: int) -> list: def get_matches(user_id: int) -> list:
"""Get all users that match with this user""" """Get all users that match with this user"""
@ -68,11 +83,75 @@ class LikeService:
matches = [] matches = []
for match_id in match_ids: for match_id in match_ids:
cur.execute( 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,) (match_id,)
) )
row = cur.fetchone() row = cur.fetchone()
if row: 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 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

View File

@ -50,7 +50,8 @@ class ProfileService:
return None return None
profile_id = row[0] 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 # Fetch photos
cur.execute("SELECT id, file_path FROM photos WHERE profile_id = %s ORDER BY display_order", (profile_id,)) 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 = [] profiles = []
for row in cur.fetchall(): for row in cur.fetchall():
profile_id = row[0] 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 # Fetch photos
cur.execute( cur.execute(

View File

@ -1,11 +1,12 @@
fastapi==0.104.1 fastapi==0.104.1
uvicorn==0.24.0 uvicorn==0.24.0
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
passlib==1.7.4 passlib==1.7.4.1
bcrypt==3.2.0 bcrypt==4.1.1
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
python-multipart==0.0.6 python-multipart==0.0.6
alembic==1.13.1 alembic==1.13.1
pydantic==2.5.0 pydantic==2.5.0
pydantic-settings==2.1.0 pydantic-settings==2.1.0
python-dotenv==1.0.0 python-dotenv==1.0.0
email-validator==2.1.0

View File

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dating App</title> <title>Dating App</title>
</head> </head>

View File

@ -3,6 +3,26 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--bg-primary); 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 { .navbar {
@ -25,11 +45,65 @@
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
letter-spacing: 0.5px; 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 { .nav-links {
display: flex; display: flex;
gap: 1rem; 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 { .nav-links button {
@ -42,13 +116,142 @@
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.9rem;
transition: all 0.3s ease; transition: all 0.3s ease;
position: relative;
} }
.nav-links button:hover { .nav-links button:hover {
background: var(--accent-primary); background: var(--accent-primary);
color: var(--bg-primary); color: var(--bg-primary);
transform: translateY(-2px); 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 { .main-content {

View File

@ -5,53 +5,233 @@ import ProfileEditor from './pages/ProfileEditor'
import Discover from './pages/Discover' import Discover from './pages/Discover'
import Matches from './pages/Matches' import Matches from './pages/Matches'
import Chat from './pages/Chat' 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' import './App.css'
function App() { function App() {
const [currentPage, setCurrentPage] = useState('login') const [currentPage, setCurrentPage] = useState('login')
const [isAuthenticated, setIsAuthenticated] = useState(false) 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(() => { useEffect(() => {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
const savedPage = localStorage.getItem('currentPage')
if (token) { if (token) {
setIsAuthenticated(true) 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 = () => { const handleLoginSuccess = () => {
setIsAuthenticated(true) setIsAuthenticated(true)
setCurrentPage('discover') setCurrentPage('discover')
loadUserProfile()
} }
const handleRegisterSuccess = () => { const handleRegisterSuccess = () => {
setIsAuthenticated(true) setIsAuthenticated(true)
setCurrentPage('profile-editor') setCurrentPage('profile-editor')
loadUserProfile()
} }
const handleLogout = () => { const confirmLogout = () => {
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('user_id') localStorage.removeItem('user_id')
setIsAuthenticated(false) setIsAuthenticated(false)
setCurrentPage('login') 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 ( return (
<div className="app"> <div className="app">
{isAuthenticated && ( {isAuthenticated && (
<nav className="navbar"> <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"> <div className="nav-links">
<button onClick={() => setCurrentPage('discover')}>Discover</button> <button
<button onClick={() => setCurrentPage('matches')}>Matches</button> onClick={() => setCurrentPage('discover')}
<button onClick={() => setCurrentPage('chat')}>Chat</button> className="nav-icon-btn"
<button onClick={() => setCurrentPage('profile-editor')}>Profile</button> title="Discover profiles"
<button onClick={handleLogout}>Logout</button> >
<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> </div>
</nav> </nav>
)} )}
<main className="main-content"> <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' && ( {currentPage === 'login' && (
<Login onLoginSuccess={handleLoginSuccess} onRegisterClick={() => setCurrentPage('register')} /> <Login onLoginSuccess={handleLoginSuccess} onRegisterClick={() => setCurrentPage('register')} />
)} )}
@ -59,10 +239,35 @@ function App() {
<Register onRegisterSuccess={handleRegisterSuccess} onLoginClick={() => setCurrentPage('login')} /> <Register onRegisterSuccess={handleRegisterSuccess} onLoginClick={() => setCurrentPage('login')} />
)} )}
{isAuthenticated && currentPage === 'profile-editor' && <ProfileEditor />} {isAuthenticated && currentPage === 'profile-editor' && <ProfileEditor />}
{isAuthenticated && currentPage === 'discover' && <Discover />} {isAuthenticated && currentPage === 'discover' && <Discover onNotificationsUpdate={loadNotifications} onNavigateToChat={() => setCurrentPage('chat')} />}
{isAuthenticated && currentPage === 'matches' && <Matches />} {isAuthenticated && currentPage === 'matches' && <Matches onNavigateToChat={() => setCurrentPage('chat')} />}
{isAuthenticated && currentPage === 'chat' && <Chat />} {isAuthenticated && currentPage === 'chat' && <Chat onNotificationsUpdate={loadNotifications} />}
</main> </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> </div>
) )
} }

View File

@ -37,10 +37,10 @@ api.interceptors.response.use(
// Auth endpoints // Auth endpoints
export const authAPI = { export const authAPI = {
register: (email, password, displayName) => register: (username, email, password, displayName) =>
api.post('/auth/register', { email, password, display_name: displayName }), api.post('/auth/register', { username, email, password, display_name: displayName }),
login: (email, password) => login: (emailOrUsername, password) =>
api.post('/auth/login', { email, password }), api.post('/auth/login', { email_or_username: emailOrUsername, password }),
getCurrentUser: () => getCurrentUser: () =>
api.get('/auth/me'), api.get('/auth/me'),
} }
@ -76,8 +76,16 @@ export const photoAPI = {
export const likeAPI = { export const likeAPI = {
likeUser: (userId) => likeUser: (userId) =>
api.post(`/likes/${userId}`), api.post(`/likes/${userId}`),
unlikeUser: (userId) =>
api.delete(`/likes/${userId}`),
getMatches: () => getMatches: () =>
api.get('/likes/matches/list'), 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 // Chat endpoints
@ -88,6 +96,8 @@ export const chatAPI = {
api.get(`/chat/conversations/${conversationId}/messages`, { params: { limit } }), api.get(`/chat/conversations/${conversationId}/messages`, { params: { limit } }),
sendMessage: (conversationId, content) => sendMessage: (conversationId, content) =>
api.post(`/chat/conversations/${conversationId}/messages`, { content }), api.post(`/chat/conversations/${conversationId}/messages`, { content }),
markAsRead: (conversationId) =>
api.post(`/chat/conversations/${conversationId}/mark-read`),
} }
export { API_BASE_URL } export { API_BASE_URL }

View 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

View 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

View 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

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

View 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

View 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

View 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

View 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

View 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}>&times;</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>
)
}

View 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}>&times;</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>
)
}

View 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}>&times;</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>
)
}

View File

@ -19,9 +19,39 @@
} }
html { html {
background: var(--bg-primary);
color: var(--text-primary);
scroll-behavior: smooth; 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 { body {
font-family: 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', font-family: 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
'Droid Sans', 'Helvetica Neue', sans-serif; 'Droid Sans', 'Helvetica Neue', sans-serif;
@ -30,6 +60,12 @@ body {
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
line-height: 1.6; 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 { button {

View File

@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { chatAPI } from '../api' import { chatAPI, API_BASE_URL } from '../api'
import '../styles/chat.css' import '../styles/chat.css'
export default function Chat({ conversationId }) { export default function Chat({ conversationId, onNotificationsUpdate }) {
const [conversations, setConversations] = useState([]) const [conversations, setConversations] = useState([])
const [selectedConversation, setSelectedConversation] = useState(conversationId || null) const [selectedConversation, setSelectedConversation] = useState(conversationId || null)
const [messages, setMessages] = useState([]) const [messages, setMessages] = useState([])
@ -10,6 +10,7 @@ export default function Chat({ conversationId }) {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const messageInputRef = useRef(null)
const currentUserId = localStorage.getItem('user_id') const currentUserId = localStorage.getItem('user_id')
@ -31,6 +32,10 @@ export default function Chat({ conversationId }) {
setIsLoading(true) setIsLoading(true)
const response = await chatAPI.getConversations() const response = await chatAPI.getConversations()
setConversations(response.data || []) setConversations(response.data || [])
// Notify parent about notifications update
if (onNotificationsUpdate) {
onNotificationsUpdate()
}
} catch (err) { } catch (err) {
setError('Failed to load conversations') setError('Failed to load conversations')
} finally { } finally {
@ -44,6 +49,23 @@ export default function Chat({ conversationId }) {
try { try {
const response = await chatAPI.getMessages(selectedConversation) const response = await chatAPI.getMessages(selectedConversation)
setMessages(response.data || []) 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) { } catch (err) {
setError('Failed to load messages') setError('Failed to load messages')
} }
@ -58,6 +80,8 @@ export default function Chat({ conversationId }) {
const response = await chatAPI.sendMessage(selectedConversation, newMessage) const response = await chatAPI.sendMessage(selectedConversation, newMessage)
setMessages((prev) => [...prev, response.data]) setMessages((prev) => [...prev, response.data])
setNewMessage('') setNewMessage('')
// Auto-focus back to input field after sending
setTimeout(() => messageInputRef.current?.focus(), 0)
} catch (err) { } catch (err) {
setError('Failed to send message') setError('Failed to send message')
} finally { } finally {
@ -92,8 +116,26 @@ export default function Chat({ conversationId }) {
className={`conversation-item ${selectedConversation === conv.id ? 'active' : ''}`} className={`conversation-item ${selectedConversation === conv.id ? 'active' : ''}`}
onClick={() => setSelectedConversation(conv.id)} onClick={() => setSelectedConversation(conv.id)}
> >
<h4>{conv.other_user_display_name}</h4> <div className="conversation-header">
<p className="latest-msg">{conv.latest_message || 'No messages yet'}</p> {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>
))} ))}
</div> </div>
@ -123,11 +165,13 @@ export default function Chat({ conversationId }) {
<form onSubmit={handleSendMessage} className="message-form"> <form onSubmit={handleSendMessage} className="message-form">
<input <input
ref={messageInputRef}
type="text" type="text"
value={newMessage} value={newMessage}
onChange={(e) => setNewMessage(e.target.value)} onChange={(e) => setNewMessage(e.target.value)}
placeholder="Type a message..." placeholder="Type a message..."
disabled={isSending} disabled={isSending}
autoFocus
/> />
<button type="submit" disabled={isSending || !newMessage.trim()}> <button type="submit" disabled={isSending || !newMessage.trim()}>
Send Send

View File

@ -1,12 +1,18 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { profileAPI, likeAPI, API_BASE_URL } from '../api' 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' import '../styles/discover.css'
export default function Discover() { export default function Discover({ onNotificationsUpdate, onNavigateToChat }) {
const [profiles, setProfiles] = useState([]) const [profiles, setProfiles] = useState([])
const [currentIndex, setCurrentIndex] = useState(0) const [currentIndex, setCurrentIndex] = useState(0)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [likedProfiles, setLikedProfiles] = useState(new Set())
const [showMatchModal, setShowMatchModal] = useState(false)
const [matchName, setMatchName] = useState('')
useEffect(() => { useEffect(() => {
loadProfiles() loadProfiles()
@ -17,6 +23,16 @@ export default function Discover() {
setIsLoading(true) setIsLoading(true)
const response = await profileAPI.discoverProfiles() const response = await profileAPI.discoverProfiles()
setProfiles(response.data || []) 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) { } catch (err) {
setError('Failed to load profiles') setError('Failed to load profiles')
} finally { } finally {
@ -28,14 +44,32 @@ export default function Discover() {
if (currentIndex >= profiles.length) return if (currentIndex >= profiles.length) return
const profile = profiles[currentIndex] const profile = profiles[currentIndex]
const isAlreadyLiked = likedProfiles.has(profile.id)
try { try {
const response = await likeAPI.likeUser(profile.id) if (isAlreadyLiked) {
if (response.data.is_match) { // Unlike
alert(`It's a match with ${profile.display_name}!`) 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) { } 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() nextProfile()
} }
const previousProfile = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : profiles.length - 1))
}
const nextProfile = () => { const nextProfile = () => {
if (currentIndex < profiles.length - 1) { setCurrentIndex((prev) => (prev < profiles.length - 1 ? prev + 1 : 0))
setCurrentIndex((prev) => prev + 1)
} else {
setError('No more profiles to discover')
}
} }
if (isLoading) { if (isLoading) {
@ -68,6 +102,17 @@ export default function Discover() {
} }
const profile = profiles[currentIndex] 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 ( return (
<div className="discover"> <div className="discover">
@ -75,44 +120,95 @@ export default function Discover() {
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
<div className="card-container"> <div className="card-container">
<div className="profile-card"> <button
{profile.photos && profile.photos.length > 0 ? ( className="nav-arrow nav-arrow-left"
<img src={`${API_BASE_URL}/media/${profile.photos[0].file_path}`} alt={profile.display_name} /> onClick={previousProfile}
) : ( title="Previous profile"
<div className="no-photo">No photo</div> >
)}
</button>
<div className="card-info"> {cardsToShow.map((prof, idx) => {
<h2> const isCurrentLiked = likedProfiles.has(prof.id)
{profile.display_name}, {profile.age} return (
</h2> <div key={prof.id} className="profile-card" style={{zIndex: 10 - idx}}>
<p className="location">{profile.location}</p> {idx === 0 && isCurrentLiked && <div className="liked-badge"> Liked</div>}
{profile.bio && <p className="bio">{profile.bio}</p>}
{profile.interests && profile.interests.length > 0 && ( {prof.photos && prof.photos.length > 0 ? (
<div className="interests"> <img src={`${API_BASE_URL}/media/${prof.photos[0].file_path}`} alt={prof.display_name} />
{profile.interests.map((interest) => ( ) : (
<span key={interest} className="interest-tag"> <div className="no-photo">No photo</div>
{interest} )}
</span>
))}
</div>
)}
</div>
<div className="card-actions"> {idx === 0 && (
<button className="pass-btn" onClick={handlePass}> <>
Pass <div className="card-info">
</button> <h2>
<button className="like-btn" onClick={handleLike}> {prof.display_name}, {prof.age}
Like </h2>
</button> <p className="location">{prof.location}</p>
</div> {prof.bio && <p className="bio">{prof.bio}</p>}
</div> {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>
<div className="progress"> {/* <div className="profile-counter">
Profile {currentIndex + 1} of {profiles.length} 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> </div>
) )
} }

View 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>
)
}

View File

@ -3,7 +3,7 @@ import { authAPI } from '../api'
import '../styles/auth.css' import '../styles/auth.css'
export default function Login({ onLoginSuccess, onRegisterClick }) { export default function Login({ onLoginSuccess, onRegisterClick }) {
const [email, setEmail] = useState('') const [emailOrUsername, setEmailOrUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@ -14,7 +14,7 @@ export default function Login({ onLoginSuccess, onRegisterClick }) {
setIsLoading(true) setIsLoading(true)
try { try {
const response = await authAPI.login(email, password) const response = await authAPI.login(emailOrUsername, password)
const { access_token, user_id } = response.data const { access_token, user_id } = response.data
// Store token and user ID // Store token and user ID
@ -37,10 +37,10 @@ export default function Login({ onLoginSuccess, onRegisterClick }) {
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
<form onSubmit={handleLogin}> <form onSubmit={handleLogin}>
<input <input
type="email" type="text"
placeholder="Email" placeholder="Email or Username"
value={email} value={emailOrUsername}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmailOrUsername(e.target.value)}
required required
/> />
<input <input

View File

@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { likeAPI } from '../api' import { likeAPI, API_BASE_URL } from '../api'
import '../styles/matches.css' import '../styles/matches.css'
export default function Matches() { export default function Matches({ onNavigateToChat }) {
const [matches, setMatches] = useState([]) const [matches, setMatches] = useState([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
@ -23,8 +23,8 @@ export default function Matches() {
} }
} }
const handleStartChat = (userId) => { const handleStartChat = () => {
window.location.href = `/chat?user_id=${userId}` onNavigateToChat()
} }
if (isLoading) { if (isLoading) {
@ -51,8 +51,15 @@ export default function Matches() {
<div className="matches-grid"> <div className="matches-grid">
{matches.map((match) => ( {matches.map((match) => (
<div key={match.user_id} className="match-card"> <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> <h3>{match.display_name}</h3>
<button onClick={() => handleStartChat(match.user_id)}> <button onClick={handleStartChat}>
Message Message
</button> </button>
</div> </div>

View File

@ -125,10 +125,9 @@ export default function ProfileEditor() {
<div className="form-group"> <div className="form-group">
<label>Gender</label> <label>Gender</label>
<select name="gender" value={profile.gender} onChange={handleInputChange}> <select name="gender" value={profile.gender} onChange={handleInputChange}>
<option value="">Select...</option> <option value="">Select Gender</option>
<option value="male">Male</option> <option value="male">Male</option>
<option value="female">Female</option> <option value="female">Female</option>
<option value="other">Other</option>
</select> </select>
</div> </div>
@ -182,12 +181,39 @@ export default function ProfileEditor() {
</div> </div>
</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}> <button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Profile'} {isLoading ? 'Saving...' : 'Save Profile'}
</button> </button>
</form> </form>
<div className="photo-section"> <div className="photo-section" style={{display: 'none'}}>
<h2>Photos</h2> <h2>Photos</h2>
<div className="photo-upload"> <div className="photo-upload">
<input <input

View File

@ -3,6 +3,7 @@ import { authAPI } from '../api'
import '../styles/auth.css' import '../styles/auth.css'
export default function Register({ onRegisterSuccess, onLoginClick }) { export default function Register({ onRegisterSuccess, onLoginClick }) {
const [username, setUsername] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('') const [displayName, setDisplayName] = useState('')
@ -15,21 +16,16 @@ export default function Register({ onRegisterSuccess, onLoginClick }) {
setIsLoading(true) setIsLoading(true)
try { try {
console.log('Attempting to register:', { email, displayName }) const response = await authAPI.register(username, email, password, displayName)
const response = await authAPI.register(email, password, displayName)
console.log('Register response:', response.data)
const { access_token, user_id } = response.data const { access_token, user_id } = response.data
// Store token and user ID // Store token and user ID
localStorage.setItem('token', access_token) localStorage.setItem('token', access_token)
localStorage.setItem('user_id', user_id) localStorage.setItem('user_id', user_id)
console.log('Registration successful, calling onRegisterSuccess')
onRegisterSuccess() onRegisterSuccess()
} catch (err) { } catch (err) {
console.error('Registration error:', err)
const errorMsg = err.response?.data?.detail || err.message || 'Registration failed' const errorMsg = err.response?.data?.detail || err.message || 'Registration failed'
console.error('Error message:', errorMsg)
setError(errorMsg) setError(errorMsg)
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@ -43,6 +39,13 @@ export default function Register({ onRegisterSuccess, onLoginClick }) {
<h2>Register</h2> <h2>Register</h2>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
<form onSubmit={handleRegister}> <form onSubmit={handleRegister}>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<input <input
type="email" type="email"
placeholder="Email" placeholder="Email"

View File

@ -27,9 +27,10 @@
.conversations-list h2 { .conversations-list h2 {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
font-size: 1.2rem; font-size: 1.5rem;
color: var(--text-primary);
font-weight: 700; font-weight: 700;
color: var(--text-primary);
font-family: 'Poppins', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
} }
.conversation-item { .conversation-item {
@ -54,9 +55,57 @@
border-color: transparent; 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 { .conversation-item h4 {
margin: 0; margin: 0;
margin-bottom: 0.6rem;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);

View File

@ -1,54 +1,111 @@
.discover { .discover {
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
overflow: visible;
} }
.discover h1 { .discover h1 {
text-align: center; text-align: center;
margin-bottom: 2rem; margin-bottom: 2rem;
color: var(--text-primary); color: var(--text-primary);
font-size: 2.5rem; font-size: 3rem;
font-weight: 800;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
font-family: 'Poppins', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
letter-spacing: -0.5px;
} }
.card-container { .card-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-bottom: 2rem; margin-bottom: 2rem;
perspective: 1000px;
position: relative;
min-height: 900px;
} }
.profile-card { .profile-card {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-lg); /* box-shadow: var(--shadow-lg); */
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
transition: all 0.3s ease; transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
transform: scale(1); position: absolute;
z-index: 10;
animation: cardEnter 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
.profile-card:hover { @keyframes cardEnter {
transform: scale(1.02); 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); 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 { .profile-card img {
width: 100%; width: 100%;
height: 450px; height: 450px;
aspect-ratio: 1 / 1.125;
object-fit: cover; object-fit: cover;
display: block; display: block;
transition: transform 0.3s ease; transition: transform 0.3s ease;
border-radius: 16px 16px 0 0;
} }
.profile-card:hover img { .profile-card:hover img {
transform: scale(1.05); 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 { .no-photo {
width: 100%; width: 100%;
height: 450px; height: 450px;
@ -58,6 +115,7 @@
justify-content: center; justify-content: center;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 1.2rem; font-size: 1.2rem;
border-radius: 16px 16px 0 0;
} }
.card-info { .card-info {
@ -124,6 +182,10 @@
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
} }
.pass-btn { .pass-btn {
@ -137,6 +199,10 @@
border-color: var(--text-secondary); border-color: var(--text-secondary);
transform: translateY(-2px); transform: translateY(-2px);
} }
.pass-btn svg {
width: 20px;
height: 20px;
} }
.like-btn { .like-btn {
@ -146,6 +212,12 @@
.like-btn:hover { .like-btn:hover {
background-color: #c82333; background-color: #c82333;
transform: translateY(-2px);
}
.like-btn .like-button-icon {
width: 20px;
height: 20px;
} }
.progress { .progress {
@ -158,3 +230,78 @@
text-align: center; text-align: center;
padding: 3rem; 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;
}

View 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;
}
}

View 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;
}
}

View File

@ -6,11 +6,14 @@
.matches h1 { .matches h1 {
margin-bottom: 2rem; margin-bottom: 2rem;
color: var(--text-primary); color: var(--text-primary);
font-size: 2.5rem; font-size: 3rem;
font-weight: 800;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
font-family: 'Poppins', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
letter-spacing: -0.5px;
} }
.matches-grid { .matches-grid {
@ -36,16 +39,16 @@
border-color: var(--accent-primary); border-color: var(--accent-primary);
} }
.match-card img { .match-card-photo {
width: 100%; width: 200px;
height: 250px; height: 200px;
object-fit: cover; object-fit: cover;
border-radius: 12px; border-radius: 50%;
margin-bottom: 1rem; margin: 0 auto 1rem;
transition: transform 0.3s ease; transition: transform 0.3s ease;
} }
.match-card:hover img { .match-card:hover .match-card-photo {
transform: scale(1.05); transform: scale(1.05);
} }

View 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);
}

View File

@ -29,6 +29,15 @@
border: 1px solid var(--border-color); 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 { .form-group {
margin-bottom: 1.8rem; margin-bottom: 1.8rem;
} }
@ -69,6 +78,27 @@
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1); 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 { .interest-input {
display: flex; display: flex;
gap: 0.8rem; gap: 0.8rem;
@ -159,8 +189,6 @@
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.photo-section h2 { .photo-section h2 {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;

View 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);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

View 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;

View 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';

View File

@ -16,17 +16,21 @@ EXCEPTION WHEN DUPLICATE_OBJECT THEN
END END
$do$; $do$;
-- Grant privileges to user before database creation -- Grant all privileges on database to dating_app_user
ALTER DEFAULT PRIVILEGES GRANT ALL ON TABLES TO dating_app_user; GRANT ALL PRIVILEGES ON DATABASE dating_app TO dating_app_user;
ALTER DEFAULT PRIVILEGES GRANT ALL ON SEQUENCES TO dating_app_user;
-- Create the database owned by dating_app_user -- Grant all privileges on all tables in public schema to dating_app_user
CREATE DATABASE dating_app OWNER dating_app_user; GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO dating_app_user;
-- Grant connection privileges -- Grant all privileges on all sequences in public schema to dating_app_user
GRANT CONNECT ON DATABASE dating_app TO dating_app_user; GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public 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 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 -- TABLE: USERS
@ -34,6 +38,7 @@ GRANT CREATE ON SCHEMA public TO dating_app_user;
-- Stores user account information -- Stores user account information
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL,
hashed_password VARCHAR(255) NOT NULL, hashed_password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 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_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
-- ============================================================================ -- ============================================================================
-- TABLE: PROFILES -- TABLE: PROFILES
@ -120,6 +126,7 @@ CREATE TABLE IF NOT EXISTS messages (
conversation_id INTEGER NOT NULL, conversation_id INTEGER NOT NULL,
sender_id INTEGER NOT NULL, sender_id INTEGER NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
read_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE, FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (sender_id) REFERENCES users(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') -- Test user 1: Alice (Password hash for 'password123')
INSERT INTO users (email, hashed_password) INSERT INTO users (username, email, hashed_password)
VALUES ('alice@example.com', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5YmMxSUmGEJiq') VALUES ('alice', 'alice@example.com', '$2b$12$u9bdKguk7ROP404cOx9FIuyKZoc3DK6el3y5muR8ayUnHG1bJewh2')
ON CONFLICT (email) DO NOTHING; ON CONFLICT (email) DO NOTHING;
-- Test user 2: Bob -- Test user 2: Bob
INSERT INTO users (email, hashed_password) INSERT INTO users (username, email, hashed_password)
VALUES ('bob@example.com', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5YmMxSUmGEJiq') VALUES ('bob', 'bob@example.com', '$2b$12$u9bdKguk7ROP404cOx9FIuyKZoc3DK6el3y5muR8ayUnHG1bJewh2')
ON CONFLICT (email) DO NOTHING; ON CONFLICT (email) DO NOTHING;
-- Test user 3: Charlie -- Test user 3: Charlie
INSERT INTO users (email, hashed_password) INSERT INTO users (username, email, hashed_password)
VALUES ('charlie@example.com', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5YmMxSUmGEJiq') VALUES ('charlie', 'charlie@example.com', '$2b$12$u9bdKguk7ROP404cOx9FIuyKZoc3DK6el3y5muR8ayUnHG1bJewh2')
ON CONFLICT (email) DO NOTHING; ON CONFLICT (email) DO NOTHING;
-- Test user 4: Diana -- Test user 4: Diana
INSERT INTO users (email, hashed_password) INSERT INTO users (username, email, hashed_password)
VALUES ('diana@example.com', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5YmMxSUmGEJiq') VALUES ('diana', 'diana@example.com', '$2b$12$u9bdKguk7ROP404cOx9FIuyKZoc3DK6el3y5muR8ayUnHG1bJewh2')
ON CONFLICT (email) DO NOTHING; ON CONFLICT (email) DO NOTHING;
-- ============================================================================ -- ============================================================================
@ -208,75 +215,6 @@ VALUES (
) )
ON CONFLICT (user_id) DO NOTHING; 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 -- VERIFICATION QUERIES
-- ============================================================================ -- ============================================================================
@ -293,7 +231,7 @@ AND (SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) < 2;
-- ============================================================================ -- ============================================================================
-- --
-- Password hashes used in sample data: -- Password hashes used in sample data:
-- - Hash: $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5YmMxSUmGEJiq -- - Hash: $2b$12$u9bdKguk7ROP404cOx9FIuyKZoc3DK6el3y5muR8ayUnHG1bJewh2
-- - Password: 'password123' -- - Password: 'password123'
-- --
-- To generate your own bcrypt hash, use Python: -- 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) -- 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;