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) => ( -
-
-
{name}
-
GK
-
- ))} + {/* 탭 내용 */} +
+ {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) => ( -
-
-
{name}
-
DF
-
- ))} + + {/* 최근 골/어시스트 알림 영역 */} +
+
+ ⚽ 최근 알림 +
+
+ {recentAlerts.length > 0 ? ( + recentAlerts.map((alert) => ( +
+
{alert.content}
+
{alert.time}
+
+ )) + ) : ( +
+ 아직 골/어시스트 알림이 없습니다 +
+ )} +
-
- {formations[selectedUser]?.players.mf.map((name, idx) => ( -
-
-
{name}
-
MF
-
- ))} +
+ )} + + {activeTab === 'formation' && ( +
+
+ {currentRoster ? `${scoreboard.find(s => s.participantId === selectedParticipantId)?.email || ''}의 팀` : '팀 선택'} + {currentRoster && ` (${currentRoster.formation})`}
-
- {formations[selectedUser]?.players.fw.map((name, idx) => ( -
-
-
{name}
-
FW
-
- ))} +
+
+
+ {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 순위

    {renderTopUsers()}
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', + }, })