diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 466e3eb..2c06430 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,9 +1,7 @@ -- 제목 : feat(issue 번호): 기능명 - ex) feat(17): pull request template 작성 +- 제목 : [BE/FE] feat#이슈번호 기능명 + ex) [BE] feat#156 자동배포 구현 (확인 후 지워주세요) -▲ PR 제목 관련 추가 논의 후 수정 - ## ➕ 이슈 번호 - #이슈번호 @@ -22,14 +20,13 @@
- - -## 🎯 리뷰 요구사항 +## 🎯 리뷰 요구사항 (선택) - 특별히 봐줬으면 하는 부분이 있다면 적어주세요
## ✅ Check List + - [ ] merge할 브랜치의 위치를 확인했나요? - [ ] Label을 지정했나요? diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..d0c6f23 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,86 @@ +name: Deploy To EC2 + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest +# defaults: +# run: +# working-directory: ./BE # BE 디렉토리를 작업 디렉토리로 설정 + + steps: + - name: Github Repository 파일 불러오기 + uses: actions/checkout@v4 + + - name: Node 설치 + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: BE 의존성 설치 + working-directory: ./BE + run: npm ci + + - name: FE 의존성 설치 + working-directory: ./FE + run: npm ci + + - name: .env 파일 만들기 + run: | + echo '${{ secrets.ENV }}' > .env + + - name: 테스트 코드 실행 + run: npm run test + + - name: 빌드 + run: npm run build + + - name: github-action 컴퓨터에서 압축하기 + run: | + # 현재 위치 확인 (디버깅용) + pwd + + # BE와 FE 모든 필요 파일들을 함께 압축 + tar -czvf project.tar.gz \ + BE/dist \ + BE/package.json \ + BE/package-lock.json \ + BE/.env \ + FE/build \ + FE/package.json \ + FE/package-lock.json \ + FE/.env + + # 압축 파일 내용 확인 (디버깅용) + echo "Created archive with contents:" + tar -tvf project.tar.gz + + - name: SCP로 EC2에 빌드된 파일 전송하기 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_PRIVATE_KEY }} + source: project.tar.gz + target: /root/nest-server/tobe + + - name: SSH로 EC2에 접속하기 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_PRIVATE_KEY }} + script_stop: true + script: | + rm -rf /root/nest-server/current + mkdir /root/nest-server/current + mv /root/nest-server/tobe/project.tar.gz /root/nest-server/current/project.tar.gz + cd /root/nest-server/current + tar -xvf project.tar.gz + npm i + pm2 kill + pm2 start dist/main.js --name "backend-server" \ No newline at end of file diff --git a/FE/package-lock.json b/FE/package-lock.json index 46bdf47..4c94a40 100644 --- a/FE/package-lock.json +++ b/FE/package-lock.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@eslint/js": "^9.13.0", + "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", @@ -1789,6 +1790,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", + "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.8" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -4776,6 +4787,13 @@ } } }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", diff --git a/FE/package.json b/FE/package.json index 3e8d37a..933c281 100644 --- a/FE/package.json +++ b/FE/package.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@eslint/js": "^9.13.0", + "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", diff --git a/FE/src/App.tsx b/FE/src/App.tsx index 5e28b9c..7f91482 100644 --- a/FE/src/App.tsx +++ b/FE/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { MainPage } from './pages/MainPage'; import { GameSetupPage } from './pages/GameSetupPage'; +import { GamePage } from './pages/GamePage'; function App() { return ( @@ -8,6 +9,7 @@ function App() { } /> } /> + } /> not found} /> diff --git a/FE/src/api/socket.ts b/FE/src/api/socket.ts index a259a14..b2967e9 100644 --- a/FE/src/api/socket.ts +++ b/FE/src/api/socket.ts @@ -1,26 +1,26 @@ import { io, Socket } from 'socket.io-client'; import SocketEvents from '../constants/socketEvents'; -type SocketEvent = (typeof SocketEvents)[keyof typeof SocketEvents]; +// type SocketEvent = (typeof SocketEvents)[keyof typeof SocketEvents]; -interface ChatMessage { +type ChatMessage = { userId: string; message: string; -} +}; -interface CreateRoomPayload { - roomName: string; - maxPlayers: number; +type CreateRoomPayload = { + title: string; + maxPlayerCount: number; gameMode: string; isPublic: boolean; -} +}; // 이벤트의 데이터 타입을 정의 -interface SocketDataMap { - chatMessage: ChatMessage; - createRoom: CreateRoomPayload; - // 다른 이벤트의 데이터 타입을 추가 -} +// type SocketDataMap = { +// [SocketEvents.CHAT_MESSAGE]: ChatMessage; +// [SocketEvents.CREATE_ROOM]: CreateRoomPayload; +// // 다른 이벤트의 데이터 타입을 추가 +// }; class SocketService { private socket: Socket; @@ -44,11 +44,11 @@ class SocketService { } // 이벤트 수신 메서드 - on(event: T, callback: (data: SocketDataMap[T]) => void) { - this.socket.on(event, (data: SocketDataMap[T]) => { - callback(data); - }); - } + // on(event: T, callback: (data: SocketDataMap[T]) => void) { + // this.socket.on(event, (data: SocketDataMap[T]) => { + // callback(data); + // }); + // } // 메시지 전송 메서드 sendChatMessage(message: ChatMessage) { @@ -65,6 +65,14 @@ class SocketService { disconnect() { this.socket.disconnect(); } + + joinRoom(gameId: string, playerName: string) { + this.socket.send(SocketEvents.JOIN_ROOM, { gameId, playerName }); + } + + chatMessage(gameId: string, message: string) { + this.socket.send(SocketEvents.CHAT_MESSAGE, { gameId, message }); + } } export const socketService = new SocketService(''); diff --git a/FE/src/components/Chat.tsx b/FE/src/components/Chat.tsx new file mode 100644 index 0000000..80e46ec --- /dev/null +++ b/FE/src/components/Chat.tsx @@ -0,0 +1,61 @@ +import { socketService } from '@/api/socket'; +import { useEffect, useState } from 'react'; + +const sampleChat = Array(100) + .fill(null) + .map((_, i) => ({ name: 'user' + i, message: 'messagemessagemessagemessagemessagemessage' })); + +const Chat = () => { + const [messages, setMessages] = useState>([]); + const [inputValue, setInputValue] = useState(''); + + useEffect(() => { + setMessages(sampleChat); //TODO 나중에 고쳐야 함 + // 서버에서 메시지를 받을 때 + // socket.on('chat message', (message) => { + // setMessages((prevMessages) => [...prevMessages, message]); + // }); + // return () => { + // socket.off('chat message'); + // }; + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (inputValue.trim()) { + socketService.chatMessage('1234', inputValue); + setInputValue(''); + } + }; + return ( +
+
메시지
+
+ {messages.map((e, i) => ( +
+ {e.name} + {e.message} +
+ ))} +
+
+
+ +
+
+
+ ); +}; + +export default Chat; diff --git a/FE/src/components/ClipboardCopy.tsx b/FE/src/components/ClipboardCopy.tsx new file mode 100644 index 0000000..7fc2247 --- /dev/null +++ b/FE/src/components/ClipboardCopy.tsx @@ -0,0 +1,26 @@ +import { Button } from '@mui/material'; + +type ClipboardCopyProps = { + valueToCopy: string; + message: string; + className?: string; +}; + +export const ClipboardCopy: React.FC = ({ valueToCopy, message }) => { + const handleCopyToClipboard = (): void => { + navigator.clipboard + .writeText(valueToCopy) + .then(() => { + alert('클립보드에 복사되었습니다: '); + }) + .catch((err) => { + console.error('복사 실패:', err); + }); + }; + + return ( +
+ +
+ ); +}; diff --git a/FE/src/components/GameHeader.tsx b/FE/src/components/GameHeader.tsx new file mode 100644 index 0000000..1382324 --- /dev/null +++ b/FE/src/components/GameHeader.tsx @@ -0,0 +1,29 @@ +import { ClipboardCopy } from './ClipboardCopy'; +import Card from '@mui/material/Card'; +import { QuizPreview } from './QuizView'; + +export const GameHeader = () => { + // 임시값 + const pinNum = '123456'; + const linkURL = 'naver.com'; + return ( + +
+ + +
+
+ 퀴즈이름22 +
+ +
+ + +
+
+ ); +}; diff --git a/FE/src/components/HeaderBar.tsx b/FE/src/components/HeaderBar.tsx new file mode 100644 index 0000000..120c638 --- /dev/null +++ b/FE/src/components/HeaderBar.tsx @@ -0,0 +1,7 @@ +export const HeaderBar = () => { + return ( +
+ QuizGround +
+ ); +}; diff --git a/FE/src/components/Modal.tsx b/FE/src/components/Modal.tsx new file mode 100644 index 0000000..d6bfbe4 --- /dev/null +++ b/FE/src/components/Modal.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; + +type ModalProps = { + isOpen: boolean; + title: string; + placeholder?: string; + initialValue?: string; + onClose: () => void; + onSubmit: (value: string) => void; +}; + +export const Modal: React.FC = ({ + isOpen, + title, + placeholder, + initialValue = '', + onClose, + onSubmit +}) => { + const [inputValue, setInputValue] = useState(initialValue); + + if (!isOpen) return null; + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + }; + + const handleSubmit = () => { + onSubmit(inputValue); + setInputValue(''); // 입력값 초기화 + }; + + return ( +
+
+

{title}

+ +
+ + {/* 닉네임 등록 모달에서 취소버튼은 없어야할것같은데 일단 넣어둠 */} + +
+
+
+ ); +}; diff --git a/FE/src/components/ParticipantDisplay.tsx b/FE/src/components/ParticipantDisplay.tsx new file mode 100644 index 0000000..60ea9cf --- /dev/null +++ b/FE/src/components/ParticipantDisplay.tsx @@ -0,0 +1,21 @@ +const samplePalyer = Array(100) + .fill(null) + .map((_, i) => ({ id: i, name: 'user' + i })); + +const ParticipantDisplay = () => { + return ( +
+
참가자
+
+ {samplePalyer.map((e, i) => ( +
+
{i + 1 + '. ' + e.name}
+ +
+ ))} +
+
+ ); +}; + +export default ParticipantDisplay; diff --git a/FE/src/components/QuizOptionBoard.tsx b/FE/src/components/QuizOptionBoard.tsx new file mode 100644 index 0000000..8db97cf --- /dev/null +++ b/FE/src/components/QuizOptionBoard.tsx @@ -0,0 +1,32 @@ +type Params = { + options: string[]; +}; + +const optionColors = [ + '#FF9AA2', // pastel red + '#FFB3BA', // pastel pink + '#FFDAC1', // pastel peach + '#FFE156', // pastel yellow + '#E2F0CB', // pastel green + '#B5EAD7', // pastel mint + '#C7CEEA', // pastel blue + '#A0C4FF', // pastel light blue + '#B9D8FF', // pastel lavender + '#C3B3E0' // pastel purple +]; + +export const QuizOptionBoard = ({ options }: Params) => { + return ( +
+ {options.map((option, i) => ( +
+ {i + 1 + '. ' + option} +
+ ))} +
+ ); +}; diff --git a/FE/src/components/QuizView.tsx b/FE/src/components/QuizView.tsx new file mode 100644 index 0000000..9b9afce --- /dev/null +++ b/FE/src/components/QuizView.tsx @@ -0,0 +1,21 @@ +const sampleQuizImage = + 'https://i.namu.wiki/i/fcBRfQZOo2eCcLsPe63ZCKbzOBizhxvSKUrzEBqaMfTMSOe8I81p9s2SY_YxDxCEArNkSh_mwUTrnqX6ITkfUp3ey-p2xz1I6hk1oIxKEH-n3RFlgczUZFTxiu5xnvQUKPEo8BIOiKclL0-kJgi79w.webp'; + +type Props = { + title: string; + description: string; +}; + +export const QuizPreview = ({ title, description }: Props) => { + return ( +
+
+ +
+
+
{title}
+
{description}
+
+
+ ); +}; diff --git a/FE/src/constants/roomConfig.ts b/FE/src/constants/roomConfig.ts new file mode 100644 index 0000000..d7aaa29 --- /dev/null +++ b/FE/src/constants/roomConfig.ts @@ -0,0 +1,7 @@ +const RoomConfig = { + MAX_PLAYERS: 500, + MIN_PLAYERS: 1, + DEFAULT_PLAYERS: 50 +} as const; + +export default RoomConfig; diff --git a/FE/src/index.css b/FE/src/index.css index bd6213e..ca11f3a 100644 --- a/FE/src/index.css +++ b/FE/src/index.css @@ -1,3 +1,41 @@ +@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-dynamic-subset.min.css'); + @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@tailwind utilities; + +@layer base { + html { + font-family: 'Pretendard', ui-sans-serif, system-ui; + box-sizing: border-box; + color: #5f6e76; + } + *, + *::before, + *::after { + box-sizing: inherit; + } +} + +::-webkit-scrollbar { + width: 14px; + height: 14px; +} + +::-webkit-scrollbar-thumb { + outline: none; + border-radius: 10px; + border: 4px solid transparent; + box-shadow: inset 6px 6px 0 rgba(34, 34, 34, 0.2); + min-height: 100px; +} + +::-webkit-scrollbar-thumb:hover { + border: 4px solid transparent; + box-shadow: inset 6px 6px 0 rgba(34, 34, 34, 0.4); +} + +::-webkit-scrollbar-track { + box-shadow: none; + background-color: transparent; +} diff --git a/FE/src/pages/GamePage.tsx b/FE/src/pages/GamePage.tsx new file mode 100644 index 0000000..cc544df --- /dev/null +++ b/FE/src/pages/GamePage.tsx @@ -0,0 +1,54 @@ +import Chat from '@/components/Chat'; +import ParticipantDisplay from '@/components/ParticipantDisplay'; +import { QuizOptionBoard } from '@/components/QuizOptionBoard'; +import { Modal } from '../components/Modal'; +import { useState } from 'react'; +import { GameHeader } from '@/components/GameHeader'; +import { HeaderBar } from '@/components/HeaderBar'; +import { useParams } from 'react-router-dom'; +import { socketService } from '@/api/socket'; + +export const GamePage = () => { + const [playerName, setPlayerName] = useState(''); + const [isModalOpen, setIsModalOpen] = useState(true); + const pin = useParams(); + + const handleNameSubmit = (name: string) => { + setPlayerName(name); + // 닉네임 설정 소켓 요청 + socketService.joinRoom(String(pin), name); + setIsModalOpen(false); // 이름이 설정되면 모달 닫기 + }; + + return ( + <> + +
+
+ +
+
+
+ +
+ +
+ +
+ +
+ +
+ + setIsModalOpen(false)} + onSubmit={handleNameSubmit} + /> +
+
+ + ); +}; diff --git a/FE/src/pages/GameSetupPage.tsx b/FE/src/pages/GameSetupPage.tsx index 59cbec4..c676936 100644 --- a/FE/src/pages/GameSetupPage.tsx +++ b/FE/src/pages/GameSetupPage.tsx @@ -10,35 +10,29 @@ import { TextField } from '@mui/material'; import { useState } from 'react'; -import { socketService } from '../api/socket'; - -const MAX_PLAYERS = 500; -const MIN_PLAYERS = 2; -const DEFAULT_PLAYERS = 50; +import { socketService } from '@/api/socket'; +import RoomConfig from '@/constants/roomConfig'; export const GameSetupPage = () => { - const [roomName, setRoomName] = useState(''); - const [maxPlayers, setMaxPlayers] = useState(DEFAULT_PLAYERS); - const [selectGameMode, setSelectGameMode] = useState('ranking'); + const [title, setTitle] = useState(''); + const [maxPlayerCount, setMaxPlayerCount] = useState(RoomConfig.DEFAULT_PLAYERS); + const [gameMode, setGameMode] = useState<'SURVIVAL' | 'RANKING'>('RANKING'); const [roomPublic, setRoomPublic] = useState(true); const handleModeChange = (e: React.ChangeEvent) => { - setSelectGameMode(e.target.value); + const value = e.target.value === 'RANKING' ? 'RANKING' : 'SURVIVAL'; + setGameMode(value); }; const handleSubmit = async () => { const roomData = { - roomName, - maxPlayers, - gameMode: selectGameMode, + title, + maxPlayerCount, + gameMode, isPublic: roomPublic }; - try { - socketService.createRoom(roomData); - } catch (error) { - console.error('Error creating room:', error); - } + socketService.createRoom(roomData); }; return ( @@ -47,28 +41,28 @@ export const GameSetupPage = () => { {'<'} setRoomName(e.target.value)} + onChange={(e) => setTitle(e.target.value)} /> setMaxPlayers(newValue)} + value={maxPlayerCount} + onChange={(_, newValue) => setMaxPlayerCount(newValue as number)} > 게임 모드 선택 - } label="서바이벌" /> - } label="랭킹" /> + } label="서바이벌" /> + } label="랭킹" /> diff --git a/FE/tailwind.config.js b/FE/tailwind.config.js index de7dd6f..b447f52 100644 --- a/FE/tailwind.config.js +++ b/FE/tailwind.config.js @@ -6,7 +6,59 @@ export default { "./node_modules/@mui/**/*.{js,ts,jsx,tsx}" ], theme: { - extend: {}, + extend: { + colors: { + main: '#7890E7' + }, + fontSize: { + l: '1.5rem', + m: '1rem', + r: '0.75rem', + s: '0.625rem', + }, + textColor: { + default: '#5F6E76', + weak: '#879298' + }, + backgroundColor: { + surface: { + default: 'white', + alt: '#F5F7F9' + } + }, + borderColor: { + default: 'E9E9E9' + }, + borderRadius: { + m: '1rem', + s: '0.5rem' + }, + dropShadow: { + default: '0 4px 2px rgba(20, 33, 43, 0.02)' + } + }, }, - plugins: [], + plugins: [ + function ({ addUtilities, theme }) { + const newUtilities = { + '.component-default': { + borderWidth: '1px', + borderColor: theme('borderColor.default'), + borderRadius: theme('borderRadius.m'), + backgroundColor: theme('backgroundColor.surface.default'), + }, + '.component-popup': { + borderRadius: theme('borderRadius.m'), + backgroundColor: theme('backgroundColor.surface.default'), + dropShadow: theme('dropShadow.default'), + }, + '.center': { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + } + }; + addUtilities(newUtilities, ['responsive', 'hover']); + } + ], } \ No newline at end of file diff --git a/FE/tsconfig.app.json b/FE/tsconfig.app.json index f867de0..9e42e38 100644 --- a/FE/tsconfig.app.json +++ b/FE/tsconfig.app.json @@ -20,7 +20,12 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } }, "include": ["src"] } diff --git a/FE/tsconfig.node.json b/FE/tsconfig.node.json index abcd7f0..6655e90 100644 --- a/FE/tsconfig.node.json +++ b/FE/tsconfig.node.json @@ -18,7 +18,12 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } }, "include": ["vite.config.ts"] } diff --git a/FE/vite.config.ts b/FE/vite.config.ts index 8b0f57b..0604559 100644 --- a/FE/vite.config.ts +++ b/FE/vite.config.ts @@ -1,7 +1,14 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; // https://vite.dev/config/ export default defineConfig({ plugins: [react()], -}) + resolve: { + alias: { + '@': path.resolve(process.cwd(), 'src') + } + }, + publicDir: 'public' +});