diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index e9d3b13..bf7b986 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -10,7 +10,7 @@ import './profile.css'; import '../modals/NotificationModal.css'; import axios from "axios"; import NotiService from '../services/NotiService'; -import webSocketService from '../services/WebSocketService'; +import webSocketNotificationService from '../services/WebSocketNotificationService'; const Home = () => { // 알림 모달 @@ -21,6 +21,19 @@ const Home = () => { const [userInfo, setUserInfo] = useState({}); const [userLoading, setUserLoading] = useState(true); const [userError, setUserError] = useState(null); + + const [loading, setLoading] = useState(true); + const [user, setUser] = useState(null); + const [error, setError] = useState(null); + const [totalCommitData, setTotalCommitData] = useState(null); + const [seasonData, setSeasonData] = useState({ + spring: null, + summer: null, + fall: null, + winter: null + }); + const [connected, setConnected] = useState(false); // 웹소켓 연결 상태 + const navigate = useNavigate(); // 사용자 정보 불러오기 @@ -40,50 +53,29 @@ const Home = () => { fetchUserInfo(); }, []); - const toggleModal = () => { + const toggleModal = async () => { setIsModalOpen(!isModalOpen); - if (isModalOpen) { - setHasNewNotification(false); // 모달을 열면 새로운 알림 표시 제거 - } - }; - - // 알림 불러오기 - useEffect(() => { - const fetchNotifications = async () => { - try { - const response = await fetch('/api/notifications', { credentials: 'include' }); - if (!response.ok) { - throw new Error('알림 데이터를 가져오는데 실패했습니다'); - } - - const data = await response.json(); - console.log(data.data); - setNotifications(data.data); - - // 새로운 알림이 있는지 확인 - if (data.length > 0) { - setHasNewNotification(true); + if (!isModalOpen) { // 모달이 열릴 때 (false -> true) + try { + // 읽지 않은 알림만 필터링 + const unreadNotifications = notifications.filter(noti => !noti.read); + if (unreadNotifications.length > 0) { + const unreadIds = unreadNotifications.map(noti => noti.id); + await NotiService.markAsRead(unreadIds); + + // 상태 업데이트: 알림을 읽음으로 표시 + setNotifications(prev => prev.map(noti => + unreadIds.includes(noti.id) + ? { ...noti, read: true } + : noti + )); + setHasNewNotification(false); // 모든 알림을 읽음으로 표시했으므로 빨간 점 제거 + } + } catch (error) { + console.error('Failed to mark notifications as read:', error); } - } catch (error) { - console.error('알림 데이터 가져오기 오류:', error); - } - }; - - fetchNotifications(); - }, []); - - const [loading, setLoading] = useState(true); - const [user, setUser] = useState(null); - const [error, setError] = useState(null); - const [totalCommitData, setTotalCommitData] = useState(null); - const [seasonData, setSeasonData] = useState({ - spring: null, - summer: null, - fall: null, - winter: null - }); - const [connected, setConnected] = useState(false); // 웹소켓 연결 상태 - const navigate = useNavigate(); + } +}; // 메시지 목록을 로드하는 함수 const loadNotis = async () => { @@ -92,6 +84,11 @@ const Home = () => { const response = await NotiService.getNotis(); console.log('Noti response:', response); setNotifications(response.data); + + // 읽지 않은 알림이 있는지 확인 + const hasUnread = response.data.some(noti => !noti.read); + setHasNewNotification(hasUnread); + setLoading(false); } catch (err) { console.error('Error loading Noti:', err); @@ -101,26 +98,51 @@ const Home = () => { }; useEffect(() => { - loadNotis(); - - // 웹소켓 연결 - webSocketService.connect(); - - // 연결 상태 변경 이벤트 리스너 등록 - const unsubscribeFromConnection = webSocketService.onConnectionChange(setConnected); - - // 채팅방 구독 시도 - setTimeout(() => { - const success = webSocketService.subscribeToNotificationChannel(); - console.log('Notis subscription success:', success); - }, 1000); // 약간의 지연을 두어 연결이 설정될 시간을 줌 - - // 컴포넌트 언마운트 시 이벤트 리스너 제거 및 구독 해제 - return () => { - if (unsubscribeFromConnection) { - unsubscribeFromConnection(); + const setupWebSocket = async () => { + try { + await webSocketNotificationService.connect(); + + // 연결 상태 변경 이벤트 리스너 등록 + const unsubscribeFromConnection = webSocketNotificationService.onConnectionChange((isConnected) => { + console.log('WebSocket connection status:', isConnected); + setConnected(isConnected); + + // 연결되면 바로 구독 시도 + if (isConnected) { + const success = webSocketNotificationService.subscribeToNotificationChannel(); + console.log('Notification subscription success:', success); + } + }); + + // 새로운 알림 메시지 핸들러 등록 + const unsubscribeFromMessages = webSocketNotificationService.onMessage((newNotification) => { + console.log('New notification received:', newNotification); + + // 새로운 알림을 상태에 추가 + setNotifications(prev => [{ + ...newNotification, + read: false + }, ...prev]); + + // 새 알림 표시 + setHasNewNotification(true); + }); + + // 초기 알림 데이터 로드 + await loadNotis(); + + // 컴포넌트 언마운트 시 정리 + return () => { + unsubscribeFromConnection(); + unsubscribeFromMessages(); + webSocketNotificationService.disconnect(); + }; + } catch (error) { + console.error('Error setting up WebSocket:', error); } }; + + setupWebSocket(); }, []); useEffect(() => { @@ -246,16 +268,43 @@ useEffect(() => { } }; + const notificationBtnStyle = { + position: 'relative', + backgroundColor: 'transparent', + border: 'none', + cursor: 'pointer', + alignItems: 'center', + }; + + const notificationIconStyle = { + fontSize: '20px', + }; + + const notificationBadgeStyle = { + position: 'absolute', + top: '4px', + right: '12px', + width: '8px', + height: '8px', + backgroundColor: '#ef4444', + borderRadius: '50%', + display: hasNewNotification ? 'block' : 'none', + }; + return (
CommitField
- + {/* 채팅 버튼 추가 */} + + {isRefreshing ? '새로고침 중...' : `마지막 업데이트: ${formatRefreshTime(lastRefreshTime)}`} +
-
성장 단계: {userInfo.petGrow}
); }; -export default Profile; +export default Profile; \ No newline at end of file diff --git a/src/pages/profile.css b/src/pages/profile.css index 6ef84a6..30251bb 100644 --- a/src/pages/profile.css +++ b/src/pages/profile.css @@ -1,74 +1,298 @@ -@keyframes breathe { - 0%, 100% { - transform: scaleY(1); /* 원래 크기 */ - } - 50% { - transform: scaleY(0.8); /* 0.8배로 작아짐 */ - } -} - -h2 { - color: #222222; +/* Enhanced Profile Styles */ +.profile-container { + width: 100%; + background-color: white; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + padding: 24px; + margin-bottom: 32px; + box-sizing: border-box; + display: flex; + align-items: center; + gap: 24px; + transition: all 0.3s ease; } - -.animated-pet { - animation: breathe 1.5s infinite ease-in-out; +.profile-container:hover { + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); + transform: translateY(-2px); } -.flex-box{ - width:100%; - display: flex; - justify-content: center; +/* Pet section styling */ +.pet-section { + position: relative; + flex-shrink: 0; } -.profile-container { +.pet-frame { + width: 140px; + height: 140px; + background: linear-gradient(135deg, #f0f9ff 0%, #e1f5fe 100%); + border-radius: 70px; + padding: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); display: flex; + justify-content: center; align-items: center; - gap: 20px; - padding: 15px; - width: 750px; - height: 140px; - background-color: #fefefe; - color: #010101; - border: 1px solid #fff; - border-radius: 10px; - line-height: 1.4; + position: relative; + overflow: hidden; } -.pet-box { - width: 128px; - height: 128px; - border: 1px solid #ff8a9e; - background-image: url('/pets/BACK.png'); +.pet-frame::before { + content: ''; + position: absolute; + width: 100%; + height: 100%; + background: linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0) 50%); + border-radius: 70px; + z-index: 1; } -.pet-box img { + +.pet-image { width: 128px; height: 128px; + object-fit: contain; + z-index: 2; +} + +.pet-stage { + position: absolute; + bottom: -5px; + right: -5px; + background-color: #ff6b6b; + color: white; + font-weight: bold; + font-size: 14px; + padding: 6px 10px; + border-radius: 20px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + z-index: 3; } -.info-box { +/* User info section styling */ +.user-info { flex: 1; + display: flex; + flex-direction: column; + gap: 12px; } -.info-box img { - width: 32px; - height: 32px; +.user-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; } -.avatar { - width: 100px; + +.avatar-container { + width: 50px; + height: 50px; border-radius: 50%; + overflow: hidden; + border: 3px solid #f0f0f0; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1); +} + +.avatar-container img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.username { + font-size: 20px; + font-weight: 700; + color: #1f2937; +} + +.user-details { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.detail-item { + display: flex; + align-items: center; + gap: 8px; +} + +.detail-icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: #6b7280; +} + +.detail-text { + font-size: 14px; + color: #4b5563; +} + +.detail-value { + font-weight: 600; + color: #111827; +} + +/* Pet info section */ +.pet-info { + margin-top: 8px; + padding-top: 12px; + border-top: 1px solid #f3f4f6; +} + +.pet-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 16px; + font-weight: 600; + color: #4b5563; + margin-bottom: 8px; +} + +.exp-bar-container { + display: flex; + align-items: center; + gap: 12px; + margin-top: 8px; } .exp-bar { + flex: 1; + height: 8px; + background-color: #f3f4f6; + border-radius: 4px; + overflow: hidden; + position: relative; +} + +.exp-progress { + height: 100%; + background: linear-gradient(to right, #3fb27f, #4ade80); + border-radius: 4px; + transition: width 0.5s ease; +} + +.exp-text { + font-size: 14px; + font-weight: 600; + color: #6b7280; + min-width: 70px; + text-align: right; +} + +/* Tier badge */ +.tier-badge { + display: inline-flex; + align-items: center; + background-color: #f3f4f6; + padding: 4px 10px; + border-radius: 16px; + font-size: 14px; + font-weight: 600; +} + +/* Animation for pet */ +@keyframes float { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-5px); + } +} + +@keyframes rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 0.7; + } + 50% { + transform: scale(1.05); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 0.7; + } +} + +.animated-pet { + animation: float 3s ease-in-out infinite; +} + +.animated-pet.refreshing { + animation: pulse 1s ease-in-out infinite; +} + +/* 새로고침 관련 스타일 */ +.refresh-info { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + font-size: 12px; + color: #9ca3af; +} + +.refresh-button { + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 50%; display: flex; align-items: center; - gap: 10px; + justify-content: center; + transition: all 0.2s ease; +} + +.refresh-button:hover { + background-color: #f3f4f6; +} + +.refresh-button:disabled { + cursor: not-allowed; + opacity: 0.7; } -.bar { - width: 200px; - display: flex; - align-items: center; - height: 30px; +.refresh-icon { + font-size: 14px; + display: inline-block; } + +.refresh-icon.rotating { + animation: rotate 1s linear infinite; +} + +.last-refresh-time { + font-size: 11px; + color: #9ca3af; +} + +/* 새로고침 진행 중 표시 */ +.refreshing-indicator { + position: absolute; + top: -5px; + right: -5px; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: #3b82f6; + box-shadow: 0 0 0 2px white; + z-index: 10; + animation: pulse 1s infinite ease-in-out; +} \ No newline at end of file diff --git a/src/services/NotiService.js b/src/services/NotiService.js index 0145189..412fa8d 100644 --- a/src/services/NotiService.js +++ b/src/services/NotiService.js @@ -59,6 +59,28 @@ const NotiService = { cache.clearCache(); }, + markAsRead: async(notificationIds) => { + try { + const response = await fetch(`${API_BACKEND_URL}/api/notifications/read`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ notificationIds }) + }); + + if (!response.ok) { + throw new Error('Failed to mark notifications as read'); + } + + return await response.json(); + } catch (error) { + console.error('Error marking notifications as read:', error); + throw error; + } + }, + // 알림 조회 getNotis: async () => { try { diff --git a/src/services/WebSocketNotificationService.js b/src/services/WebSocketNotificationService.js new file mode 100644 index 0000000..98a4a03 --- /dev/null +++ b/src/services/WebSocketNotificationService.js @@ -0,0 +1,191 @@ +import { API_BACKEND_URL } from '../config'; + +class WebSocketNotificationService { + constructor() { + this.webSocket = null; + this.isConnected = false; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 5; + this.reconnectTimeout = null; + this.messageCallbacks = new Set(); + this.connectionCallbacks = new Set(); + this.pendingMessages = []; + this.connectionPromise = null; + } + + connect() { + if (this.connectionPromise) { + return this.connectionPromise; + } + + this.connectionPromise = new Promise((resolve, reject) => { + if (this.isConnected && this.webSocket) { + console.log('WebSocket already connected'); + this.notifyConnectionStatus(true); + resolve(true); + return; + } + + if (this.webSocket) { + try { + this.webSocket.close(); + } catch (err) { + console.error('Error closing previous connection:', err); + } + } + + try { + this.notifyConnectionStatus(false); + const baseUrl = API_BACKEND_URL; + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsBaseUrl = baseUrl.replace(/^https?:/, wsProtocol); + + this.webSocket = new WebSocket(`${wsBaseUrl}/notifications`); + + this.webSocket.onopen = (event) => { + console.log('WebSocket Connected:', event); + this.isConnected = true; + this.reconnectAttempts = 0; + this.notifyConnectionStatus(true); + + // 연결 즉시 알림 채널 구독 + const subscribed = this.subscribeToNotificationChannel(); + console.log('Notification subscription success:', subscribed); + + this.processPendingMessages(); + resolve(true); + }; + + this.webSocket.onmessage = (event) => { + try { + const receivedMessage = JSON.parse(event.data); + console.log('Notification received:', receivedMessage); + this.messageCallbacks.forEach(callback => callback(receivedMessage)); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + this.webSocket.onerror = (error) => { + console.error('WebSocket error:', error); + this.notifyConnectionStatus(false); + reject(error); + }; + + this.webSocket.onclose = (event) => { + console.log('WebSocket closed:', event); + this.isConnected = false; + this.notifyConnectionStatus(false); + reject(new Error('WebSocket connection closed')); + + this.connectionPromise = null; + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = setTimeout(() => { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + console.log(`Reconnection attempt ${this.reconnectAttempts}`); + this.connect(); + } else { + console.error('Max reconnection attempts reached'); + } + }, 3000); + }; + } catch (error) { + console.error('Error creating WebSocket connection:', error); + this.notifyConnectionStatus(false); + reject(error); + this.connectionPromise = null; + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = setTimeout(() => this.connect(), 5000); + } + }); + return this.connectionPromise; + } + + onMessage(callback) { + this.messageCallbacks.add(callback); + return () => this.messageCallbacks.delete(callback); + } + + // 알림 채널 구독 + subscribeToNotificationChannel() { + if (!this.webSocket || !this.isConnected) { + console.warn('WebSocket is not connected. Unable to subscribe to notifications.'); + return false; + } + + try { + console.log('Subscribing to notification channel...'); + + if (this.webSocket.readyState === WebSocket.OPEN) { + // 구독 요청 메시지 전송 + this.webSocket.send(JSON.stringify({ + type: 'SUBSCRIBE', + channel: 'notifications' + })); + + // 연결 성공 로그 + console.log('Successfully subscribed to notification channel'); + return true; + } + + return false; + } catch (error) { + console.error('Error subscribing to notification channel:', error); + return false; + } + } + + notifyConnectionStatus(isConnected) { + this.connectionCallbacks.forEach(callback => callback(isConnected)); + } + + onConnectionChange(callback) { + this.connectionCallbacks.add(callback); + callback(this.isConnected); + return () => this.connectionCallbacks.delete(callback); + } + + processPendingMessages() { + if (this.pendingMessages.length > 0) { + console.log(`Processing ${this.pendingMessages.length} pending messages`); + + const messages = [...this.pendingMessages]; + this.pendingMessages = []; + + messages.forEach(msg => { + try { + if (this.webSocket && this.webSocket.readyState === WebSocket.OPEN) { + this.webSocket.send(JSON.stringify(msg)); + console.log('Sent pending message:', msg); + } else { + // 연결이 아직 준비되지 않은 경우 다시 pending 목록에 추가 + this.pendingMessages.push(msg); + } + } catch (err) { + console.error('Error sending pending message:', err); + // 실패한 메시지는 다시 pending 목록에 추가 + this.pendingMessages.push(msg); + } + }); + } + } + + disconnect() { + if (this.webSocket) { + try { + this.webSocket.close(); + this.isConnected = false; + this.notifyConnectionStatus(false); + this.pendingMessages = []; + this.connectionPromise = null; + console.log('WebSocket disconnected'); + } catch (error) { + console.error('Error disconnecting WebSocket:', error); + } + } + } +} + +const WebSocketService = new WebSocketNotificationService(); +export default WebSocketService; \ No newline at end of file