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} + ) : ( +
+ {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}

+ +
+
+

{message}

+
+
+ + +
+
+
+ ) +} 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:

+ Current profile +
+ )} + +
+

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} + ) : ( +
+ {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 }) {
setNewMessage(e.target.value)} placeholder="Type a message..." disabled={isSending} + autoFocus /> -
-

- {profile.display_name}, {profile.age} -

-

{profile.location}

- {profile.bio &&

{profile.bio}

} - {profile.interests && profile.interests.length > 0 && ( -
- {profile.interests.map((interest) => ( - - {interest} - - ))} -
- )} -
+ {cardsToShow.map((prof, idx) => { + const isCurrentLiked = likedProfiles.has(prof.id) + return ( +
+ {idx === 0 && isCurrentLiked &&
❤️ Liked
} + + {prof.photos && prof.photos.length > 0 ? ( + {prof.display_name} + ) : ( +
No photo
+ )} -
- - -
-
+ {idx === 0 && ( + <> +
+

+ {prof.display_name}, {prof.age} +

+

{prof.location}

+ {prof.bio &&

{prof.bio}

} + {prof.interests && prof.interests.length > 0 && ( +
+ {prof.interests.map((interest) => ( + + {interest} + + ))} +
+ )} +
+
+ + +
+ + )} + + ) + })} + + -
+ {/*
Profile {currentIndex + 1} of {profiles.length} -
+
*/} + + { + setShowMatchModal(false) + if (onNavigateToChat) { + onNavigateToChat() + } + }} + onCancel={() => { + setShowMatchModal(false) + nextProfile() + }} + /> ) } diff --git a/frontend/src/pages/LikesReceived.jsx b/frontend/src/pages/LikesReceived.jsx new file mode 100644 index 0000000..78ebc37 --- /dev/null +++ b/frontend/src/pages/LikesReceived.jsx @@ -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
Loading likes...
+ } + + if (likes.length === 0) { + return ( +
+
+

No likes yet

+

Keep discovering to get more likes!

+
+
+ ) + } + + return ( +
+

People Who Liked You ❤️

+ {error &&
{error}
} + +
+ {likes.map((like) => { + const profile = profiles[like.user_id] + return ( +
+
+ {profile?.photos && profile.photos.length > 0 ? ( + {profile.display_name} + ) : ( +
No photo
+ )} +
+ +
+

{profile?.display_name}, {profile?.age}

+

{profile?.location}

+ {profile?.bio &&

{profile.bio}

} + {profile?.interests && profile.interests.length > 0 && ( +
+ {profile.interests.map((interest) => ( + + {interest} + + ))} +
+ )} +
+ +
+ + +
+
+ ) + })} +
+
+ ) +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 975cdc7..d7eda3d 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -3,7 +3,7 @@ import { authAPI } from '../api' import '../styles/auth.css' export default function Login({ onLoginSuccess, onRegisterClick }) { - const [email, setEmail] = useState('') + const [emailOrUsername, setEmailOrUsername] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const [isLoading, setIsLoading] = useState(false) @@ -14,7 +14,7 @@ export default function Login({ onLoginSuccess, onRegisterClick }) { setIsLoading(true) try { - const response = await authAPI.login(email, password) + const response = await authAPI.login(emailOrUsername, password) const { access_token, user_id } = response.data // Store token and user ID @@ -37,10 +37,10 @@ export default function Login({ onLoginSuccess, onRegisterClick }) { {error &&
{error}
} setEmail(e.target.value)} + type="text" + placeholder="Email or Username" + value={emailOrUsername} + onChange={(e) => setEmailOrUsername(e.target.value)} required /> { - window.location.href = `/chat?user_id=${userId}` + const handleStartChat = () => { + onNavigateToChat() } if (isLoading) { @@ -51,8 +51,15 @@ export default function Matches() {
{matches.map((match) => (
+ {match.photo && ( + {match.display_name} + )}

{match.display_name}

-
diff --git a/frontend/src/pages/ProfileEditor.jsx b/frontend/src/pages/ProfileEditor.jsx index a8cbdf0..dfb48e6 100644 --- a/frontend/src/pages/ProfileEditor.jsx +++ b/frontend/src/pages/ProfileEditor.jsx @@ -125,10 +125,9 @@ export default function ProfileEditor() {
@@ -182,12 +181,39 @@ export default function ProfileEditor() {
+
+

Photos

+
+ + +
+
+ {photos.map((photo) => ( +
+ Profile + +
+ ))} +
+
+ -
+

Photos

Register {error &&
{error}
}
+ setUsername(e.target.value)} + required + />