diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index bc24876..8ef2d34 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -5,8 +5,9 @@ class User:
TABLE_NAME = "users"
- def __init__(self, id, email, hashed_password, created_at=None, updated_at=None):
+ def __init__(self, id, username, email, hashed_password, created_at=None, updated_at=None):
self.id = id
+ self.username = username
self.email = email
self.hashed_password = hashed_password
self.created_at = created_at or datetime.utcnow()
@@ -15,6 +16,7 @@ class User:
def to_dict(self):
return {
"id": self.id,
+ "username": self.username,
"email": self.email,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py
index ba426aa..be44142 100644
--- a/backend/app/routers/chat.py
+++ b/backend/app/routers/chat.py
@@ -43,3 +43,17 @@ def send_message(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e)
)
+
+@router.post("/conversations/{conversation_id}/mark-read")
+def mark_messages_as_read(
+ conversation_id: int,
+ current_user: dict = Depends(get_current_user)
+):
+ """Mark all unread messages in a conversation as read"""
+ try:
+ return ChatService.mark_messages_as_read(current_user["user_id"], conversation_id)
+ except ValueError as e:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=str(e)
+ )
diff --git a/backend/app/routers/likes.py b/backend/app/routers/likes.py
index e7d9386..d2d40ee 100644
--- a/backend/app/routers/likes.py
+++ b/backend/app/routers/likes.py
@@ -19,7 +19,30 @@ def like_user(
detail=str(e)
)
+@router.delete("/{liked_user_id}")
+def unlike_user(
+ liked_user_id: int,
+ current_user: dict = Depends(get_current_user)
+):
+ """Unlike another user"""
+ return LikeService.unlike_user(current_user["user_id"], liked_user_id)
+
+@router.get("/received/list")
+def get_likes_received(current_user: dict = Depends(get_current_user)):
+ """Get all likes received (unmatched)"""
+ return LikeService.get_likes_received(current_user["user_id"])
+
@router.get("/matches/list")
def get_matches(current_user: dict = Depends(get_current_user)):
"""Get all matches"""
return LikeService.get_matches(current_user["user_id"])
+
+@router.post("/received/acknowledge")
+def acknowledge_likes(current_user: dict = Depends(get_current_user)):
+ """Mark all received likes as acknowledged"""
+ return LikeService.acknowledge_likes(current_user["user_id"])
+
+@router.get("/sent/list")
+def get_liked_profiles(current_user: dict = Depends(get_current_user)):
+ """Get all profiles that current user has liked"""
+ return {"liked_ids": LikeService.get_liked_profiles(current_user["user_id"])}
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
index 0d759ec..fd4b72c 100644
--- a/backend/app/schemas/auth.py
+++ b/backend/app/schemas/auth.py
@@ -1,12 +1,14 @@
-from pydantic import BaseModel, EmailStr
+from pydantic import BaseModel, EmailStr, Field
+from typing import Optional
class UserRegister(BaseModel):
+ username: str = Field(..., min_length=3, max_length=50)
email: EmailStr
- password: str
+ password: str = Field(..., min_length=6)
display_name: str
class UserLogin(BaseModel):
- email: EmailStr
+ email_or_username: str
password: str
class TokenResponse(BaseModel):
diff --git a/backend/app/schemas/conversation.py b/backend/app/schemas/conversation.py
index 4d943ce..53771e7 100644
--- a/backend/app/schemas/conversation.py
+++ b/backend/app/schemas/conversation.py
@@ -1,5 +1,5 @@
from pydantic import BaseModel
-from typing import List
+from typing import List, Optional
from .message import MessageResponse
class ConversationResponse(BaseModel):
@@ -8,5 +8,7 @@ class ConversationResponse(BaseModel):
user_id_2: int
other_user_display_name: str
other_user_id: int
+ other_user_photo: Optional[str] = None
latest_message: str = ""
+ unread_count: int = 0
created_at: str
diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py
index 9d38b3e..18ad415 100644
--- a/backend/app/services/auth_service.py
+++ b/backend/app/services/auth_service.py
@@ -17,11 +17,16 @@ class AuthService:
if cur.fetchone():
raise ValueError("Email already registered")
+ # Check if username already exists
+ cur.execute("SELECT id FROM users WHERE username = %s", (user_data.username,))
+ if cur.fetchone():
+ raise ValueError("Username already taken")
+
# Hash password and create user
hashed_pwd = hash_password(user_data.password)
cur.execute(
- "INSERT INTO users (email, hashed_password) VALUES (%s, %s) RETURNING id",
- (user_data.email, hashed_pwd)
+ "INSERT INTO users (username, email, hashed_password) VALUES (%s, %s, %s) RETURNING id",
+ (user_data.username, user_data.email, hashed_pwd)
)
user_id = cur.fetchone()[0]
conn.commit()
@@ -41,15 +46,20 @@ class AuthService:
@staticmethod
def login(user_data: UserLogin) -> TokenResponse:
- """Authenticate user and return token"""
+ """Authenticate user and return token - accepts email or username"""
with get_db_connection() as conn:
cur = conn.cursor()
- cur.execute("SELECT id, hashed_password FROM users WHERE email = %s", (user_data.email,))
+ # Try to find user by email first, then by username
+ cur.execute(
+ "SELECT id, hashed_password, email FROM users WHERE email = %s OR username = %s",
+ (user_data.email_or_username, user_data.email_or_username)
+ )
row = cur.fetchone()
if not row or not verify_password(user_data.password, row[1]):
- raise ValueError("Invalid email or password")
+ raise ValueError("Invalid email, username or password")
user_id = row[0]
- token = create_access_token(user_id, user_data.email)
+ email = row[2]
+ token = create_access_token(user_id, email)
return TokenResponse(access_token=token, token_type="bearer", user_id=user_id)
diff --git a/backend/app/services/chat_service.py b/backend/app/services/chat_service.py
index 970e0bf..eba6c04 100644
--- a/backend/app/services/chat_service.py
+++ b/backend/app/services/chat_service.py
@@ -62,10 +62,19 @@ class ChatService:
conv_id, user_1, user_2, created_at, updated_at = row
other_user_id = user_2 if user_1 == user_id else user_1
- # Get other user's display name
- cur.execute("SELECT display_name FROM profiles WHERE user_id = %s", (other_user_id,))
+ # Get other user's display name and photo
+ cur.execute(
+ """SELECT p.display_name, ph.file_path
+ FROM profiles p
+ LEFT JOIN photos ph ON p.id = ph.profile_id
+ WHERE p.user_id = %s
+ ORDER BY ph.display_order
+ LIMIT 1""",
+ (other_user_id,)
+ )
profile_row = cur.fetchone()
other_user_name = profile_row[0] if profile_row else "Unknown"
+ other_user_photo = profile_row[1] if profile_row and profile_row[1] else None
# Get latest message
cur.execute(
@@ -75,13 +84,22 @@ class ChatService:
msg_row = cur.fetchone()
latest_msg = msg_row[0] if msg_row else ""
+ # Get unread message count (messages not read by current user and not sent by them)
+ cur.execute(
+ "SELECT COUNT(*) FROM messages WHERE conversation_id = %s AND sender_id != %s AND read_at IS NULL",
+ (conv_id, user_id)
+ )
+ unread_count = cur.fetchone()[0]
+
conversations.append(ConversationResponse(
id=conv_id,
user_id_1=user_1,
user_id_2=user_2,
other_user_id=other_user_id,
other_user_display_name=other_user_name,
+ other_user_photo=other_user_photo,
latest_message=latest_msg,
+ unread_count=unread_count,
created_at=created_at.isoformat()
))
@@ -123,3 +141,31 @@ class ChatService:
))
return list(reversed(messages)) # Return in chronological order
+
+ @staticmethod
+ def mark_messages_as_read(user_id: int, conversation_id: int) -> dict:
+ """Mark all unread messages in a conversation as read"""
+ with get_db_connection() as conn:
+ cur = conn.cursor()
+
+ # Verify user is in this conversation
+ cur.execute(
+ "SELECT user_id_1, user_id_2 FROM conversations WHERE id = %s",
+ (conversation_id,)
+ )
+ row = cur.fetchone()
+ if not row or (user_id != row[0] and user_id != row[1]):
+ raise ValueError("Not authorized to view this conversation")
+
+ # Mark all unread messages from other user as read
+ cur.execute(
+ """UPDATE messages
+ SET read_at = CURRENT_TIMESTAMP
+ WHERE conversation_id = %s
+ AND sender_id != %s
+ AND read_at IS NULL""",
+ (conversation_id, user_id)
+ )
+ conn.commit()
+
+ return {"message": "Messages marked as read"}
diff --git a/backend/app/services/like_service.py b/backend/app/services/like_service.py
index ea6eaf4..4e3a680 100644
--- a/backend/app/services/like_service.py
+++ b/backend/app/services/like_service.py
@@ -44,6 +44,21 @@ class LikeService:
return LikeResponse(id=like_id, liker_id=liker_id, liked_id=liked_id, is_match=is_match)
+ @staticmethod
+ def unlike_user(liker_id: int, liked_id: int) -> dict:
+ """User A unlikes User B"""
+ with get_db_connection() as conn:
+ cur = conn.cursor()
+
+ # Delete the like
+ cur.execute(
+ "DELETE FROM likes WHERE liker_id = %s AND liked_id = %s",
+ (liker_id, liked_id)
+ )
+ conn.commit()
+
+ return {"message": "Like removed"}
+
@staticmethod
def get_matches(user_id: int) -> list:
"""Get all users that match with this user"""
@@ -68,11 +83,75 @@ class LikeService:
matches = []
for match_id in match_ids:
cur.execute(
- "SELECT id, display_name FROM profiles WHERE user_id = %s",
+ """SELECT p.id, p.display_name, ph.file_path
+ FROM profiles p
+ LEFT JOIN photos ph ON p.id = ph.profile_id AND ph.display_order = 1
+ WHERE p.user_id = %s""",
(match_id,)
)
row = cur.fetchone()
if row:
- matches.append({"user_id": match_id, "display_name": row[1]})
+ matches.append({
+ "user_id": match_id,
+ "display_name": row[1],
+ "photo": row[2] if row[2] else None
+ })
return matches
+
+ @staticmethod
+ def get_likes_received(user_id: int) -> list:
+ """Get count of likes received from other users (not matched yet and not acknowledged)"""
+ with get_db_connection() as conn:
+ cur = conn.cursor()
+ # Get likes where this user is the liked_id and not yet acknowledged
+ cur.execute(
+ """SELECT l.liker_id, p.display_name, l.created_at
+ FROM likes l
+ JOIN profiles p ON l.liker_id = p.user_id
+ WHERE l.liked_id = %s
+ AND l.acknowledged_at IS NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM likes l2
+ WHERE l2.liker_id = %s AND l2.liked_id = l.liker_id
+ )
+ ORDER BY l.created_at DESC""",
+ (user_id, user_id)
+ )
+
+ likes = []
+ for row in cur.fetchall():
+ likes.append({"user_id": row[0], "display_name": row[1]})
+
+ return likes
+
+ @staticmethod
+ def acknowledge_likes(user_id: int) -> dict:
+ """Mark all received likes as acknowledged by this user"""
+ with get_db_connection() as conn:
+ cur = conn.cursor()
+
+ # Update all unacknowledged likes where user is the liked_id
+ cur.execute(
+ """UPDATE likes SET acknowledged_at = CURRENT_TIMESTAMP
+ WHERE liked_id = %s AND acknowledged_at IS NULL""",
+ (user_id,)
+ )
+ conn.commit()
+
+ return {"message": "Likes acknowledged"}
+
+ @staticmethod
+ def get_liked_profiles(user_id: int) -> list:
+ """Get all profiles that this user has liked (sent likes to)"""
+ with get_db_connection() as conn:
+ cur = conn.cursor()
+ cur.execute(
+ """SELECT DISTINCT liked_id
+ FROM likes
+ WHERE liker_id = %s""",
+ (user_id,)
+ )
+
+ liked_ids = [row[0] for row in cur.fetchall()]
+ return liked_ids
diff --git a/backend/app/services/profile_service.py b/backend/app/services/profile_service.py
index 2bca3bc..9e937ec 100644
--- a/backend/app/services/profile_service.py
+++ b/backend/app/services/profile_service.py
@@ -50,7 +50,8 @@ class ProfileService:
return None
profile_id = row[0]
- interests = json.loads(row[7]) if row[7] else []
+ # PostgreSQL JSONB returns as list/dict, not string
+ interests = row[7] if isinstance(row[7], list) else (json.loads(row[7]) if row[7] else [])
# Fetch photos
cur.execute("SELECT id, file_path FROM photos WHERE profile_id = %s ORDER BY display_order", (profile_id,))
@@ -85,7 +86,8 @@ class ProfileService:
profiles = []
for row in cur.fetchall():
profile_id = row[0]
- interests = json.loads(row[7]) if row[7] else []
+ # PostgreSQL JSONB returns as list/dict, not string
+ interests = row[7] if isinstance(row[7], list) else (json.loads(row[7]) if row[7] else [])
# Fetch photos
cur.execute(
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 69e0390..a58df60 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,11 +1,12 @@
fastapi==0.104.1
uvicorn==0.24.0
psycopg2-binary==2.9.9
-passlib==1.7.4
-bcrypt==3.2.0
+passlib==1.7.4.1
+bcrypt==4.1.1
python-jose[cryptography]==3.3.0
python-multipart==0.0.6
alembic==1.13.1
pydantic==2.5.0
pydantic-settings==2.1.0
python-dotenv==1.0.0
+email-validator==2.1.0
diff --git a/frontend/index.html b/frontend/index.html
index 3b6543a..7afc70c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,7 +2,7 @@
-
+
Dating App
diff --git a/frontend/src/App.css b/frontend/src/App.css
index e0b7e30..b7600bd 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -3,6 +3,26 @@
display: flex;
flex-direction: column;
background: var(--bg-primary);
+ transition: background-color 0.3s ease, color 0.3s ease;
+}
+
+.greeting-banner {
+ padding: 1rem 0;
+ margin-bottom: 1.5rem;
+}
+
+.greeting-banner h2 {
+ margin: 0;
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ text-align: left;
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ letter-spacing: 0.5px;
+}
+
+.greeting-banner p {
+ display: none;
}
.navbar {
@@ -25,11 +45,65 @@
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 0.5px;
+ font-family: 'Poppins', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.nav-logo {
+ width: 40px;
+ height: 40px;
+ object-fit: contain;
}
.nav-links {
display: flex;
gap: 1rem;
+ align-items: center;
+}
+
+.nav-profile {
+ display: flex;
+ align-items: center;
+ margin: 0 0.5rem;
+}
+
+.nav-profile-pic {
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 2px solid var(--accent-primary);
+ cursor: pointer;
+ transition: all 0.3s ease;
+ margin-right: 10px;
+}
+
+.nav-profile-pic:hover {
+ transform: scale(1.1);
+ box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
+}
+
+.nav-profile-pic-placeholder {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--bg-primary);
+ font-weight: 700;
+ font-size: 1rem;
+ border: 2px solid var(--accent-primary);
+ cursor: pointer;
+ transition: all 0.3s ease;
+}
+
+.nav-profile-pic-placeholder:hover {
+ transform: scale(1.1);
+ box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
}
.nav-links button {
@@ -42,13 +116,142 @@
font-weight: 600;
font-size: 0.9rem;
transition: all 0.3s ease;
+ position: relative;
}
.nav-links button:hover {
background: var(--accent-primary);
color: var(--bg-primary);
transform: translateY(-2px);
- box-shadow: 0 8px 16px rgba(0, 212, 255, 0.3);
+}
+
+/* Icon button styling */
+.nav-icon-btn {
+ border: none !important;
+ background: transparent !important;
+ color: var(--text-primary) !important;
+ padding: 0.5rem !important;
+ font-size: 1.5rem !important;
+ width: 50px;
+ height: 50px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50% !important;
+ transition: all 0.3s ease;
+ position: relative;
+}
+
+.nav-icon-btn:hover {
+ background: rgba(0, 212, 255, 0.1) !important;
+ transform: scale(1.15);
+ color: var(--accent-primary);
+}
+
+.nav-icon-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.nav-icon-btn.rotating {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.theme-icon {
+ width: 35px;
+ height: 35px;
+ margin-right: 23px;
+}
+
+.nav-icon-btn svg {
+ width: 28px;
+ height: 28px;
+}
+
+.nav-logout-btn {
+ font-size: 1.8rem !important;
+ width: 50px !important;
+ height: 50px !important;
+ margin-left: auto;
+ margin-right: 1rem;
+}
+
+.logout-icon {
+ width: 35px;
+ height: 35px;
+}
+
+.logout-text {
+ font-size: 0.85rem;
+ font-weight: 600;
+ margin-left: 0.5rem;
+ white-space: nowrap;
+}
+
+.nav-logout-btn:hover .logout-icon {
+ filter: invert(20%) brightness(1.4) hue-rotate(320deg);
+}
+.chat-icon {
+ width: 35px;
+ height: 35px;
+}
+
+.discover-icon {
+ width: 45px;
+ height: 45px;
+}
+
+.matches-icon {
+ width: 40px;
+ height: 40px;
+}
+
+.like-icon {
+ width: 35px;
+ height: 35px;
+}
+.nav-logout-btn:hover {
+ background: rgba(255, 71, 87, 0.15) !important;
+ color: #ff4757;
+ transform: scale(1.2) !important;
+ box-shadow: 0 0 20px rgba(255, 71, 87, 0.4);
+}
+
+.nav-btn-with-badge {
+ position: relative;
+}
+
+.notification-badge {
+ position: absolute;
+ top: 0px;
+ right: 0px;
+ background: #ff4757;
+ color: white;
+ border-radius: 50%;
+ min-width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.7rem;
+ font-weight: 700;
+ border: 2px solid var(--bg-secondary);
+ box-shadow: 0 4px 12px rgba(255, 71, 87, 0.4);
+ white-space: nowrap;
+ padding: 0 4px;
+}
+
+.notification-badge.likes-badge {
+ background: linear-gradient(135deg, #ff4757, #ff006e);
}
.main-content {
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 9e4c5fa..e54d95b 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -5,53 +5,233 @@ import ProfileEditor from './pages/ProfileEditor'
import Discover from './pages/Discover'
import Matches from './pages/Matches'
import Chat from './pages/Chat'
+import Modal from './components/Modal'
+import LikesModal from './components/LikesModal'
+import ProfilePhotoModal from './components/ProfilePhotoModal'
+import { likeAPI, chatAPI, profileAPI, API_BASE_URL } from './api'
+import logo from './assets/icons/logo.png'
+import logoutIcon from './assets/icons/logout.svg'
+import discoverIcon from './assets/icons/discover.svg'
+import chatIcon from './assets/icons/chat.svg'
+import matchesIcon from './assets/icons/matches.svg'
+import likeIcon from './assets/icons/likes.svg'
+import lightModeIcon from './assets/icons/lightMode.svg'
+import darkModeIcon from './assets/icons/darkMode.svg'
import './App.css'
function App() {
const [currentPage, setCurrentPage] = useState('login')
const [isAuthenticated, setIsAuthenticated] = useState(false)
+ const [notificationData, setNotificationData] = useState({ unreadMessages: 0, likesReceived: 0 })
+ const [showLogoutModal, setShowLogoutModal] = useState(false)
+ const [showLikesModal, setShowLikesModal] = useState(false)
+ const [showProfilePhotoModal, setShowProfilePhotoModal] = useState(false)
+ const [userProfile, setUserProfile] = useState(null)
+ const [isDarkMode, setIsDarkMode] = useState(() => {
+ const saved = localStorage.getItem('darkMode')
+ const isDark = saved === null ? true : saved === 'true'
+ // Set theme immediately on init
+ document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light')
+ return isDark
+ })
+ const [isRefreshing, setIsRefreshing] = useState(false)
useEffect(() => {
const token = localStorage.getItem('token')
+ const savedPage = localStorage.getItem('currentPage')
if (token) {
setIsAuthenticated(true)
- setCurrentPage('discover')
+ setCurrentPage(savedPage || 'discover')
+ loadUserProfile()
}
}, [])
+ useEffect(() => {
+ // Set theme whenever isDarkMode changes
+ document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light')
+ localStorage.setItem('darkMode', isDarkMode ? 'true' : 'false')
+ }, [isDarkMode])
+
+ useEffect(() => {
+ if (isAuthenticated && currentPage !== 'login' && currentPage !== 'register') {
+ localStorage.setItem('currentPage', currentPage)
+ }
+ }, [currentPage, isAuthenticated])
+
+ const loadUserProfile = async () => {
+ try {
+ const response = await profileAPI.getMyProfile()
+ setUserProfile(response.data)
+ } catch (err) {
+ console.error('Failed to load user profile', err)
+ }
+ }
+
+ useEffect(() => {
+ if (isAuthenticated) {
+ loadNotifications()
+ // Refresh notifications every 5 seconds
+ const interval = setInterval(loadNotifications, 5000)
+ return () => clearInterval(interval)
+ }
+ }, [isAuthenticated])
+
+ const loadNotifications = async () => {
+ try {
+ const [messagesRes, likesRes] = await Promise.all([
+ chatAPI.getConversations(),
+ likeAPI.getLikesReceived(),
+ ])
+
+ const unreadMessages = messagesRes.data?.reduce((sum, conv) => sum + (conv.unread_count || 0), 0) || 0
+ const likesReceived = likesRes.data?.length || 0
+
+ setNotificationData({
+ unreadMessages,
+ likesReceived,
+ })
+ } catch (err) {
+ console.error('Failed to load notifications', err)
+ }
+ }
+
const handleLoginSuccess = () => {
setIsAuthenticated(true)
setCurrentPage('discover')
+ loadUserProfile()
}
const handleRegisterSuccess = () => {
setIsAuthenticated(true)
setCurrentPage('profile-editor')
+ loadUserProfile()
}
- const handleLogout = () => {
+ const confirmLogout = () => {
localStorage.removeItem('token')
localStorage.removeItem('user_id')
setIsAuthenticated(false)
setCurrentPage('login')
+ setShowLogoutModal(false)
+ setUserProfile(null)
}
+ const handleRefresh = async () => {
+ setIsRefreshing(true)
+ try {
+ await Promise.all([loadNotifications(), loadUserProfile()])
+ } finally {
+ setIsRefreshing(false)
+ }
+ }
+
+ const toggleTheme = () => {
+ setIsDarkMode(!isDarkMode)
+ }
+
+ const userPhoto = userProfile?.photos && userProfile.photos.length > 0
+ ? `${API_BASE_URL}/media/${userProfile.photos[0].file_path}`
+ : null
+
return (
{isAuthenticated && (
)}
+ {isAuthenticated && currentPage === 'discover' && userProfile && (
+
+
Welcome back, {userProfile.display_name}! 👋
+
Ready to find your perfect match?
+
+ )}
{currentPage === 'login' && (
setCurrentPage('register')} />
)}
@@ -59,10 +239,35 @@ function App() {
setCurrentPage('login')} />
)}
{isAuthenticated && currentPage === 'profile-editor' && }
- {isAuthenticated && currentPage === 'discover' && }
- {isAuthenticated && currentPage === 'matches' && }
- {isAuthenticated && currentPage === 'chat' && }
+ {isAuthenticated && currentPage === 'discover' && setCurrentPage('chat')} />}
+ {isAuthenticated && currentPage === 'matches' && setCurrentPage('chat')} />}
+ {isAuthenticated && currentPage === 'chat' && }
+
+
setShowLogoutModal(false)}
+ />
+
+ setShowLikesModal(false)}
+ onLikesUpdate={loadNotifications}
+ />
+
+ setShowProfilePhotoModal(false)}
+ currentPhoto={userPhoto?.replace(`${API_BASE_URL}/media/`, '')}
+ onPhotoUpdate={() => loadUserProfile()}
+ onEditProfile={() => setCurrentPage('profile-editor')}
+ />
)
}
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 9216113..ce5787a 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -37,10 +37,10 @@ api.interceptors.response.use(
// Auth endpoints
export const authAPI = {
- register: (email, password, displayName) =>
- api.post('/auth/register', { email, password, display_name: displayName }),
- login: (email, password) =>
- api.post('/auth/login', { email, password }),
+ register: (username, email, password, displayName) =>
+ api.post('/auth/register', { username, email, password, display_name: displayName }),
+ login: (emailOrUsername, password) =>
+ api.post('/auth/login', { email_or_username: emailOrUsername, password }),
getCurrentUser: () =>
api.get('/auth/me'),
}
@@ -76,8 +76,16 @@ export const photoAPI = {
export const likeAPI = {
likeUser: (userId) =>
api.post(`/likes/${userId}`),
+ unlikeUser: (userId) =>
+ api.delete(`/likes/${userId}`),
getMatches: () =>
api.get('/likes/matches/list'),
+ getLikesReceived: () =>
+ api.get('/likes/received/list'),
+ getLikedProfiles: () =>
+ api.get('/likes/sent/list'),
+ acknowledgeLikes: () =>
+ api.post('/likes/received/acknowledge'),
}
// Chat endpoints
@@ -88,6 +96,8 @@ export const chatAPI = {
api.get(`/chat/conversations/${conversationId}/messages`, { params: { limit } }),
sendMessage: (conversationId, content) =>
api.post(`/chat/conversations/${conversationId}/messages`, { content }),
+ markAsRead: (conversationId) =>
+ api.post(`/chat/conversations/${conversationId}/mark-read`),
}
export { API_BASE_URL }
diff --git a/frontend/src/assets/icons/chat.svg b/frontend/src/assets/icons/chat.svg
new file mode 100644
index 0000000..87aa6ef
--- /dev/null
+++ b/frontend/src/assets/icons/chat.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/darkMode.svg b/frontend/src/assets/icons/darkMode.svg
new file mode 100644
index 0000000..a36f82b
--- /dev/null
+++ b/frontend/src/assets/icons/darkMode.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/discover.svg b/frontend/src/assets/icons/discover.svg
new file mode 100644
index 0000000..711ae81
--- /dev/null
+++ b/frontend/src/assets/icons/discover.svg
@@ -0,0 +1,16 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/lightMode.svg b/frontend/src/assets/icons/lightMode.svg
new file mode 100644
index 0000000..62a2ab2
--- /dev/null
+++ b/frontend/src/assets/icons/lightMode.svg
@@ -0,0 +1,23 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/like.svg b/frontend/src/assets/icons/like.svg
new file mode 100644
index 0000000..fc1ccbc
--- /dev/null
+++ b/frontend/src/assets/icons/like.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/likes.svg b/frontend/src/assets/icons/likes.svg
new file mode 100644
index 0000000..cee350c
--- /dev/null
+++ b/frontend/src/assets/icons/likes.svg
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/logo.png b/frontend/src/assets/icons/logo.png
new file mode 100644
index 0000000..3595c60
Binary files /dev/null and b/frontend/src/assets/icons/logo.png differ
diff --git a/frontend/src/assets/icons/logout.svg b/frontend/src/assets/icons/logout.svg
new file mode 100644
index 0000000..4dd9328
--- /dev/null
+++ b/frontend/src/assets/icons/logout.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/matches.svg b/frontend/src/assets/icons/matches.svg
new file mode 100644
index 0000000..b046b5b
--- /dev/null
+++ b/frontend/src/assets/icons/matches.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/notLike.svg b/frontend/src/assets/icons/notLike.svg
new file mode 100644
index 0000000..3fe206e
--- /dev/null
+++ b/frontend/src/assets/icons/notLike.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/profile.svg b/frontend/src/assets/icons/profile.svg
new file mode 100644
index 0000000..d460c40
--- /dev/null
+++ b/frontend/src/assets/icons/profile.svg
@@ -0,0 +1,12 @@
+
diff --git a/frontend/src/components/LikesModal.jsx b/frontend/src/components/LikesModal.jsx
new file mode 100644
index 0000000..be50caf
--- /dev/null
+++ b/frontend/src/components/LikesModal.jsx
@@ -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 (
+
+
e.stopPropagation()}>
+
+
❤️ New Likes
+
+
+
+
+ {isLoading ? (
+
Loading...
+ ) : likes.length === 0 ? (
+
No new likes yet
+ ) : (
+
+ {likes.map((like) => {
+ const profile = profiles[like.user_id]
+ return (
+
+
+ {profile?.photos && profile.photos.length > 0 ? (
+

+ ) : (
+
+ {profile?.display_name?.charAt(0).toUpperCase()}
+
+ )}
+
+
+
{profile?.display_name}, {profile?.age}
+
{profile?.location}
+
+
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/components/Modal.jsx b/frontend/src/components/Modal.jsx
new file mode 100644
index 0000000..e723268
--- /dev/null
+++ b/frontend/src/components/Modal.jsx
@@ -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 (
+
+
e.stopPropagation()}>
+
+
{title}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/ProfilePhotoModal.jsx b/frontend/src/components/ProfilePhotoModal.jsx
new file mode 100644
index 0000000..203e58e
--- /dev/null
+++ b/frontend/src/components/ProfilePhotoModal.jsx
@@ -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 (
+
+
e.stopPropagation()}>
+
+
Update Profile Photo
+
+
+
+
+ {currentPhoto && (
+
+
Current Photo:
+

+
+ )}
+
+
+
Upload New Photo:
+
+
+ {isUploading &&
Uploading...
}
+
+
+ {error &&
{error}
}
+ {success &&
{success}
}
+
+
+ 💡 Your profile photo will be displayed on your profile and in the discover section.
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 4c41189..39dd671 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -19,9 +19,39 @@
}
html {
+ background: var(--bg-primary);
+ color: var(--text-primary);
scroll-behavior: smooth;
}
+[data-theme="dark"] {
+ --bg-primary: #0f0f1e;
+ --bg-secondary: #1a1a2e;
+ --bg-tertiary: #16213e;
+ --accent-primary: #00d4ff;
+ --accent-secondary: #ff006e;
+ --text-primary: #ffffff;
+ --text-secondary: #b0b0b0;
+ --border-color: #2a2a3e;
+ --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5);
+}
+
+[data-theme="light"] {
+ --bg-primary: #f5f5f5;
+ --bg-secondary: #ffffff;
+ --bg-tertiary: #f0f0f0;
+ --accent-primary: #6366f1;
+ --accent-secondary: #ec4899;
+ --text-primary: #1f2937;
+ --text-secondary: #6b7280;
+ --border-color: #e5e7eb;
+ --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.1);
+ --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.15);
+ --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.2);
+}
+
body {
font-family: 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
'Droid Sans', 'Helvetica Neue', sans-serif;
@@ -30,6 +60,12 @@ body {
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
+ transition: background-color 0.3s ease, color 0.3s ease;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ font-family: 'Poppins', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
+ letter-spacing: -0.3px;
}
button {
diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx
index 71b82c9..3fa543f 100644
--- a/frontend/src/pages/Chat.jsx
+++ b/frontend/src/pages/Chat.jsx
@@ -1,8 +1,8 @@
-import React, { useState, useEffect } from 'react'
-import { chatAPI } from '../api'
+import React, { useState, useEffect, useRef } from 'react'
+import { chatAPI, API_BASE_URL } from '../api'
import '../styles/chat.css'
-export default function Chat({ conversationId }) {
+export default function Chat({ conversationId, onNotificationsUpdate }) {
const [conversations, setConversations] = useState([])
const [selectedConversation, setSelectedConversation] = useState(conversationId || null)
const [messages, setMessages] = useState([])
@@ -10,6 +10,7 @@ export default function Chat({ conversationId }) {
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
const [isSending, setIsSending] = useState(false)
+ const messageInputRef = useRef(null)
const currentUserId = localStorage.getItem('user_id')
@@ -31,6 +32,10 @@ export default function Chat({ conversationId }) {
setIsLoading(true)
const response = await chatAPI.getConversations()
setConversations(response.data || [])
+ // Notify parent about notifications update
+ if (onNotificationsUpdate) {
+ onNotificationsUpdate()
+ }
} catch (err) {
setError('Failed to load conversations')
} finally {
@@ -44,6 +49,23 @@ export default function Chat({ conversationId }) {
try {
const response = await chatAPI.getMessages(selectedConversation)
setMessages(response.data || [])
+
+ // Mark messages as read
+ await chatAPI.markAsRead(selectedConversation)
+
+ // Update unread_count to 0 for this conversation immediately
+ setConversations(prev =>
+ prev.map(conv =>
+ conv.id === selectedConversation
+ ? { ...conv, unread_count: 0 }
+ : conv
+ )
+ )
+
+ // Update parent notifications
+ if (onNotificationsUpdate) {
+ onNotificationsUpdate()
+ }
} catch (err) {
setError('Failed to load messages')
}
@@ -58,6 +80,8 @@ export default function Chat({ conversationId }) {
const response = await chatAPI.sendMessage(selectedConversation, newMessage)
setMessages((prev) => [...prev, response.data])
setNewMessage('')
+ // Auto-focus back to input field after sending
+ setTimeout(() => messageInputRef.current?.focus(), 0)
} catch (err) {
setError('Failed to send message')
} finally {
@@ -92,8 +116,26 @@ export default function Chat({ conversationId }) {
className={`conversation-item ${selectedConversation === conv.id ? 'active' : ''}`}
onClick={() => setSelectedConversation(conv.id)}
>
- {conv.other_user_display_name}
- {conv.latest_message || 'No messages yet'}
+
+ {conv.other_user_photo ? (
+

+ ) : (
+
+ {conv.other_user_display_name.charAt(0).toUpperCase()}
+
+ )}
+
+
{conv.other_user_display_name}
+
{conv.latest_message || 'No messages yet'}
+
+ {conv.unread_count > 0 && (
+
{conv.unread_count}
+ )}
+
))}
@@ -123,11 +165,13 @@ export default function Chat({ conversationId }) {