diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 2723299..53347f1 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -25,6 +25,9 @@ jobs:
- name: Build application
run: npm run build
+ env: │
+ VITE_API_BASE_URL: ${{ secrets.VITE_API_BASE_URL }} │
+ VITE_API_WS_URL: ${{ secrets.VITE_API_WS_URL }}
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v2
diff --git a/package-lock.json b/package-lock.json
index 47004e9..8d9105a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,10 +10,16 @@
"dependencies": {
"@stomp/stompjs": "^7.1.1",
"axios": "^1.11.0",
+ "date-fns": "^4.1.0",
+ "lodash.debounce": "^4.0.8",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-intersection-observer": "^9.16.0",
"react-router-dom": "^7.7.1",
- "sockjs-client": "^1.6.1"
+ "react-virtualized-auto-sizer": "^1.0.26",
+ "react-window": "^1.8.11",
+ "sockjs-client": "^1.6.1",
+ "uuid": "^11.1.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
@@ -277,6 +283,15 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz",
+ "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -1938,6 +1953,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -2769,6 +2794,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -2795,6 +2826,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/memoize-one": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
+ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
+ "license": "MIT"
+ },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -3053,6 +3090,21 @@
"react": "^19.1.1"
}
},
+ "node_modules/react-intersection-observer": {
+ "version": "9.16.0",
+ "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz",
+ "integrity": "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3101,6 +3153,33 @@
"react-dom": ">=18"
}
},
+ "node_modules/react-virtualized-auto-sizer": {
+ "version": "1.0.26",
+ "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz",
+ "integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-window": {
+ "version": "1.8.11",
+ "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
+ "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.0.0",
+ "memoize-one": ">=3.1.1 <6"
+ },
+ "engines": {
+ "node": ">8.0.0"
+ },
+ "peerDependencies": {
+ "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -3381,6 +3460,19 @@
"requires-port": "^1.0.0"
}
},
+ "node_modules/uuid": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
+ "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/esm/bin/uuid"
+ }
+ },
"node_modules/vite": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz",
diff --git a/package.json b/package.json
index cb46596..2765d8e 100644
--- a/package.json
+++ b/package.json
@@ -12,10 +12,16 @@
"dependencies": {
"@stomp/stompjs": "^7.1.1",
"axios": "^1.11.0",
+ "date-fns": "^4.1.0",
+ "lodash.debounce": "^4.0.8",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-intersection-observer": "^9.16.0",
"react-router-dom": "^7.7.1",
- "sockjs-client": "^1.6.1"
+ "react-virtualized-auto-sizer": "^1.0.26",
+ "react-window": "^1.8.11",
+ "sockjs-client": "^1.6.1",
+ "uuid": "^11.1.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
diff --git a/src/App.jsx b/src/App.jsx
index 4f63475..3b50801 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -16,6 +16,7 @@ function App() {
} />
} />
} />
+ } />
} />
diff --git a/src/api/axiosConfig.js b/src/api/axiosConfig.js
new file mode 100644
index 0000000..f6abe75
--- /dev/null
+++ b/src/api/axiosConfig.js
@@ -0,0 +1,30 @@
+// src/api/axiosConfig.js
+import axios from 'axios';
+
+// 백엔드 API의 기본 URL
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';
+
+const axiosInstance = axios.create({
+ baseURL: API_BASE_URL,
+});
+
+// 요청 인터셉터 (요청을 보내기 전에 실행)
+axiosInstance.interceptors.request.use(
+ (config) => {
+ // localStorage에서 accessToken 가져오기
+ const accessToken = localStorage.getItem('accessToken');
+
+ // 토큰이 존재하면 Authorization 헤더에 추가
+ if (accessToken) {
+ config.headers['Authorization'] = `Bearer ${accessToken}`;
+ }
+
+ return config;
+ },
+ (error) => {
+ // 요청 에러 처리
+ return Promise.reject(error);
+ }
+);
+
+export default axiosInstance;
\ No newline at end of file
diff --git a/src/api/axiosInstance.js b/src/api/axiosInstance.js
index d2df600..1c2a717 100644
--- a/src/api/axiosInstance.js
+++ b/src/api/axiosInstance.js
@@ -1,8 +1,10 @@
import axios from 'axios';
// 백엔드 API의 기본 URL
+
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; // ALB의 Path-based 라우팅을 위해 상대 경로 사용
+
const axiosInstance = axios.create({
baseURL: API_BASE_URL,
});
diff --git a/src/pages/Chatroom.css b/src/pages/Chatroom.css
index 55cd31c..d80108f 100644
--- a/src/pages/Chatroom.css
+++ b/src/pages/Chatroom.css
@@ -1,338 +1,910 @@
+/* Chatroom.css - 채팅방 페이지 전용 스타일 */
+
+/* ========== 전역 스타일 ========== */
* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
}
body {
- font-family: 'Arial', sans-serif;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- min-height: 100vh;
- color: #333;
+ font-family: 'Arial', sans-serif;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ min-height: 100vh;
+ color: #333;
}
+/* ========== 헤더 영역 ========== */
.header {
- background: rgba(255, 255, 255, 0.95);
- backdrop-filter: blur(10px);
- padding: 1rem 2rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
- box-shadow: 0 2px 20px rgba(0, 0, 0, 0.1);
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(10px);
+ padding: 1rem 2rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ box-shadow: 0 2px 20px rgba(0, 0, 0, 0.1);
}
.logo {
- font-size: 2rem;
- font-weight: bold;
- color: #764ba2;
- text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
- cursor: pointer;
+ font-size: 2rem;
+ font-weight: bold;
+ color: #764ba2;
+ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
+ cursor: pointer;
}
.room-info {
- display: flex;
- align-items: center;
- gap: 2rem;
- color: #764ba2;
- font-weight: bold;
+ display: flex;
+ align-items: center;
+ gap: 2rem;
+ color: #764ba2;
+ font-weight: bold;
}
.status {
- background: linear-gradient(45deg, #4CAF50, #45a049);
- color: white;
- padding: 0.5rem 1rem;
- border-radius: 20px;
+ background: linear-gradient(45deg, #4CAF50, #45a049);
+ color: white;
+ padding: 0.5rem 1rem;
+ border-radius: 20px;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.connection-status {
+ font-size: 0.8rem;
+}
+
+.connection-status.connected {
+ color: #2E7D32;
+ animation: pulse 2s infinite;
+}
+
+.connection-status.disconnected {
+ color: #f44336;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
}
.exit-btn {
- background: linear-gradient(45deg, #667eea, #764ba2);
- color: white;
- border: none;
- padding: 0.8rem 2rem;
- border-radius: 25px;
- cursor: pointer;
- font-size: 1rem;
- font-weight: bold;
- transition: transform 0.3s ease, box-shadow 0.3s ease;
- box-shadow: 0 4px 15px rgba(118, 75, 162, 0.3);
+ background: linear-gradient(45deg, #667eea, #764ba2);
+ color: white;
+ border: none;
+ padding: 0.8rem 2rem;
+ border-radius: 25px;
+ cursor: pointer;
+ font-size: 1rem;
+ font-weight: bold;
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
+ box-shadow: 0 4px 15px rgba(118, 75, 162, 0.3);
}
.exit-btn:hover {
- transform: translateY(-2px);
- box-shadow: 0 6px 20px rgba(118, 75, 162, 0.4);
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(118, 75, 162, 0.4);
}
+/* ========== 메인 컨테이너 ========== */
.main-container {
- display: flex;
- gap: 1rem;
- padding: 1rem;
- height: calc(100vh - 100px);
+ display: flex;
+ gap: 1rem;
+ padding: 1rem;
+ height: calc(100vh - 100px);
}
+/* ========== 왼쪽 섹션 (탭 방식) ========== */
.left-section {
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 1rem;
+ flex: 1;
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(10px);
+ border-radius: 15px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+/* 탭 헤더 */
+.tab-header {
+ display: flex;
+ background: rgba(240, 240, 240, 0.8);
+ border-radius: 15px 15px 0 0;
}
-.right-section {
- flex: 1;
- background: rgba(255, 255, 255, 0.95);
- backdrop-filter: blur(10px);
- border-radius: 15px;
- padding: 1rem;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
- border: 1px solid rgba(255, 255, 255, 0.2);
- display: flex;
- flex-direction: column;
+.tab-button {
+ flex: 1;
+ padding: 1rem;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ font-size: 1rem;
+ font-weight: bold;
+ color: #666;
+ transition: all 0.3s ease;
+ border-radius: 15px 15px 0 0;
}
-.users-section {
- background: rgba(255, 255, 255, 0.95);
- backdrop-filter: blur(10px);
- border-radius: 15px;
- padding: 1rem;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
- border: 1px solid rgba(255, 255, 255, 0.2);
+.tab-button.active {
+ background: linear-gradient(45deg, #667eea, #764ba2);
+ color: white;
+ box-shadow: 0 2px 10px rgba(102, 126, 234, 0.3);
}
-.formation-section {
- flex: 1;
- background: rgba(255, 255, 255, 0.95);
- backdrop-filter: blur(10px);
- border-radius: 15px;
- padding: 1rem;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
- border: 1px solid rgba(255, 255, 255, 0.2);
- overflow: hidden;
+.tab-button:hover:not(.active) {
+ background: rgba(102, 126, 234, 0.1);
+ color: #667eea;
}
-.section-title {
- font-size: 1.2rem;
- font-weight: bold;
- margin-bottom: 1rem;
- color: #764ba2;
- text-align: center;
- border-bottom: 2px solid #667eea;
- padding-bottom: 0.5rem;
+/* 탭 내용 */
+.tab-content {
+ flex: 1;
+ padding: 1rem;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+/* 참가자 탭 */
+.participants-tab {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.participants-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.8rem;
+ /* 4명 고정이므로 스크롤 없이 딱 맞춤 */
+}
+
+.participant-item {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem;
+ background: rgba(249, 249, 249, 0.8);
+ border-radius: 10px;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ border: 2px solid transparent;
+ /* 4명이 균등하게 들어가도록 flex-grow 추가 */
+ flex: 1;
+}
+
+.participant-item:hover {
+ transform: translateY(-2px);
+ background: rgba(240, 240, 240, 0.9);
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
+}
+
+.participant-item.active {
+ background: linear-gradient(45deg, #667eea, #764ba2);
+ color: white;
+ box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
+ border: 2px solid #667eea;
+}
+
+.participant-rank {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.3rem;
+ min-width: 60px;
+ text-align: center;
+}
+
+.medal-icon {
+ font-size: 2rem;
+ line-height: 1;
+}
+
+.rank-text {
+ font-size: 0.9rem;
+ font-weight: bold;
+ color: #764ba2;
+}
+
+.participant-item.active .rank-text {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.participant-info {
+ flex: 1;
}
-/* 유저 정보 영역 */
+.participant-name {
+ font-weight: bold;
+ font-size: 0.95rem;
+ margin-bottom: 0.3rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.participant-points {
+ font-size: 1.1rem;
+ font-weight: bold;
+ color: #4CAF50;
+}
+
+.participant-item.active .participant-points {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+/* 포메이션 탭 */
+.formation-tab {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.formation-title {
+ font-size: 1.1rem;
+ font-weight: bold;
+ color: #764ba2;
+ text-align: center;
+ margin-bottom: 1rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 2px solid #667eea;
+}
+
+.formation-field {
+ flex: 1;
+ background: linear-gradient(180deg, #4CAF50 0%, #45a049 100%);
+ border-radius: 10px;
+ padding: 1.5rem 1rem;
+ position: relative;
+ overflow-y: auto;
+ overflow-x: hidden;
+ min-height: 300px;
+}
+
+.section-title {
+ font-size: 1.2rem;
+ font-weight: bold;
+ margin-bottom: 1rem;
+ color: #764ba2;
+ text-align: center;
+ border-bottom: 2px solid #667eea;
+ padding-bottom: 0.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.unread-count {
+ background: linear-gradient(45deg, #ff6b6b, #ff8e8e);
+ color: white;
+ font-size: 0.8rem;
+ padding: 0.2rem 0.5rem;
+ border-radius: 12px;
+ font-weight: bold;
+ animation: pulse 1.5s infinite;
+}
+
+/* ========== 참가자 정보 영역 ========== */
.users-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 0.8rem;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.8rem;
}
.user-card {
- background: #f9f9f9;
- padding: 1rem;
- border-radius: 10px;
- text-align: center;
- cursor: pointer;
- transition: all 0.3s ease;
- border: 2px solid transparent;
+ background: #f9f9f9;
+ padding: 0.7rem;
+ border-radius: 10px;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ border: 2px solid transparent;
}
.user-card:hover {
- transform: scale(1.02);
- background: #f0f0f0;
+ transform: scale(1.02);
+ background: #f0f0f0;
}
.user-card.active {
- background: linear-gradient(45deg, #667eea, #764ba2);
- color: white;
- box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
- border: 2px solid #667eea;
+ background: linear-gradient(45deg, #667eea, #764ba2);
+ color: white;
+ box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
+ border: 2px solid #667eea;
}
.user-name {
- font-weight: bold;
- font-size: 0.9rem;
- margin-bottom: 0.5rem;
+ font-weight: bold;
+ font-size: 0.9rem;
+ margin-bottom: 0.5rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.user-score {
- font-size: 1.2rem;
- font-weight: bold;
- color: #4CAF50;
+ font-size: 1.2rem;
+ font-weight: bold;
+ color: #4CAF50;
}
.user-card.active .user-score {
- color: rgba(255, 255, 255, 0.9);
+ color: rgba(255, 255, 255, 0.9);
}
.user-rank {
- font-size: 0.8rem;
- color: #666;
- margin-top: 0.3rem;
+ font-size: 0.8rem;
+ color: #666;
+ margin-top: 0.3rem;
}
.user-card.active .user-rank {
- color: rgba(255, 255, 255, 0.7);
+ color: rgba(255, 255, 255, 0.7);
}
-/* 포메이션 영역 */
+/* ========== 포메이션 영역 ========== */
.formation-field {
- background: linear-gradient(180deg, #4CAF50 0%, #45a049 100%);
- border-radius: 10px;
- padding: 2rem 1rem;
- height: 100%;
- position: relative;
- overflow: hidden;
+ background: linear-gradient(180deg, #4CAF50 0%, #45a049 100%);
+ border-radius: 10px;
+ padding: 2rem 1rem;
+ height: calc(100% - 3rem);
+ position: relative;
+ overflow-y: auto;
+ overflow-x: hidden;
}
.field-lines {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background:
- linear-gradient(0deg, rgba(255,255,255,0.3) 1px, transparent 1px),
- linear-gradient(90deg, rgba(255,255,255,0.3) 1px, transparent 1px);
- background-size: 50px 50px;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background:
+ linear-gradient(0deg, rgba(255,255,255,0.3) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255,255,255,0.3) 1px, transparent 1px);
+ background-size: 50px 50px;
}
.formation-container {
- position: relative;
- height: 100%;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- padding: 1rem 0;
+ position: relative;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ gap: 1rem;
+ padding: 1rem 0;
+ min-height: fit-content;
}
.formation-line {
- display: flex;
- justify-content: center;
- gap: 1rem;
- margin: 0.5rem 0;
+ display: flex;
+ justify-content: center;
+ gap: 1rem;
+ margin: 0.5rem 0;
}
.player-card {
- background: rgba(255, 255, 255, 0.95);
- border-radius: 8px;
- padding: 0.5rem;
- text-align: center;
- min-width: 80px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
- transition: transform 0.2s ease;
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 8px;
+ padding: 0.5rem;
+ text-align: center;
+ min-width: 80px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+ transition: transform 0.2s ease;
}
.player-card:hover {
- transform: scale(1.05);
+ transform: scale(1.05);
}
.player-photo-small {
- width: 40px;
- height: 40px;
- border-radius: 50%;
- background: linear-gradient(45deg, #667eea, #764ba2);
- margin: 0 auto 0.3rem;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: linear-gradient(45deg, #667eea, #764ba2);
+ margin: 0 auto 0.3rem;
}
.player-name-small {
- font-size: 0.7rem;
- font-weight: bold;
- color: #333;
- line-height: 1.2;
+ font-size: 0.7rem;
+ font-weight: bold;
+ color: #333;
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.player-position-badge {
- background: linear-gradient(45deg, #ff6b6b, #ff8e8e);
- color: white;
- font-size: 0.6rem;
- padding: 0.1rem 0.3rem;
- border-radius: 8px;
- margin-top: 0.2rem;
+ background: linear-gradient(45deg, #ff6b6b, #ff8e8e);
+ color: white;
+ font-size: 0.6rem;
+ padding: 0.1rem 0.3rem;
+ border-radius: 8px;
+ margin-top: 0.2rem;
}
-/* 채팅 영역 */
+/* ========== 오른쪽 섹션 (채팅) ========== */
+.right-section {
+ flex: 1;
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(10px);
+ border-radius: 15px;
+ padding: 1rem;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ display: flex;
+ flex-direction: column;
+}
+
+/* ========== 채팅 검색 영역 ========== */
+.chat-search-container {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+ align-items: center;
+}
+
+.chat-search-input {
+ flex: 1;
+ padding: 0.8rem;
+ border: 2px solid #ddd;
+ border-radius: 15px;
+ font-size: 0.9rem;
+ outline: none;
+ transition: border-color 0.3s ease;
+}
+
+.chat-search-input:focus {
+ border-color: #667eea;
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+.chat-search-btn {
+ background: linear-gradient(45deg, #667eea, #764ba2);
+ color: white;
+ border: none;
+ padding: 0.8rem 1.2rem;
+ border-radius: 15px;
+ cursor: pointer;
+ font-weight: bold;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+ box-shadow: 0 2px 10px rgba(102, 126, 234, 0.3);
+ font-size: 0.9rem;
+ min-width: 80px;
+}
+
+.chat-search-btn:hover:not(:disabled) {
+ transform: scale(1.05);
+ box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
+}
+
+.chat-search-btn:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+.chat-search-clear {
+ background: #ff6b6b;
+ color: white;
+ border: none;
+ padding: 0.8rem;
+ border-radius: 50%;
+ cursor: pointer;
+ font-weight: bold;
+ transition: all 0.2s ease;
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.chat-search-clear:hover {
+ background: #ff5252;
+ transform: scale(1.1);
+}
+
+/* ========== 채팅 영역 ========== */
.chat-messages {
- flex: 1;
- overflow-y: auto;
- border: 1px solid #ddd;
- border-radius: 10px;
- padding: 1rem;
- margin-bottom: 1rem;
- background: #f9f9f9;
- max-height: 400px;
+ flex: 1;
+ overflow-y: auto;
+ border: 1px solid #ddd;
+ border-radius: 10px;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ background: #f9f9f9;
+ max-height: 600px;
+ scroll-behavior: smooth;
}
+/* 스크롤바 스타일 */
+.chat-messages::-webkit-scrollbar,
+.formation-field::-webkit-scrollbar,
+.participants-list::-webkit-scrollbar {
+ width: 8px;
+}
+
+.chat-messages::-webkit-scrollbar-track,
+.formation-field::-webkit-scrollbar-track,
+.participants-list::-webkit-scrollbar-track {
+ background: #f1f1f1;
+ border-radius: 10px;
+}
+
+.chat-messages::-webkit-scrollbar-thumb,
+.formation-field::-webkit-scrollbar-thumb,
+.participants-list::-webkit-scrollbar-thumb {
+ background: #667eea;
+ border-radius: 10px;
+}
+
+.chat-messages::-webkit-scrollbar-thumb:hover,
+.formation-field::-webkit-scrollbar-thumb:hover,
+.participants-list::-webkit-scrollbar-thumb:hover {
+ background: #764ba2;
+}
+
+/* 채팅 메시지 */
.chat-message {
- margin-bottom: 1rem;
- padding: 0.8rem;
- border-radius: 10px;
- background: white;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ margin-bottom: 1rem;
+ padding: 0.8rem;
+ border-radius: 10px;
+ background: white;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ animation: slideIn 0.3s ease;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateX(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+/* 알림 메시지 스타일 */
+.chat-message.alert-message {
+ background: linear-gradient(45deg, #ffd93d, #ffb347);
+ border-left: 4px solid #ff6b6b;
+ animation: alertSlideIn 0.5s ease;
+}
+
+@keyframes alertSlideIn {
+ from {
+ opacity: 0;
+ transform: scale(0.9);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+.alert-message .chat-user {
+ color: #d32f2f;
+ font-weight: bold;
+}
+
+.alert-message .chat-text {
+ color: #333;
+ font-weight: 600;
}
.chat-user {
- font-weight: bold;
- color: #764ba2;
- font-size: 0.9rem;
- margin-bottom: 0.3rem;
+ font-weight: bold;
+ color: #764ba2;
+ font-size: 0.9rem;
+ margin-bottom: 0.3rem;
}
.chat-text {
- color: #333;
- line-height: 1.4;
+ color: #333;
+ line-height: 1.4;
+ word-wrap: break-word;
}
.chat-time {
- font-size: 0.7rem;
- color: #999;
- margin-top: 0.3rem;
+ font-size: 0.7rem;
+ color: #999;
+ margin-top: 0.3rem;
}
+/* ========== 검색 결과 관련 스타일 ========== */
+.search-results-header {
+ background: linear-gradient(45deg, #667eea, #764ba2);
+ color: white;
+ padding: 0.8rem;
+ border-radius: 10px;
+ margin-bottom: 1rem;
+ text-align: center;
+ font-weight: bold;
+ font-size: 0.9rem;
+}
+
+.search-result {
+ border-left: 4px solid #667eea;
+ background: rgba(102, 126, 234, 0.05);
+}
+
+.search-result:hover {
+ background: rgba(102, 126, 234, 0.1);
+ transform: translateX(4px);
+}
+
+.no-search-results {
+ text-align: center;
+ color: #999;
+ font-size: 0.9rem;
+ padding: 2rem;
+ font-style: italic;
+ background: rgba(255, 255, 255, 0.8);
+ border-radius: 10px;
+ border: 2px dashed #ddd;
+}
+
+/* 채팅 입력 영역 */
.chat-input-container {
- display: flex;
- gap: 0.5rem;
+ display: flex;
+ gap: 0.5rem;
}
.chat-input {
- flex: 1;
- padding: 1rem;
- border: 2px solid #ddd;
- border-radius: 20px;
- font-size: 1rem;
- outline: none;
- transition: border-color 0.3s ease;
+ flex: 1;
+ padding: 1rem;
+ border: 2px solid #ddd;
+ border-radius: 20px;
+ font-size: 1rem;
+ outline: none;
+ transition: border-color 0.3s ease;
}
.chat-input:focus {
- border-color: #667eea;
+ border-color: #667eea;
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+.chat-input:disabled {
+ background: #f5f5f5;
+ cursor: not-allowed;
}
.chat-send {
- background: linear-gradient(45deg, #667eea, #764ba2);
- color: white;
- border: none;
- padding: 1rem 2rem;
- border-radius: 20px;
- cursor: pointer;
- font-weight: bold;
- transition: transform 0.2s ease;
+ background: linear-gradient(45deg, #667eea, #764ba2);
+ color: white;
+ border: none;
+ padding: 1rem 2rem;
+ border-radius: 20px;
+ cursor: pointer;
+ font-weight: bold;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+ box-shadow: 0 2px 10px rgba(102, 126, 234, 0.3);
+}
+
+.chat-send:hover:not(:disabled) {
+ transform: scale(1.05);
+ box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
+}
+
+.chat-send:active:not(:disabled) {
+ transform: scale(0.98);
}
-.chat-send:hover {
- transform: scale(1.05);
+.chat-send:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
}
+/* ========== 무한스크롤 관련 ========== */
+.load-more-trigger {
+ padding: 1rem;
+ text-align: center;
+ min-height: 50px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.loading-message {
+ color: #667eea;
+ font-size: 0.9rem;
+ font-style: italic;
+ animation: fadeInOut 1.5s infinite;
+}
+
+.loading-indicator {
+ color: #667eea;
+ font-size: 0.9rem;
+ font-style: italic;
+ animation: fadeInOut 1.5s infinite;
+}
+
+@keyframes fadeInOut {
+ 0%, 100% { opacity: 0.5; }
+ 50% { opacity: 1; }
+}
+
+.chat-end-message {
+ text-align: center;
+ color: #999;
+ font-size: 0.9rem;
+ padding: 1rem;
+ border-bottom: 1px solid #eee;
+ margin-bottom: 1rem;
+ background: rgba(255, 255, 255, 0.5);
+ border-radius: 10px;
+}
+
+/* ========== 반응형 디자인 ========== */
@media (max-width: 1200px) {
- .main-container {
- flex-direction: column;
- height: auto;
- }
-
- .left-section {
- flex-direction: row;
- }
-
- .users-section, .formation-section {
- flex: 1;
- min-height: 300px;
- }
+ .main-container {
+ flex-direction: column;
+ height: auto;
+ }
+
+ .left-section {
+ flex-direction: row;
+ }
+
+ .users-section,
+ .formation-section {
+ flex: 1;
+ min-height: 300px;
+ }
+
+ .formation-field {
+ height: 300px;
+ }
+
+ .chat-messages {
+ max-height: 400px;
+ }
+}
+
+@media (max-width: 768px) {
+ .header {
+ padding: 0.8rem 1rem;
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .logo {
+ font-size: 1.5rem;
+ }
+
+ .room-info {
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .left-section {
+ flex-direction: column;
+ }
+
+ .users-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .formation-line {
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ }
+
+ .player-card {
+ min-width: 60px;
+ padding: 0.3rem;
+ }
+
+ .player-photo-small {
+ width: 30px;
+ height: 30px;
+ }
+
+ .player-name-small {
+ font-size: 0.6rem;
+ }
+
+ .chat-messages {
+ max-height: 300px;
+ }
+
+ .chat-send {
+ padding: 1rem 1.5rem;
+ }
+}
+
+/* ========== 최근 알림 영역 ========== */
+.recent-alerts {
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 2px solid rgba(102, 126, 234, 0.2);
+}
+
+.alerts-header {
+ margin-bottom: 0.8rem;
+}
+
+.alerts-title {
+ font-size: 1rem;
+ font-weight: bold;
+ color: #764ba2;
+ text-align: center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.alerts-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ max-height: 120px;
+ overflow-y: auto;
+}
+
+.alert-item {
+ background: rgba(255, 215, 0, 0.1);
+ border: 1px solid rgba(255, 215, 0, 0.3);
+ border-radius: 8px;
+ padding: 0.6rem;
+ font-size: 0.85rem;
+ transition: all 0.2s ease;
+}
+
+.alert-item:hover {
+ background: rgba(255, 215, 0, 0.15);
+ transform: translateY(-1px);
+}
+
+.alert-content {
+ color: #333;
+ font-weight: 500;
+ line-height: 1.3;
+ margin-bottom: 0.2rem;
+ word-wrap: break-word;
+}
+
+.alert-time {
+ color: #666;
+ font-size: 0.75rem;
+ text-align: right;
+}
+
+.no-alerts {
+ text-align: center;
+ color: #999;
+ font-size: 0.85rem;
+ font-style: italic;
+ padding: 1rem;
+}
+
+/* 알림 리스트 스크롤바 스타일 */
+.alerts-list::-webkit-scrollbar {
+ width: 4px;
+}
+
+.alerts-list::-webkit-scrollbar-track {
+ background: rgba(241, 241, 241, 0.5);
+ border-radius: 10px;
+}
+
+.alerts-list::-webkit-scrollbar-thumb {
+ background: rgba(102, 126, 234, 0.5);
+ border-radius: 10px;
+}
+
+.alerts-list::-webkit-scrollbar-thumb:hover {
+ background: rgba(118, 75, 162, 0.7);
}
\ No newline at end of file
diff --git a/src/pages/Chatroom.jsx b/src/pages/Chatroom.jsx
index c3b1fe4..ec15d46 100644
--- a/src/pages/Chatroom.jsx
+++ b/src/pages/Chatroom.jsx
@@ -1,149 +1,866 @@
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
import './Chatroom.css';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate, useParams } from 'react-router-dom';
+import { useInView } from 'react-intersection-observer';
+import SockJS from 'sockjs-client';
+import { Client } from '@stomp/stompjs';
+import debounce from 'lodash.debounce';
+// import axiosInstance from '../api/axiosConfig'; // 프로젝트 구조에 맞게 경로 수정 필요
+import axios from 'axios';
+
+// axiosInstance 직접 생성 (동적 토큰 처리)
+const axiosInstance = axios.create({
+ baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'
+});
+
+// 요청 인터셉터 추가
+axiosInstance.interceptors.request.use(
+ (config) => {
+ const token = localStorage.getItem('accessToken');
+ if (token) {
+ config.headers['Authorization'] = `Bearer ${token}`;
+ }
+ return config;
+ },
+ (error) => {
+ return Promise.reject(error);
+ }
+);
export default function Chatroom() {
const navigate = useNavigate();
+ const { roomId } = useParams(); // URL에서 roomId 가져오기
const chatRef = useRef(null);
- const [selectedUser, setSelectedUser] = useState('user1');
- const [chatList, setChatList] = useState([
- { user: 'test1234@gmail.com', text: '와! 드래프트 재밌었네요! 다들 수고하셨습니다 🎉', time: '오후 3:25' },
- { user: 'soccer_king@gmail.com', text: '1위 축하해요! 정말 좋은 팀 구성이네요', time: '오후 3:26' },
- { user: 'fantasy_master@gmail.com', text: '홀란드 뽑힌 거 아쉽다 ㅠㅠ 다음엔 더 빨리 선택해야겠어요', time: '오후 3:27' },
- { user: 'epl_lover@gmail.com', text: '다들 정말 좋은 전략으로 팀 꾸미셨네요! 다음 시즌에 또 만나요!', time: '오후 3:28' }
- ]);
+ const stompClientRef = useRef(null);
+
+ // roomId가 없으면 테스트용 ID 사용
+ const actualRoomId = roomId || 'test-' + Date.now();
+
+ // 기본 상태
+ const [selectedParticipantId, setSelectedParticipantId] = useState(null);
+ const [isComposing, setIsComposing] = useState(false);
+ const [chatList, setChatList] = useState([]);
const [message, setMessage] = useState('');
+ const [isConnected, setIsConnected] = useState(false);
+
+
+ // 무한스크롤 관련 상태
+ const [hasMore, setHasMore] = useState(true);
+ const [loading, setLoading] = useState(false);
+ const [nextCursor, setNextCursor] = useState(null);
+ const [isInitialLoad, setIsInitialLoad] = useState(true);
+
+ // 스코어보드 및 로스터 데이터
+ const [scoreboard, setScoreboard] = useState([]);
+ const [currentRoster, setCurrentRoster] = useState(null);
+
+ // 사용자 정보
+ const [currentUser, setCurrentUser] = useState(null);
+
+ // 읽음 표시 관련 상태
+ const [lastReadMessageId, setLastReadMessageId] = useState(null);
+ const [unreadCount, setUnreadCount] = useState(0);
+
+ // 탭 관련 상태
+ const [activeTab, setActiveTab] = useState('participants'); // 'participants' | 'formation'
+
+ // 최근 골/어시스트 알림 상태 (최대 3개 저장)
+ const [recentAlerts, setRecentAlerts] = useState([]);
+
+ // 검색 관련 상태
+ const [searchQuery, setSearchQuery] = useState('');
+ const [searchResults, setSearchResults] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+ const [showSearchResults, setShowSearchResults] = useState(false);
+
+ // 무한스크롤 감지를 위한 IntersectionObserver
+ const { ref: loadMoreRef, inView } = useInView({
+ threshold: 0,
+ rootMargin: '100px 0px',
+ });
+
+ // JWT 토큰 가져오기
+ const getAuthToken = () => {
+ return localStorage.getItem('accessToken') || sessionStorage.getItem('accessToken');
+ };
+
+ // 현재 사용자 정보 가져오기
+ const fetchCurrentUser = async () => {
+ try {
+ // /api/users/me가 404면 다른 엔드포인트 시도
+ const response = await axiosInstance.get('/api/user/me');
+ setCurrentUser(response.data);
+ } catch (error) {
+ console.error('사용자 정보 로드 실패:', error);
+ // 임시 사용자 정보 설정
+ setCurrentUser({
+ id: localStorage.getItem('userId') || 'test-user',
+ email: localStorage.getItem('userEmail') || 'test@gmail.com'
+ });
+ }
+ };
+
+ // 스코어보드 정보 가져오기
+ const fetchScoreboard = async () => {
+ try {
+ const response = await axiosInstance.get(`/api/chat-rooms/${roomId}/scoreboard`);
+ setScoreboard(response.data);
- const users = [
- { id: 'user1', name: 'test1234@gmail.com', score: 89, rank: '1위' },
- { id: 'user2', name: 'soccer_king@gmail.com', score: 85, rank: '2위' },
- { id: 'user3', name: 'fantasy_master@gmail.com', score: 82, rank: '3위' },
- { id: 'user4', name: 'epl_lover@gmail.com', score: 78, rank: '4위' }
- ];
-
- const formations = {
- user1: {
- name: 'test1234@gmail.com',
- players: {
- gk: ['앨리송'],
- df: ['반 다이크', '루벤 디아스', '칸셀루', '로버트슨'],
- mf: ['드 브라위너', '엔조 페르난데스', '브루노'],
- fw: ['손흥민', '홀란드', '살라']
+ // 첫 번째 참가자를 기본 선택
+ if (response.data.length > 0 && !selectedParticipantId) {
+ setSelectedParticipantId(response.data[0].participantId);
}
+ } catch (error) {
+ console.error('스코어보드 로드 실패:', error);
}
};
- const handleSendMessage = () => {
- if (!message.trim()) return;
- const now = new Date();
- const formattedTime = now.toLocaleTimeString('ko-KR', {
+ // 선택된 참가자의 로스터 정보 가져오기
+ const fetchRoster = async (participantId) => {
+ if (!participantId) return;
+
+ try {
+ const response = await axiosInstance.get(
+ `/api/chat-rooms/${roomId}/participants/${participantId}/roster`
+ );
+ setCurrentRoster(response.data);
+ } catch (error) {
+ console.error('로스터 로드 실패:', error);
+ }
+ };
+
+ // 읽음 상태 조회
+ const fetchReadState = async () => {
+ try {
+ const response = await axiosInstance.get(`/api/chat-rooms/${actualRoomId}/read-state`);
+ setLastReadMessageId(response.data.lastReadMessageId);
+ setUnreadCount(response.data.unreadCount);
+ } catch (error) {
+ console.error('읽음 상태 조회 실패:', error);
+ }
+ };
+
+ // 읽음 표시 업데이트
+ const markReadUpTo = async (messageId) => {
+ try {
+ const response = await axiosInstance.post(`/api/chat-rooms/${actualRoomId}/read-state`, {
+ messageId: messageId
+ });
+ setUnreadCount(response.data.unreadCount);
+ setLastReadMessageId(messageId);
+ } catch (error) {
+ console.error('읽음 표시 업데이트 실패:', error);
+ }
+ };
+
+ // 읽음 표시 업데이트 디바운스
+ const debouncedMarkRead = useCallback(
+ debounce((messageId) => {
+ markReadUpTo(messageId);
+ }, 1000),
+ [actualRoomId]
+ );
+
+ // 채팅 검색 함수
+ const searchChatMessages = async (query) => {
+ if (!query.trim()) {
+ setSearchResults([]);
+ setShowSearchResults(false);
+ return;
+ }
+
+ setIsSearching(true);
+ try {
+ const response = await axiosInstance.get(`/api/chat-rooms/${actualRoomId}/search`, {
+ params: {
+ q: query.trim(),
+ limit: 20
+ }
+ });
+
+ const { items } = response.data;
+ setSearchResults(items.map(formatMessage));
+ setShowSearchResults(true);
+ } catch (error) {
+ console.error('채팅 검색 실패:', error);
+ setSearchResults([]);
+ setShowSearchResults(false);
+ } finally {
+ setIsSearching(false);
+ }
+ };
+
+ // 검색 실행
+ const handleSearch = () => {
+ searchChatMessages(searchQuery);
+ };
+
+ // 검색 초기화
+ const clearSearch = () => {
+ setSearchQuery('');
+ setSearchResults([]);
+ setShowSearchResults(false);
+ };
+
+ // 시간 포맷
+ const formatTime = (timestamp) => {
+ const date = new Date(timestamp);
+ return date.toLocaleTimeString('ko-KR', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
- setChatList(prev => [...prev, { user: '나', text: message.trim(), time: formattedTime }]);
- setMessage('');
};
- const handleSelectUser = (userId) => {
- setSelectedUser(userId);
+ // 메시지 포맷 변환
+ const formatMessage = useCallback((item) => {
+ // 사용자 이름 결정
+ let userName = '시스템';
+ if (item.type === 'ALERT' || item.type === 'SYSTEM') {
+ userName = '⚽ 알림';
+ } else if (item.userId) {
+ // 스코어보드에서 사용자 정보 찾기
+ const user = scoreboard.find(s => s.userId === item.userId);
+ if (user) {
+ userName = user.email;
+ } else if (currentUser && item.userId === currentUser.id) {
+ // 현재 사용자인 경우 현재 사용자 이메일 사용
+ userName = currentUser.email;
+ } else {
+ // 디버깅: Unknown인 경우 정보 출력
+ console.log('Unknown user detected:', {
+ userId: item.userId,
+ currentUser: currentUser,
+ scoreboard: scoreboard,
+ content: item.content
+ });
+ userName = 'Unknown';
+ }
+ }
+
+ return {
+ id: item.id,
+ user: userName,
+ text: item.content,
+ time: formatTime(item.createdAt),
+ type: item.type,
+ userId: item.userId
+ };
+ }, [scoreboard, currentUser]);
+
+ // 채팅 히스토리 초기 로드 (최신 메시지부터)
+ const loadInitialHistory = async () => {
+ if (loading) return;
+ setLoading(true);
+
+ try {
+ // 최신 메시지부터 20개 가져오기 (백엔드에서 이미 처리됨)
+ const response = await axiosInstance.get(`/api/chat-rooms/${actualRoomId}/messages`, {
+ params: { limit: 20 }
+ });
+
+ const { items, nextCursor: cursor, hasMore: more } = response.data;
+
+ // 백엔드에서 이미 오래된->최신 순으로 정렬해서 반환하므로 그대로 사용
+ setChatList(items.map(formatMessage));
+ setNextCursor(cursor);
+ setHasMore(more);
+ setIsInitialLoad(false);
+
+ // 초기 로드 완료 후 즉시 맨 아래로 스크롤 (애니메이션 없이)
+ requestAnimationFrame(() => {
+ if (chatRef.current) {
+ chatRef.current.scrollTop = chatRef.current.scrollHeight;
+ }
+ });
+
+ } catch (error) {
+ console.error('채팅 히스토리 초기 로드 실패:', error);
+ } finally {
+ setLoading(false);
+ }
};
+ // 이전 메시지 로드 (무한스크롤)
+ const loadMoreMessages = async () => {
+ if (loading || !nextCursor || !hasMore) return;
+ setLoading(true);
+
+ try {
+ const response = await axiosInstance.get(`/api/chat-rooms/${roomId}/messages/before`, {
+ params: {
+ cursor: nextCursor,
+ limit: 20 // 20개씩 로드
+ }
+ });
+
+ const { items, nextCursor: cursor, hasMore: more } = response.data;
+
+ // 스크롤 위치 보존을 위해 현재 스크롤 높이 저장
+ const currentScrollHeight = chatRef.current?.scrollHeight || 0;
+
+ // 기존 메시지 위에 추가 (오래된 메시지를 위에)
+ setChatList(prev => [...items.map(formatMessage), ...prev]);
+ setNextCursor(cursor);
+ setHasMore(more);
+
+ // 스크롤 위치 조정 (새 메시지가 추가되어도 사용자가 보던 위치 유지)
+ setTimeout(() => {
+ if (chatRef.current) {
+ const newScrollHeight = chatRef.current.scrollHeight;
+ const heightDiff = newScrollHeight - currentScrollHeight;
+ chatRef.current.scrollTop = chatRef.current.scrollTop + heightDiff;
+ }
+ }, 50);
+
+ } catch (error) {
+ console.error('이전 메시지 로드 실패:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // WebSocket 연결
+ const connectWebSocket = useCallback(() => {
+ if (stompClientRef.current?.connected) return;
+
+ const token = getAuthToken();
+ if (!token) {
+ console.error('인증 토큰이 없습니다.');
+ setIsConnected(false);
+ return;
+ }
+
+ try {
+ // SockJS 연결 (백엔드가 SockJS를 지원하지 않으면 일반 WebSocket 사용)
+ const wsUrl = import.meta.env.VITE_API_WS_URL || 'ws://localhost:8080';
+ const socket = new SockJS(`${wsUrl.replace('ws://', 'http://')}/ws`);
+ const stompClient = new Client({
+ webSocketFactory: () => socket,
+ connectHeaders: {
+ 'Authorization': `Bearer ${token}`
+ },
+ debug: (str) => {
+ console.log('STOMP:', str);
+ },
+ onConnect: (frame) => {
+ console.log('WebSocket 연결 성공:', frame);
+ setIsConnected(true);
+
+ // 채팅방 구독
+ const subscription = stompClient.subscribe(`/topic/chat/${actualRoomId}`, (message) => {
+ console.log('메시지 수신:', message.body);
+ const newMessage = JSON.parse(message.body);
+
+ // 사용자 이름 결정
+ let userName = '시스템';
+ if (newMessage.type === 'ALERT' || newMessage.type === 'SYSTEM') {
+ userName = '⚽ 알림';
+ } else if (newMessage.userId) {
+ const user = scoreboard.find(s => s.userId === newMessage.userId);
+ if (user) {
+ userName = user.email;
+ } else if (currentUser && newMessage.userId === currentUser.id) {
+ // 현재 사용자인 경우 현재 사용자 이메일 사용
+ userName = currentUser.email;
+ } else {
+ userName = 'Unknown';
+ }
+ }
+
+ const formattedMessage = {
+ id: newMessage.id || Date.now().toString(),
+ user: userName,
+ text: newMessage.content,
+ time: formatTime(newMessage.createdAt || new Date()),
+ type: newMessage.type,
+ userId: newMessage.userId
+ };
+
+ setChatList(prev => {
+ const newList = [...prev, formattedMessage];
+
+ // 새 메시지 추가 후 스크롤을 맨 아래로 (카카오톡 방식)
+ setTimeout(() => {
+ if (chatRef.current) {
+ chatRef.current.scrollTop = chatRef.current.scrollHeight;
+ }
+ }, 100);
+
+ return newList;
+ });
+
+ // 알림 메시지일 경우 스코어보드 새로고침 및 최근 알림에 추가
+ if (newMessage.type === 'ALERT') {
+ fetchScoreboard();
+
+ // 골/어시스트 관련 알림인지 확인
+ const isGoalOrAssist = newMessage.content.includes('골') ||
+ newMessage.content.includes('어시스트') ||
+ newMessage.content.includes('득점') ||
+ newMessage.content.includes('도움');
+
+ if (isGoalOrAssist) {
+ setRecentAlerts(prev => {
+ const newAlert = {
+ id: newMessage.id || Date.now().toString(),
+ content: newMessage.content,
+ time: formatTime(newMessage.createdAt || new Date()),
+ type: 'goal-assist'
+ };
+
+ // 최대 3개까지만 저장 (최신순)
+ const updatedAlerts = [newAlert, ...prev].slice(0, 3);
+ return updatedAlerts;
+ });
+ }
+ }
+ }, {
+ 'Authorization': `Bearer ${token}`
+ });
+
+ console.log('구독 완료:', subscription);
+ },
+ onDisconnect: (frame) => {
+ console.log('WebSocket 연결 해제:', frame);
+ setIsConnected(false);
+ },
+ onStompError: (frame) => {
+ console.error('STOMP 오류:', frame);
+ setIsConnected(false);
+
+ // 에러 메시지 파싱
+ if (frame.headers && frame.headers.message) {
+ console.error('오류 메시지:', frame.headers.message);
+ }
+
+ // 재연결 시도
+ setTimeout(() => {
+ if (!stompClientRef.current?.connected) {
+ console.log('재연결 시도...');
+ connectWebSocket();
+ }
+ }, 3000);
+ },
+ onWebSocketError: (error) => {
+ console.error('WebSocket 오류:', error);
+ },
+ reconnectDelay: 5000,
+ heartbeatIncoming: 4000,
+ heartbeatOutgoing: 4000
+ });
+
+ stompClient.activate();
+ stompClientRef.current = stompClient;
+ console.log('STOMP 클라이언트 활성화됨');
+ } catch (error) {
+ console.error('WebSocket 연결 실패:', error);
+ setIsConnected(false);
+ }
+ }, [actualRoomId, scoreboard]);
+
+ // 메시지 전송
+ const handleSendMessage = () => {
+
+ if (isComposing) return;
+ if (!message.trim()) return;
+
+ if (!stompClientRef.current?.connected) {
+ alert('채팅 서버에 연결되지 않았습니다. 잠시 후 다시 시도해주세요.');
+ return;
+ }
+
+ const messageData = {
+ roomId: actualRoomId,
+ content: message.trim()
+ };
+
+ try {
+ stompClientRef.current.publish({
+ destination: `/app/chat/${actualRoomId}/send`,
+ body: JSON.stringify(messageData)
+ });
+ setMessage('');
+ } catch (error) {
+ console.error('메시지 전송 실패:', error);
+ alert('메시지 전송에 실패했습니다.');
+ }
+ };
+
+ // 참가자 선택
+ const handleSelectParticipant = (participantId) => {
+ setSelectedParticipantId(participantId);
+ fetchRoster(participantId);
+ };
+
+ // 채팅방 나가기
const exitRoom = () => {
if (window.confirm('채팅방에서 나가시겠습니까?')) {
+ if (stompClientRef.current) {
+ stompClientRef.current.deactivate();
+ }
navigate('/');
}
};
+ // 무한스크롤 로드 디바운스
+ const debouncedLoadMore = useCallback(
+ debounce(() => {
+ if (hasMore && !loading && !isInitialLoad) {
+ loadMoreMessages();
+ }
+ }, 300),
+ [hasMore, loading, nextCursor, isInitialLoad]
+ );
+
+ // 무한스크롤 트리거
useEffect(() => {
- chatRef.current.scrollTop = chatRef.current.scrollHeight;
- }, [chatList]);
+ if (inView && hasMore && !loading && !isInitialLoad) {
+ debouncedLoadMore();
+ }
+ }, [inView, hasMore, loading, isInitialLoad, debouncedLoadMore]);
+
+ // 컴포넌트 마운트
+ useEffect(() => {
+ if (!roomId) {
+ navigate('/');
+ return;
+ }
+
+ // 데이터 로드
+ fetchCurrentUser();
+ fetchScoreboard();
+ loadInitialHistory();
+ fetchReadState();
+
+ return () => {
+ if (stompClientRef.current) {
+ stompClientRef.current.deactivate();
+ }
+ };
+ }, [roomId]);
+
+ // 선택된 참가자 변경 시 로스터 로드
+ useEffect(() => {
+ if (selectedParticipantId) {
+ fetchRoster(selectedParticipantId);
+ }
+ }, [selectedParticipantId]);
+
+ // WebSocket 연결 (스코어보드 로드 후)
+ useEffect(() => {
+ if (scoreboard.length > 0 && !isConnected) {
+ connectWebSocket();
+ }
+ }, [scoreboard, connectWebSocket]);
+
+ // 사용자 정보 또는 스코어보드 업데이트 시 기존 메시지 다시 포맷팅
+ useEffect(() => {
+ if ((currentUser || scoreboard.length > 0) && chatList.length > 0) {
+ setChatList(prevList =>
+ prevList.map(msg => ({
+ ...msg,
+ user: (() => {
+ // 메시지가 이미 포맷된 것이면서 userId가 있는 경우만 다시 처리
+ if (!msg.userId || msg.user === '⚽ 알림' || msg.user === '시스템') {
+ return msg.user;
+ }
+
+ // 스코어보드에서 사용자 정보 찾기
+ const user = scoreboard.find(s => s.userId === msg.userId);
+ if (user) {
+ return user.email;
+ } else if (currentUser && msg.userId === currentUser.id) {
+ return currentUser.email;
+ } else {
+ return msg.user; // 기존 값 유지 (Unknown일 수도 있음)
+ }
+ })()
+ }))
+ );
+ }
+ }, [currentUser, scoreboard]);
+
+ // 새 메시지 추가 시 스크롤 및 읽음 표시 업데이트
+ useEffect(() => {
+ if (chatRef.current && chatList.length > 0) {
+ const { scrollTop, scrollHeight, clientHeight } = chatRef.current;
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
+
+ if (isNearBottom) {
+ setTimeout(() => {
+ if (chatRef.current) {
+ chatRef.current.scrollTop = chatRef.current.scrollHeight;
+ }
+ }, 100);
+
+ // 최신 메시지를 읽음 처리 (디바운스 적용)
+ const lastMessage = chatList[chatList.length - 1];
+ if (lastMessage && lastMessage.id !== lastReadMessageId) {
+ debouncedMarkRead(lastMessage.id);
+ }
+ }
+ }
+ }, [chatList, lastReadMessageId, debouncedMarkRead]);
+
+ // 스크롤 이벤트로 읽음 표시 업데이트
+ useEffect(() => {
+ const handleScroll = () => {
+ if (!chatRef.current || chatList.length === 0) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = chatRef.current;
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
+
+ if (isNearBottom) {
+ const lastMessage = chatList[chatList.length - 1];
+ if (lastMessage && lastMessage.id !== lastReadMessageId) {
+ debouncedMarkRead(lastMessage.id);
+ }
+ }
+ };
+
+ const chatElement = chatRef.current;
+ if (chatElement) {
+ chatElement.addEventListener('scroll', handleScroll);
+ return () => chatElement.removeEventListener('scroll', handleScroll);
+ }
+ }, [chatList, lastReadMessageId, debouncedMarkRead]);
+
+ // 포메이션 렌더링 헬퍼
+ const renderFormation = () => {
+ if (!currentRoster) return null;
+
+ const positions = {
+ GK: [],
+ DF: [],
+ MID: [],
+ FWD: []
+ };
+
+ currentRoster.players.forEach(player => {
+ const pos = player.position === 'FW' ? 'FWD' : player.position;
+ if (positions[pos]) {
+ positions[pos].push(player);
+ }
+ });
+
+ return (
+ <>
+ {Object.entries(positions).map(([position, players]) => (
+ players.length > 0 && (
+
+ {players.map((player) => (
+
+
+
{player.name}
+
{position}
+
+ ))}
+
+ )
+ ))}
+ >
+ );
+ };
return (
<>
Fantasy11
-
드래프트 룸 #1234
+
+ 접속중
+
+ {isConnected ? ' ●' : ' ●'}
+
+
-
-
참가자 순위
-
- {users.map(user => (
-
handleSelectUser(user.id)}
- >
-
{user.name}
-
{user.score}점
-
{user.rank}
-
- ))}
-
+ {/* 탭 헤더 */}
+
+
+
-
-
{formations[selectedUser]?.name}의 팀
-
-
-
-
- {formations[selectedUser]?.players.gk.map((name, idx) => (
-
- ))}
+ {/* 탭 내용 */}
+
+ {activeTab === 'participants' && (
+
+
+ {scoreboard.map((participant, index) => {
+ // 메달 이모티콘 결정
+ const getMedalIcon = (rank) => {
+ switch(rank) {
+ case 1: return '🥇';
+ case 2: return '🥈';
+ case 3: return '🥉';
+ default: return '4️⃣';
+ }
+ };
+
+ const getRankText = (rank) => {
+ return `${rank}위`;
+ };
+
+ return (
+
{
+ handleSelectParticipant(participant.participantId);
+ setActiveTab('formation'); // 선택 후 포메이션 탭으로 자동 이동
+ }}
+ >
+
+ {getMedalIcon(participant.rank)}
+ {getRankText(participant.rank)}
+
+
+
+ {participant.email}
+ {participant.userId === currentUser?.id && ' (나)'}
+
+
{participant.totalPoints}점
+
+
+ );
+ })}
-
- {formations[selectedUser]?.players.df.map((name, idx) => (
-
- ))}
+
+ {/* 최근 골/어시스트 알림 영역 */}
+
+
+ ⚽ 최근 알림
+
+
+ {recentAlerts.length > 0 ? (
+ recentAlerts.map((alert) => (
+
+
{alert.content}
+
{alert.time}
+
+ ))
+ ) : (
+
+ 아직 골/어시스트 알림이 없습니다
+
+ )}
+
-
- {formations[selectedUser]?.players.mf.map((name, idx) => (
-
- ))}
+
+ )}
+
+ {activeTab === 'formation' && (
+
+
+ {currentRoster ? `${scoreboard.find(s => s.participantId === selectedParticipantId)?.email || ''}의 팀` : '팀 선택'}
+ {currentRoster && ` (${currentRoster.formation})`}
-
- {formations[selectedUser]?.players.fw.map((name, idx) => (
-
- ))}
+
+
+
+ {renderFormation()}
+
-
+ )}
-
채팅
+
+ 채팅
+ {unreadCount > 0 && 미읽음 {unreadCount}}
+ {loading && 로딩중...}
+
+
+ {/* 채팅 검색 영역 */}
+
+ setSearchQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleSearch();
+ }
+ }}
+ placeholder="채팅 메시지 검색..."
+ />
+
+ {showSearchResults && (
+
+ )}
+
- {chatList.map((msg, idx) => (
-
-
{msg.user}
-
{msg.text}
-
{msg.time}
-
- ))}
+ {showSearchResults ? (
+ /* 검색 결과 표시 */
+ <>
+
+ 검색 결과: "{searchQuery}" ({searchResults.length}건)
+
+ {searchResults.length > 0 ? (
+ searchResults.map((msg) => (
+
+
{msg.user}
+
{msg.text}
+
{msg.time}
+
+ ))
+ ) : (
+
+ 검색 결과가 없습니다.
+
+ )}
+ >
+ ) : (
+ /* 일반 채팅 메시지 표시 */
+ <>
+ {hasMore && (
+
+ {loading &&
이전 메시지를 불러오는 중...
}
+
+ )}
+ {!hasMore && chatList.length > 0 && (
+
채팅의 시작입니다.
+ )}
+ {chatList.map((msg) => (
+
+
{msg.user}
+
{msg.text}
+
{msg.time}
+
+ ))}
+ >
+ )}
setMessage(e.target.value)}
- onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
- placeholder="메시지를 입력하세요..."
+ onCompositionStart={() => setIsComposing(true)}
+ onCompositionEnd={(e) => {
+ // 조합
+ // 종료 시 최종 텍스트 반영(안전)
+ setMessage(e.currentTarget.value);
+ setIsComposing(false);
+ }}
+ onKeyDown={(e) => {
+ // 브라우저별 안전 가드: 로컬 state || native flag 둘 다 확인
+ const composing = isComposing || e.nativeEvent.isComposing;
+ if (e.key === 'Enter' && !e.shiftKey) {
+ if (composing) return; // ⬅️ 조합 중이면 전송 금지
+ e.preventDefault();
+ handleSendMessage();
+ }
+ }}
+ placeholder={isConnected ? "메시지를 입력하세요..." : "연결 중..."}
+ disabled={!isConnected}
/>
-
+
>
);
-}
+}
\ No newline at end of file
diff --git a/src/pages/Main.jsx b/src/pages/Main.jsx
index 746a70f..0edeab5 100644
--- a/src/pages/Main.jsx
+++ b/src/pages/Main.jsx
@@ -95,7 +95,9 @@ export default function Main() {
const fetchTopUsers = async () => {
try {
setIsLoadingUsers(true);
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/user/seasonBestScore`);
+
const data = res.ok ? await res.json() : [];
setTopUsers(data);
} catch {
@@ -107,7 +109,9 @@ export default function Main() {
const fetchTopPlayers = async () => {
try {
setIsLoadingPlayers(true);
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/player/previousPlayer`);
+
const data = res.ok ? await res.json() : [];
setTopPlayers(data);
} catch {
@@ -119,7 +123,9 @@ export default function Main() {
const fetchTeamTable = async () => {
try {
setIsLoadingTeams(true);
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/team/getTable`);
+
const data = res.ok ? await res.json() : [];
setTeamTable(data);
} catch {
@@ -221,6 +227,35 @@ export default function Main() {
navigate("/waiting");
};
+ // Main.jsx 수정 - draft-btn 아래에 추가
+// handleDraftClick 함수 아래에 이 함수 추가:
+
+ const handleChatroomClick = async () => {
+ if (!isLoggedIn) {
+ const go = window.confirm("로그인이 필요합니다. 로그인 페이지로 이동할까요?");
+ if (go) navigate("/login");
+ return;
+ }
+
+ try {
+ // 사용자의 현재 채팅방 정보 조회
+ const response = await axiosInstance.get("/api/user/current-room");
+ if (response.data && response.data.roomId) {
+ navigate(`/chatroom/${response.data.roomId}`);
+ } else {
+ alert("참가중인 채팅방이 없습니다. 드래프트에 먼저 참가해주세요.");
+ }
+ } catch (error) {
+ console.error("채팅방 정보 조회 실패:", error);
+ if (error.response?.status === 401) {
+ alert("로그인이 만료되었습니다. 다시 로그인해주세요.");
+ navigate("/login");
+ } else {
+ alert("채팅방 정보를 불러올 수 없습니다. 잠시 후 다시 시도해주세요.");
+ }
+ }
+ };
+
const draftDisabled = matchState !== "OPEN";
const getMatchStatusTextJSX = () => {
@@ -461,6 +496,27 @@ export default function Main() {
>
🏆 드래프트 참가
+
+
Top 10 순위
diff --git a/vite.config.js b/vite.config.js
index db2be37..9d87c9b 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -4,8 +4,9 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
-
+
define: {
- global: "window", // 브라우저에서 global → window 로 대체
+ global: 'globalThis',
+
},
})