diff --git a/.github/workflows/client-ci-cd.yml b/.github/workflows/client-ci-cd.yml index 75eea801..c1e9fb86 100644 --- a/.github/workflows/client-ci-cd.yml +++ b/.github/workflows/client-ci-cd.yml @@ -7,6 +7,7 @@ on: - 'client/**' - 'core/**' - '.github/workflows/client-ci-cd.yml' + - 'nginx.conf' jobs: ci: @@ -39,17 +40,33 @@ jobs: working-directory: ./client run: pnpm test | true - - name: Build Client - working-directory: ./client - env: - VITE_API_URL: ${{secrets.VITE_API_URL}} - VITE_SOCKET_URL: ${{secrets.VITE_SOCKET_URL}} - run: pnpm build - - deploy: + docker-deploy: needs: ci runs-on: ubuntu-latest steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Docker Setup + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and Push Docker Image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.nginx + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/troublepainter-nginx:latest + build-args: | + VITE_API_URL=${{secrets.VITE_API_URL}} + VITE_SOCKET_URL=${{secrets.VITE_SOCKET_URL}} + - name: Deploy to Server uses: appleboy/ssh-action@v1.0.0 with: @@ -58,7 +75,6 @@ jobs: key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /home/mira/web30-stop-troublepainter - git pull origin develop - - docker compose build nginx + export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/troublepainter-nginx:latest docker compose up -d nginx \ No newline at end of file diff --git a/.github/workflows/server-ci-cd.yml b/.github/workflows/server-ci-cd.yml index e5aa6dd3..a69b65b7 100644 --- a/.github/workflows/server-ci-cd.yml +++ b/.github/workflows/server-ci-cd.yml @@ -58,9 +58,9 @@ jobs: uses: docker/build-push-action@v5 with: context: . - file: ./Dockerfile.nestjs + file: ./Dockerfile.api push: true - tags: ${{ secrets.DOCKERHUB_USERNAME }}/troublepainter:latest + tags: ${{ secrets.DOCKERHUB_USERNAME }}/troublepainter-api:latest build-args: | REDIS_HOST=${{ secrets.REDIS_HOST }} REDIS_PORT=${{ secrets.REDIS_PORT }} @@ -75,11 +75,6 @@ jobs: key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /home/mira/web30-stop-troublepainter - git pull origin develop - - echo "REDIS_HOST=${{ secrets.REDIS_HOST }}" > .env - echo "REDIS_PORT=${{ secrets.REDIS_PORT }}" >> .env - echo "CLOVA_API_KEY=${{ secrets.CLOVA_API_KEY }}" >> .env - echo "CLOVA_GATEWAY_KEY=${{ secrets.CLOVA_GATEWAY_KEY }}" >> .env - - docker compose up -d \ No newline at end of file + export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/troublepainter-api:latest + docker compose up -d api \ No newline at end of file diff --git a/Dockerfile.nestjs b/Dockerfile.api similarity index 91% rename from Dockerfile.nestjs rename to Dockerfile.api index 18a9fa34..8971ea5e 100644 --- a/Dockerfile.nestjs +++ b/Dockerfile.api @@ -28,13 +28,8 @@ RUN corepack enable && corepack prepare pnpm@9.12.3 --activate && cd server && p WORKDIR /app/server -ARG REDIS_HOST -ARG REDIS_PORT - ENV NODE_ENV=production ENV PORT=3000 -ENV REDIS_HOST=$REDIS_HOST -ENV REDIS_PORT=$REDIS_PORT EXPOSE 3000 diff --git a/Dockerfile.nginx b/Dockerfile.nginx index 73a78717c..55efa50a 100644 --- a/Dockerfile.nginx +++ b/Dockerfile.nginx @@ -4,15 +4,24 @@ RUN corepack enable && corepack prepare pnpm@9.12.3 --activate WORKDIR /app +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ +COPY core/package.json ./core/ +COPY client/package.json ./client/ + +RUN pnpm install --frozen-lockfile + COPY . . -RUN pnpm install --frozen-lockfile && pnpm build +ARG VITE_API_URL +ARG VITE_SOCKET_URL + +RUN echo "VITE_API_URL=$VITE_API_URL" > client/.env && echo "VITE_SOCKET_URL=$VITE_SOCKET_URL" >> client/.env && pnpm --filter @troublepainter/core build && pnpm --filter client build FROM nginx:alpine COPY nginx.conf /etc/nginx/templates/default.conf.template COPY --from=builder /app/client/dist /usr/share/nginx/html -EXPOSE 80 +EXPOSE 80 443 CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/client/src/api/api.config.ts b/client/src/api/api.config.ts index 672c05c9..f5eeeb2f 100644 --- a/client/src/api/api.config.ts +++ b/client/src/api/api.config.ts @@ -6,7 +6,7 @@ export const API_CONFIG = { BASE_URL, ENDPOINTS: { GAME: { - CREATE_ROOM: '/game/rooms', + CREATE_ROOM: '/api/game/rooms', }, }, OPTIONS: { @@ -92,7 +92,7 @@ export class ApiError extends Error { * }; */ export async function fetchApi(endpoint: string, options?: RequestInit): Promise { - const response = await fetch(`${API_CONFIG.BASE_URL}${endpoint}`, { + const response = await fetch(`${endpoint}`, { ...API_CONFIG.OPTIONS, ...options, headers: { diff --git a/client/src/components/modal/NavigationModal.tsx b/client/src/components/modal/NavigationModal.tsx new file mode 100644 index 00000000..aecfa94b --- /dev/null +++ b/client/src/components/modal/NavigationModal.tsx @@ -0,0 +1,66 @@ +import { KeyboardEvent, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; +import { useNavigationModalStore } from '@/stores/navigationModal.store'; + +export const NavigationModal = () => { + const navigate = useNavigate(); + const { isOpen, actions } = useNavigationModalStore(); + + const handleConfirmExit = () => { + actions.closeModal(); + navigate('/', { replace: true }); + }; + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + switch (e.key) { + case 'Enter': + handleConfirmExit(); + break; + case 'Escape': + actions.closeModal(); + break; + } + }, + [actions, navigate], + ); + + return ( + +
+

+ 정말 게임을 나가실거에요...?? +
+ 퇴장하면 다시 돌아오기 힘들어요! 🥺💔 +

+
+ + +
+
+
+ ); +}; diff --git a/client/src/components/ui/Modal.tsx b/client/src/components/ui/Modal.tsx index 943ad734..26342631 100644 --- a/client/src/components/ui/Modal.tsx +++ b/client/src/components/ui/Modal.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes, KeyboardEvent, PropsWithChildren } from 'react'; +import { HTMLAttributes, KeyboardEvent, PropsWithChildren, useEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; // Import ReactDOM explicitly import { cn } from '@/utils/cn'; @@ -11,11 +11,19 @@ export interface ModalProps extends PropsWithChildren { const modalRoot = document.getElementById('modal-root'); + const modalRef = useRef(null); if (!modalRoot) return null; + useEffect(() => { + if (isModalOpened && modalRef.current) { + modalRef.current.focus(); + } + }, [isModalOpened]); + return ReactDOM.createPortal(
{ if (word) gameActions.updateCurrentWord(word); gameActions.updateTimer(TimerType.DRAWING, drawTime); gameActions.updateRoomStatus(RoomStatus.DRAWING); - navigate(`/game/${roomId}`); + navigate(`/game/${roomId}`, { replace: true }); // replace: true로 설정, 히스토리에서 대기방 제거 }, guesserRoundStarted: (response: RoundStartResponse) => { @@ -164,7 +164,7 @@ export const useGameSocket = () => { guessers?.forEach((playerId) => gameActions.updatePlayerRole(playerId, PlayerRole.GUESSER)); gameActions.updateTimer(TimerType.DRAWING, drawTime); gameActions.updateRoomStatus(RoomStatus.DRAWING); - navigate(`/game/${roomId}`); + navigate(`/game/${roomId}`, { replace: true }); }, timerSync: (response: TimerSyncResponse) => { diff --git a/client/src/index.css b/client/src/index.css index b5c61c95..07fbb39e 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1,3 +1,51 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@layer components { + /* 스크롤바 hide 기능 */ + /* Hide scrollbar for Chrome, Safari and Opera */ + .scrollbar-hide::-webkit-scrollbar { + display: none; + } + /* Hide scrollbar for IE, Edge and Firefox */ + .scrollbar-hide { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + .scrollbar-custom { + scrollbar-width: thin; /* Firefox */ + } + + /* ===== Scrollbar CSS ===== */ + /* Firefox */ + * { + scrollbar-width: auto; + scrollbar-color: rgb(67, 79, 115) rgba(178, 199, 222, 0.5); + } + /* Chrome, Safari and Opera etc. */ + *::-webkit-scrollbar { + width: 6px; + /* height: 6px; */ + } + *::-webkit-scrollbar-track { + background-color: rgba(178, 199, 222, 0.5); /* eastbay-500 with opacity */ + border-radius: 9999px; + } + *::-webkit-scrollbar-thumb { + background-color: rgb(67, 79, 115); /* eastbay-900 */ + border-radius: 9999px; + } + *::-webkit-scrollbar-thumb:hover { + background-color: rgb(84, 103, 161); /* eastbay-700 */ + } + + /* 가로 스크롤바 변형 */ + .scrollbar-custom.horizontal::-webkit-scrollbar-track { + background-color: rgba(178, 199, 222, 0.5); + } + .scrollbar-custom.horizontal::-webkit-scrollbar-thumb { + background-color: rgb(67, 79, 115); + } +} diff --git a/client/src/layouts/BrowserNavigationGuard.tsx b/client/src/layouts/BrowserNavigationGuard.tsx new file mode 100644 index 00000000..61669d5c --- /dev/null +++ b/client/src/layouts/BrowserNavigationGuard.tsx @@ -0,0 +1,56 @@ +import { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useNavigationModalStore } from '@/stores/navigationModal.store'; + +const BrowserNavigationGuard = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { actions: modalActions } = useNavigationModalStore(); + + useEffect(() => { + // 새로고침, beforeunload 이벤트 핸들러 + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + // 브라우저 기본 경고 메시지 표시 + e.preventDefault(); + e.returnValue = ''; // 레거시 브라우저 지원 + + // 새로고침 시 메인으로 이동하도록 로컬스토리지에 플래그 저장 + localStorage.setItem('shouldRedirect', 'true'); + + // 사용자 정의 메시지 반환 (일부 브라우저에서는 무시될 수 있음) + return '게임을 종료하시겠습니까? 현재 진행 상태가 저장되지 않을 수 있습니다.'; + }; + + // popstate 이벤트 핸들러 (브라우저 뒤로가기/앞으로가기) + const handlePopState = (e: PopStateEvent) => { + e.preventDefault(); // 기본 동작 중단 + modalActions.openModal(); + + // 취소 시 현재 URL 유지를 위해 history stack에 다시 추가하도록 조작 + window.history.pushState(null, '', location.pathname); + }; + + // 초기 진입 시 history stack에 현재 상태 추가 + window.history.pushState(null, '', location.pathname); + + // 이벤트 리스너 등록 + window.addEventListener('beforeunload', handleBeforeUnload); + window.addEventListener('popstate', handlePopState); + + // 새로고침 후 리다이렉트 체크 + const shouldRedirect = localStorage.getItem('shouldRedirect'); + if (shouldRedirect === 'true' && location.pathname !== '/') { + navigate('/', { replace: true }); + localStorage.removeItem('shouldRedirect'); + } + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + window.removeEventListener('popstate', handlePopState); + }; + }, [navigate, location.pathname]); + + return null; +}; + +export default BrowserNavigationGuard; diff --git a/client/src/layouts/GameHeader.tsx b/client/src/layouts/GameHeader.tsx new file mode 100644 index 00000000..58926c02 --- /dev/null +++ b/client/src/layouts/GameHeader.tsx @@ -0,0 +1,19 @@ +import { Logo } from '@/components/ui/Logo'; +import { useNavigationModalStore } from '@/stores/navigationModal.store'; + +const GameHeader = () => { + const { actions } = useNavigationModalStore(); + + return ( +
+ +
+ ); +}; + +export default GameHeader; diff --git a/client/src/layouts/GameLayout.tsx b/client/src/layouts/GameLayout.tsx index e8f263bf..05eba4c8 100644 --- a/client/src/layouts/GameLayout.tsx +++ b/client/src/layouts/GameLayout.tsx @@ -1,8 +1,10 @@ import { Outlet } from 'react-router-dom'; import { Chat } from '@/components/chat/Chat'; +import { NavigationModal } from '@/components/modal/NavigationModal'; import { PlayerCardList } from '@/components/player/PlayerCardList'; -import { Logo } from '@/components/ui/Logo'; import { useGameSocket } from '@/hooks/socket/useGameSocket'; +import BrowserNavigationGuard from '@/layouts/BrowserNavigationGuard'; +import GameHeader from '@/layouts/GameHeader'; import { cn } from '@/utils/cn'; const GameLayout = () => { @@ -21,65 +23,67 @@ const GameLayout = () => { } return ( -
- {/* 상단 헤더 */} -
- -
+ <> + + +
+ {/* 상단 헤더 */} + -
-
- {/* 플레이어 정보 영역 */} - + {/* 플레이어 정보 영역 */} + - {/* 중앙 영역 */} -
- -
+ {/* 중앙 영역 */} +
+ +
- {/* 채팅 영역 */} - -
-
-
+ {/* 채팅 영역 */} + +
+ +
+ ); }; diff --git a/client/src/stores/navigationModal.store.ts b/client/src/stores/navigationModal.store.ts new file mode 100644 index 00000000..89dcd444 --- /dev/null +++ b/client/src/stores/navigationModal.store.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand'; + +interface NavigationModalStore { + isOpen: boolean; + actions: { + openModal: () => void; + closeModal: () => void; + }; +} + +export const useNavigationModalStore = create((set) => ({ + isOpen: false, + actions: { + openModal: () => set({ isOpen: true }), + closeModal: () => set({ isOpen: false }), + }, +})); diff --git a/client/src/stores/socket/socket.config.ts b/client/src/stores/socket/socket.config.ts index e714a3d8..4dd445eb 100644 --- a/client/src/stores/socket/socket.config.ts +++ b/client/src/stores/socket/socket.config.ts @@ -85,9 +85,9 @@ export const SOCKET_CONFIG = { }, /** 네임스페이스별 경로 */ PATHS: { - [SocketNamespace.GAME]: '/game', - [SocketNamespace.DRAWING]: '/drawing', - [SocketNamespace.CHAT]: '/chat', + [SocketNamespace.GAME]: '/socket.io/game', + [SocketNamespace.DRAWING]: '/socket.io/drawing', + [SocketNamespace.CHAT]: '/socket.io/chat', }, } as const; @@ -114,6 +114,7 @@ type SocketCreator = (auth?: SocketAuth) => T; */ const createSocket = (namespace: SocketNamespace, auth?: SocketAuth): T => { const options = auth ? { ...SOCKET_CONFIG.BASE_OPTIONS, auth } : SOCKET_CONFIG.BASE_OPTIONS; + console.log(`${SOCKET_CONFIG.URL}${SOCKET_CONFIG.PATHS[namespace]}`); return io(`${SOCKET_CONFIG.URL}${SOCKET_CONFIG.PATHS[namespace]}`, options) as T; }; diff --git a/docker-compose.yml b/docker-compose.yml index 14217294..bc23d238 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,6 @@ -version: '3' - services: api: - build: - context: . - dockerfile: Dockerfile.nestjs + image: ${DOCKERHUB_USERNAME}/troublepainter-api:latest container_name: troublepainter_api environment: - NODE_ENV=production @@ -17,12 +13,13 @@ services: restart: unless-stopped nginx: - build: - context: . - dockerfile: Dockerfile.nginx + image: ${DOCKERHUB_USERNAME}/troublepainter-nginx:latest container_name: troublepainter_nginx + volumes: + - /etc/letsencrypt:/etc/letsencrypt ports: - "80:80" + - "443:443" depends_on: - api networks: @@ -31,4 +28,5 @@ services: networks: app_network: + name: app_network driver: bridge \ No newline at end of file diff --git a/nginx.conf b/nginx.conf index ac92736f..37ea4d11 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,6 +1,14 @@ server { listen 80; - server_name 175.45.205.6; + server_name www.troublepainter.site; + return 301 https://$server_name$request_uri; +} +server { + listen 443 ssl; + server_name www.troublepainter.site; + + ssl_certificate /etc/letsencrypt/live/www.troublepainter.site/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/www.troublepainter.site/privkey.pem; gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; diff --git a/server/src/chat/chat.gateway.ts b/server/src/chat/chat.gateway.ts index beb61a7e..89d98b1c 100644 --- a/server/src/chat/chat.gateway.ts +++ b/server/src/chat/chat.gateway.ts @@ -7,7 +7,7 @@ import { BadRequestException } from 'src/exceptions/game.exception'; @WebSocketGateway({ cors: '*', - namespace: 'chat', + namespace: '/socket.io/chat', }) @UseFilters(WsExceptionFilter) export class ChatGateway { diff --git a/server/src/drawing/drawing.gateway.ts b/server/src/drawing/drawing.gateway.ts index 36760c7a..61fe64bd 100644 --- a/server/src/drawing/drawing.gateway.ts +++ b/server/src/drawing/drawing.gateway.ts @@ -13,7 +13,7 @@ import { WsExceptionFilter } from 'src/filters/ws-exception.filter'; @WebSocketGateway({ cors: '*', - namespace: 'drawing', + namespace: '/socket.io/drawing', }) @UseFilters(WsExceptionFilter) export class DrawingGateway implements OnGatewayConnection { diff --git a/server/src/game/game.gateway.ts b/server/src/game/game.gateway.ts index 9ecf64fa..6d2c9e26 100644 --- a/server/src/game/game.gateway.ts +++ b/server/src/game/game.gateway.ts @@ -18,7 +18,7 @@ import { TimerType } from 'src/common/enums/game.timer.enum'; @WebSocketGateway({ cors: '*', - namespace: 'game', + namespace: '/socket.io/game', }) @UseFilters(WsExceptionFilter) export class GameGateway implements OnGatewayDisconnect { diff --git a/server/src/main.ts b/server/src/main.ts index a33713c2..95c970b3 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,12 +1,21 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { IoAdapter } from '@nestjs/platform-socket.io'; async function bootstrap() { const app = await NestFactory.create(AppModule); + + app.setGlobalPrefix('api'); + app.enableCors({ origin: '*', methods: '*', }); + + const httpServer = app.getHttpServer(); + const ioAdapter = new IoAdapter(httpServer); + app.useWebSocketAdapter(ioAdapter); + await app.listen(process.env.PORT ?? 3000); } bootstrap();