-

{userInfo.username}
+ {/* 유저 세부 정보 */}
+
+
+ 📊
+
+ 이번 시즌 커밋: {seasonCommitCount}
+
+
+
+
+ 🔄
+
+ 업데이트 커밋: {commitCount}
+
-
이번 시즌 커밋 수: {seasonCommitCount}
-
업데이트 커밋 수: {commitCount}
-
티어: {tierEmojis[userInfo.tier] || userInfo.tier} / 마지막 커밋 날짜: {new Date(userInfo.lastCommitted).toLocaleDateString()}
-
- {/* 펫 정보 */}
-
🐾 펫 정보
-
-
-
+
+
+ 🏆
+
+ 티어:
+ {tierEmojis[userInfo.tier] || ""} {userInfo.tier || "없음"}
+
+
+
+
+
+ 📅
+
+ 마지막 커밋: {formatDate(userInfo.lastCommitted)}
+
+
+
+
+ {/* 펫 정보 */}
+
+
+ 🐾 펫 정보
+
+
+
+
-
+
{petExp} / {maxExp}
-
+
+
+
+ {/* 새로고침 버튼 및 마지막 새로고침 시간 표시 */}
+
+
+
+ {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