diff --git a/output/README.md b/output/README.md new file mode 100644 index 0000000..df8c92f --- /dev/null +++ b/output/README.md @@ -0,0 +1,88 @@ +## Getting Started + +First, run the development server: + +```bash +yarn dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Next.js 사용 팁 + +1. 페이지 컴포넌트 파일들은 src/app 하위에 생성하면 됨 + +- 라우팅은 폴더를 생성하면 사용 가능, 동적 라우팅의 경우 `/[파라미터]`로 생성할 것 +- 이 밖에도 지정 파일들이 있으니(page.tsx, layout.tsx, error.tsx 등) 공식문서 참고할 것 + +2. components, utils, assets 등의 파일 폴더들은 src 아래 app 폴더와 동등한 레벨에 생성 및 사용할 것 + +3. 기본값으로 server component로 사용됨. useState등의 훅을 사용하려면 'use client'를 파일 최상단에 기입할 것 + +## 간단한 코드 컨벤션 + +### 커밋 컨벤션 + +| 태그 이름 | 설명 | +| ---------------- | -------------------------------------------------------------------------------------------------------- | +| Feat | 새로운 기능을 추가할 경우 | +| Fix | 버그를 고친 경우 | +| Design | CSS 등 사용자 UI 디자인 변경 | +| !BREAKING CHANGE | 커다란 API 변경의 경우 | +| !HOTFIX | 급하게 치명적인 버그를 고쳐야하는 경우 | +| Style | 코드 포맷 변경, 세미 콜론 누락, 오타 수정, 탭 사이즈 변경, 변수명 변경 등 코어 로직을 안건드는 변경 사항 | +| Refactor | 프로덕션 코드 리팩토링 | +| Comment | 필요한 주석 추가 및 변경 | +| Docs | 문서(Readme.md)를 수정한 경우 | +| Rename | 파일 혹은 폴더명을 수정하거나 옮기는 작업만인 경우 | +| Remove | 파일을 삭제하는 작업만 수행한 경우 | +| Test | 테스트 추가, 테스트 리팩토링(프로덕션 코드 변경 X) | +| Chore | 빌드 태스트 업데이트, 패키지 매니저를 설정하는 경우(프로덕션 코드 변경 X) | + +### 네이밍 컨벤션 + +1. **\*.tsx** : PascalCase +2. **\*.ts** : camelCase +3. 페이지 폴더 (**pages/**/**\***.tsx\*\*) : index.tsx +4. 나머지 폴더 : **kebab-case** +5. constants : camelCase +6. git branch name : kebab-case + +### 폴더 구조 + +``` +├── 📁 node_modules +├── 📁 public +├── 📁 src +│ ├── 📁 assets +│ │ ├── 📁 contants +│ │ ├── 📁 fonts +│ │ └── 📁 images +│ ├── 📁 components +│ │ ├── 📁 layout +│ │ ├── 📁 ... +│ │ └── ... +│ ├── 📁 app +│ │ ├── 📁 home +│ │ ├── 📁 ... +│ │ ├── 📁 ... +│ │ ├── page.tsx +│ │ ├── layout.tsx +│ │ └── global.css +│ └── 📁 utils +│ ├── 📁 hooks +│ ├── 📁 recoil +│ └── 📁 types +├── .eslintrc.json +├── .gitgnore +├── next-env.d.ts +├── next.config.js +├── package.json +├── postcss.config.js +├── README.md +├── tailwind.config.ts +├── tsconfjg.json +└── yarn.lock +``` diff --git a/output/build.sh b/output/build.sh new file mode 100644 index 0000000..cfecf99 --- /dev/null +++ b/output/build.sh @@ -0,0 +1,5 @@ +#!/bin/sh +cd ../ +mkdir output +cp -R ./client/* ./output +cp -R ./output ./client/ \ No newline at end of file diff --git a/output/next.config.js b/output/next.config.js new file mode 100644 index 0000000..8e85f77 --- /dev/null +++ b/output/next.config.js @@ -0,0 +1,48 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + domains: [ + 'hufstreaming.s3.ap-northeast-2.amazonaws.com', + 'lh3.googleusercontent.com', + 'velog.velcdn.com', + ], + }, +}; + +module.exports = nextConfig; + +// Injected content via Sentry wizard below + +const { withSentryConfig } = require('@sentry/nextjs'); + +module.exports = withSentryConfig( + module.exports, + { + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options + + // Suppresses source map uploading logs during build + silent: true, + org: 'hufs-td', + project: 'hufstreaming', + }, + { + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Transpiles SDK to be compatible with IE11 (increases bundle size) + transpileClientSDK: true, + + // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load) + tunnelRoute: '/monitoring', + + // Hides source maps from generated client bundles + hideSourceMaps: true, + + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: true, + }, +); diff --git a/output/package.json b/output/package.json new file mode 100644 index 0000000..f196153 --- /dev/null +++ b/output/package.json @@ -0,0 +1,55 @@ +{ + "name": "hufs-sports-live-client", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "prepare": "husky install" + }, + "lint-staged": { + "**/*.{js, jsx, ts, tsx}": [ + "eslint", + "prettier --config ./.prettierrc --write -u" + ] + }, + "dependencies": { + "@radix-ui/react-icons": "^1.3.0", + "@sentry/nextjs": "^7.73.0", + "@tanstack/react-query": "^5.8.2", + "@tanstack/react-query-devtools": "^5.8.2", + "axios": "^1.5.1", + "clsx": "^2.0.0", + "next": "13.5.4", + "react": "^18", + "react-dom": "^18", + "tailwind-merge": "^2.0.0" + }, + "devDependencies": { + "@commitlint/cli": "^18.2.0", + "@commitlint/config-conventional": "^18.1.0", + "@tanstack/eslint-plugin-query": "^5.6.0", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@typescript-eslint/eslint-plugin": "^6.9.1", + "@typescript-eslint/parser": "^6.9.1", + "autoprefixer": "^10", + "eslint": "^8", + "eslint-config-next": "13.5.4", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-import": "^2.29.0", + "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-prettier": "^5.0.1", + "eslint-plugin-simple-import-sort": "^10.0.0", + "husky": "^8.0.3", + "lint-staged": "^15.1.0", + "postcss": "^8", + "prettier": "^3.0.3", + "prettier-plugin-tailwindcss": "^0.5.7", + "tailwindcss": "^3", + "typescript": "^5" + } +} diff --git a/output/postcss.config.js b/output/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/output/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/output/public/icon_hufstreaming.svg b/output/public/icon_hufstreaming.svg new file mode 100644 index 0000000..5673d73 --- /dev/null +++ b/output/public/icon_hufstreaming.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/output/public/images/not-found.png b/output/public/images/not-found.png new file mode 100644 index 0000000..1bd5ad3 Binary files /dev/null and b/output/public/images/not-found.png differ diff --git a/output/public/logo_hufstreaming.png b/output/public/logo_hufstreaming.png new file mode 100644 index 0000000..137f156 Binary files /dev/null and b/output/public/logo_hufstreaming.png differ diff --git a/output/sentry.client.config.ts b/output/sentry.client.config.ts new file mode 100644 index 0000000..0ac2b1f --- /dev/null +++ b/output/sentry.client.config.ts @@ -0,0 +1,30 @@ +// This file configures the initialization of Sentry on the client. +// The config you add here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + dsn: 'https://cc1dcf1845d69a9079c615d462e122b2@o4506026798088192.ingest.sentry.io/4506026816503808', + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 0.2, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + replaysOnErrorSampleRate: 1.0, + + // This sets the sample rate to be 10%. You may want this to be 100% while + // in development and sample at a lower rate in production + replaysSessionSampleRate: 0.1, + + // You can remove this option if you're not planning to use the Sentry Session Replay feature: + integrations: [ + new Sentry.Replay({ + // Additional Replay configuration goes in here, for example: + maskAllText: true, + blockAllMedia: true, + }), + ], +}); diff --git a/output/sentry.edge.config.ts b/output/sentry.edge.config.ts new file mode 100644 index 0000000..8d9aaf5 --- /dev/null +++ b/output/sentry.edge.config.ts @@ -0,0 +1,16 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// The config you add here will be used whenever one of the edge features is loaded. +// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: "https://cc1dcf1845d69a9079c615d462e122b2@o4506026798088192.ingest.sentry.io/4506026816503808", + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); diff --git a/output/sentry.server.config.ts b/output/sentry.server.config.ts new file mode 100644 index 0000000..0a9a7d1 --- /dev/null +++ b/output/sentry.server.config.ts @@ -0,0 +1,15 @@ +// This file configures the initialization of Sentry on the server. +// The config you add here will be used whenever the server handles a request. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + dsn: 'https://cc1dcf1845d69a9079c615d462e122b2@o4506026798088192.ingest.sentry.io/4506026816503808', + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 0.2, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); diff --git a/output/src/api/admin.ts b/output/src/api/admin.ts new file mode 100644 index 0000000..da61cb0 --- /dev/null +++ b/output/src/api/admin.ts @@ -0,0 +1,42 @@ +import * as Sentry from '@sentry/nextjs'; +import { AxiosError } from 'axios'; + +import { GameScorePayload, NewGamePayload } from '@/types/admin'; + +import { adminInstance } from './instance'; + +export const createNewGame = (body: NewGamePayload) => { + try { + return adminInstance.post('/manage/game/register/', body); + } catch (error) { + const axiosError = error as AxiosError; + + Sentry.captureException(axiosError); + + if (axiosError.response) { + throw new Error(axiosError.response.statusText); + } else { + throw new Error('경기를 생성하는 데에 실패했습니다!'); + } + } +}; + +export const postGameScore = (id: number, body: GameScorePayload) => { + try { + return adminInstance.post(`/manage/game/score/${id}/`, body); + } catch (error) { + const axiosError = error as AxiosError; + + Sentry.captureException(axiosError); + + if (axiosError.response) { + throw new Error(axiosError.response.statusText); + } else { + throw new Error('경기 점수를 변경하는 데에 실패했습니다!'); + } + } +}; + +export const postBlockComment = (id: number) => { + return adminInstance.post(`/manage/comments/block/${id}/`); +}; diff --git a/output/src/api/auth.ts b/output/src/api/auth.ts new file mode 100644 index 0000000..832a496 --- /dev/null +++ b/output/src/api/auth.ts @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/nextjs'; +import { AxiosError } from 'axios'; + +import { AuthPayload, AuthType } from '@/types/auth'; +import { GameStatusType } from '@/types/game'; + +import { adminInstance } from './instance'; + +export const postLogin = async (body: AuthPayload) => { + try { + const response = await adminInstance.post( + '/accounts/login/', + body, + ); + + return response.data; + } catch (error) { + const axiosError = error as AxiosError; + + Sentry.captureException(axiosError); + + if (axiosError.response) { + throw new Error(axiosError.response.statusText); + } else { + throw new Error('팀 목록을 불러오는 데에 실패했습니다!'); + } + } +}; + +export const postGameStatus = async ( + id: number, + gameStatus: GameStatusType, +) => { + adminInstance.post(`/manage/game/statustype/${id}/`, { gameStatus }); +}; diff --git a/output/src/api/game.ts b/output/src/api/game.ts new file mode 100644 index 0000000..3d2d39d --- /dev/null +++ b/output/src/api/game.ts @@ -0,0 +1,80 @@ +import * as Sentry from '@sentry/nextjs'; +import { AxiosError } from 'axios'; + +import { GameCommentType, GameDetailType, GameType } from '@/types/game'; + +import instance from './instance'; + +export const getAllGames = async () => { + try { + const response = await instance.get('/games'); + + return response.data; + } catch (error) { + const axiosError = error as AxiosError; + + Sentry.captureException(axiosError); + + if (axiosError.response) { + throw new Error(axiosError.response.statusText); + } else { + throw new Error('경기 목록을 불러오는 데에 실패했습니다!'); + } + } +}; + +export const getGameDetail = async (gameID: number) => { + try { + const response = await instance.get(`/games/${gameID}`); + return response.data; + } catch (error) { + const axiosError = error as AxiosError; + + Sentry.captureException(error); + + if (axiosError.response) { + throw new Error(axiosError.response.statusText); + } else { + throw new Error('경기 상세 정보를 불러오는 데에 실패했습니다!'); + } + } +}; + +export const getGameComments = async (gameID: number) => { + try { + const response = await instance.get( + `/games/${gameID}/comments`, + ); + + return response.data; + } catch (error) { + const axiosError = error as AxiosError; + + Sentry.captureException(axiosError); + + if (axiosError.response) { + throw new Error(axiosError.response.statusText); + } else { + throw new Error('댓글을 불러오는 데에 실패했습니다!'); + } + } +}; + +export const postGameComment = async (body: { + content: string; + gameId: number; +}) => { + try { + await instance.post('/comments/register', body); + } catch (error) { + const axiosError = error as AxiosError; + + Sentry.captureException(axiosError); + + if (axiosError.response) { + throw new Error(axiosError.response.statusText); + } else { + throw new Error('댓글 등록에 실패했습니다!'); + } + } +}; diff --git a/output/src/api/instance.ts b/output/src/api/instance.ts new file mode 100644 index 0000000..9c272f0 --- /dev/null +++ b/output/src/api/instance.ts @@ -0,0 +1,24 @@ +import axios from 'axios'; + +const instance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +export const adminInstance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_BACK_OFFICE_BASE_URL, + headers: { + Authorization: `Bearer `, + 'Content-Type': 'application/json', + }, +}); + +adminInstance.interceptors.request.use(config => { + config.headers.Authorization = `Bearer ${localStorage.getItem('token')}`; + + return config; +}); + +export default instance; diff --git a/output/src/api/team.ts b/output/src/api/team.ts new file mode 100644 index 0000000..680c67e --- /dev/null +++ b/output/src/api/team.ts @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/nextjs'; +import { AxiosError } from 'axios'; + +import { GameTeamType } from '@/types/game'; + +import instance from './instance'; + +export const getTeams = async () => { + try { + const response = await instance.get('/teams'); + + return response.data; + } catch (error) { + const axiosError = error as AxiosError; + + Sentry.captureException(axiosError); + + if (axiosError.response) { + throw new Error(axiosError.response.statusText); + } else { + throw new Error('팀 목록을 불러오는 데에 실패했습니다!'); + } + } +}; diff --git a/output/src/app/ReactQueryProvider.tsx b/output/src/app/ReactQueryProvider.tsx new file mode 100644 index 0000000..ff525a8 --- /dev/null +++ b/output/src/app/ReactQueryProvider.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { ReactNode, useState } from 'react'; + +type ReactQueryProviderProps = { + children: ReactNode; +}; + +export default function ReactQueryProvider({ + children, +}: ReactQueryProviderProps) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + refetchInterval: false, + staleTime: 1000 * 60 * 10, + }, + }, + }), + ); + + return ( + + {children} + + + ); +} diff --git a/output/src/app/_error.tsx b/output/src/app/_error.tsx new file mode 100644 index 0000000..4631f54 --- /dev/null +++ b/output/src/app/_error.tsx @@ -0,0 +1,38 @@ +/** + * NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher. + * + * This page is loaded by Nextjs: + * - on the server, when data-fetching methods throw or reject + * - on the client, when `getInitialProps` throws or rejects + * - on the client, when a React lifecycle method throws or rejects, and it's + * caught by the built-in Nextjs error boundary + * + * See: + * - https://nextjs.org/docs/basic-features/data-fetching/overview + * - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props + * - https://reactjs.org/docs/error-boundaries.html + */ + +import * as Sentry from '@sentry/nextjs'; +import type { NextPage } from 'next'; +import type { ErrorProps } from 'next/error'; +import NextErrorComponent from 'next/error'; + +const CustomErrorComponent: NextPage = props => { + // If you're using a Nextjs version prior to 12.2.1, uncomment this to + // compensate for https://github.com/vercel/next.js/issues/8592 + // Sentry.captureUnderscoreErrorException(props); + + return ; +}; + +CustomErrorComponent.getInitialProps = async contextData => { + // In case this is running in a serverless function, await this in order to give Sentry + // time to send the error before the lambda exits + await Sentry.captureUnderscoreErrorException(contextData); + + // This will contain the status code of the response + return NextErrorComponent.getInitialProps(contextData); +}; + +export default CustomErrorComponent; diff --git a/output/src/app/admin/page.tsx b/output/src/app/admin/page.tsx new file mode 100644 index 0000000..710b57f --- /dev/null +++ b/output/src/app/admin/page.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { notFound, useRouter } from 'next/navigation'; +import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; + +import { createNewGame } from '@/api/admin'; +import { getTeams } from '@/api/team'; +import Input from '@/components/common/Input/Input'; +import Select from '@/components/common/Select/Select'; +import useDate from '@/hooks/useDate'; +import useValidate from '@/hooks/useValidate'; +import { GameTeamType } from '@/types/game'; + +export default function Admin() { + const router = useRouter(); + + const { month, day } = useDate(new Date().toString()); + const [teams, setTeams] = useState([]); + const [gameData, setGameData] = useState({ + name: '삼건물대회', + sportsName: '축구', + firstTeam: 0, + secondTeam: 0, + date: '', + time: '', + }); + + const { isError: isDateError } = useValidate( + gameData.date, + dateValue => new Date(dateValue) < new Date(`2023-${month}-${day}`), + ); + const { isError: isTimeError } = useValidate(gameData.time, timeValue => { + const [hour] = (timeValue as string).split(':').map(Number); + + return hour < 8 || hour > 18; + }); + const { isError: isTeamError } = useValidate( + gameData.secondTeam, + teamValue => teamValue === gameData.firstTeam, + ); + + const handleInput = ( + e: ChangeEvent, + ) => { + const { name, value } = e.target; + + setGameData(prev => ({ ...prev, [name]: value })); + }; + + const submitHandler = (e: FormEvent) => { + e.preventDefault(); + + if (isDateError || isTeamError || isTimeError) return; + + createNewGame({ + name: gameData.name, + sportsName: gameData.sportsName, + firstTeam: Number(gameData.firstTeam), + secondTeam: Number(gameData.secondTeam), + startTime: new Date(`${gameData.date}T${gameData.time}:00`), + }).then(() => router.push('/')); + }; + + useEffect(() => { + const loadTeams = async () => { + const res = await getTeams(); + + if (typeof res === 'number') return notFound(); + + setTeams(res); + }; + + loadTeams(); + }, []); + + return ( +
+ +

팀 선택

+ + + {isTeamError && ( +
팀을 다시 선택해주세요!
+ )} + + + + + +
+ ); +} diff --git a/output/src/app/detail/[id]/modify/page.tsx b/output/src/app/detail/[id]/modify/page.tsx new file mode 100644 index 0000000..4fde17f --- /dev/null +++ b/output/src/app/detail/[id]/modify/page.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useParams, useRouter } from 'next/navigation'; +import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; + +import { postGameScore } from '@/api/admin'; +import { getGameDetail } from '@/api/game'; +import { Game } from '@/components/common/Game'; +import Input from '@/components/common/Input/Input'; +import Select from '@/components/common/Select/Select'; +import { GameDetailType, GameType } from '@/types/game'; +import { getUtcHours } from '@/utils/utc-times'; + +export default function ModifyGame() { + const router = useRouter(); + const params = useParams(); + const id = Number(params.id); + const [scoreData, setScoreData] = useState({ + playerName: '', + team: 0, + hour: new Date().getHours(), + minute: new Date().getMinutes(), + }); + const [gameInfo, setGameInfo] = useState(); + + const handleChange = ( + e: ChangeEvent, + ) => { + const { name, value } = e.target; + + setScoreData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + + const date = getUtcHours({ + hour: scoreData.hour, + minute: scoreData.minute, + }); + + postGameScore(id, { + playerName: scoreData.playerName, + team: scoreData.team, + scoredAt: date, + }).then(() => router.push('/')); + }; + + useEffect(() => { + const getGameInfo = async () => { + const id = Number(params.id); + const gameDetail = await getGameDetail(id); + + if (typeof gameDetail === 'number') return; + + setGameInfo(gameDetail); + }; + + getGameInfo(); + }, [params.id]); + + return ( +
+ + + + + +

득점한 팀

+ + + + +
+ ); +} diff --git a/output/src/app/detail/[id]/page.tsx b/output/src/app/detail/[id]/page.tsx new file mode 100644 index 0000000..7c1335d --- /dev/null +++ b/output/src/app/detail/[id]/page.tsx @@ -0,0 +1,55 @@ +'use client'; + +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import { getGameDetail } from '@/api/game'; +import CommentList from '@/components/detail/CommentList'; +import GameInfo from '@/components/detail/GameInfo'; +import GameTimeline from '@/components/detail/GameTimeline'; +import { GameDetailType } from '@/types/game'; + +export default function DetailPage({ params }: { params: { id: string } }) { + const [gameData, setGameData] = useState(); + const [isLoggedIn, setIsLoggedIn] = useState(false); + + const gameId = params.id; + + useEffect(() => { + const getGameData = async () => { + const res = await getGameDetail(Number(gameId)); + + if (typeof res === 'number') return notFound(); + + setGameData(res); + }; + getGameData(); + const token = localStorage.getItem('token'); + if (!token) return; + setIsLoggedIn(true); + }, [gameId]); + + return ( +
+ {isLoggedIn && ( + + 수정 + + )} + {gameData && } + {isLoggedIn && ( + + 전/후반 변경하러 가기 + + )} + {gameData && ( + + )} + {gameData && } +
+ ); +} diff --git a/output/src/app/detail/[id]/status/page.tsx b/output/src/app/detail/[id]/status/page.tsx new file mode 100644 index 0000000..2b63cf9 --- /dev/null +++ b/output/src/app/detail/[id]/status/page.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useParams, useRouter } from 'next/navigation'; +import { useState } from 'react'; + +import { postGameStatus } from '@/api/auth'; +import Select from '@/components/common/Select/Select'; +import { GameStatusType } from '@/types/game'; + +export default function Status() { + const router = useRouter(); + const params = useParams(); + const [gameState, setGameState] = useState('BEFORE'); + + const updateGameStatus = async () => { + postGameStatus(Number(params.id), gameState).then(() => + router.push(`/detail/${params.id}`), + ); + }; + + return ( +
+ + +
+ ); +} diff --git a/output/src/app/globals.css b/output/src/app/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/output/src/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/output/src/app/layout.tsx b/output/src/app/layout.tsx new file mode 100644 index 0000000..a880579 --- /dev/null +++ b/output/src/app/layout.tsx @@ -0,0 +1,40 @@ +import './globals.css'; + +import type { Metadata } from 'next'; +import { Noto_Sans_KR } from 'next/font/google'; + +import Footer from '@/components/layout/Footer'; +import Header from '@/components/layout/Header'; + +import ReactQueryProvider from './ReactQueryProvider'; + +const inter = Noto_Sans_KR({ + subsets: ['latin'], + weight: ['400', '500', '700'], +}); + +export const metadata: Metadata = { + title: 'HUFStreaming', + description: '한국외대 스포츠 경기 중계 플랫폼', + icons: { + icon: '/icon_hufstreaming.svg', + }, +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + +
+ {children} +