diff --git a/.github/workflows/Auto_PR_Setting.yml b/.github/workflows/Auto_PR_Setting.yml index 41a2ed60..f2382397 100644 --- a/.github/workflows/Auto_PR_Setting.yml +++ b/.github/workflows/Auto_PR_Setting.yml @@ -45,7 +45,8 @@ jobs: LABELS=$(echo "${ISSUE_DATA}" | jq -r '.labels | join(",")') # 팀 멤버 목록을 정의하고, 할당되지 않은 멤버를 리뷰어로 추가. - TEAM_MEMBERS=("jaeml06" "i-meant-to-be" "eunwoo-levi" "katie424") + # 단, 현재 활동하지 않는 멤버는 제외. (현재 비활성 멤버 = 엘, 케이티) + TEAM_MEMBERS=("jaeml06" "i-meant-to-be" "useon") IFS=', ' read -r -a ASSIGNEE_ARRAY <<< "${ASSIGNEES}" REVIEWERS=() for MEMBER in "${TEAM_MEMBERS[@]}"; do diff --git a/.github/workflows/Notion_Sync.yml b/.github/workflows/Notion_Sync.yml new file mode 100644 index 00000000..3ea2c40b --- /dev/null +++ b/.github/workflows/Notion_Sync.yml @@ -0,0 +1,32 @@ +name: Notion Sync + +on: + issues: + types: [opened] + pull_request: + types: [opened, reopened, closed] + pull_request_target: # For accessing secrets from forked repos + types: [opened, reopened, closed] + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: pip install requests pytz + + - name: Run Notion Sync Script + env: + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }} + NOTION_USER_UUID_MAP: ${{ secrets.NOTION_USER_UUID_MAP }} + GITHUB_EVENT_PATH: ${{ github.event_path }} + run: python ./notion_sync.py diff --git a/.github/workflows/deploy-development.yml b/.github/workflows/deploy-development.yml index c3aadd46..e623150e 100644 --- a/.github/workflows/deploy-development.yml +++ b/.github/workflows/deploy-development.yml @@ -8,6 +8,7 @@ on: jobs: build-and-deploy: + if: github.event_name != 'pull_request' || github.event.pull_request.merged == true runs-on: ubuntu-latest environment: DEPLOY_DEV diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index bc5bd905..916069b5 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -8,6 +8,7 @@ on: jobs: build-and-deploy: + if: github.event_name != 'pull_request' || github.event.pull_request.merged == true runs-on: ubuntu-latest environment: DEPLOY_PROD diff --git a/.storybook/main.ts b/.storybook/main.ts index 11634b8b..9379e51e 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -16,5 +16,17 @@ const config: StorybookConfig = { core: { builder: '@storybook/builder-vite', }, + async viteFinal(config, { configType }) { + console.log(`# configType = ${configType}`); + + // Storybook의 실행 모드를 테스트 모드로 고정하여 + // msw가 잘 동작할 수 있게 준비 + config.define = { + ...config.define, + 'import.meta.env.MODE': JSON.stringify('test'), + }; + + return config; + }, }; export default config; diff --git a/README.md b/README.md index 85c910f6..c3cd54c5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,83 @@ -# debate-timer-fe -디베이트 타이머 프론트엔드 레포지토리입니다 +# ⏲️ 디베이트 타이머: Debate Timer (Front-end) + +디베이트 타이머의 Front-end 저장소입니다. +우리는 토론 진행을 효율적으로 돕는 서비스를 제공하는 것을 목표로 토론에 진행에 필요한 다양한 형식의 타이머를 제공해요. +전체 프로젝트 및 서비스 소개는 [조직 메인 페이지](https://github.com/debate-timer)에서 확인하세요. + +[서비스 바로가기](https://www.debate-timer.com) + +## 🛠️ 기술 스택 + +| 항목 | 스택 | +| ------------- | ------------------------------ | +| Core | React, TypeScript | +| 빌드 | Vite | +| 스타일링 | TailwindCSS v3 | +| 테스트 | Vitest, Storybook, MSW | +| 네트워킹 | Axios, TanStack Query | +| 린팅 / 포매팅 | ESLint, StyleLint, Prettier | +| CI / CD | S3, CloudFront, Github Actions | +| 분석 | GA4 | + +--- + +## 실행 방법 + +```bash +사전 준비 +Node.js: v20.x LTS +패키지 매니저: npm + +# 의존성 설치 +npm install + +# 개발 서버 실행 +npm run dev + +# 테스트 실행 +npm run test + +# Storybook 실행 +npm run storybook + +# 빌드 +npm run build +``` + +--- + +## 배포 + +- AWS S3 + CloudFront를 사용하여 웹사이트 호스팅 +- GitHub Actions를 이용해 main 브랜치에 푸시 시 자동 배포 + +--- + +## 브랜치 전략 + +- main: 배포용 안정화 브랜치 +- develop: 개발 중인 기능 통합 브랜치 +- feature/\*: 각 기능별 작업 브랜치 + +--- + +## 기여자 + +### 현재 활동 중 + +| **썬데이** | **치코** | **숀** | +| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| 썬데이 | 치코 | 숀 | + +### 휴식 중 + +| **엘** | **케이티** | +| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| 엘 | 케이티 | + +--- + +## 추가 링크 + +- Backend Repository: +- Debate Timer Organization: diff --git a/notion_sync.py b/notion_sync.py new file mode 100644 index 00000000..c42e4247 --- /dev/null +++ b/notion_sync.py @@ -0,0 +1,230 @@ +import os +import json +import re +import datetime +import requests +import pytz + +# Import envs +NOTION_TOKEN = os.getenv("NOTION_TOKEN") +NOTION_DATABASE_ID = os.getenv("NOTION_DATABASE_ID") +NOTION_USER_UUID_MAP_STR = os.getenv("NOTION_USER_UUID_MAP") +NOTION_API_URL = "https://api.notion.com/v1" + +# Parse GitHub ID-Notion UUID map +if NOTION_USER_UUID_MAP_STR: + NOTION_USER_UUID_MAP = json.loads(NOTION_USER_UUID_MAP_STR) +else: + print("NOTION_USER_UUID_MAP secret not found or empty.") + NOTION_USER_UUID_MAP = {} + +# Set header for HTTP request +HEADERS = { + "Authorization": f"Bearer {NOTION_TOKEN}", + "Notion-Version": "2022-06-28", + "Content-Type": "application/json", +} + +# Load GitHub event payload +def get_github_event_payload(): + event_path = os.getenv("GITHUB_EVENT_PATH") + if not event_path: + print("GITHUB_EVENT_PATH is not valid.") + return None + try: + with open(event_path, "r") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError) as e: + print(f"Failed to read event payload: {e}") + return None + +# Find Notion DB item by GitHub issue id +def find_notion_page_by_github_id(github_id): + query_url = f"{NOTION_API_URL}/databases/{NOTION_DATABASE_ID}/query" + payload = { + "filter": { + "property": "ID", + "number": { + "equals": github_id + } + } + } + response = requests.post(query_url, headers=HEADERS, json=payload) + response.raise_for_status() + results = response.json().get("results") + return results[0] if results else None + +# Create new Notion DB item +def create_notion_page(title, status, github_assignee_id, github_url, github_id): + create_url = f"{NOTION_API_URL}/pages" + + # Parse Notion user id + notion_user_id = NOTION_USER_UUID_MAP.get(github_assignee_id, "") if github_assignee_id else "" + + # Parse current time (KST) + korea_timezone = pytz.timezone('Asia/Seoul') + current_datetime = datetime.datetime.now(korea_timezone) + current_datetime_str = current_datetime.isoformat() + + data = { + "parent": {"database_id": NOTION_DATABASE_ID}, + "properties": { + "이름": { + "title": [ + { + "text": { + "content": title + } + } + ] + }, + "URL": { + "url": github_url + }, + "상태": { + "select": { + "name": status + } + }, + "ID": { + "number": github_id + }, + "생성 날짜": { + "date": { + "start": current_datetime_str + } + } + } + } + if (notion_user_id): + data["properties"]["작업자"] = { + "people": [ + { + "id": notion_user_id + } + ] + } + + response = requests.post(create_url, headers=HEADERS, json=data) + response.raise_for_status() + print(f"Notion item created / Title: '{title}', Status: '{status}'") + return response.json() + +# Patch Notion DB item +def update_notion_page_status(page_id, status): + update_url = f"{NOTION_API_URL}/pages/{page_id}" + data = { + "properties": { + "상태": { + "select": { + "name": status + } + } + } + } + response = requests.patch(update_url, headers=HEADERS, json=data) + response.raise_for_status() + print(f"Updated status of Notion page '{page_id}' as '{status}'") + return response.json() + +# Handle issue-related event +def handle_issue_event(payload): + issue = payload.get("issue") + if not issue: + print("Issue data is not in payload.") + return + + action = payload.get("action") + issue_title = issue.get("title") + issue_url = issue.get("html_url") + issue_number = issue.get("number") + issue_assignees = issue.get("assignees", []) + issue_assignee_id = issue_assignees[0].get("id") if issue_assignees else None + + if action == "opened": + print(f"Issue opened: #{issue_number} - {issue_title}") + # Check duplication + notion_page = find_notion_page_by_github_id(issue_number) + if not notion_page: + create_notion_page(issue_title, "열림", issue_assignee_id, issue_url, issue_number) + else: + print(f"Issue #{issue_number} is already in Notion DB. This task will be skipped.") + else: + print(f"Not supported action: {action}") + +# Handle PR event +def handle_pull_request_event(payload): + pr = payload.get("pull_request") + if not pr: + print("PR data is not in payload.") + return + + action = payload.get("action") + pr_title = pr.get("title") + pr_number = pr.get("number") + pr_url = pr.get("html_url") + pr_assignees = pr.get("assignees", []) + pr_assignee_id = pr_assignees[0].get("id") if pr_assignees else None + + # Find all related issue ID + body = pr.get("body", "") + issue_ids = re.findall(r"(?:close|closes|closed|fix|fixes|fixed)\s+#(\d+)", body, re.IGNORECASE) + issue_ids = [int(num) for num in issue_ids] + + # If no specified issue, create Notion DB item with PR id + if not issue_ids: + print(f"Cannot find related issue numbers in PR #{pr_number}. Add PR id itself on the list.") + issue_ids = [pr_number] + + for related_issue_number in issue_ids: + notion_page = find_notion_page_by_github_id(related_issue_number) + + if not notion_page: + # There is no related issue on Notion or have to add PR itself + if related_issue_number == pr_number: # Register PR itself + print(f"Add PR #{pr_number} on the Notion DB.") + create_notion_page(f"PR: {pr_title}", "병합 요청됨", pr_assignee_id, pr_url, pr_number) + else: + print(f"Issue #{related_issue_number} is not in Notion DB. Create new one.") + create_notion_page(f"이슈 #{related_issue_number} (PR 연동)", "병합 요청됨", pr_assignee_id, pr_url, related_issue_number) + continue + + page_id = notion_page["id"] + + if action == "opened" or action == "reopened": + print(f"PR opened/reopened: #{pr_number} - {pr_title}. Change status of related issue #{related_issue_number}.") + update_notion_page_status(page_id, "병합 요청됨") + elif action == "closed": + if pr.get("merged"): + print(f"PR merged: #{pr_number} - {pr_title}. Change status of related issue #{related_issue_number}.") + update_notion_page_status(page_id, "병합됨") + else: + print(f"PR closed (not merged): #{pr_number} - {pr_title}. Change status of related issue #{related_issue_number}.") + update_notion_page_status(page_id, "닫힘") + else: + print(f"Not supported PR action: {action}") + +if __name__ == "__main__": + event_payload = get_github_event_payload() + + if not event_payload: + print("Cannot read event payload. Terminate.") + else: + event_name = os.getenv("GITHUB_EVENT_NAME") + print(f"GitHub Event Name: {event_name}") + + try: + if event_name == "issues": + handle_issue_event(event_payload) + elif event_name == "pull_request" or event_name == "pull_request_target": + handle_pull_request_event(event_payload) + else: + print(f"Not supported GitHub event: {event_name}") + except requests.exceptions.RequestException as e: + print(f"Notion API request error: {e}") + if e.response: + print(f"Failure response: {e.response.text}") + exit(1) + except Exception as e: + print(f"Unpredicted error occured: {e}") + exit(1) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5ab570cb..1bbc07d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "@tanstack/eslint-plugin-query": "^5.62.9", "@tanstack/react-query": "^5.62.12", "axios": "^1.7.9", + "clsx": "^2.1.1", + "framer-motion": "^12.23.11", "pako": "^2.1.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", @@ -1027,12 +1029,12 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", - "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.5", + "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -1040,10 +1042,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/core": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz", - "integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==", + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" @@ -1053,9 +1064,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", - "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "license": "MIT", "dependencies": { "ajv": "^6.12.4", @@ -1088,29 +1099,33 @@ } }, "node_modules/@eslint/js": { - "version": "9.17.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", - "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", + "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", - "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.4.tgz", - "integrity": "sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", "license": "Apache-2.0", "dependencies": { + "@eslint/core": "^0.15.1", "levn": "^0.4.1" }, "engines": { @@ -1166,9 +1181,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", - "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "license": "Apache-2.0", "engines": { "node": ">=18.18" @@ -2623,9 +2638,9 @@ } }, "node_modules/@tanstack/eslint-plugin-query/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -3213,9 +3228,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3443,9 +3458,9 @@ } }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3924,9 +3939,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4016,7 +4031,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4286,6 +4300,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4700,7 +4723,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4831,7 +4853,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4841,7 +4862,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4886,7 +4906,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4896,15 +4915,15 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -5014,21 +5033,22 @@ } }, "node_modules/eslint": { - "version": "9.17.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", - "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", + "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.9.0", - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.17.0", - "@eslint/plugin-kit": "^0.2.3", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.15.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.31.0", + "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.1", + "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", @@ -5036,9 +5056,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -5350,9 +5370,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", - "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -5366,9 +5386,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5378,14 +5398,14 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5680,13 +5700,15 @@ } }, "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -5707,6 +5729,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.11", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.11.tgz", + "integrity": "sha512-VzNi+exyI3bn7Pzvz1Fjap1VO9gQu8mxrsSsNamMidsZ8AA8W2kQsR+YQOciEUbMtkKAWIbPHPttfn5e9jqqJQ==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.9", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5726,7 +5775,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5786,7 +5834,6 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5859,9 +5906,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5987,7 +6034,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6072,7 +6118,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6085,7 +6130,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -6101,7 +6145,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7311,7 +7354,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7443,6 +7485,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "12.23.9", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.9.tgz", + "integrity": "sha512-6Sv++iWS8XMFCgU1qwKj9l4xuC47Hp4+2jvPfyTXkqDg2tTzSgX6nWKD4kNFXk0k7llO59LZTPuJigza4A2K1A==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10202,7 +10259,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tween-functions": { diff --git a/package.json b/package.json index 8837a61d..e8c35368 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "@tanstack/eslint-plugin-query": "^5.62.9", "@tanstack/react-query": "^5.62.12", "axios": "^1.7.9", + "clsx": "^2.1.1", + "framer-motion": "^12.23.11", "pako": "^2.1.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", diff --git a/public/sounds/bell-1.mp3 b/public/sounds/bell-1.mp3 new file mode 100644 index 00000000..262c01b6 Binary files /dev/null and b/public/sounds/bell-1.mp3 differ diff --git a/public/sounds/bell-2.mp3 b/public/sounds/bell-2.mp3 new file mode 100644 index 00000000..b6dfffa8 Binary files /dev/null and b/public/sounds/bell-2.mp3 differ diff --git a/public/sounds/bell-3.mp3 b/public/sounds/bell-3.mp3 new file mode 100644 index 00000000..33a83964 Binary files /dev/null and b/public/sounds/bell-3.mp3 differ diff --git a/public/sounds/bell-finish.mp3 b/public/sounds/bell-finish.mp3 deleted file mode 100644 index a679421c..00000000 Binary files a/public/sounds/bell-finish.mp3 and /dev/null differ diff --git a/public/sounds/bell-warning.mp3 b/public/sounds/bell-warning.mp3 deleted file mode 100644 index 96c7d3bd..00000000 Binary files a/public/sounds/bell-warning.mp3 and /dev/null differ diff --git a/public/sounds/cointoss-result.mp3 b/public/sounds/cointoss-result.mp3 new file mode 100644 index 00000000..4a3fa57d Binary files /dev/null and b/public/sounds/cointoss-result.mp3 differ diff --git a/public/sounds/cointoss.mp3 b/public/sounds/cointoss.mp3 new file mode 100644 index 00000000..dd6fb18f Binary files /dev/null and b/public/sounds/cointoss.mp3 differ diff --git a/setup.ts b/setup.ts index 81e08389..ce75593c 100644 --- a/setup.ts +++ b/setup.ts @@ -15,3 +15,20 @@ afterEach(() => server.resetHandlers()); // msw 서버 종료 afterAll(() => server.close()); + +// vitest.setup.ts 또는 setupTests.ts +// ResizeObserver를 전역적으로 모킹합니다. +global.ResizeObserver = class ResizeObserver { + observe() { + // do nothing + } + unobserve() { + // do nothing + } + disconnect() { + // do nothing + } +}; + +// 만약 window 객체에 직접 할당해야 한다면 (예: 일부 라이브러리가 window.ResizeObserver를 직접 참조하는 경우) +// window.ResizeObserver = global.ResizeObserver; diff --git a/src/apis/apis/debateTable.ts b/src/apis/apis/debateTable.ts index dd038843..4a7ec83e 100644 --- a/src/apis/apis/debateTable.ts +++ b/src/apis/apis/debateTable.ts @@ -56,8 +56,6 @@ export async function postDebateTableData({ agenda: info.agenda, prosTeamName: info.prosTeamName, consTeamName: info.consTeamName, - warningBell: info.warningBell, - finishBell: info.finishBell, }, table, }, @@ -82,8 +80,6 @@ export async function putDebateTableData({ agenda: info.agenda, prosTeamName: info.prosTeamName, consTeamName: info.consTeamName, - warningBell: info.warningBell, - finishBell: info.finishBell, }, table, }, diff --git a/src/apis/axiosInstance.ts b/src/apis/axiosInstance.ts index 6723086d..89e9bede 100644 --- a/src/apis/axiosInstance.ts +++ b/src/apis/axiosInstance.ts @@ -1,28 +1,38 @@ import axios from 'axios'; -import { getAccessToken, setAccessToken } from '../util/accessToken'; +import { + getAccessToken, + removeAccessToken, + setAccessToken, +} from '../util/accessToken'; -axios.defaults.withCredentials = true; +// Get current mode (DEV, PROD or TEST) +const currentMode = import.meta.env.MODE; + +// Axios instance export const axiosInstance = axios.create({ baseURL: - import.meta.env.MODE !== 'production' - ? undefined - : import.meta.env.VITE_API_BASE_URL, - timeout: 10000, + currentMode === 'test' ? undefined : import.meta.env.VITE_API_BASE_URL, + timeout: 5000, + timeoutErrorMessage: + '시간 초과로 인해 요청을 처리하지 못했어요... 잠시 후 다시 시도해 주세요.', headers: { 'Content-Type': 'application/json', }, withCredentials: true, }); -// 요청 인터셉터: Access Token을 헤더에 붙여 전송 +// Request interceptor axiosInstance.interceptors.request.use((config) => { const accessToken = getAccessToken(); + + // Access token을 헤더에 붙여 전송 if (accessToken && config.headers) { config.headers.Authorization = `${accessToken}`; } return config; }); +// Response interceptor axiosInstance.interceptors.response.use( (response) => response, async (error) => { @@ -57,6 +67,7 @@ axiosInstance.interceptors.response.use( console.error('Refresh Token is invalid or expired', refreshError); // 재발급도 실패하면 -> 로그인 페이지 이동 window.location.href = '/home'; + removeAccessToken(); return Promise.reject(refreshError); } } diff --git a/src/apis/primitives.ts b/src/apis/primitives.ts index f98b332e..8a3858b7 100644 --- a/src/apis/primitives.ts +++ b/src/apis/primitives.ts @@ -1,11 +1,23 @@ import axios from 'axios'; -import { AxiosResponse, AxiosError } from 'axios'; -import { ErrorResponseType } from './responses/global'; +import { AxiosResponse } from 'axios'; import axiosInstance from './axiosInstance'; // HTTP request methods export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; +// Define APIError; It only represents error that is returned from API response. +export class APIError extends Error { + public readonly status: number; + public readonly data: unknown; + + constructor(message: string, status: number, data: unknown) { + super(message); + this.status = status; + this.data = data; + this.name = 'APIError'; + } +} + // Low-level http request function export async function request( method: HttpMethod, @@ -20,22 +32,25 @@ export async function request( const response: AxiosResponse = await instance({ method, url: endpoint, - data: data ? JSON.stringify(data) : null, + data, params, }); + // If successful, return it return response; } catch (error) { - // Handle error if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError; - console.error('Error message:', axiosError.message); - if (axiosError.response) { - console.error('Error response data:', axiosError.response.data.message); - } - } else { - console.error('Unexpected error:', error); + // If error is raised during API request, + // pass it as an APIError + const apiError = new APIError( + error.response?.data || error.message, + error.response?.status || 500, + error.response?.data, + ); + throw apiError; } + + // Else, just throw it throw error; } } diff --git a/src/assets/debateEnd/clap.png b/src/assets/debateEnd/clap.png new file mode 100644 index 00000000..c865303a Binary files /dev/null and b/src/assets/debateEnd/clap.png differ diff --git a/src/assets/debateEnd/feedback_timer.png b/src/assets/debateEnd/feedback_timer.png new file mode 100644 index 00000000..1e7444ad Binary files /dev/null and b/src/assets/debateEnd/feedback_timer.png differ diff --git a/src/assets/debateEnd/vote_stamp.png b/src/assets/debateEnd/vote_stamp.png new file mode 100644 index 00000000..a9d80333 Binary files /dev/null and b/src/assets/debateEnd/vote_stamp.png differ diff --git a/src/assets/landing/bell_setting.png b/src/assets/landing/bell_setting.png new file mode 100644 index 00000000..321b9fd0 Binary files /dev/null and b/src/assets/landing/bell_setting.png differ diff --git a/src/assets/landing/bottom_arrow.png b/src/assets/landing/bottom_arrow.png new file mode 100644 index 00000000..f950956d Binary files /dev/null and b/src/assets/landing/bottom_arrow.png differ diff --git a/src/assets/landing/debate_info_setting.png b/src/assets/landing/debate_info_setting.png new file mode 100644 index 00000000..5ba04119 Binary files /dev/null and b/src/assets/landing/debate_info_setting.png differ diff --git a/src/assets/landing/key_info.png b/src/assets/landing/key_info.png new file mode 100644 index 00000000..eeffdd1f Binary files /dev/null and b/src/assets/landing/key_info.png differ diff --git a/src/assets/landing/table_list.png b/src/assets/landing/table_list.png new file mode 100644 index 00000000..c1bb840f Binary files /dev/null and b/src/assets/landing/table_list.png differ diff --git a/src/assets/landing/timebox_add_button.png b/src/assets/landing/timebox_add_button.png new file mode 100644 index 00000000..67601767 Binary files /dev/null and b/src/assets/landing/timebox_add_button.png differ diff --git a/src/assets/landing/timebox_step.png b/src/assets/landing/timebox_step.png new file mode 100644 index 00000000..2a760c1b Binary files /dev/null and b/src/assets/landing/timebox_step.png differ diff --git a/src/assets/landing/timebox_step_button.png b/src/assets/landing/timebox_step_button.png new file mode 100644 index 00000000..ff89ee86 Binary files /dev/null and b/src/assets/landing/timebox_step_button.png differ diff --git a/src/assets/landing/timeout_button.png b/src/assets/landing/timeout_button.png new file mode 100644 index 00000000..c26dabd0 Binary files /dev/null and b/src/assets/landing/timeout_button.png differ diff --git a/src/assets/landing/timer.png b/src/assets/landing/timer.png new file mode 100644 index 00000000..f819c75d Binary files /dev/null and b/src/assets/landing/timer.png differ diff --git a/src/assets/landing/timer_operation_time.png b/src/assets/landing/timer_operation_time.png new file mode 100644 index 00000000..4e629433 Binary files /dev/null and b/src/assets/landing/timer_operation_time.png differ diff --git a/src/assets/landing/timer_timebased.png b/src/assets/landing/timer_timebased.png new file mode 100644 index 00000000..90ff8199 Binary files /dev/null and b/src/assets/landing/timer_timebased.png differ diff --git a/src/assets/landing/two_timer.png b/src/assets/landing/two_timer.png new file mode 100644 index 00000000..4ab18a66 Binary files /dev/null and b/src/assets/landing/two_timer.png differ diff --git a/src/assets/teamSelection/coinback.png b/src/assets/teamSelection/coinback.png new file mode 100644 index 00000000..941bf0fa Binary files /dev/null and b/src/assets/teamSelection/coinback.png differ diff --git a/src/assets/teamSelection/coinfront.png b/src/assets/teamSelection/coinfront.png new file mode 100644 index 00000000..76e85331 Binary files /dev/null and b/src/assets/teamSelection/coinfront.png differ diff --git a/src/assets/teamSelection/coins.png b/src/assets/teamSelection/coins.png new file mode 100644 index 00000000..a20eef6a Binary files /dev/null and b/src/assets/teamSelection/coins.png differ diff --git a/src/assets/teamSelection/cointoss.png b/src/assets/teamSelection/cointoss.png new file mode 100644 index 00000000..bb98601f Binary files /dev/null and b/src/assets/teamSelection/cointoss.png differ diff --git a/src/assets/template_logo/government.png b/src/assets/template_logo/government.png new file mode 100644 index 00000000..27c93db6 Binary files /dev/null and b/src/assets/template_logo/government.png differ diff --git a/src/assets/template_logo/han_alm.png b/src/assets/template_logo/han_alm.png new file mode 100644 index 00000000..1167f15d Binary files /dev/null and b/src/assets/template_logo/han_alm.png differ diff --git a/src/assets/template_logo/hantomak.png b/src/assets/template_logo/hantomak.png new file mode 100644 index 00000000..9e4faee5 Binary files /dev/null and b/src/assets/template_logo/hantomak.png differ diff --git a/src/assets/template_logo/igam.png b/src/assets/template_logo/igam.png new file mode 100644 index 00000000..65f8a591 Binary files /dev/null and b/src/assets/template_logo/igam.png differ diff --git a/src/assets/template_logo/jungseonto.png b/src/assets/template_logo/jungseonto.png new file mode 100644 index 00000000..a16e8e8a Binary files /dev/null and b/src/assets/template_logo/jungseonto.png differ diff --git a/src/assets/template_logo/kogito.png b/src/assets/template_logo/kogito.png new file mode 100644 index 00000000..36292c6c Binary files /dev/null and b/src/assets/template_logo/kogito.png differ diff --git a/src/assets/template_logo/kondae_time.png b/src/assets/template_logo/kondae_time.png new file mode 100644 index 00000000..ed4dcee0 Binary files /dev/null and b/src/assets/template_logo/kondae_time.png differ diff --git a/src/assets/template_logo/nogotte.png b/src/assets/template_logo/nogotte.png new file mode 100644 index 00000000..56ac3066 Binary files /dev/null and b/src/assets/template_logo/nogotte.png differ diff --git a/src/assets/template_logo/osansi.png b/src/assets/template_logo/osansi.png new file mode 100644 index 00000000..5eba3fbb Binary files /dev/null and b/src/assets/template_logo/osansi.png differ diff --git a/src/assets/template_logo/seobangjeongto.png b/src/assets/template_logo/seobangjeongto.png new file mode 100644 index 00000000..95c4daed Binary files /dev/null and b/src/assets/template_logo/seobangjeongto.png differ diff --git a/src/assets/template_logo/todallae.png b/src/assets/template_logo/todallae.png new file mode 100644 index 00000000..45250602 Binary files /dev/null and b/src/assets/template_logo/todallae.png differ diff --git a/src/assets/template_logo/visual.png b/src/assets/template_logo/visual.png new file mode 100644 index 00000000..1a74153f Binary files /dev/null and b/src/assets/template_logo/visual.png differ diff --git a/src/assets/timer/normal_timer.png b/src/assets/timer/normal_timer.png deleted file mode 100644 index 9f526350..00000000 Binary files a/src/assets/timer/normal_timer.png and /dev/null differ diff --git a/src/assets/timer/normal_timer_cons.jpg b/src/assets/timer/normal_timer_cons.jpg new file mode 100644 index 00000000..73901200 Binary files /dev/null and b/src/assets/timer/normal_timer_cons.jpg differ diff --git a/src/assets/timer/normal_timer_neutral.jpg b/src/assets/timer/normal_timer_neutral.jpg new file mode 100644 index 00000000..bc86660f Binary files /dev/null and b/src/assets/timer/normal_timer_neutral.jpg differ diff --git a/src/assets/timer/normal_timer_pros.jpg b/src/assets/timer/normal_timer_pros.jpg new file mode 100644 index 00000000..86c8f2af Binary files /dev/null and b/src/assets/timer/normal_timer_pros.jpg differ diff --git a/src/assets/timer/time_based_timer.jpg b/src/assets/timer/time_based_timer.jpg new file mode 100644 index 00000000..099cc235 Binary files /dev/null and b/src/assets/timer/time_based_timer.jpg differ diff --git a/src/assets/timer/time_based_timer_only_total.jpg b/src/assets/timer/time_based_timer_only_total.jpg new file mode 100644 index 00000000..ad1d246d Binary files /dev/null and b/src/assets/timer/time_based_timer_only_total.jpg differ diff --git a/src/assets/timer/timebased_perSpeaking_timer.png b/src/assets/timer/timebased_perSpeaking_timer.png deleted file mode 100644 index 7225a971..00000000 Binary files a/src/assets/timer/timebased_perSpeaking_timer.png and /dev/null differ diff --git a/src/assets/timer/timebased_timer.png b/src/assets/timer/timebased_timer.png deleted file mode 100644 index d8072261..00000000 Binary files a/src/assets/timer/timebased_timer.png and /dev/null differ diff --git a/src/components/ClearableInput/ClearableInput.stories.tsx b/src/components/ClearableInput/ClearableInput.stories.tsx index 6d9d6db2..1daa9ea1 100644 --- a/src/components/ClearableInput/ClearableInput.stories.tsx +++ b/src/components/ClearableInput/ClearableInput.stories.tsx @@ -19,3 +19,12 @@ export const Default: Story = { placeholder: 'Enter text...', }, }; + +export const Disabled: Story = { + args: { + value: 'Hello Storybook', + onClear: () => alert('Clear clicked'), + placeholder: 'Enter text...', + disabled: true, + }, +}; diff --git a/src/components/ClearableInput/ClearableInput.tsx b/src/components/ClearableInput/ClearableInput.tsx index 79954730..b7ed6244 100644 --- a/src/components/ClearableInput/ClearableInput.tsx +++ b/src/components/ClearableInput/ClearableInput.tsx @@ -1,24 +1,29 @@ +import clsx from 'clsx'; import { InputHTMLAttributes } from 'react'; import { IoMdCloseCircle } from 'react-icons/io'; interface ClearableInputProps extends InputHTMLAttributes { value: string; - onClear: () => void; + disabled?: boolean; + onClear?: () => void; } export default function ClearableInput({ value, onClear, + disabled = false, + className, ...rest }: ClearableInputProps) { return ( -
+
- {value && ( + {value && !disabled && onClear && (
+ ); +} diff --git a/src/components/DesignSystemSample/DesignSystemSample.stories.tsx b/src/components/DesignSystemSample/DesignSystemSample.stories.tsx new file mode 100644 index 00000000..b4308308 --- /dev/null +++ b/src/components/DesignSystemSample/DesignSystemSample.stories.tsx @@ -0,0 +1,72 @@ +import { Meta, StoryObj } from '@storybook/react'; + +interface ColorItemProps { + className: string; + title: string; +} + +const ColorItem = ({ className, title }: ColorItemProps) => { + return ( +
+
+

{title}

+
+ ); +}; + +const DesignSystemSample = () => ( +
+
+

Typography

+ +

대제목

+

제목

+

부제목

+

세부사항

+

내용

+
+ +
+

Colors

+ + + + + + + + + + + + + +
+
+); + +const meta: Meta = { + title: 'Design System/DesignSystemSample', + component: DesignSystemSample, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, + render: () => , +}; diff --git a/src/components/DialogModal/DialogModal.tsx b/src/components/DialogModal/DialogModal.tsx index f19f71ee..eecb3175 100644 --- a/src/components/DialogModal/DialogModal.tsx +++ b/src/components/DialogModal/DialogModal.tsx @@ -52,7 +52,7 @@ export default function DialogModal({ {/** Right button */} + +
+ {options.map((option) => ( +
handleOptionClick(option.value)} + role="option" + aria-selected={option.value === selectedValue} + > + {option.label} +
+ ))} +
+
+ ); +} diff --git a/src/components/ErrorBoundary/ErrorBoundary.tsx b/src/components/ErrorBoundary/ErrorBoundary.tsx index 584a95a0..84a8709d 100644 --- a/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -1,7 +1,5 @@ import { Component, ErrorInfo, ReactNode } from 'react'; import ErrorPage from './ErrorPage'; -import { AxiosError } from 'axios'; -import { ErrorResponseType } from '../../apis/responses/global'; interface ErrorBoundaryProps { children: ReactNode; @@ -9,11 +7,11 @@ interface ErrorBoundaryProps { interface ErrorBoundaryState { hasError: boolean; - message: string; + error: Error; stack: string; } -const defaultMessage = '오류 정보 없음'; +const defaultError = new Error('알 수 없는 오류'); const defaultStack = '스택 정보 없음'; class ErrorBoundary extends Component { @@ -21,7 +19,7 @@ class ErrorBoundary extends Component { super(props); this.state = { hasError: false, - message: defaultMessage, + error: defaultError, stack: defaultStack, }; } @@ -29,14 +27,7 @@ class ErrorBoundary extends Component { static getDerivedStateFromError(error: Error): ErrorBoundaryState { // Update state so the next render will show the fallback UI. const stack = error.stack === undefined ? defaultStack : error.stack; - let message: string; - - if (error instanceof AxiosError && error.response) { - message = (error.response.data as ErrorResponseType).message; - } else { - message = error.message; - } - return { hasError: true, message: message, stack: stack }; + return { hasError: true, error: error, stack: stack }; } componentDidCatch(error: Error, errorInfo: ErrorInfo): void { @@ -47,7 +38,7 @@ class ErrorBoundary extends Component { resetError = () => { this.setState({ hasError: false, - message: defaultMessage, + error: defaultError, stack: defaultStack, }); }; @@ -56,7 +47,7 @@ class ErrorBoundary extends Component { if (this.state.hasError) { return ( diff --git a/src/components/ErrorBoundary/ErrorPage.stories.tsx b/src/components/ErrorBoundary/ErrorPage.stories.tsx index 4a41dcbc..7ba36503 100644 --- a/src/components/ErrorBoundary/ErrorPage.stories.tsx +++ b/src/components/ErrorBoundary/ErrorPage.stories.tsx @@ -1,5 +1,6 @@ import { Meta, StoryObj } from '@storybook/react'; import ErrorPage from './ErrorPage'; +import { APIError } from '../../apis/primitives'; const meta: Meta = { title: 'Components/ErrorPage', @@ -13,7 +14,14 @@ type Story = StoryObj; export const Default: Story = { args: { - message: '샘플 오류 메시지', + error: new Error('샘플 오류 메시지'), + stack: '샘플 오류 스택', + }, +}; + +export const OnAPIError: Story = { + args: { + error: new APIError('Internal Server Error', 500, null), stack: '샘플 오류 스택', }, }; diff --git a/src/components/ErrorBoundary/ErrorPage.tsx b/src/components/ErrorBoundary/ErrorPage.tsx index 7c9a160c..51c69ccc 100644 --- a/src/components/ErrorBoundary/ErrorPage.tsx +++ b/src/components/ErrorBoundary/ErrorPage.tsx @@ -1,19 +1,29 @@ import { IoHome } from 'react-icons/io5'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import { useNavigate } from 'react-router-dom'; +import { APIError } from '../../apis/primitives'; +import { ERROR_STATUS_TABLE } from '../../constants/errors'; interface ErrorPageProps { - message: string; + error: Error; stack: string; onReset: () => void; } -export default function ErrorPage({ message, stack, onReset }: ErrorPageProps) { +export default function ErrorPage({ error, stack, onReset }: ErrorPageProps) { const navigate = useNavigate(); const goToHome = () => { onReset(); - navigate('/', { replace: true }); // 현재 라우트가 "/"여도 강제 이동 + navigate('/home', { replace: true }); }; + + // If error is from API request, print status code + // to let user know exact reason of error. + const title = + error instanceof APIError + ? ERROR_STATUS_TABLE[error.status] || `${error.status} 오류` + : '오류가 발생했어요...'; + return ( @@ -26,12 +36,12 @@ export default function ErrorPage({ message, stack, onReset }: ErrorPageProps) {

😭

-

오류가 발생했어요...

+

{title}

오류 내용

-

{message}

+

{error.message}

diff --git a/src/components/ErrorIndicator/ErrorIndicator.stories.tsx b/src/components/ErrorIndicator/ErrorIndicator.stories.tsx new file mode 100644 index 00000000..12864b35 --- /dev/null +++ b/src/components/ErrorIndicator/ErrorIndicator.stories.tsx @@ -0,0 +1,25 @@ +import { Meta, StoryObj } from '@storybook/react'; +import ErrorIndicator from './ErrorIndicator'; + +const meta: Meta = { + title: 'Components/ErrorIndicator', + component: ErrorIndicator, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: '오류가 발생했어요.', + }, +}; + +export const OnRetryButtonEnabled: Story = { + args: { + children: '오류가 발생했어요. 다시 시도하시겠어요?', + onClickRetry: () => {}, + }, +}; diff --git a/src/components/ErrorIndicator/ErrorIndicator.tsx b/src/components/ErrorIndicator/ErrorIndicator.tsx new file mode 100644 index 00000000..9f0b8b59 --- /dev/null +++ b/src/components/ErrorIndicator/ErrorIndicator.tsx @@ -0,0 +1,33 @@ +import { PropsWithChildren } from 'react'; +import { MdErrorOutline } from 'react-icons/md'; + +interface ErrorIndicatorProps extends PropsWithChildren { + onClickRetry?: () => void; +} + +export default function ErrorIndicator({ + children = ( + <> + 데이터를 불러오지 못했어요. +
+ 다시 시도할까요? + + ), + onClickRetry, +}: ErrorIndicatorProps) { + return ( +
+ +

{children}

+ + {onClickRetry && ( + + )} +
+ ); +} diff --git a/src/components/FloatingActionButton/FloatingActionButton.stories.tsx b/src/components/FloatingActionButton/FloatingActionButton.stories.tsx new file mode 100644 index 00000000..bf930192 --- /dev/null +++ b/src/components/FloatingActionButton/FloatingActionButton.stories.tsx @@ -0,0 +1,27 @@ +import { Meta, StoryObj } from '@storybook/react'; +import FloatingActionButton from './FloatingActionButton'; +import DTAdd from '../icons/Add'; + +const meta: Meta = { + title: 'Components/FloatingActionButton', + component: FloatingActionButton, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Enabled: Story = { + render: () => ( + {}} + className="transform bg-brand duration-200 ease-in-out hover:bg-brand-hover" + > + + +

추가하기

+
+
+ ), +}; diff --git a/src/components/FloatingActionButton/FloatingActionButton.tsx b/src/components/FloatingActionButton/FloatingActionButton.tsx new file mode 100644 index 00000000..e2b4eaba --- /dev/null +++ b/src/components/FloatingActionButton/FloatingActionButton.tsx @@ -0,0 +1,32 @@ +import { PropsWithChildren } from 'react'; + +interface FloatingActionButtonProps extends PropsWithChildren { + onClick: () => void; + disabled?: boolean; + className?: string; +} + +/** + * Material 3의 Floating Action Button입니다. + * 개발 과정에서의 유연성을 위해 Padding을 명시하지는 않았으나, + * p-[16px]이 적정 값임을 알립니다. + */ +export default function FloatingActionButton({ + onClick, + disabled = false, + className = '', + children, +}: FloatingActionButtonProps) { + return ( + + ); +} diff --git a/src/components/GoToHomeButton/GoToHomeButton.tsx b/src/components/GoToHomeButton/GoToHomeButton.tsx new file mode 100644 index 00000000..822ec6a3 --- /dev/null +++ b/src/components/GoToHomeButton/GoToHomeButton.tsx @@ -0,0 +1,20 @@ +import { useNavigate } from 'react-router-dom'; + +export default function GoToHomeButton() { + const navigate = useNavigate(); + + const handleClick = () => { + navigate('/'); + }; + + return ( + + ); +} diff --git a/src/components/HeaderTableInfo/HeaderTableInfo.tsx b/src/components/HeaderTableInfo/HeaderTableInfo.tsx index e69dcf95..fcd70cae 100644 --- a/src/components/HeaderTableInfo/HeaderTableInfo.tsx +++ b/src/components/HeaderTableInfo/HeaderTableInfo.tsx @@ -1,15 +1,27 @@ +import Skeleton from '../Skeleton/Skeleton'; + interface HeaderTitleProps { name?: string; + skeletonEnabled?: boolean; } export default function HeaderTableInfo(props: HeaderTitleProps) { - const { name } = props; + const { name, skeletonEnabled: isLoading = false } = props; const displayName = !name?.trim() ? '테이블 이름 없음' : name.trim(); return ( -
-

테이블 이름

-

{displayName}

-
+ <> + {isLoading && ( +
+ + +
+ )} + {!isLoading && ( +
+

{displayName}

+
+ )} + ); } diff --git a/src/components/HeaderTitle/HeaderTitle.tsx b/src/components/HeaderTitle/HeaderTitle.tsx index 5cebfc02..d3bebe59 100644 --- a/src/components/HeaderTitle/HeaderTitle.tsx +++ b/src/components/HeaderTitle/HeaderTitle.tsx @@ -1,13 +1,26 @@ +import Skeleton from '../Skeleton/Skeleton'; + interface HeaderTitleProps { title?: string; + skeletonEnabled?: boolean; } -export default function HeaderTitle({ title }: HeaderTitleProps) { +export default function HeaderTitle(props: HeaderTitleProps) { + const { title, skeletonEnabled: isLoading = false } = props; const displayTitle = !title?.trim() ? '주제 없음' : title.trim(); return ( -

- {displayTitle} -

+ <> + {isLoading && ( +
+ +
+ )} + {!isLoading && ( +

+ {displayTitle} +

+ )} + ); } diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index 8c1db84e..771f000b 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -7,7 +7,7 @@ interface IconButtonProps extends ButtonHTMLAttributes { export default function IconButton({ icon, ...props }: IconButtonProps) { return (
); diff --git a/src/components/Skeleton/Skeleton.stories.tsx b/src/components/Skeleton/Skeleton.stories.tsx new file mode 100644 index 00000000..4fde0a69 --- /dev/null +++ b/src/components/Skeleton/Skeleton.stories.tsx @@ -0,0 +1,16 @@ +import { Meta, StoryObj } from '@storybook/react'; +import Skeleton from './Skeleton'; + +const meta: Meta = { + title: 'components/Skeleton', + component: Skeleton, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/src/components/Skeleton/Skeleton.tsx b/src/components/Skeleton/Skeleton.tsx new file mode 100644 index 00000000..a806c421 --- /dev/null +++ b/src/components/Skeleton/Skeleton.tsx @@ -0,0 +1,20 @@ +interface SkeletonProps { + height?: number; + width?: number; +} + +/** + * 스켈레톤 UI를 나타내는 가장 기본적인 컴포넌트 단위 + * @param props.width 너비, 단위는 px이며 기본값 120 px + * @param props.height 높이, 단위는 px이며 기본값 24 px + */ +export default function Skeleton(props: SkeletonProps) { + const { height = 24, width = 120 } = props; + + return ( +
+ ); +} diff --git a/src/components/SmallIconContainer/SmallIconContainer.stories.tsx b/src/components/SmallIconContainer/SmallIconContainer.stories.tsx new file mode 100644 index 00000000..ff26bed0 --- /dev/null +++ b/src/components/SmallIconContainer/SmallIconContainer.stories.tsx @@ -0,0 +1,19 @@ +import { Meta, StoryObj } from '@storybook/react'; +import SmallIconButtonContainer from './SmallIconContainer'; +import DTHome from '../icons/Home'; + +const meta: Meta = { + title: 'components/SmallIconContainer', + component: SmallIconButtonContainer, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: , + }, +}; diff --git a/src/components/SmallIconContainer/SmallIconContainer.tsx b/src/components/SmallIconContainer/SmallIconContainer.tsx new file mode 100644 index 00000000..cd7b00cf --- /dev/null +++ b/src/components/SmallIconContainer/SmallIconContainer.tsx @@ -0,0 +1,30 @@ +import clsx from 'clsx'; +import { ButtonHTMLAttributes, PropsWithChildren } from 'react'; + +interface SmallIconContainerProps + extends PropsWithChildren, + Omit, 'className'> { + background?: string; + className?: string; +} + +export default function SmallIconButtonContainer({ + background, + children, + className, + ...buttonProps +}: SmallIconContainerProps) { + return ( + + ); +} diff --git a/src/components/icons/Add.tsx b/src/components/icons/Add.tsx new file mode 100644 index 00000000..cc51cc3c --- /dev/null +++ b/src/components/icons/Add.tsx @@ -0,0 +1,25 @@ +import { IconProps } from './IconProps'; + +export default function DTAdd({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + ); +} diff --git a/src/components/icons/Bell.tsx b/src/components/icons/Bell.tsx new file mode 100644 index 00000000..93e81730 --- /dev/null +++ b/src/components/icons/Bell.tsx @@ -0,0 +1,61 @@ +import { IconProps } from './IconProps'; + +export default function DTBell({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + + + + + + + + ); +} diff --git a/src/components/icons/Check.tsx b/src/components/icons/Check.tsx new file mode 100644 index 00000000..3048f72b --- /dev/null +++ b/src/components/icons/Check.tsx @@ -0,0 +1,27 @@ +import { IconProps } from './IconProps'; + +export default function DTCheck({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + ); +} diff --git a/src/components/icons/Close.tsx b/src/components/icons/Close.tsx new file mode 100644 index 00000000..98c08280 --- /dev/null +++ b/src/components/icons/Close.tsx @@ -0,0 +1,35 @@ +import { IconProps } from './IconProps'; + +export default function DTClose({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + + ); +} diff --git a/src/components/icons/Copy.tsx b/src/components/icons/Copy.tsx new file mode 100644 index 00000000..82d0a248 --- /dev/null +++ b/src/components/icons/Copy.tsx @@ -0,0 +1,39 @@ +import { IconProps } from './IconProps'; + +export default function DTCopy({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + + + + + ); +} diff --git a/src/components/icons/Debate.tsx b/src/components/icons/Debate.tsx new file mode 100644 index 00000000..1894a63b --- /dev/null +++ b/src/components/icons/Debate.tsx @@ -0,0 +1,37 @@ +import { IconProps } from './IconProps'; + +export default function DTDebate({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + + + + ); +} diff --git a/src/components/icons/Delete.tsx b/src/components/icons/Delete.tsx new file mode 100644 index 00000000..128c8e56 --- /dev/null +++ b/src/components/icons/Delete.tsx @@ -0,0 +1,25 @@ +import { IconProps } from './IconProps'; + +export default function DTDelete({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + ); +} diff --git a/src/components/icons/Drag.tsx b/src/components/icons/Drag.tsx new file mode 100644 index 00000000..60b4a58c --- /dev/null +++ b/src/components/icons/Drag.tsx @@ -0,0 +1,37 @@ +import { IconProps } from './IconProps'; + +export default function DTDrag({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + + + + ); +} diff --git a/src/components/icons/Edit.tsx b/src/components/icons/Edit.tsx new file mode 100644 index 00000000..047b09ec --- /dev/null +++ b/src/components/icons/Edit.tsx @@ -0,0 +1,25 @@ +import { IconProps } from './IconProps'; + +export default function DTEdit({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + ); +} diff --git a/src/components/icons/Exchange.tsx b/src/components/icons/Exchange.tsx new file mode 100644 index 00000000..186b702d --- /dev/null +++ b/src/components/icons/Exchange.tsx @@ -0,0 +1,37 @@ +import { IconProps } from './IconProps'; + +export default function DTExchange({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + + + + ); +} diff --git a/src/components/icons/Expand.tsx b/src/components/icons/Expand.tsx new file mode 100644 index 00000000..b7b59ca6 --- /dev/null +++ b/src/components/icons/Expand.tsx @@ -0,0 +1,25 @@ +import { IconProps } from './IconProps'; + +export default function DTExpand({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + ); +} diff --git a/src/components/icons/Help.tsx b/src/components/icons/Help.tsx new file mode 100644 index 00000000..b90e133e --- /dev/null +++ b/src/components/icons/Help.tsx @@ -0,0 +1,31 @@ +import { IconProps } from './IconProps'; + +export default function DTHelp({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + + ); +} diff --git a/src/components/icons/Home.tsx b/src/components/icons/Home.tsx new file mode 100644 index 00000000..746206a7 --- /dev/null +++ b/src/components/icons/Home.tsx @@ -0,0 +1,27 @@ +import { IconProps } from './IconProps'; + +export default function DTHome({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + ); +} diff --git a/src/components/icons/Icon.stories.tsx b/src/components/icons/Icon.stories.tsx new file mode 100644 index 00000000..ddfa56bf --- /dev/null +++ b/src/components/icons/Icon.stories.tsx @@ -0,0 +1,258 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import DTLogin from './Login'; +import DTHelp from './Help'; +import DTClose from './Close'; +import DTHome from './Home'; +import DTCheck from './Check'; +import DTExpand from './Expand'; +import DTDebate from './Debate'; +import DTRightArrow from './RightArrow'; +import DTLeftArrow from './LeftArrow'; +import DTDrag from './Drag'; +import DTCopy from './Copy'; +import DTDelete from './Delete'; +import DTEdit from './Edit'; +import DTPlay from './Play'; +import DTReset from './Reset'; +import DTShare from './Share'; +import DTExchange from './Exchange'; +import DTBell from './Bell'; + +const meta: Meta = { + title: 'Design System/Icons', + component: DTLogin, + parameters: { + layout: 'centered', + }, + argTypes: { + color: { + control: 'color', + description: '아이콘의 색상', + }, + className: { + control: 'text', + description: 'Tailwind CSS 클래스 추가 (크기도 여기에서 관리)', + }, + }, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const OnLoginIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

로그인

+
+ ), +}; + +export const OnHomeIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

+
+ ), +}; + +export const OnHelpIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

도움말

+
+ ), +}; + +export const OnCloseIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

닫기

+
+ ), +}; + +export const OnCheckIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

확인

+
+ ), +}; + +export const OnExpandIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

확장 (더보기)

+
+ ), +}; + +export const OnDebateIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

토론

+
+ ), +}; + +export const OnRightArrowIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

우측 화살표

+
+ ), +}; + +export const OnLeftArrowIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

좌측 화살표

+
+ ), +}; + +export const OnDragIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

드래그

+
+ ), +}; + +export const OnCopyIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

복사

+
+ ), +}; + +export const OnDeleteIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

삭제

+
+ ), +}; + +export const OnEditIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

수정

+
+ ), +}; + +export const OnPlayIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

시작/재생

+
+ ), +}; + +export const OnResetIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

초기화

+
+ ), +}; + +export const OnShareIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

공유

+
+ ), +}; + +export const OnExchangeIcon: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

교체

+
+ ), +}; + +export const OnBell: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

타종

+
+ ), +}; diff --git a/src/components/icons/IconProps.tsx b/src/components/icons/IconProps.tsx new file mode 100644 index 00000000..d12059da --- /dev/null +++ b/src/components/icons/IconProps.tsx @@ -0,0 +1,6 @@ +import { SVGProps } from 'react'; + +export interface IconProps extends SVGProps { + color?: string; + className?: string; +} diff --git a/src/components/icons/LeftArrow.tsx b/src/components/icons/LeftArrow.tsx new file mode 100644 index 00000000..c663e688 --- /dev/null +++ b/src/components/icons/LeftArrow.tsx @@ -0,0 +1,29 @@ +import { IconProps } from './IconProps'; + +export default function DTLeftArrow({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + + ); +} diff --git a/src/components/icons/Login.tsx b/src/components/icons/Login.tsx new file mode 100644 index 00000000..a0736687 --- /dev/null +++ b/src/components/icons/Login.tsx @@ -0,0 +1,49 @@ +import { IconProps } from './IconProps'; + +export default function DTLogin({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + + + + ); +} diff --git a/src/components/icons/Play.tsx b/src/components/icons/Play.tsx new file mode 100644 index 00000000..1b59eebf --- /dev/null +++ b/src/components/icons/Play.tsx @@ -0,0 +1,25 @@ +import { IconProps } from './IconProps'; + +export default function DTPlay({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + ); +} diff --git a/src/components/icons/Reset.tsx b/src/components/icons/Reset.tsx new file mode 100644 index 00000000..26d6f16d --- /dev/null +++ b/src/components/icons/Reset.tsx @@ -0,0 +1,25 @@ +import { IconProps } from './IconProps'; + +export default function DTReset({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + ); +} diff --git a/src/components/icons/RightArrow.tsx b/src/components/icons/RightArrow.tsx new file mode 100644 index 00000000..fb492995 --- /dev/null +++ b/src/components/icons/RightArrow.tsx @@ -0,0 +1,29 @@ +import { IconProps } from './IconProps'; + +export default function DTRightArrow({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + + ); +} diff --git a/src/components/icons/Share.tsx b/src/components/icons/Share.tsx new file mode 100644 index 00000000..b2c09aab --- /dev/null +++ b/src/components/icons/Share.tsx @@ -0,0 +1,27 @@ +import { IconProps } from './IconProps'; + +export default function DTShare({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + ); +} diff --git a/src/constants/debate_template.ts b/src/constants/debate_template.ts new file mode 100644 index 00000000..570de293 --- /dev/null +++ b/src/constants/debate_template.ts @@ -0,0 +1,270 @@ +import government from '../assets/template_logo/government.png'; +import igam from '../assets/template_logo/igam.png'; +import kondae_time from '../assets/template_logo/kondae_time.png'; +import han_alm from '../assets/template_logo/han_alm.png'; +import todallae from '../assets/template_logo/todallae.png'; +import jungseonto from '../assets/template_logo/jungseonto.png'; +import visual from '../assets/template_logo/visual.png'; +import hantomak from '../assets/template_logo/hantomak.png'; +import seobangjeongto from '../assets/template_logo/seobangjeongto.png'; +import osansi from '../assets/template_logo/osansi.png'; +import nogotte from '../assets/template_logo/nogotte.png'; +import kogito from '../assets/template_logo/kogito.png'; +import { DebateTemplate } from '../type/type'; +function createTableShareUrl(encodeData: string): string { + return `${import.meta.env.VITE_SHARE_BASE_URL}/share?data=${encodeData}`; +} +interface DebateTemplateList { + ONE: DebateTemplate[]; + TWO: DebateTemplate[]; + THREE: DebateTemplate[]; +} +export const DEBATE_TEMPLATE: DebateTemplateList = { + ONE: [ + { + title: '산업통상자원부', + subtitle: '', + logoSrc: government, + actions: [ + { + label: '3vs3 통상토론대회 형식', + href: createTableShareUrl( + 'eJzFkj9PwkAYh79K884dVAZMN8AOJNKStiwaY45yhcZybXr4L6SJRmGRwUVDTDXV2QEHEr9Tr9%2FBO6pYjIsLbndP7n6%2FJ%2B%2FdENwOKKXtsgwucXxQhkBQH4MCJemESiUpG8%2FZ1WU2TtLnJJ1cZA8TKZvesZtHkGFwHoiTtZZp6Y36nsoR6mLSQRzydRD61MKor%2BWBbPbKrt84t31S5OlsyoM5P0UhcUm3ij0PFAd5FMvguMSlvQKKeC1qe%2Fzi%2FhDoABFbZDQN3eQJNMDY7lm5FnsacWdO2%2F7ZJ9J0o1HZFeauqN7c2siXTRwKIVDIsectkRlgdMSFvjAVexzm20gu1Nd07T%2FrNbVlGYvgHwa3LE7yp1vxsOoN9bBaMdWdpctKcy5TLuh9uyyk%2FziJecxeRlI6i9n9%2B1oG8ut3WIvFQfQBTEMy%2BQ%3D%3D', + ), + }, + ], + }, + { + title: '건대타임', + subtitle: '건국대', + logoSrc: kondae_time, + actions: [ + { + label: '타임식 토론', + href: createTableShareUrl( + 'eJyrVspMUbIyMTDWUcrMS8tXsqpWykvMTVWyUnq1ccvrnoa3zQ1v5rW86Z6r8LZtweuFC5R0lEoqC0DyzqHBIf6%2BnlGuQKHE9NS8lESgIJBdUJRfHJKamOsHMebNhjVvWjYCxZPz85DFX2%2BYATQeKF6eWJSXmZfulJqTo2SVlphTnKqjlJaZl1mcAREqKSpNrQXampiUA9QXXa1UXJKYlwwyws81NCTI0QdoRnFBampyRgjEYRCHvlnQAnLym7kzgPJJ%2BRVQST%2F%2FIF%2BwlpJMkDMMLQwgzIDUIpDjlKzySnNy4ELBBamJ2UDHwYSLQfzUIgi3VodYt%2BBzgZGhAZWcEBDkH4xu%2F5t5rQTsNzSikvXO%2Fn4DaT0234PSGAHrqRX%2F2HxPY%2BtjawF7h1Ut', + ), + }, + ], + }, + { + title: '한앎', + subtitle: '연합동아리', + logoSrc: han_alm, + actions: [ + { + label: 'CEDA 토론', + href: createTableShareUrl( + 'eJyrVspMUbIytjTUUcrMS8tXsqpWykvMTVWyUno7dc6bqX1vuucqOLu6OCq8bVvweuECJR2lksoCkLRzaHCIv69nlCtQKDE9NS8lESgIZBcU5ReHpCbm%2BkFMebNhzZuWjUDx5Pw8ZPHXG2a87mkAipcnFuVl5qU7pebkKFmlJeYUp%2BoopWXmZRZnIAnVAq1NTMoBaoyuViouScxLBpkREOQfDDShuCA1NTkjBOKsN%2FNaIc5Myq%2BACvn5B%2Fk6%2BoBcngmy2tDUAMIMSC0COUjJKq80JwcuFFyQmpgNdBBMuBjETy0CmmP4elOHUq0OkgOc%2Ff0wHPBq64Q3G1a8WbjhTdMavM4wItMZRkQ5g67hgC0iaB4OxDmDhuFAXEQMQDjQOT1ghsOApAfiogNU7uAPByNDmiZIujqAziFAXEqguQNiawHVv27d', + ), + }, + ], + }, + { + title: '토달래', + subtitle: '성신여대', + logoSrc: todallae, + actions: [ + { + label: 'CEDA 토론', + href: createTableShareUrl( + 'eJzVk09LAkEUwL%2FKMuc9WJHU3vyzByF3xV0vRcSooy6ts8uuUSFCBwnBDnYRDyrrJTvsYYICP5MzfYdm2jIF8abSbd7vzbz34zGvCawyUI5OjmVg4YoDlCbAsI6AAj4fA9oN6XjAuiMppaYTkiCTAMigce%2BKG6mCYerZzLnKEawiXIYc8rPrOb6JYF2LCjESsvYb5yUHL3NKBvTpgfNb6GELV5PItoFSgbaPZFCxsOXXllCLt4VFmz%2B8aAK%2FAXFJ1MjldYNX8F2ESjUz0prPCJsQSoasP%2BO5onP3k9D0fDZxJvwtIXAai0455AkroOAb214gw0Xwmlv9Yl%2FEyIvClrzkkNK1vTusncNHj5FXNu2w0WCTw8HhNgexawlNLZj578IrHmz8zIK2xLrDOWlvEolv26PHhsFik%2F40zExWvUomDDW9UFnpHMnEY7F%2FPJN1H4ROOzR8py%2BhtN9t2ZXHZesLlsH%2Fxw%3D%3D', + ), + }, + ], + }, + ], + TWO: [ + { + title: '중앙선거방송토론위원회', + subtitle: '', + logoSrc: jungseonto, + actions: [ + { + label: '열린 토론대회 예선, 본선', + href: createTableShareUrl( + 'eJzFks9LAkEUx%2F%2BVZc4SG9KhvZV5EPIHul2KiMlGXVpH2TUqRPCwhaFgHUqJ1YyiLkYbWXnw1J%2Bz8%2Fwfmm1KlKBDiJ1m5jvvfd%2Fnzbwi0naQ4pf9PqTRVA4pRURxliAFsVp5eG6zx4H78iBBo8funOFxh113vIvLmgTNClid9zf23Ocr8qHCYd7LC6wl1Gg4tB7kEk4TuoO5yD24EzQct1%2BRoNVk3Z7rlKF9BC1LGtZP4Z4Xa0KjC%2BcDyStbvZ3j%2BXkjZ6oEZyMCCZwuWE9cT%2BbouM6cJofi%2Bj42qEbTy0TXkZLCukl8KKVRzcwIqWDskRInxds6z9soIrOAadKziMWjCW5g5glJZlTRiSg3rJUlqNrQPmOODRd9HrWdO%2FgKiUTj4aVVr3nNI1mUxS5GDA8PKXRP10dSIk%2FwLsf7lk3vTAxxLPnGaALRyA8a0eR%2F0ESCa2r803fyeW4GrG65rzVJzMUEihoKB7eWlxLBlRHORHUB5JflKfJUbdexoNdiVyfQsaeGM78g%2F%2F2v2H2Fzzq760qz%2B6%2FfZ3lWRJulD1QmwAI%3D', + ), + }, + { + label: '열린 토론대회 결승', + href: createTableShareUrl( + 'eJzFks9LAkEUx%2F%2BVZc4SGgWxN7U9CPkDXS9FxKijLq2j7CoVIniwsBSsgymxmlHoxWgLCwP%2Fop3Z%2F6HZ1kzJk0Hd5n3nfd%2F7vJlXBFIC8E4HkHAyC%2FgiwDCDAA9IvWw2NfI0MV4fOdoakb7OmWc9ctezbm7qnPGi04t34AD5k5xl8EYjYtDv2xWYBFMIJyATmZmVoC3dGFc52mmT4cjQy7R7SjsVzmxc0gHr0qatIW1OOKtf7WGN%2BXNKVhURzARsFqoPaeWZ6fEsnteJ3mYwTD%2BCCpZwyoNkGfB5pYAcIClhSU1%2FKyUGCmMys%2B0VgZqHOG5VCIWDEeZXcwjF06I9iN3NrJc5WtNo94ojukavxywtlj2e5gSCYb97xxpeskhc6077GEKKxQd4XJDlmRTJIXjI%2BL5k1YqRYoclxxyPNxj4wWNP%2BT88ASEqhj8LLz7R%2FYQ0KsZbfboSCyyizy8ceNwRYXvGs9DdBtqYQ%2Fw9T00z9AoddcjtOe1pK%2BC4tpbhuDadq%2F8WGVTZupP%2B8C8%2FbNlC%2FwPSfukDpaO6ow%3D%3D', + ), + }, + ], + }, + { + title: '통일부', + subtitle: '', + logoSrc: government, + actions: [ + { + label: '통일토론대회 형식 (예선)', + href: createTableShareUrl( + 'eJzdk09LAkEYh7%2BKzKlgD6kQtTe1PQi5K%2B56KSJGndWldXbZNSpE6LCKkIe6hITG1qUOHbZA6NAncsfv0ExbsvbPEDLpNvMw83sf3nmnDrQS4OPrMQ5oWDUAXwcYVhHgwag1IP2nyKjl%2Bleu3zkeXXSWSLdNHHcZcKB2ZLJDqbysSJn0lkARLCNcghTStWkZtoJgVQyyiHdHnHvKiwYOc9%2Fr0mTKD6CFNVxOIl0HvAp1G3FA1bBmV0KoQcvCgk4vbteBXYO4yDKyOUmmCbaJULGiBFrkskmlKS0Yh69IlHKZxCYz11jp6NpKsMwiiwkBHu%2Fr%2BhjJJoJ7VOgN22yPLJoT9R%2FaoMGFBFKS%2BLcCopBXci%2FR7xzOiOtEyElv6DnfqazOZsK2UxvBXnhKI2IzNiL2oRGfjcJcBRblJb7wGA5OiXdLbtqk353QUNIZYTeZkIWNscpE5UAmPLL%2FymUhfgoZ9Mh1M%2BJ7PXL%2B%2BCvzGv%2FRh5mXx07jGWqQVcg%3D', + ), + }, + { + label: '통일토론대회 형식 (준결승, 결승)', + href: createTableShareUrl( + 'eJztk09LAkEYh7%2FKMqeCPaRG1N7SPAi5K%2B56KSJGHXVpnV1co0IED6tIerBDIaWxRVCHDpsgFPSJ3PE7NNuWaH8srKxDp515YH7vs%2B87UwByEnC%2BJR8LZJxSAVcAGGYR4EC%2F0iXte6ZfMe0z066X%2Bsd1pt88JLX2DLko9ToW2b9jGfc7C1iQ39OcY4GYKAnh0FqQIphGOAkppGstp%2BoSglneTSfWNTFuKE%2BoeJjbVpPWonwH5rCM036kKIBLQUVHLEjJWNYzQ6hIy8K4Qg%2BuF4CehzjhZESigkgTdA2hREZytchpmf4GpXF19wnxQjS8vOqYy05p7%2Fycu4ygnCMEOLytKAMkaghuUaFnrDt7lKM5HrtTBUV2SCAg8L8rwAdjUvQx%2BoXDATENhtRaPcsYp7IwmYmz%2FbARzoTHN8KzOGEjvK8a8dZVmKrAVyfh8X7TKN4TMQ1y0mB63QaxrshllbSbIzpSKBzc9C%2BLwZWB0oiA6zTcsH%2Bl6Sj9hbdMui1yXmZsq0WObn%2FiGgPfp570tDw2ig%2BaOaDC', + ), + }, + ], + }, + { + title: '비주얼', + subtitle: '명지대', + logoSrc: visual, + actions: [ + { + label: '2:2 자유토론', + href: createTableShareUrl( + 'eJyrVspMUbIytjDRUcrMS8tXsqpWykvMTVWyUnq9s%2BXN4j1vpu150z1XwcjKSOHNvAlv5ix427bg9cIFSjpKJZUFIGXOocEh%2Fr6eUa5AocT01LyURKAgkF1QlF8ckpqY6wcx7c2GNW9aNgLFk%2FPzkMVfb5jxuqcBKF6eWJSXmZfulJqTo2SVlphTnKqjlJaZl1mcAREqKSpNrQXampiUA9QXXa1UXJKYlwwyIiDIPxhoQHFBampyRgjEVW%2FmtUJcmZRfARXy8w%2FydfQBOTwTZLOZAYQVkFoEco6SVV5pTg5cKLggNTEb6ByYcDGIn1oE4dbqINnu7O83gLb7uYaGBIHNRXMASlQhnBHi6esa7%2BQY7OoCdwqKzRDHmBkYYHGLoZEByQGxdc6bRa0KrzfMeTNtBz3CA2taoIcjYmsBmUkxug%3D%3D', + ), + }, + { + label: '2:2 CEDA 토론', + href: createTableShareUrl( + 'eJzllD9PwkAUwL9Kc3MHaIjRbvzpQCItoWXRGHOUAxrLtWkhagiJQzEmOOBCGIqpOujgUAcSTfhE9PwOXqkCKmFRiInb3S937%2F3y7t5rAa0M%2BESMY4GGKwbgWwDDOgI8CF4ccjcm%2FTHpDhmO55i0kEkyr%2BdecOMBFjROzfBUuigrUi67J1AEqwiXIYV0bVqGrSBYF6NgxH8kzhPlqoEXeeAPgsszyo%2BhhTVcTSFdB3wF6jZiQUXDml2LUMNqojbNCks6vbffAnYDYjUMkS9IMg1gmwipNSWyItedyLJknLwjUSrkkruhuBZm3olFqzyyQh3A46auz5BsInhEdT6wHe6RFW3b7EJ2USgqhWncLwJXxHMY0nUnvrPKY%2BuXPNKS%2BK0Kk1GP%2BA8Mub8gw8Eqifj2Gi3%2B31ss%2B5Gbf4ufViPOrVukR1xvNk%2FmHko2JxymkrKQmbl8yhzJJBbqNHeZSv%2F9H7K0T0Yuue0wge%2BS%2FvMm2mXp4NyExEH7DcztU4k%3D', + ), + }, + ], + }, + { + title: '이감', + subtitle: '경희대', + logoSrc: igam, + actions: [ + { + label: '하이브리드 토론', + href: createTableShareUrl( + 'eJzFkk1LAkEYx7%2BKPOc9%2BAIhe1Pbg5C74q6XImLUUZfW2WXXqBAhIiPIgxCFhzW0Q3TwsEWCn8kZv0OzrZm9kBJkt3l%2BzPyfH88zDdBLIMbiMQF0UjZBbABBNQwisN7zxOuwy15oet3lBR236f2QXrmh6XmfDvogQP3Y8m%2Bm8qqmZNLbEkeogkkJccjPlm06GkY1eRboDdnZI%2BdFkyxy6nVp%2B4TzQ2QTnVSS2DBALCPDwQKUdaI71QXU5G1RweAPdxrg1BEp%2BhnZnKLyBMfCuFjVAi122wo0C%2BbRDMlKLpPY8s11v3UkHg6OWWz7QiCSA8OYI9XCaJ8LvWHHr7HNcyL06QKawoJASpG%2FCExGHeY9sIHHToc%2FakR%2FqRFdSWOtc%2FhuEf8wB1nKa7nX6E%2Bj6DC3P%2F%2B%2F7yZaOiPtJROqtDm3%2BdA70NkIh5fb%2BOXynYxcdtcKUc9lN%2BM%2FmUlspdWsy2O3%2BQJwVp3c', + ), + }, + { + label: '2:2 자유 토론', + href: createTableShareUrl( + 'eJyrVspMUbIyMTTQUcrMS8tXsqpWykvMTVWyUnozd8urDRPedM9VMLIyUngzb8KbOQveti14vXCBko5SSWUBSI1zaHCIv69nlCtQKDE9NS8lESgIZBcU5ReHpCbm%2BkGN2rDmTctGoHhyfh6y%2BOsNM173NADFyxOL8jLz0p1Sc3KUrNISc4pTdZTSMvMyizOQhGqB1iYm5QA1RlcrFZck5iWDzAgI8g8GmlBckJqanBECcdabea0QZyblV0CF%2FPyDfB19QC7PBFltaQBhBaQWgdyjZJVXmpMDFwouSE3MBroHJlwM4qcWQbi1Oki2O%2Fv7DaDtfq6hIUFgc9EcgBJXCGeEePq6xjs5Bru6wJ2CYjPEMeZGBljcYggSJTEgts55s6hV4fWGOW%2Bm7aBHeGBNC%2FRwRGwtADF%2FL8A%3D', + ), + }, + ], + }, + { + title: '한토막', + subtitle: '한양대', + logoSrc: hantomak, + actions: [ + { + label: '반토막 토론', + href: createTableShareUrl( + 'eJyrVspMUbIytjTTUcrMS8tXsqpWykvMTVWyUno7dc7btgWvl3e%2B6Z6r8HrDDAhHAUQtXKCko1RSWQBS5hwaHOLv6xnlChRKTE%2FNS0kECgLZBUX5xSGpibl%2BENPebFjzpmUjUDw5Pw9ZHGjw654GoHh5YlFeZl66U2pOjpJVWmJOcaqOUlpmXmZxBkSopKg0tRZoa2JSDlBfdLVScUliXjLIiIAg%2F2CgAcUFqanJGSEQV72Z1wpxZVJ%2BBVTIzz%2FI19EH5PBMkM2GFgYQZkBqEcg9SlZ5pTk5cKHggtTEbKB7YMLFIH5qEdAcw9ebOpRqdZAc4OzvN%2Fgc8GrrhDcbVrxZuOFN0xqaOMMIwxnYImIAnOHnGhoSBDYaLUYmvJmzAJ58ES4J8fR1jXdyDHZ1gbsGxW6Ic8yNDLC4xtIAyS0gXYQTxtY5bxa1AjPUnDfTduANESMyQ8SYqIihlztiawHvP5m7', + ), + }, + { + label: 'CEDA 토론', + href: createTableShareUrl( + 'eJyrVspMUbIytjTXUcrMS8tXsqpWykvMTVWyUno7dc7btgWvl3e%2B6Z6r4Ozq4qgA4i5coKSjVFJZAFLhHBoc4u%2FrGeUKFEpMT81LSQQKAtkFRfnFIamJuX4Qg95sWPOmZSNQPDk%2FD1n89YYZr3sagOLliUV5mXnpTqk5OUpWaYk5xak6SmmZeZnFGRChkqLS1FqgrYlJOUB90dVKxSWJeckgIwKC%2FIOBBhQXpKYmZ4RAXPVmXivElUn5FVAhP%2F8gX0cfkMMzQTYbmRhAmAGpRSD3KFnllebkwIWCC1ITs4HugQkXg%2FipRUBzDF9v6lCq1UFygLO%2FH4YDXm2d8GbDijcLN7xpWoPPGYYWZDrDiChn0DUcsEUEzcOBOGfQMByIi4gBCAc6pwfMcBiQ9EBcdICKHfzhQOMESVcH0DkEiEsJNHdAbC0Axtlw5w%3D%3D', + ), + }, + ], + }, + { + title: '서방정토', + subtitle: '서강대', + logoSrc: seobangjeongto, + actions: [ + { + label: 'CEDA 토론', + href: createTableShareUrl( + 'eJzFlMFLwlAcx%2F%2BV8TvvkHrJ3cw8BLmJzkvR4alPHc23sSkVIkTMCOxQh6LDitmpwMMKhP4m9%2Fofeq9ZSAYOYfP2e1%2Fevu%2FD9%2FtjfdAaIGWyWRE00jRA6gNBHQwSUMcN%2FFfq3X1eenT0KOQLuzmBzcHYAxG6Zya%2FlK9WVKW4d1BgEmph0kBMZLNpGbaKUUeee%2FkT6rwxvW6QRT3wH4Lrc6afIItopLWDdR2kJtJtLEJTI5rdDqWu1cMD9iqq6ey7wz7YXUTq3KJUVirMwDYxrrfVkIo%2BDUPKmnE6l2SlXMztc3CNv5xKb4VjCVucByTS0%2FVfqWJidMx4fmSbn7HFfFLB%2BxUMxAWAvCIvAcymN9R%2FEejYpxeTWDjSkTgSDeK%2FJuIPIhpHjEFEa2ITQSS8EctBbGYjljnkQlUtf1v%2FyeKWeo5AR%2B7Md%2BJA4ceVjfB%2F4IpGttdMIhOpkUQB1kwg3p3cOACduvR5KAS%2BS%2B8%2FYuGI1kRSHEeDL6cNB4E%3D', + ), + }, + { + label: '국회의장배 토론', + href: createTableShareUrl( + 'eJzNkkFLAkEUx7%2FKMuc9rOah9qa2ByF3RbdLETHqqEvr7LKrVIgQsUWgBzsEEirrISgwmAS%2FlDt%2Bh2Zd2ywlJJC6zfyY%2Bb8f770G0IpAjAkCDzRcMoDYABhWERABdXoeeaHuw%2BzWpa0%2BN528zh7btN%2BlgyePEI5hb%2BgCHtQuTf998jCnKunUkcQQLCNchAyys2kZtopgVV7EkhF13hgvGHiZe6Trta8YP4cW1nA5gXQdiCWo24gHJQ1rdiVANauOmqwqzOvs33ED2DWIC35EJqvkWIBtIlSoqIEVHdwElnnjYoFkJZuOH%2Fjiml85EhWCYwZZvg8QcV3XQ5QzETxjPh%2FY9u%2FIYjkRb3wHmvySQFKR%2F5%2FAdNKh5JmjQ0KvR1vxiK54rJvEX3jI0qGanUd%2Fm0mH9txwgT9N1FRaOk3Ec9J%2BaPOldqAT2xXW2My1Qxn%2F22Yq99R1ONrqTYnzU1f2fteUFY%2B1oxmTbe3ozmY7um2Bk%2BY7y2LI5w%3D%3D', + ), + }, + ], + }, + ], + THREE: [ + { + title: '오산시', + subtitle: '', + logoSrc: osansi, + actions: [ + { + label: '오산시 토론 - 초등부', + href: createTableShareUrl( + 'eJyrVspMUbIy0FHKzEvLV7KqVspLzE1VslJ6M2PJm6YNb7rnKLxtW%2FB64QIFXYU3WzpeT974eluDko5SYnpqXkoiUB2QXZ5YlJeZl%2B6UmpOjZJWWmFOcqqOUlpmXWZyBIlRQlF8ckpqY6we1YMOaNy0bgdqT8%2FOQxV9vmPG6p0GpVkepJDEpBygSXa1UXJKYlwySDAjyDwZqKS5ITU3OCKksABu0afXrTR1vlu9QgBip8HrDnDfTdgCVJeVXQNX4%2BQf5OvoAhUoyQZYYmRhAmAGpRSCrlazySnNy4ELBBamJ2UAfwYSLQfzUIgi3VgfJPX6uoSFBYINRnTRv4psFLQrA0Hu1oQWfQ8zIc4eSEoornP39CIfKm%2B41r9fgDRVDi6EdKhjuwB8ukIQ2AlILhjvw5yJouIzA9IItXF5PnDDSShei8hFGuIzA9II%2FXEbLF7zhMgLTC9Zyt2XHaPmCrZ6Ghwt98lFsLQBY%2BCHr', + ), + }, + { + label: '오산시 토론 - 중등부', + href: createTableShareUrl( + 'eJztVTFLw0AY%2FSvhmyNUC1KyaXEQbFLadFEcru21DaaXkFRUSkEkiFAHiyAdikQsONgh7SAd%2FEXe%2BR%2B8M7FUDHERl3S7e%2Fnu3cvjfd91waiDspHLymCQhgVKFwhqY1CADcfsImD9kfR%2B6dMHX1qT2HhAb6f05Rxk6JzZoipfKetaYXd%2Fh0OoiUkdcTA6wB5fmT%2FiH2zHcnWM2mrEHEyYN%2BV4zSLLOA2G9FpwnyCHGKS5jU0TlAYyXSxDwyCG21qCelwDqpr84EEX3A4iNcFRLGllzuDaGNdaeqiRzZ7p7Io9zaXwZokGI3Y352VV6zSqUbVSYWtP%2FJchtGQzmXBZxI5QCAo5Ns0FVLYxOuIKv2BX7LETbnvykh51p6KXPom%2FS7ofMN%2BTuLtvgZckZPOPdOQ19XdfWH9CJ4m%2BrOdS5UuYyDTmJbmPIl9SmJc4X%2BjgZjVfYvrohy8pzEuyL6v5kuhLCvMSO3e9%2BWq%2BxL3TC1%2F%2Bp48Oex87ajH7', + ), + }, + { + label: '오산시 토론 - 고등부', + href: createTableShareUrl( + 'eJyrVspMUbIysjDRUcrMS8tXsqpWykvMTVWyUnozY8mbpg1vuucovG1b8HrhAgVdhVebF7yevPH1tgYlHaWSygKQKufQ4BB%2FX88oV6BQYnpqXkoiUBCq4c3iPW8WzAFKFBTlF4ekJub6QU3esOZNy0ageHJ%2BHrL46w0zXveAzC5PLMrLzEt3Ss3JUbJKS8wpTtVRSsvMyyzOQBKqBbohMSkHqDG6Wqm4JDEvGWRGQJB%2FMNCE4oLU1OSMEIgb32xa%2FXpTx5vlOxQgNiu83jDnzbQdQGVJ%2BRVQNX7%2BQb6OPiB%2FZYLcYmxmAGEGpBaBXKhklVeakwMXCi5ITcwGuhAmXAzipxZBuLU6SO7xcw0NCQIbjOqkeRPfLGhRAIbuqw0t%2BBxCLXc4%2B%2FsRDpc33Wter8EbLoYWIypcIClyJKYX%2FPkIGi4jML1gC5fXEyeMli9Y8hFGuIzA9II%2FXEbLF7zhMgLTC9Zyt2XHaPmCrZ6Ghwt98lFsLQAnZTI8', + ), + }, + ], + }, + { + title: '노곳떼', + subtitle: '한국외대', + logoSrc: nogotte, + actions: [ + { + label: '자유토론', + href: createTableShareUrl( + 'eJyrVspMUbIytrDQUcrMS8tXsqpWykvMTVWyUnrduuPV5s2vp%2B150z1X4c28CW%2FmLHjbtuD1wgVKOkollQUgJc6hwSH%2Bvp5RrkChxPTUvJREoCCQXVCUXxySmpjrBzHpzYY1b1o2AsWT8%2FOQxV9vmPG6pwEoXp5YlJeZl%2B6UmpOjZJWWmFOcqqOUlpmXWZyBJFQLtDYxKQeoMbpaqbgkMS8ZZEZAkH8w0ITigtTU5IwQiLPezGuFODMpvwIq5Ocf5OvoA3J5JshqMwMIKyC1COQeJau80pwcuFBwQWpiNtA9MOFiED%2B1CMKt1UGy3dnfbwBt93MNDQkCm4vmAJS4wukMQyMDKjkEWyS82rRhACOBtrbH1gIA5Ksorg%3D%3D', + ), + }, + { + label: 'CEDA 토론', + href: createTableShareUrl( + 'eJyrVspMUbIytrDUUcrMS8tXsqpWykvMTVWyUnrduuPV5s2vp%2B150z1XwdnVxVHhbduC1wsXKOkolVQWgFQ4hwaH%2BPt6RrkChRLTU%2FNSEoGCQHZBUX5xSGpirh%2FEoDcb1rxp2QgUT87PQxZ%2FvWHG654GoHh5YlFeZl66U2pOjpJVWmJOcaqOUlpmXmZxBpJQLdDaxKQcoMboaqXiksS8ZJAZAUH%2BwUATigtSU5MzQiDOejOvFeLMpPwKqJCff5Cvow%2FI5Zkgqw2NDCDMgNQikIOUrPJKc3LgQsEFqYnZQAfBhItB%2FNQioDmGrzd1KNXqIDnA2d8PwwFvZ059M3fHm%2BUdr9fswOcMMzJdYUSUK%2BgaDNjigf7BgM0VoFRGo2AwHhSpgbg0SddgGIjUgBkMfq6hIUFgk9HyxYQ3cxbAyzKcDrEk0yUgLnHumPhmQYvCm%2B45rza00CJiMBxC5%2BxBXDFFVwdgrS62znmzqFXh9YY5b6bhTZk0Lifo5Y7YWgA%2FKNwf', + ), + }, + { + label: '의회식 토론', + href: createTableShareUrl( + 'eJzN001LAkEYB%2FCvInNeyFUI25uWByFXcddLETHqqEvrKK5RIYKERqAHgxIJlbVTBw9bYnjoE%2B2O36EZt3whKRHMbjN%2FZp%2F58ew8RaAkgODec3JAwcksEIoAwwwCArCqI3MwsJrvpNZxkE5r%2FFhnq%2FGNbvV0wIHCVY4d249KcigYOPLTCKYQTkAa0nUun9VkBDOiXY0YfVJ5oXk8i%2Bdzy2hZ9TLNL2AeKzjlQ6oKhCRUNcSBpIIVLT0Xlei1MKbSD4%2BLQCtAHGc1wpGQRCtoOYTiadlmkW7VZsayl5%2BRGIoEvYdMrrCreZfTXoZRnoGAgM9VdRpJOQTPKOgr1tge5Wkd3nq9BSVuDrAfErcLWNqBt3vTKNsMzsH6bDxshONarR%2Fb44j%2BqByZlF4Q8QI%2Fe80zhxwI%2Bk99Xsl%2FMLUs3GxjeI%2FzdwvbriIh3QZp62tb3LvLLJMGrkG5I3rFQWpt06hs4v98gyx7LOawQYxn0jPIdf9HhGejI%2FRPGGTYJk9VOjNt0hxtZmZ23KsN8Z9JTkofiV5mow%3D%3D', + ), + }, + ], + }, + { + title: '코기토', + subtitle: '고려대', + logoSrc: kogito, + actions: [ + { + label: '2:2 일반토론대회 형식', + href: createTableShareUrl( + 'eJzFkjFPwkAYhv9Kc3MHIAzaDbADibSElkVjzAEHNJZr00LUEBIHcJFEB0Vi0KAmTg41UcOgf4ge%2F8HvLBLALiYGtrunufd78vZrIqOEpHgkLiKDli0kNRHFNYIkxD4vxyNvcjpkZ7dCTIoJ7PrVf%2BLAvx%2F63ZPJTVeY9K%2FgKxJR%2Fdjmb1J5TVcz6R0ZEK4QWsIA4Ww7lqsTXFOm0d4za78AL1p0nvteH4KBH2KHGrSSJKaJpDI2XSKiskENtxqgutMgLZiKCya8220it45pkUdkc6oGAa5NSLGqB1bsrgPKQAvW0RQpai6T2ObiBp%2B8GQlOWeJwHSTRhmnOkGYTfAA6P9jld%2BIE15Y4Nz2lKmucrsh5PfeduyRwwQbD4L8taOjpjLyfTGjy1kxlYXIgE49FQlyinP6xiMcP%2F7w9foe1%2Be2yVEl04586Cd2HdYiENvI2YA8dwfcGrDdaxYaEtrEKib3WF4vqn1s%3D', + ), + }, + { + label: '3:3 통일토론대회 형식', + href: createTableShareUrl( + 'eJztlU9LAkEYxr%2FKMmcP9pfaW4mHIF3R9VJ0GHXUpXV22VUqRPCgEihUUCGhYXWoQ4eN2PBgX2h39jv0rmshIdLFXQ%2FdZn7DvM%2FDMy%2FvVJGUQ%2Fx6eCOEJJpXEF9FFJcI4hH7vLaGhtMasHafW%2BPXOKdlsv4IgP0wsDt1567DOd0bOEUhVD5T3TuRdEoUYnsHUUC4QGgOA4S1qim6SHApPiltvLLGG%2FCsQqe5bXShMPATrFGJFnaJLCM%2Bj2WdhFBeopJe9FBZq5AaqOKMDPcOq0gvY5p1SySSQgoK6Coh2aLouWL3TbAMNKOcTlBcSMZ29l3jkqu8shr2lgmiuX4QTyuy%2FINSKsHH4Ocb6%2B6eaN62FpqSjwjxIOXj0bSYHBf%2B5eCKDRoca%2FcsozHPyOYCY7DMS2a8sOdz1u%2F6EsasVrDbpv1enye%2FvUB13yOY9Q7%2BRbAs3TizEWDUBDgTfJRf5ldgTyP7omF9wE8y%2FlbmBrK1yBkdhJH%2FRP5kxOyxxyZnGz12Owzs4%2FDHxVHtCye%2Bd2A%3D', + ), + }, + { + label: '3:3 통상토론대회 형식', + href: createTableShareUrl( + 'eJyrVspMUbIyMTDTUcrMS8tXsqpWykvMTVWyUnqzd8qrHRveti140z1XwdjKWOFt29Y3zY1AgdcLF7zuaXg7q0fh7YypQFklHaWSygKQHufQ4BB%2FX88oV6BQYnpqXkoiUBDILijKLw5JTcz1gxq9Yc2blo1A8eT8PGTx1xtmAA0GipcnFuVl5qU7pebkKFmlJeYUp%2BoopWXmZRZnQIRKikpTa4G2JiblAPVFVysVlyTmJYOMCAjyDwYaUFyQmpqcEQJx1Zt5rUAnA0WT8iugQn7%2BQb6OPiCHZ4JsNjQygDADUotA7lGyyivNyYELBRekJmYD3QMTLgbxU4sg3FodJOud%2Ff0G0no%2F19CQILDBaC6Y8GbOAkjMobgjxNPXNd7JMdjVBe4WFJshjjFHch7CLWBHkxYSrzZtoFdIYEsHNLY%2BthYAnmMxuw%3D%3D', + ), + }, + ], + }, + ], +}; diff --git a/src/constants/errors.ts b/src/constants/errors.ts new file mode 100644 index 00000000..76717204 --- /dev/null +++ b/src/constants/errors.ts @@ -0,0 +1,10 @@ +export const ERROR_STATUS_TABLE: Record = { + 400: '400 잘못된 요청', + 401: '401 권한 없음', + 403: '403 거부됨', + 404: '404 찾을 수 없음', + 500: '500 내부 서버 오류', + 502: '502 게이트웨이 불량', + 503: '503 서비스가 일시적으로 중단됨', + 504: '504 게이트웨이 시간 초과', +} as const; diff --git a/src/constants/sample_table.ts b/src/constants/sample_table.ts index 7e72ae87..cf7ad335 100644 --- a/src/constants/sample_table.ts +++ b/src/constants/sample_table.ts @@ -6,8 +6,6 @@ export const SAMPLE_TABLE_DATA: DebateTableData = { name: '나의 시간표', prosTeamName: '찬성', consTeamName: '반대', - finishBell: true, - warningBell: false, }, table: [ { @@ -18,6 +16,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = { timePerSpeaking: null, timePerTeam: null, speaker: '1번', + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], }, { boxType: 'NORMAL', @@ -27,6 +26,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = { timePerSpeaking: null, timePerTeam: null, speaker: '1번', + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], }, { boxType: 'NORMAL', @@ -36,6 +36,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = { timePerSpeaking: null, timePerTeam: null, speaker: '2번', + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], }, { boxType: 'NORMAL', @@ -45,6 +46,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = { timePerSpeaking: null, timePerTeam: null, speaker: '2번', + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], }, { boxType: 'TIME_BASED', @@ -54,6 +56,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = { timePerSpeaking: 120, timePerTeam: 420, speaker: '2번', + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], }, { boxType: 'NORMAL', @@ -63,6 +66,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = { timePerSpeaking: null, timePerTeam: null, speaker: '3번', + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], }, { boxType: 'NORMAL', @@ -72,6 +76,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = { timePerSpeaking: null, timePerTeam: null, speaker: '3번', + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], }, ], } as const; diff --git a/src/constants/urls.ts b/src/constants/urls.ts index 1af36251..4f604f97 100644 --- a/src/constants/urls.ts +++ b/src/constants/urls.ts @@ -4,4 +4,5 @@ export const LANDING_URLS = { 'https://bustling-bathtub-b3a.notion.site/2071550c60cf80f18395e03440fba80a?source=copy_link', TERMS_OF_SERVICE_URL: 'https://bustling-bathtub-b3a.notion.site/1b01550c60cf8020b34adff2d40cf605?source=copy_link', + TEMPLATE_REGISTER_URL: 'https://forms.gle/471ojcPqSdkWqVCaA', } as const; diff --git a/src/hooks/mutations/useAddDebateTable.ts b/src/hooks/mutations/useAddDebateTable.ts index 6ebd0bd6..5acf3d8f 100644 --- a/src/hooks/mutations/useAddDebateTable.ts +++ b/src/hooks/mutations/useAddDebateTable.ts @@ -1,10 +1,10 @@ -import { useMutation } from '@tanstack/react-query'; import { PostDebateTableResponseType } from '../../apis/responses/debateTable'; import { getRepository } from '../../repositories/DebateTableRepository'; import { DebateTableData } from '../../type/type'; +import { usePreventDuplicateMutation } from './usePreventDuplicateMutation'; export default function useAddDebateTable(onSuccess: (id: number) => void) { - return useMutation({ + return usePreventDuplicateMutation({ mutationFn: async (params: DebateTableData) => { const repo = getRepository(); return repo.addTable(params); diff --git a/src/hooks/mutations/usePreventDuplicateMutation.ts b/src/hooks/mutations/usePreventDuplicateMutation.ts new file mode 100644 index 00000000..d0c2ae9c --- /dev/null +++ b/src/hooks/mutations/usePreventDuplicateMutation.ts @@ -0,0 +1,52 @@ +import { useRef, useCallback } from 'react'; +import { + type DefaultError, + useMutation, + type UseMutationOptions, + type UseMutationResult, +} from '@tanstack/react-query'; + +export function usePreventDuplicateMutation< + TData = unknown, + TError = DefaultError, + TVariables = void, + TContext = unknown, +>( + options: UseMutationOptions, +): UseMutationResult { + // useRef를 통해 요청 여부를 저장 + const isMutatingRef = useRef(false); + + // 요청이 끝난 후 실행 + const onSettled: UseMutationOptions< + TData, + TError, + TVariables, + TContext + >['onSettled'] = (data, error, variables, context) => { + isMutatingRef.current = false; + options.onSettled?.(data, error, variables, context); + }; + + const mutation = useMutation({ ...options, onSettled }); + + // 중복 요청을 방지하는 mutation wrapper + const preventDuplicateMutate = useCallback( + ( + variables: TVariables, + mutateOptions?: Parameters[1], + ) => { + if (isMutatingRef.current) { + console.warn('이미 요청이 처리 중 입니다.'); + return; + } + isMutatingRef.current = true; + mutation.mutate(variables, mutateOptions); + }, + + [mutation], + ); + + // 중복 요청을 방지하는 커스텀 mutate를 반환 + return { ...mutation, mutate: preventDuplicateMutate }; +} diff --git a/src/hooks/mutations/usePutDebateTable.ts b/src/hooks/mutations/usePutDebateTable.ts index 03397723..b9b30d5b 100644 --- a/src/hooks/mutations/usePutDebateTable.ts +++ b/src/hooks/mutations/usePutDebateTable.ts @@ -1,14 +1,18 @@ -import { useMutation } from '@tanstack/react-query'; import { DebateTableData } from '../../type/type'; import { PutDebateTableResponseType } from '../../apis/responses/debateTable'; import { getRepository } from '../../repositories/DebateTableRepository'; +import { usePreventDuplicateMutation } from './usePreventDuplicateMutation'; interface PutDebateTableParams extends DebateTableData { tableId: number; } export function usePutDebateTable(onSuccess: (tableId: number) => void) { - return useMutation({ + return usePreventDuplicateMutation< + PutDebateTableResponseType, + Error, + PutDebateTableParams + >({ mutationFn: ({ tableId, info, table }) => { const repo = getRepository(); return repo.editTable({ id: tableId, info, table }); diff --git a/src/hooks/query/useGetDebateTableData.ts b/src/hooks/query/useGetDebateTableData.ts index b1c4635e..6d4fd45f 100644 --- a/src/hooks/query/useGetDebateTableData.ts +++ b/src/hooks/query/useGetDebateTableData.ts @@ -10,5 +10,6 @@ export function useGetDebateTableData(tableId: number, enabled?: boolean) { return repo.getTable(tableId); }, enabled, + throwOnError: false, }); } diff --git a/src/hooks/query/useGetDebateTableList.ts b/src/hooks/query/useGetDebateTableList.ts index 7940f8f3..650e725a 100644 --- a/src/hooks/query/useGetDebateTableList.ts +++ b/src/hooks/query/useGetDebateTableList.ts @@ -1,8 +1,8 @@ -import { useQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { getDebateTableList } from '../../apis/apis/member'; export function useGetDebateTableList() { - return useQuery({ + return useSuspenseQuery({ queryKey: ['DebateTableList'], queryFn: () => getDebateTableList(), }); diff --git a/src/hooks/useBreakpoint.ts b/src/hooks/useBreakpoint.ts new file mode 100644 index 00000000..ee668232 --- /dev/null +++ b/src/hooks/useBreakpoint.ts @@ -0,0 +1,36 @@ +import { useState, useEffect } from 'react'; + +const breakpoints = { + md: 768, + lg: 1280, + xl: 1600, +} as const; + +export type Breakpoint = 'default' | keyof typeof breakpoints; + +export default function useBreakpoint() { + const [breakpoint, setBreakpoint] = useState('default'); + + useEffect(() => { + const handleResize = () => { + const { innerWidth, innerHeight } = window; + + if (innerWidth >= breakpoints.xl && innerHeight > 1024) { + setBreakpoint('xl'); + } else if (innerWidth >= breakpoints.lg) { + setBreakpoint('lg'); + } else if (innerWidth >= breakpoints.md) { + setBreakpoint('md'); + } else { + setBreakpoint('default'); + } + }; + + window.addEventListener('resize', handleResize); + handleResize(); + + return () => window.removeEventListener('resize', handleResize); + }, []); + + return breakpoint; +} diff --git a/src/hooks/useModal.tsx b/src/hooks/useModal.tsx index 5292c5c1..dcdb1beb 100644 --- a/src/hooks/useModal.tsx +++ b/src/hooks/useModal.tsx @@ -1,6 +1,6 @@ import { ReactNode, useState, useCallback, useEffect } from 'react'; import { GlobalPortal } from '../util/GlobalPortal'; -import { IoMdClose } from 'react-icons/io'; +import DTClose from '../components/icons/Close'; interface UseModalOptions { closeOnOverlayClick?: boolean; @@ -65,7 +65,7 @@ export function useModal(options: UseModalOptions = {}) { className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50" onClick={handleOverlayClick} > -
+
{children} {isCloseButtonExist && ( )}
diff --git a/src/hooks/useTableShare.tsx b/src/hooks/useTableShare.tsx index b52deaee..8f8f9cd4 100644 --- a/src/hooks/useTableShare.tsx +++ b/src/hooks/useTableShare.tsx @@ -7,7 +7,6 @@ import { createTableShareUrl } from '../util/arrayEncoding'; export function useTableShare(tableId: number) { const { isOpen, openModal, closeModal, ModalWrapper } = useModal(); const [copyState, setCopyState] = useState(false); - const [isUrlReady, setIsUrlReady] = useState(false); const [shareUrl, setShareUrl] = useState(''); const baseUrl = import.meta.env.MODE !== 'production' @@ -21,22 +20,34 @@ export function useTableShare(tableId: number) { console.error('Failed to copy: ', err); } }; - const data = useGetDebateTableData(tableId, isOpen); + const { + data, + isLoading: isFetching, + isError: isFetchError, + refetch, + isRefetching, + isRefetchError, + } = useGetDebateTableData(tableId, isOpen); + const isLoading = isFetching || isRefetching; + const isError = isFetchError || isRefetchError; + + // Process URL when data is successfully fetched useEffect(() => { - if (data.data) { - setShareUrl(createTableShareUrl(baseUrl, data.data)); - setIsUrlReady(true); + if (data) { + setShareUrl(createTableShareUrl(baseUrl, data)); } }, [baseUrl, data]); + // Close indicator after 3 seconds + // which tells user that URL is copied to clipboard useEffect(() => { if (copyState) { setTimeout(() => { setCopyState(false); }, 3000); } - }); + }, [copyState]); const TableShareModal = () => isOpen ? ( @@ -44,8 +55,10 @@ export function useTableShare(tableId: number) { handleCopy()} + isLoading={isLoading} + isError={isError} + onRefetch={() => refetch()} + onCopyClicked={() => handleCopy()} /> ) : null; diff --git a/src/index.css b/src/index.css index b7483950..41c7d9bc 100644 --- a/src/index.css +++ b/src/index.css @@ -5,54 +5,78 @@ @layer base { body { @apply font-pretendard; - @apply bg-background-default; + @apply bg-default-white; + } + + html, + body { + @apply text-default-black; } } -/* For buttons */ -.button { - @apply rounded-[24px] border-[2px] border-neutral-700 text-[24px] font-bold transition-colors duration-200; +/* Text styles */ +.text-display { + @apply text-display-raw; } -.button.enabled { - @apply bg-background-default hover:bg-brand-main text-neutral-900; +.text-title { + @apply text-title-raw; } -.button.enabled-hover-neutral { - @apply bg-background-default hover:bg-neutral-300 text-neutral-900; +.text-subtitle { + @apply text-subtitle-raw; } -.button.disabled { - @apply bg-neutral-300 text-neutral-500 border-none; +.text-detail { + @apply text-detail-raw; } -/* For small buttons */ -.small-button { - @apply rounded-[24px] border-[2px] border-neutral-700 text-[20px] font-bold transition-colors duration-200; +.text-body { + @apply text-body-raw; } -.small-button.enabled { - @apply bg-background-default hover:bg-brand-main text-neutral-900; +.text-timer { + @apply text-timer-raw font-bold; } -.small-button.enabled-hover-neutral { - @apply bg-background-default hover:bg-neutral-300 text-neutral-900; +/* Timebox styles */ +.timebox { + @apply relative flex select-none flex-col items-center justify-center rounded-[12px] p-[12px]; } -.small-button.disabled { - @apply bg-neutral-300 text-neutral-500 border-none; +.timebox.pros { + @apply bg-camp-blue/50 w-1/2; } -p { - white-space: pre-wrap; +.timebox.cons { + @apply bg-camp-red/50 w-1/2; +} + +.timebox.neutral { + @apply bg-default-neutral/60 w-full; } -.gradient-timer-running { - background-image: linear-gradient(270deg, #21c494, #21bdc4, #21c45d); +.timebox.time-based { + @apply bg-brand/70 w-full; } -.gradient-timer-warning { - background-image: linear-gradient(270deg, #f5d60a, #f5ba0a, #f5f30a); + +/* Button styles */ +.button { + @apply text-subtitle transition-colors duration-200 flex flex-row items-center justify-center h-[72px]; } -.gradient-timer-timeout { - background-image: linear-gradient(270deg, #f87171, #dc2626, #f43f5e); + +.button.enabled.brand { + @apply bg-brand font-bold text-default-black hover:bg-brand-hover; +} + +.button.enabled.neutral { + @apply border-[2px] font-semibold border-default-disabled/hover bg-default-white text-default-black hover:bg-default-disabled/hover; +} + +.button.disabled { + @apply bg-default-disabled/hover text-default-white cursor-not-allowed; +} + +p { + white-space: pre-wrap; } diff --git a/src/layout/components/footer/StickyFooterWrapper.tsx b/src/layout/components/footer/StickyFooterWrapper.tsx index 9fea3723..3a52d507 100644 --- a/src/layout/components/footer/StickyFooterWrapper.tsx +++ b/src/layout/components/footer/StickyFooterWrapper.tsx @@ -4,7 +4,7 @@ export default function StickyFooterWrapper(props: PropsWithChildren) { const { children } = props; return ( -
+
{children}
); diff --git a/src/layout/components/header/StickyTriSectionHeader.stories.tsx b/src/layout/components/header/StickyTriSectionHeader.stories.tsx new file mode 100644 index 00000000..086aa2bf --- /dev/null +++ b/src/layout/components/header/StickyTriSectionHeader.stories.tsx @@ -0,0 +1,30 @@ +import { Meta, StoryObj } from '@storybook/react'; +import StickyTriSectionHeader from './StickyTriSectionHeader'; +import HeaderTableInfo from '../../../components/HeaderTableInfo/HeaderTableInfo'; +import HeaderTitle from '../../../components/HeaderTitle/HeaderTitle'; + +const meta: Meta = { + title: 'Layout/StickyTriSectionHeader', + component: StickyTriSectionHeader, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + + + + + + + + ), +}; diff --git a/src/layout/components/header/StickyTriSectionHeader.tsx b/src/layout/components/header/StickyTriSectionHeader.tsx index 116afc70..c243f946 100644 --- a/src/layout/components/header/StickyTriSectionHeader.tsx +++ b/src/layout/components/header/StickyTriSectionHeader.tsx @@ -1,9 +1,6 @@ import { PropsWithChildren } from 'react'; -import { IoMdHome } from 'react-icons/io'; import { useNavigate } from 'react-router-dom'; import useLogout from '../../../hooks/mutations/useLogout'; -import { IoLogIn, IoLogOut } from 'react-icons/io5'; -import IconButton from '../../../components/IconButton/IconButton'; import { isLoggedIn } from '../../../util/accessToken'; import { isGuestFlow, @@ -12,13 +9,18 @@ import { import { oAuthLogin } from '../../../util/googleAuth'; import { useModal } from '../../../hooks/useModal'; import DialogModal from '../../../components/DialogModal/DialogModal'; +import DTHome from '../../../components/icons/Home'; +import DTLogin from '../../../components/icons/Login'; + +// The type of header icons will be declared here. +type HeaderIcons = 'home' | 'auth'; function StickyTriSectionHeader(props: PropsWithChildren) { const { children } = props; return ( -
-
+
+
{children}
@@ -27,89 +29,87 @@ function StickyTriSectionHeader(props: PropsWithChildren) { StickyTriSectionHeader.Left = function Left(props: PropsWithChildren) { const { children } = props; - return
{children}
; + return ( +
+ {children} +
+ ); }; StickyTriSectionHeader.Center = function Center(props: PropsWithChildren) { const { children } = props; - return
{children}
; + return ( +
+ {children} +
+ ); }; -type HeaderIcons = 'home' | 'logout' | 'guest' | 'auth'; - StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { - const { children } = props; + const { children: buttons } = props; const navigate = useNavigate(); const { mutate: logoutMutate } = useLogout(() => navigate('/home')); const { openModal, closeModal, ModalWrapper } = useModal({}); - const defaultIcons: HeaderIcons[] = []; + const defaultIcons: HeaderIcons[] = ['home', 'auth']; - if (isGuestFlow()) { - defaultIcons.push('guest'); - } - - if (isLoggedIn()) { - defaultIcons.push('home', 'auth'); - } else { - defaultIcons.push('auth'); - } + const handleLoginStart = (keepData: boolean) => { + sessionStorage.setItem('keepGuestTable', String(keepData)); + closeModal(); + oAuthLogin(); + }; return ( <> -
- {children && ( +
+ {isGuestFlow() && ( <> - {children} + {/* Guest mode indicator */} +
+ 비회원 모드 +
+ + {/* Vertical divider */}
)} + {/* Buttons given as an argument */} + {buttons} + + {/* Normal buttons */} {defaultIcons.map((iconName, index) => { switch (iconName) { - case 'guest': - return ( -
-
- 비회원 모드 -
-
- ); case 'home': return ( -
- } - onClick={() => { - if (isGuestFlow()) { - deleteSessionCustomizeTableData(); - } - navigate('/'); - }} - /> -
+ ); case 'auth': - if (isLoggedIn()) { - return ( -
- } - onClick={() => logoutMutate()} - title="로그아웃" - /> -
- ); - } else { - return ( -
- } - onClick={() => openModal()} - title="로그인" - /> -
- ); - } + return ( + + ); default: return null; } @@ -120,18 +120,11 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { { - deleteSessionCustomizeTableData(); - closeModal(); - oAuthLogin(); - }, + onClick: () => handleLoginStart(false), }} right={{ text: '네', - onClick: () => { - closeModal(); - oAuthLogin(); - }, + onClick: () => handleLoginStart(true), isBold: true, }} > diff --git a/src/main.tsx b/src/main.tsx index d2278b0a..a263a75f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,15 +7,6 @@ import router from './routes/routes.tsx'; import './index.css'; import { setupGoogleAnalytics } from './util/setupGoogleAnalytics.tsx'; -// console.log(`# URL = ${import.meta.env.VITE_API_BASE_URL}`); -if (import.meta.env.DEV && !localStorage.getItem('accessToken')) { - localStorage.setItem('accessToken', 'mock-token'); - console.log( - '# Fake access token has set to %{}', - localStorage.getItem('accessToken'), - ); -} - // Functions that calls msw mocking worker if (import.meta.env.VITE_MOCK_API === 'true') { console.log('[msw] Mocking enabled.'); diff --git a/src/mocks/handlers/customize.ts b/src/mocks/handlers/customize.ts index dab7c434..c0172fc7 100644 --- a/src/mocks/handlers/customize.ts +++ b/src/mocks/handlers/customize.ts @@ -19,8 +19,6 @@ export const customizeHandlers = [ agenda: '토론 주제', prosTeamName: '찬성', consTeamName: '반대', - warningBell: true, - finishBell: true, }, table: [ { @@ -49,6 +47,10 @@ export const customizeHandlers = [ timePerTeam: null, timePerSpeaking: null, speaker: '발언자 1', + bell: [ + { type: 'BEFORE_END', time: 0, count: 2 }, + { type: 'AFTER_START', time: 5, count: 1 }, + ], }, { stance: 'PROS', @@ -58,6 +60,10 @@ export const customizeHandlers = [ timePerTeam: null, timePerSpeaking: null, speaker: '발언자 1', + bell: [ + { type: 'BEFORE_END', time: 0, count: 2 }, + { type: 'AFTER_START', time: 7, count: 3 }, + ], }, { stance: 'NEUTRAL', @@ -67,6 +73,7 @@ export const customizeHandlers = [ timePerTeam: null, timePerSpeaking: null, speaker: null, + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], }, { stance: 'CONS', @@ -76,6 +83,7 @@ export const customizeHandlers = [ timePerTeam: null, timePerSpeaking: null, speaker: '발언자 2', + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], }, { stance: 'PROS', @@ -85,6 +93,7 @@ export const customizeHandlers = [ timePerTeam: null, timePerSpeaking: null, speaker: '발언자 2', + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], }, ], }); @@ -105,8 +114,6 @@ export const customizeHandlers = [ agenda: '토론 주제', prosTeamName: '찬성', consTeamName: '반대', - warningBell: true, - finishBell: true, }, table: [ { @@ -182,8 +189,6 @@ export const customizeHandlers = [ agenda: '토론 주제', prosTeamName: '찬성', consTeamName: '반대', - warningBell: true, - finishBell: true, }, table: [ { @@ -270,8 +275,6 @@ export const customizeHandlers = [ agenda: '토론 주제', prosTeamName: '찬성', consTeamName: '반대', - warningBell: true, - finishBell: true, }, table: [ { diff --git a/src/page/DebateEndPage/DebateEndPage.tsx b/src/page/DebateEndPage/DebateEndPage.tsx new file mode 100644 index 00000000..f631fe5d --- /dev/null +++ b/src/page/DebateEndPage/DebateEndPage.tsx @@ -0,0 +1,87 @@ +import { useNavigate, useParams } from 'react-router-dom'; + +import clapImage from '../../assets/debateEnd/clap.png'; +import feedbackTimerImage from '../../assets/debateEnd/feedback_timer.png'; +import voteStampImage from '../../assets/debateEnd/vote_stamp.png'; +import GoToHomeButton from '../../components/GoToHomeButton/GoToHomeButton'; + +export default function DebateEndPage() { + const { id: tableId } = useParams(); + const navigate = useNavigate(); + + const handleFeedbackClick = () => { + navigate(`/table/customize/${tableId}/end/feedback`); + }; + + const backgroundStyle = { + background: + 'radial-gradient(50% 50% at 50% 50%, #fecd4c21 0%, #ffffff42 100%)', + }; + + return ( +
+
+

+ 토론을 모두 마치셨습니다 +

+ 박수 +
+ +
+ {/* 피드백 타이머 카드 */} + + + {/* 승패투표 카드 */} + +
+
+ +
+
+ ); +} diff --git a/src/page/LandingPage/LandingPage.tsx b/src/page/LandingPage/LandingPage.tsx index 188766c3..9b6a599e 100644 --- a/src/page/LandingPage/LandingPage.tsx +++ b/src/page/LandingPage/LandingPage.tsx @@ -5,35 +5,40 @@ import TimerSection from './components/TimerSection'; import TableSection from './components/TableSection'; import ReviewSection from './components/ReviewSection'; import ReportSection from './components/ReportSection'; -import { oAuthLogin } from '../../util/googleAuth'; -import { createTableShareUrl } from '../../util/arrayEncoding'; -import { SAMPLE_TABLE_DATA } from '../../constants/sample_table'; - +import useLandingPageHandlers from './hooks/useLandingPageHandlers'; +import TemplateSelection from './components/TemplateSelection'; +import ScrollHint from './components/ScrollHint'; export default function LandingPage() { - const handleStartWithoutLogin = () => { - // window.location.href = LANDING_URLS.START_WITHOUT_LOGIN_URL; - window.location.href = createTableShareUrl( - import.meta.env.VITE_SHARE_BASE_URL, - SAMPLE_TABLE_DATA, - ); - }; + const { + handleStartWithoutLogin, + handleDashboardButtonClick, + handleHeaderLoginButtonClick, + handleTableSectionLoginButtonClick, + } = useLandingPageHandlers(); return ( -
+
{/* 헤더 */} -
oAuthLogin()} /> +
{/* 흰색 배경 */}
{/* 메인 화면 */} - + + + {/*템플릿 선택 화면 */} + + {/* 시간표 설정화면 */}
{/* 회색 배경 */} -
+
{/* 타이머 화면 */}
@@ -41,9 +46,13 @@ export default function LandingPage() { {/* 흰색 배경 */}
{/* 홈 설정 */} - oAuthLogin()} /> + +
+
{/* 리뷰 */} +
+
{/* 버그 및 불편사항 제보 */}
diff --git a/src/page/LandingPage/components/Header.tsx b/src/page/LandingPage/components/Header.tsx index 548d6a88..df4de7d9 100644 --- a/src/page/LandingPage/components/Header.tsx +++ b/src/page/LandingPage/components/Header.tsx @@ -1,10 +1,11 @@ import { useState, useEffect } from 'react'; +import { isLoggedIn } from '../../../util/accessToken'; interface HeaderProps { - onLogin: () => void; + onLoginButtonClicked: () => void; } -export default function Header({ onLogin }: HeaderProps) { +export default function Header({ onLoginButtonClicked }: HeaderProps) { const [isScrolled, setIsScrolled] = useState(false); useEffect(() => { @@ -28,9 +29,9 @@ export default function Header({ onLogin }: HeaderProps) {
diff --git a/src/page/LandingPage/components/MainSection.tsx b/src/page/LandingPage/components/MainSection.tsx index ded9ee28..bd86ad77 100644 --- a/src/page/LandingPage/components/MainSection.tsx +++ b/src/page/LandingPage/components/MainSection.tsx @@ -1,10 +1,15 @@ import preview from '../../../assets/landing/preview.webm'; +import { isLoggedIn } from '../../../util/accessToken'; interface MainSectionProps { onStartWithoutLogin: () => void; + onDashboardButtonClicked: () => void; } -export default function MainSection({ onStartWithoutLogin }: MainSectionProps) { +export default function MainSection({ + onStartWithoutLogin, + onDashboardButtonClicked, +}: MainSectionProps) { return (
); diff --git a/src/page/LandingPage/components/ReportSection.tsx b/src/page/LandingPage/components/ReportSection.tsx index 027d22fc..57a20ea6 100644 --- a/src/page/LandingPage/components/ReportSection.tsx +++ b/src/page/LandingPage/components/ReportSection.tsx @@ -20,7 +20,7 @@ export default function ReportSection() { 'noopener,noreferrer', ) } - className="rounded-full border border-neutral-300 bg-neutral-200 px-9 py-2 text-[min(max(0.875rem,1.25vw),1.2rem)] font-medium text-black transition-all duration-100 hover:bg-brand-main" + className="rounded-full border border-neutral-300 bg-neutral-200 px-9 py-2 text-[min(max(0.875rem,1.25vw),1.2rem)] font-medium text-default-black transition-all duration-100 hover:bg-brand" > 접수하기 @@ -55,7 +55,7 @@ export default function ReportSection() { 'noopener,noreferrer', ) } - className="text-[min(max(0.75rem,1vw),1.1rem)] text-neutral-500 transition-colors hover:text-neutral-700" + className="text-[min(max(0.75rem,1vw),1rem)] text-neutral-500 transition-colors hover:text-neutral-700" > 서비스 이용약관 diff --git a/src/page/LandingPage/components/ReviewSection.tsx b/src/page/LandingPage/components/ReviewSection.tsx index 8db59193..81a0b754 100644 --- a/src/page/LandingPage/components/ReviewSection.tsx +++ b/src/page/LandingPage/components/ReviewSection.tsx @@ -9,8 +9,11 @@ export default function ReviewSection({ onStartWithoutLogin, }: ReviewSectionProps) { return ( -
-
+
+

이미 많은 사람들이 디베이트 타이머로

더 나은 토론환경을 만들고 있어요.

@@ -19,12 +22,14 @@ export default function ReviewSection({ ))}
- +
+ +
); } diff --git a/src/page/LandingPage/components/ScrollHint.tsx b/src/page/LandingPage/components/ScrollHint.tsx new file mode 100644 index 00000000..648ce25a --- /dev/null +++ b/src/page/LandingPage/components/ScrollHint.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react'; +import arrowDown from '../../../assets/landing/bottom_arrow.png'; +type ScrollHintProps = { + topThreshold?: number; //최상단 판정 임계값 (px) +}; + +export default function ScrollHint({ topThreshold = 10 }: ScrollHintProps) { + const [visible, setVisible] = useState(true); + + useEffect(() => { + const onScroll = () => { + const atTop = window.scrollY <= topThreshold; + // 최상단이면 보이고, 아니면 숨김 + setVisible(atTop); + }; + onScroll(); + + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, [topThreshold]); + + return ( +
+
+ 아래로 스크롤 +
+
+ ); +} diff --git a/src/page/LandingPage/components/TableSection.tsx b/src/page/LandingPage/components/TableSection.tsx index e2851c32..3cab8a18 100644 --- a/src/page/LandingPage/components/TableSection.tsx +++ b/src/page/LandingPage/components/TableSection.tsx @@ -1,5 +1,5 @@ -import section301 from '../../../assets/landing/section3-1.png'; -import section302 from '../../../assets/landing/section3-2.png'; +import section301 from '../../../assets/landing/debate_info_setting.png'; +import section302 from '../../../assets/landing/table_list.png'; interface TableSectionProps { onLogin: () => void; @@ -11,24 +11,26 @@ export default function TableSection({ onLogin }: TableSectionProps) {
홈 | 설정 - +

- 토론 정보 관리 및 기록 + 토론 정보
+ 관리 및 기록

- section301

- 종소리 설정 + 토론 기본 정보 설정

- 원하는 종소리만 골라서 설정할 수 있어요. + 시간표 이름부터 주제까지!

+ section301
+ section302

시간표 목록 @@ -37,14 +39,13 @@ export default function TableSection({ onLogin }: TableSectionProps) { 내가 만든 시간표를 저장하고 싶나요?

- section302

시간표를 저장하려면,

-

디베이트 타이머에 로그인해보세요!

+

디베이트 타이머에 로그인해 보세요!

+
+ section501 + + ); +} diff --git a/src/page/LandingPage/components/TemplateCard.tsx b/src/page/LandingPage/components/TemplateCard.tsx new file mode 100644 index 00000000..3bb8ad7f --- /dev/null +++ b/src/page/LandingPage/components/TemplateCard.tsx @@ -0,0 +1,63 @@ +import { DebateTemplate } from '../../../type/type'; +import clsx from 'clsx'; + +export default function TemplateCard({ + title, + subtitle, + logoSrc, + actions, + className, +}: DebateTemplate) { + return ( +
+ {/* 헤더 */} +
+
+

+ {title} +

+

+ {subtitle ?? ''} +

+
+ + {/* 로고 */} + {logoSrc && ( + {`${title} + )} +
+ + {/* 액션 리스트 */} +
    + {actions.map((action, index) => ( +
  • +
    + + {action.label} + + + + 토론하기 + +
    +
  • + ))} +
+
+ ); +} diff --git a/src/page/LandingPage/components/TemplateList.tsx b/src/page/LandingPage/components/TemplateList.tsx new file mode 100644 index 00000000..495ce882 --- /dev/null +++ b/src/page/LandingPage/components/TemplateList.tsx @@ -0,0 +1,17 @@ +import { DebateTemplate } from '../../../type/type'; +import TemplateCard from './TemplateCard'; + +interface TemplateListProps { + data: DebateTemplate[]; +} +export default function TemplateList({ data }: TemplateListProps) { + return ( +
+ {data.map((template) => ( + + ))} +
+ ); +} diff --git a/src/page/LandingPage/components/TemplateSelection.tsx b/src/page/LandingPage/components/TemplateSelection.tsx new file mode 100644 index 00000000..d7afa6ee --- /dev/null +++ b/src/page/LandingPage/components/TemplateSelection.tsx @@ -0,0 +1,21 @@ +import { DEBATE_TEMPLATE } from '../../../constants/debate_template'; +import TemplateApplicationSection from './TemplateApplicationSection'; +import TemplateList from './TemplateList'; + +export default function TemplateSelection() { + return ( +
+
+

+ 다양한 토론 템플릿을 원클릭으로 만나보세요! +

+
+ +
{/* 구분선 */} + +
+ + +
+ ); +} diff --git a/src/page/LandingPage/components/TimeTableSection.tsx b/src/page/LandingPage/components/TimeTableSection.tsx index 5298771b..a9eb24ed 100644 --- a/src/page/LandingPage/components/TimeTableSection.tsx +++ b/src/page/LandingPage/components/TimeTableSection.tsx @@ -1,32 +1,59 @@ -import section101 from '../../../assets/landing/section1-1.png'; -import section102 from '../../../assets/landing/section1-2.png'; - +import timeboxStep from '../../../assets/landing/timebox_step.png'; +import timeboxButtons from '../../../assets/landing/timebox_step_button.png'; +import bellSetting from '../../../assets/landing/bell_setting.png'; +import twoTimer from '../../../assets/landing/two_timer.png'; +import timeboxAddButton from '../../../assets/landing/timebox_add_button.png'; export default function TimeTableSection() { return (
- 시간표 설정 화면 - + 시간표 설정화면 +
-

- 드래그 앤 드롭으로 -
간편하게 시간표 구성 -

+

+ 간편한 시간표 구성 +

- - section101 - section102 -
-

- 두 가지 타이머 -

-

- 일반형과 자유토론형 타이머로, -

-

- 다양한 토론 방식을 지원해요. -

+
+ section301 + section301 +
+
+

+ 시간표 추가 +

+ 시간표 추가 버튼 +
+
+ section302 +
+

+ 두가지 타이머 +

+

+ 일반형과 자유토론형 타이머로, +
+ 다양한 토론 방식을 지원해요. +

+
+
+
+
+

+ 종소리 설정 +

+

+ 시간에 따른 종소리를 내마음대로 +
+ 커스터마이징 할 수 있어요. +

+
+ section302
); diff --git a/src/page/LandingPage/components/TimerSection.tsx b/src/page/LandingPage/components/TimerSection.tsx index 8f3f26fc..bbcc1d01 100644 --- a/src/page/LandingPage/components/TimerSection.tsx +++ b/src/page/LandingPage/components/TimerSection.tsx @@ -1,7 +1,8 @@ -import section201 from '../../../assets/landing/section2-1.png'; -import section202 from '../../../assets/landing/section2-2.png'; -import section203 from '../../../assets/landing/section2-3.png'; - +import timer from '../../../assets/landing/timer.png'; +import timerOperationTime from '../../../assets/landing/timer_operation_time.png'; +import timerTimeBased from '../../../assets/landing/timer_timebased.png'; +import keyInfo from '../../../assets/landing/key_info.png'; +import timeoutButton from '../../../assets/landing/timeout_button.png'; export default function TimerSection() { return (
타이머 화면 - +
-

- 키보드 방향키로 더 편리한 조작 -

+

+ 원하는 때에
+ 작전 시간 사용하기 +

-
- section201 -

- 일반 타이머 -

+
+ section301 +
+

+ 토론자가 작전 시간을 +
+ 요청하면{' '} + 작전 시간 사용{' '} +
+ 버튼을 눌러 시간을 사용해요 +

+
- -
-

- 토론자들이 손을 들고 작전 시간을 요청하면 -

-

- 버튼을 통해 작전 시간을 세팅할 수 있어요. -

+
+
+

+ 작전 시간이 나타나면 +
원하는 시간을 입력하세요! +

+
+ section302
-
- section202 -

- 자유토론형 타이머 -

+
+

+ 키보드 방향키로
더 편리한 조작 +

-
-

- 키보드로 타이머를 보다 편하게 조작해보세요! -

- section203 +
+ section203 + section203
); diff --git a/src/page/LandingPage/hooks/useLandingPageHandlers.ts b/src/page/LandingPage/hooks/useLandingPageHandlers.ts new file mode 100644 index 00000000..80b94b0a --- /dev/null +++ b/src/page/LandingPage/hooks/useLandingPageHandlers.ts @@ -0,0 +1,48 @@ +import { useNavigate } from 'react-router-dom'; +import { isLoggedIn } from '../../../util/accessToken'; +import { oAuthLogin } from '../../../util/googleAuth'; +import useLogout from '../../../hooks/mutations/useLogout'; +import { createTableShareUrl } from '../../../util/arrayEncoding'; +import { SAMPLE_TABLE_DATA } from '../../../constants/sample_table'; +import { useCallback } from 'react'; + +const useLandingPageHandlers = () => { + // Prepare dependencies + const navigate = useNavigate(); + const { mutate: logoutMutate } = useLogout(() => navigate('/home')); + + // Declare functions that represent business logics + const handleStartWithoutLogin = useCallback(() => { + // window.location.href = LANDING_URLS.START_WITHOUT_LOGIN_URL; + window.location.href = createTableShareUrl( + import.meta.env.VITE_SHARE_BASE_URL, + SAMPLE_TABLE_DATA, + ); + }, []); + const handleTableSectionLoginButtonClick = useCallback(() => { + if (!isLoggedIn()) { + oAuthLogin(); + } else { + navigate('/'); + } + }, [navigate]); + const handleDashboardButtonClick = useCallback(() => { + navigate('/'); + }, [navigate]); + const handleHeaderLoginButtonClick = useCallback(() => { + if (!isLoggedIn()) { + oAuthLogin(); + } else { + logoutMutate(); + } + }, [logoutMutate]); + + return { + handleStartWithoutLogin, + handleTableSectionLoginButtonClick, + handleDashboardButtonClick, + handleHeaderLoginButtonClick, + }; +}; + +export default useLandingPageHandlers; diff --git a/src/page/OAuthPage/OAuth.tsx b/src/page/OAuthPage/OAuth.tsx index cc61114a..0262753a 100644 --- a/src/page/OAuthPage/OAuth.tsx +++ b/src/page/OAuthPage/OAuth.tsx @@ -1,19 +1,37 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { usePostUser } from '../../hooks/mutations/usePostUser'; -import { isGuestFlow } from '../../util/sessionStorage'; +import { + deleteSessionCustomizeTableData, + isGuestFlow, +} from '../../util/sessionStorage'; export default function OAuth() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); + const hasProcessedLogin = useRef(false); + const { mutate } = usePostUser(() => { + const keepGuestTable = sessionStorage.getItem('keepGuestTable'); + + if (keepGuestTable === 'false') { + deleteSessionCustomizeTableData(); + } + + sessionStorage.removeItem('keepGuestTable'); + if (isGuestFlow()) { navigate('/share'); } else { navigate('/'); } }); + useEffect(() => { + if (hasProcessedLogin.current === true) { + return; + } + const loginOAuth = async () => { const code = searchParams.get('code'); if (code === null) { @@ -23,7 +41,12 @@ export default function OAuth() { }; loginOAuth(); + + return () => { + hasProcessedLogin.current = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + return null; } diff --git a/src/page/TableComposition/TableComposition.stories.tsx b/src/page/TableComposition/TableCompositionPage.stories.tsx similarity index 61% rename from src/page/TableComposition/TableComposition.stories.tsx rename to src/page/TableComposition/TableCompositionPage.stories.tsx index c517393e..f999aca0 100644 --- a/src/page/TableComposition/TableComposition.stories.tsx +++ b/src/page/TableComposition/TableCompositionPage.stories.tsx @@ -1,9 +1,9 @@ import { Meta, StoryObj } from '@storybook/react'; -import TableComposition from './TableComposition'; +import TableCompositionPage from './TableCompositionPage'; -const meta: Meta = { +const meta: Meta = { title: 'page/TableCompositon', - component: TableComposition, + component: TableCompositionPage, tags: ['autodocs'], decorators: [ (Story) => { @@ -16,6 +16,6 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = {}; diff --git a/src/page/TableComposition/TableComposition.test.tsx b/src/page/TableComposition/TableCompositionPage.test.tsx similarity index 92% rename from src/page/TableComposition/TableComposition.test.tsx rename to src/page/TableComposition/TableCompositionPage.test.tsx index 87997fdc..07954359 100644 --- a/src/page/TableComposition/TableComposition.test.tsx +++ b/src/page/TableComposition/TableCompositionPage.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { GlobalPortal } from '../../util/GlobalPortal'; -import TableComposition from './TableComposition'; +import TableCompositionPage from './TableCompositionPage'; // ------------------ // 테스트 래퍼 (TestWrapper) @@ -37,7 +37,7 @@ function TestWrapper({ ); } -vi.mock('./components/DebatePanel/DebatePanel', () => { +vi.mock('./components/TimeBox/TimeBox', () => { const DebatePanel = ({ children }: { children: React.ReactNode }) => (
{children}
); @@ -62,7 +62,7 @@ describe('TableComposition', () => { it('Creation flow and timebox functionality test', async () => { render( - + , ); @@ -80,7 +80,9 @@ describe('TableComposition', () => { expect(finishButton).toBeDisabled(); // Add a new timebox - await userEvent.click(await screen.findByRole('button', { name: '+' })); + await userEvent.click( + await screen.findByRole('button', { name: '타이머 추가' }), + ); await userEvent.click( await screen.findByRole('button', { name: '설정 완료' }), ); @@ -96,7 +98,7 @@ describe('TableComposition', () => { - + , ); diff --git a/src/page/TableComposition/TableComposition.tsx b/src/page/TableComposition/TableCompositionPage.tsx similarity index 54% rename from src/page/TableComposition/TableComposition.tsx rename to src/page/TableComposition/TableCompositionPage.tsx index c149a713..f528cced 100644 --- a/src/page/TableComposition/TableComposition.tsx +++ b/src/page/TableComposition/TableCompositionPage.tsx @@ -3,30 +3,52 @@ import TableNameAndType from './components/TableNameAndType/TableNameAndType'; import useFunnel from '../../hooks/useFunnel'; import useTableFrom from './hook/useTableFrom'; import TimeBoxStep from './components/TimeBoxStep/TimeBoxStep'; -import { useGetDebateTableData } from '../../hooks/query/useGetDebateTableData'; import { useSearchParams } from 'react-router-dom'; import { useMemo } from 'react'; import { DebateInfo, TimeBoxInfo } from '../../type/type'; +import { useGetDebateTableData } from '../../hooks/query/useGetDebateTableData'; +import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; +import { isGuestFlow } from '../../util/sessionStorage'; export type TableCompositionStep = 'NameAndType' | 'TimeBox'; type Mode = 'edit' | 'add'; -export default function TableComposition() { +export default function TableCompositionPage() { // URL 등으로부터 "editMode"와 "tableId"를 추출 const [searchParams] = useSearchParams(); - const mode = searchParams.get('mode') as Mode; - const tableId = Number(searchParams.get('tableId') || 0); + const rawMode = searchParams.get('mode'); + const rawTableId = searchParams.get('tableId'); + + if (rawMode !== 'edit' && rawMode !== 'add') { + throw new Error('테이블 모드가 올바르지 않습니다.'); + } + const mode = rawMode as Mode; + + if ( + !isGuestFlow() && + mode === 'edit' && + (rawTableId === null || isNaN(Number(rawTableId))) + ) { + throw new Error('테이블 ID가 올바르지 않습니다.'); + } + const tableId = rawTableId ? Number(rawTableId) : 0; - // Print different funnel page by mode (edit a existing table or add a new table) const initialMode: TableCompositionStep = mode !== 'edit' ? 'NameAndType' : 'TimeBox'; const { Funnel, currentStep, goToStep } = useFunnel(initialMode); // edit 모드일 때만 서버에서 initData를 가져옴 - // 테이블 데이터 패칭 분기 - const { data } = useGetDebateTableData(tableId, mode === 'edit'); + const { + data, + isError: isFetchError, + isRefetchError, + isLoading: isFetching, + isRefetching, + refetch, + } = useGetDebateTableData(tableId, mode === 'edit'); + // 테이블 데이터 패칭 분기 const initData = useMemo(() => { if (mode === 'edit' && data) { const info = data.info as DebateInfo; @@ -39,8 +61,19 @@ export default function TableComposition() { return undefined; }, [mode, data]); - const { formData, updateInfo, updateTable, AddTable, EditTable } = - useTableFrom(currentStep, initData); + // Declare constants to handle async request + const isError = mode === 'add' ? false : isFetchError || isRefetchError; + const isLoading = mode === 'add' ? false : isFetching || isRefetching; + + const { + formData, + updateInfo, + updateTable, + addTable, + editTable, + isAddingTable, + isModifyingTable, + } = useTableFrom(currentStep, initData); const handleButtonClick = () => { const patchedInfo = { @@ -52,12 +85,27 @@ export default function TableComposition() { updateInfo(patchedInfo); if (mode === 'edit') { - EditTable(tableId); + editTable(tableId); } else { - AddTable(); + addTable(); } }; + // If error, print error message and let user be able to retry + if (isError) { + return ( + + + refetch()}> + 시간표 정보를 불러오지 못했어요...

다시 시도할까요? +
+
+
+ ); + } + + // If no error or on loading, print contents + // Only pass isLoading because isError is used right above this code line return ( goToStep('TimeBox')} @@ -73,10 +122,12 @@ export default function TableComposition() { TimeBox: ( goToStep('NameAndType')} + isSubmitting={mode === 'edit' ? isModifyingTable : isAddingTable} /> ), }} diff --git a/src/page/TableComposition/components/DebatePanel/DebatePanel.tsx b/src/page/TableComposition/components/DebatePanel/DebatePanel.tsx deleted file mode 100644 index b6a2ae09..00000000 --- a/src/page/TableComposition/components/DebatePanel/DebatePanel.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { HTMLAttributes } from 'react'; -import EditDeleteButtons from '../EditDeleteButtons/EditDeleteButtons'; -import { TimeBoxInfo } from '../../../../type/type'; -import { Formatting } from '../../../../util/formatting'; -import { LuArrowUpDown } from 'react-icons/lu'; - -interface DebatePanelProps extends HTMLAttributes { - info: TimeBoxInfo; - prosTeamName: string; - consTeamName: string; - onSubmitEdit?: (updatedInfo: TimeBoxInfo) => void; - onSubmitDelete?: () => void; -} - -export default function DebatePanel(props: DebatePanelProps) { - const { - stance, - speechType, - boxType, - time, - timePerTeam, - timePerSpeaking, - speaker, - } = props.info; - const { onSubmitEdit, onSubmitDelete, onMouseDown } = props; - - // 타이머 시간 문자열 처리 - let timeStr = ''; - let timePerSpeakingStr = ''; - - if (boxType === 'NORMAL') { - const { minutes, seconds } = Formatting.formatSecondsToMinutes(time!); - timeStr = `${minutes}분 ${seconds}초`; - } else { - const { minutes, seconds } = Formatting.formatSecondsToMinutes( - timePerTeam!, - ); - timeStr = `팀당 ${minutes}분 ${seconds}초`; - } - - if (timePerSpeaking !== null) { - const { minutes, seconds } = - Formatting.formatSecondsToMinutes(timePerSpeaking); - timePerSpeakingStr = `발언당 ${minutes}분 ${seconds}초`; - } - const fullTimeStr = timePerSpeakingStr - ? `${timeStr} | ${timePerSpeakingStr}` - : timeStr; - - const isPros = stance === 'PROS'; - const isCons = stance === 'CONS'; - const isNeutralTimeout = boxType === 'NORMAL' && stance === 'NEUTRAL'; // 작전시간 - const isNeutralCustom = boxType === 'TIME_BASED' && stance === 'NEUTRAL'; // 자유토론타이머 - - const containerClass = isPros - ? 'justify-start' - : isCons - ? 'justify-end' - : 'justify-center'; - - const renderDragHandle = () => ( -
- -
- ); - const renderProsConsPanel = () => ( -
- {onSubmitEdit && onSubmitDelete && ( - <> - {isPros ? ( - <> -
- -
- {renderDragHandle()} - - ) : ( - <> - {renderDragHandle()} -
- -
- - )} - - )} -
- {speechType} {speaker && `| ${speaker} 토론자`} -
-
{timeStr}
-
- ); - - const renderNeutralTimeoutPanel = () => ( -
- {onSubmitEdit && onSubmitDelete && ( - <> - {renderDragHandle()} -
- -
- - )} - {speechType} - {timeStr} -
- ); - - const renderNeutralCustomPanel = () => ( -
- {onSubmitEdit && onSubmitDelete && ( - <> - {renderDragHandle()} -
- -
- - )} - {speechType} - {fullTimeStr} -
- ); - - return ( -
- {(isPros || isCons) && renderProsConsPanel()} - - {isNeutralTimeout && renderNeutralTimeoutPanel()} - {isNeutralCustom && renderNeutralCustomPanel()} -
- ); -} diff --git a/src/page/TableComposition/components/EditDeleteButtons/EditDeleteButtons.tsx b/src/page/TableComposition/components/EditDeleteButtons/EditDeleteButtons.tsx deleted file mode 100644 index a7cfba20..00000000 --- a/src/page/TableComposition/components/EditDeleteButtons/EditDeleteButtons.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { RiEditFill, RiDeleteBinFill } from 'react-icons/ri'; -import { TimeBoxInfo } from '../../../../type/type'; -import { useModal } from '../../../../hooks/useModal'; -import TimerCreationContent from '../TimerCreationContent/TimerCreationContent'; -import DialogModal from '../../../../components/DialogModal/DialogModal'; - -interface EditDeleteButtonsProps { - info: TimeBoxInfo; - prosTeamName?: string; - consTeamName?: string; - onSubmitEdit: (updatedInfo: TimeBoxInfo) => void; - onSubmitDelete: () => void; -} - -export default function EditDeleteButtons(props: EditDeleteButtonsProps) { - const { - openModal: openEditModal, - closeModal: closeEditModal, - ModalWrapper: EditModalWrapper, - } = useModal({ isCloseButtonExist: false }); - const { - openModal: openDeleteModal, - closeModal: closeDeleteModal, - ModalWrapper: DeleteModalWrapper, - } = useModal({ isCloseButtonExist: false }); - const { info, onSubmitEdit, onSubmitDelete } = props; - - return ( - <> -
- - -
- - { - onSubmitEdit(newInfo); - }} - onClose={closeEditModal} - /> - - - - closeDeleteModal() }} - right={{ - text: '삭제', - onClick: () => { - onSubmitDelete(); - closeDeleteModal(); - }, - isBold: true, - }} - > -

- 이 타이머를 삭제하시겠습니까? -

-
-
- - ); -} diff --git a/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx b/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx index 246b02f8..19f23d42 100644 --- a/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx +++ b/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx @@ -1,18 +1,24 @@ import ClearableInput from '../../../../components/ClearableInput/ClearableInput'; import HeaderTitle from '../../../../components/HeaderTitle/HeaderTitle'; -import LabeledCheckbox from '../../../../components/LabledCheckBox/LabeledCheckbox'; import DefaultLayout from '../../../../layout/defaultLayout/DefaultLayout'; import { DebateInfo, StanceToString } from '../../../../type/type'; interface TableNameAndTypeProps { info: DebateInfo; + isLoading: boolean; isEdit?: boolean; onInfoChange: (newInfo: DebateInfo) => void; onButtonClick: () => void; } export default function TableNameAndType(props: TableNameAndTypeProps) { - const { info, isEdit = false, onInfoChange, onButtonClick } = props; + const { + info, + isEdit = false, + onInfoChange, + isLoading, + onButtonClick, + } = props; const handleFieldChange = ( field: K, @@ -51,8 +57,8 @@ export default function TableNameAndType(props: TableNameAndTypeProps) { -
-
+ )} -
- {/* TODO: Need to add a function here */} +
- + { - onTimeBoxChange((prev) => [...prev, data]); - }} + onSubmit={handleAddTimeBox} onClose={closeModal} /> diff --git a/src/page/TableComposition/components/TimerCreationContent/TimeInputGroup.tsx b/src/page/TableComposition/components/TimerCreationContent/TimeInputGroup.tsx new file mode 100644 index 00000000..160362b0 --- /dev/null +++ b/src/page/TableComposition/components/TimerCreationContent/TimeInputGroup.tsx @@ -0,0 +1,47 @@ +import ClearableInput from '../../../../components/ClearableInput/ClearableInput'; +import TimerCreationContentItem from './TimerCreationContentMenuItem'; + +interface TimeInputGroupProps { + title: string; + minutes: number; + seconds: number; + onMinutesChange: (value: number) => void; + onSecondsChange: (value: number) => void; +} + +export default function TimeInputGroup({ + title, + minutes, + seconds, + onMinutesChange, + onSecondsChange, +}: TimeInputGroupProps) { + const validateTime = (value: string) => + value === '' ? 0 : Math.max(0, Math.min(59, Number(value))); + + return ( + + + + onMinutesChange(validateTime(e.target.value))} + onClear={() => onMinutesChange(0)} + /> +

+
+ + + onSecondsChange(validateTime(e.target.value))} + onClear={() => onSecondsChange(0)} + /> +

+
+
+
+ ); +} diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.stories.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.stories.tsx new file mode 100644 index 00000000..87d512ca --- /dev/null +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.stories.tsx @@ -0,0 +1,16 @@ +import { Meta, StoryObj } from '@storybook/react'; +import TimerCreationContent from './TimerCreationContent'; + +const meta: Meta = { + title: 'page/TableComposition/Components/TimerCreationContent', + component: TimerCreationContent, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx index d8d96f31..8a408f45 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx @@ -1,10 +1,76 @@ -import { useState, useEffect, useMemo } from 'react'; -import { TimeBoxInfo, Stance, TimeBoxType } from '../../../../type/type'; +import { useCallback, useMemo, useState } from 'react'; +import { + TimeBoxInfo, + Stance, + TimeBoxType, + BellType, + BellTypeToString, + BellConfig, +} from '../../../../type/type'; import { Formatting } from '../../../../util/formatting'; -import normalTimer from '../../../../assets/timer/normal_timer.png'; -import timeBasedTimer from '../../../../assets/timer/timebased_timer.png'; -import LabeledCheckbox from '../../../../components/LabledCheckBox/LabeledCheckbox'; -import timeBasedPerSpeakingTimer from '../../../../assets/timer/timebased_perSpeaking_timer.png'; +import normalTimerProsImage from '../../../../assets/timer/normal_timer_pros.jpg'; +import normalTimerConsImage from '../../../../assets/timer/normal_timer_cons.jpg'; +import normalTimerNeutralImage from '../../../../assets/timer/normal_timer_neutral.jpg'; +import timeBasedTimerImage from '../../../../assets/timer/time_based_timer.jpg'; +import timeBasedTimerOnlyTotalImage from '../../../../assets/timer/time_based_timer_only_total.jpg'; +import DTClose from '../../../../components/icons/Close'; +import TimerCreationContentItem from './TimerCreationContentMenuItem'; +import LabeledRadioButton from '../../../../components/LabeledRadioButton/LabeledRadioButton'; +import ClearableInput from '../../../../components/ClearableInput/ClearableInput'; +import DropdownMenu, { + DropdownMenuItem, +} from '../../../../components/DropdownMenu/DropdownMenu'; +import clsx from 'clsx'; +import TimeInputGroup from './TimeInputGroup'; +import DTBell from '../../../../components/icons/Bell'; +import DTAdd from '../../../../components/icons/Add'; +import NotificationBadge from '../../../../components/NotificationBadge/NotificationBadge'; +import DTExpand from '../../../../components/icons/Expand'; + +type TimerCreationOption = + | 'TIMER_TYPE' + | 'SPEECH_TYPE_NORMAL' + | 'SPEECH_TYPE_TIME_BASED' + | 'TEAM' + | 'TIME_PER_TEAM' + | 'TIME_PER_SPEAKING' + | 'SPEAKER' + | 'TIME_NORMAL' + | 'BELL'; + +type SpeechType = 'OPENING' | 'REBUTTAL' | 'TIMEOUT' | 'CLOSING' | 'CUSTOM'; + +const SPEECH_TYPE_RECORD: Record = { + OPENING: '입론', + CLOSING: '최종 발언', + CUSTOM: '직접 입력', + REBUTTAL: '반론', + TIMEOUT: '작전 시간', +} as const; + +const STANCE_RECORD: Record = { + PROS: '찬성', + CONS: '반대', + NEUTRAL: '중립', +} as const; + +const NORMAL_OPTIONS: TimerCreationOption[] = [ + 'TIMER_TYPE', + 'SPEECH_TYPE_NORMAL', + 'TEAM', + 'TIME_NORMAL', + 'SPEAKER', + 'BELL', +] as const; + +const TIME_BASED_OPTIONS: TimerCreationOption[] = [ + 'TIMER_TYPE', + 'SPEECH_TYPE_TIME_BASED', + 'TIME_PER_TEAM', + 'TIME_PER_SPEAKING', +] as const; + +const SAVED_BELL_CONFIGS_KEY = 'savedBellInputConfigs'; interface TimerCreationContentProps { beforeData?: TimeBoxInfo; @@ -15,6 +81,13 @@ interface TimerCreationContentProps { onClose: () => void; } +interface BellInputConfig { + type: BellType; + min: number; + sec: number; + count: number; +} + export default function TimerCreationContent({ beforeData, initData, @@ -32,21 +105,50 @@ export default function TimerCreationContent({ : 'CONS' : (initData?.stance ?? 'PROS'), ); - const [boxType, setBoxType] = useState( + const [timerType, setTimerType] = useState( beforeData?.boxType ?? initData?.boxType ?? 'NORMAL', ); - const predefinedSpeechOptions = useMemo( - () => ['입론', '반론', '최종 발언', '작전 시간'], - [], - ); + // 발언 유형 초기화 + const getSpeechTypeFromString = (value: string): SpeechType => { + switch (value.trim()) { + case '입론': + return 'OPENING'; + case '반론': + return 'REBUTTAL'; + case '최종발언': + case '최종 발언': + return 'CLOSING'; + case '작전시간': + case '작전 시간': + return 'TIMEOUT'; + default: + return 'CUSTOM'; + } + }; + + const initBellInput: BellInputConfig = useMemo(() => { + return { + type: 'BEFORE_END', // 기본값: 종료 전 + min: 0, + sec: 0, + count: 1, + }; + }, []); const initSpeechType = beforeData?.speechType ?? initData?.speechType ?? '입론'; - const [speechType, setSpeechType] = useState(initSpeechType); - const [isCustomSpeech, setIsCustomSpeech] = useState( - !predefinedSpeechOptions.includes(initSpeechType), + const [currentSpeechType, setCurrentSpeechType] = useState( + getSpeechTypeFromString(initSpeechType), ); + const [speechTypeTextValue, setSpeechTypeTextValue] = useState( + currentSpeechType === 'CUSTOM' + ? (initData?.speechType ?? '') + : SPEECH_TYPE_RECORD[currentSpeechType], + ); + + // 종소리 영역 확장 여부 + const [isBellExpanded, setIsBellExpanded] = useState(false); // 발언 시간 const { minutes: initMinutes, seconds: initSeconds } = @@ -67,424 +169,729 @@ export default function TimerCreationContent({ // 1회당 발언 시간 const { minutes: initSpeakerMinutes, seconds: initSpeakerSeconds } = Formatting.formatSecondsToMinutes( - beforeData?.timePerSpeaking ?? initData?.timePerSpeaking ?? 180, + beforeData?.timePerSpeaking ?? initData?.timePerSpeaking ?? 0, ); const [speakerMinutes, setSpeakerMinutes] = useState(initSpeakerMinutes); const [speakerSeconds, setSpeakerSeconds] = useState(initSpeakerSeconds); - const [useSpeakerTime, setUseSpeakerTime] = useState( - (beforeData?.timePerSpeaking ?? initData?.timePerSpeaking) != null, - ); - const [speaker, setSpeaker] = useState( beforeData?.speaker ?? initData?.speaker ?? '', ); - const handleSubmit = () => { + // 시간 총량제 타이머 샘플 이미지 및 대체 텍스트 결정 + const isSpeakingTimerDisabled = speakerMinutes === 0 && speakerSeconds === 0; + const timeBasedPreviewSrc = isSpeakingTimerDisabled + ? timeBasedTimerOnlyTotalImage + : timeBasedTimerImage; + const timeBasedPreviewAlt = isSpeakingTimerDisabled + ? 'time-based-timer-only-total-timer' + : 'time-based-timer'; + + // 이전 종소리 설정 + const rawBellConfigData = sessionStorage.getItem(SAVED_BELL_CONFIGS_KEY); + const defaultBellConfig: BellInputConfig[] = [ + { type: 'BEFORE_END', min: 0, sec: 30, count: 1 }, + { type: 'BEFORE_END', min: 0, sec: 0, count: 2 }, + ]; + const savedBellOptions: BellInputConfig[] = + rawBellConfigData === null + ? defaultBellConfig + : JSON.parse(rawBellConfigData); + + // 종소리 input 상태 + const [bellInput, setBellInput] = useState(initBellInput); + + // bell의 time(초)은: before => 양수, after => 음수로 변환 + const getInitialBells = (): BellInputConfig[] => { + if (initData) { + const initBell = initData.bell === null ? [] : initData.bell; + return initBell.map(bellConfigToBellInputConfig); + } + return savedBellOptions; + }; + const [bells, setBells] = useState(getInitialBells); + + const handleAddBell = () => { + setBells([ + ...bells, + { + type: bellInput.type, + min: bellInput.min, + sec: bellInput.sec, + count: bellInput.count, + }, + ]); + setBellInput(initBellInput); + }; + + const handleDeleteBell = (idx: number) => { + setBells(bells.filter((_, i) => i !== idx)); + }; + + const isNormalTimer = timerType === 'NORMAL'; + + const speechTypeOptions: DropdownMenuItem[] = [ + { value: 'OPENING', label: SPEECH_TYPE_RECORD['OPENING'] }, + { value: 'REBUTTAL', label: SPEECH_TYPE_RECORD['REBUTTAL'] }, + { value: 'TIMEOUT', label: SPEECH_TYPE_RECORD['TIMEOUT'] }, + { value: 'CLOSING', label: SPEECH_TYPE_RECORD['CLOSING'] }, + { value: 'CUSTOM', label: SPEECH_TYPE_RECORD['CUSTOM'] }, + ] as const; + + const stanceOptions: DropdownMenuItem[] = useMemo( + () => [ + { value: 'PROS', label: prosTeamName }, + { value: 'CONS', label: consTeamName }, + { value: 'NEUTRAL', label: STANCE_RECORD['NEUTRAL'] }, + ], + [prosTeamName, consTeamName], + ); + + const bellOptions: DropdownMenuItem[] = useMemo( + () => [ + { value: 'BEFORE_END', label: BellTypeToString['BEFORE_END'] }, + { value: 'AFTER_END', label: BellTypeToString['AFTER_END'] }, + { value: 'AFTER_START', label: BellTypeToString['AFTER_START'] }, + ], + [], + ); + + const options = isNormalTimer ? NORMAL_OPTIONS : TIME_BASED_OPTIONS; + + const handleSubmit = useCallback(() => { const totalTime = minutes * 60 + seconds; const totalTimePerTeam = teamMinutes * 60 + teamSeconds; const totalTimePerSpeaking = speakerMinutes * 60 + speakerSeconds; + // 입력 검증 로직 const errors: string[] = []; - // 텍스트 길이 유효성 검사 - if (speechType.length > 10) { - errors.push('발언 유형은 최대 10자까지 입력할 수 있습니다.'); - } - if (speaker.length > 5) { - errors.push('발언자는 최대 5자까지 입력할 수 있습니다.'); + + if (timerType === 'NORMAL') { + if (totalTime <= 0) { + errors.push('발언 시간은 1초 이상이어야 해요.'); + } + + // 타종 옵션 유효성 검사 + bells.forEach((item: BellInputConfig) => { + if (item.type === 'BEFORE_END') { + const bellTime = item.min * 60 + item.sec; + + if (bellTime > totalTime) { + errors.push('종료 전 타종은 발언 시간보다 길 수 없어요.'); + } + } + }); } - // 발언시간 유효성 검사 - if ( - boxType === 'TIME_BASED' && - useSpeakerTime && - totalTimePerSpeaking > totalTimePerTeam - ) { - errors.push('1회당 발언 시간은 팀당 총 발언 시간보다 클 수 없습니다.'); + + if (timerType === 'TIME_BASED') { + if (totalTimePerTeam <= 0) { + errors.push('팀당 발언 시간은 1초 이상이어야 해요.'); + } + + if (totalTimePerSpeaking > totalTimePerTeam) { + errors.push('1회당 발언 시간은 팀당 발언 시간을 초과할 수 없어요.'); + } } - // 커스텀 타이머 발언유형 유효성 검사 - if (boxType === 'NORMAL' && speechType.trim() === '') { - errors.push('발언 유형을 입력해주세요.'); + + // SpeechType에 맞게 문자열 매핑 + let speechTypeToSend: string; + let stanceToSend: Stance; + if (currentSpeechType === 'CUSTOM') { + // 텍스트 길이 유효성 검사 + if (speechTypeTextValue.length > 10) { + errors.push('발언 유형은 최대 10자까지 입력할 수 있습니다.'); + } + if (speaker.length > 5) { + errors.push('발언자는 최대 5자까지 입력할 수 있습니다.'); + } + + // 발언시간 유효성 검사 + if ( + timerType === 'TIME_BASED' && + totalTimePerSpeaking > totalTimePerTeam + ) { + errors.push('1회당 발언 시간은 팀당 총 발언 시간보다 클 수 없습니다.'); + } + + // 커스텀 타이머 발언유형 유효성 검사 + if (timerType === 'NORMAL' && speechTypeTextValue.trim() === '') { + errors.push('발언 유형을 입력해주세요.'); + } } + if (errors.length > 0) { alert(errors.join('\n')); return; + } else { + if (currentSpeechType === 'CUSTOM') { + speechTypeToSend = speechTypeTextValue; + stanceToSend = timerType === 'TIME_BASED' ? 'NEUTRAL' : stance; + } else { + speechTypeToSend = SPEECH_TYPE_RECORD[currentSpeechType]; + stanceToSend = currentSpeechType === 'TIMEOUT' ? 'NEUTRAL' : stance; + } } - if (boxType === 'NORMAL') { + const bell = isNormalTimer ? bells.map(bellInputConfigToBellConfig) : null; + if (timerType === 'NORMAL') { + sessionStorage.setItem(SAVED_BELL_CONFIGS_KEY, JSON.stringify(bells)); onSubmit({ - stance, - speechType, - boxType, + stance: stanceToSend, + speechType: speechTypeToSend, + boxType: timerType, time: totalTime, timePerTeam: null, timePerSpeaking: null, - speaker, + speaker: stanceToSend === 'NEUTRAL' ? null : speaker, + bell, }); } else { - // TIME_BASED onSubmit({ - stance: 'NEUTRAL', - speechType: speechType.trim() === '' ? '자유토론' : speechType, - boxType, + stance: stanceToSend, + speechType: + speechTypeToSend.trim() === '' ? '자유토론' : speechTypeToSend, + boxType: timerType, time: null, timePerTeam: totalTimePerTeam, - timePerSpeaking: useSpeakerTime ? totalTimePerSpeaking : null, + timePerSpeaking: + totalTimePerSpeaking !== 0 ? totalTimePerSpeaking : null, speaker: null, + bell: null, }); } + onClose(); - }; + }, [ + bells, + isNormalTimer, + currentSpeechType, + minutes, + seconds, + onClose, + onSubmit, + speaker, + speakerMinutes, + speakerSeconds, + teamMinutes, + teamSeconds, + stance, + speechTypeTextValue, + timerType, + ]); + + const handleTimerChange = useCallback( + (event: React.ChangeEvent) => { + const newTimerType = event.target.value as TimeBoxType; + setTimerType(newTimerType); + + // 타이머 종류에 따라 발언 유형(speechType)을 적절하게 설정 + if (newTimerType === 'NORMAL') { + setCurrentSpeechType('OPENING'); // 자유토론 > 일반 전환 시 '입론'으로 초기화 + setStance('PROS'); + } else { + setCurrentSpeechType('CUSTOM'); // 일반 > 자유토론 전환 시 '직접 입력'으로 초기화 + setSpeechTypeTextValue(''); + } + }, + [], + ); - const validateTime = (value: string) => - value === '' ? 0 : Math.max(0, Math.min(59, Number(value))); + const handleSpeechTypeChange = useCallback( + (selectedValue: SpeechType) => { + setCurrentSpeechType(selectedValue); - const isNormalTimer = boxType === 'NORMAL'; + if (selectedValue === 'CUSTOM') { + setSpeechTypeTextValue(''); + } - // 자유토론 타이머로 전환되면 speechType 초기화 - useEffect(() => { - if (!isNormalTimer) { - // 자유토론 타이머로 전환 시 - /* - if (!initData?.speechType) { - setSpeechType(''); + if (selectedValue === 'TIMEOUT') { + setStance('NEUTRAL'); + setSpeaker(''); } - */ - setIsCustomSpeech(true); - } else { - // 일반 타이머로 전환 시, speechType이 predefined에 있으면 custom 아님 - setIsCustomSpeech(!predefinedSpeechOptions.includes(speechType)); - } - if (stance === 'NEUTRAL') { - setSpeaker(''); + + if ( + stance === 'NEUTRAL' && + selectedValue !== 'CUSTOM' && + selectedValue !== 'TIMEOUT' + ) { + setStance('PROS'); + } + }, + [stance], + ); + + const handleStanceChange = useCallback( + (selectedValue: Stance) => { + if (selectedValue === 'NEUTRAL') { + if (currentSpeechType !== 'CUSTOM') { + alert( + "중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다.", + ); + return; + } + } + + setStance(selectedValue); + }, + [currentSpeechType], + ); + + const handleBellExpandButtonClick = useCallback(() => { + setIsBellExpanded((prev) => !prev); + }, []); + + // 1, 2, 3으로 범위가 제한되는 종소리 횟수에 사용하는 변경 함수 + const handleBellCountChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + + // Backspace 대응 + if (value === '') { + setBellInput((prev) => ({ + ...prev, + count: 1, + })); + return; + } + + // 마지막 입력 문자를 가져옴 + const lastInput = value.slice(-1); + let newCount; + + if (['1', '2', '3'].includes(lastInput)) { + // 유효한 입력(1, 2, 3)이면 해당 값으로 설정 + newCount = lastInput; + } else { + // 유효하지 않은 문자(알파벳, 1~3 이외 숫자 등)가 마지막에 입력된 경우 무시 + return; + } + + setBellInput((prev) => ({ + ...prev, + count: Number(newCount), + })); + }, + [], + ); + + // 0 <= x <= 59로 범위가 제한되는 분과 초에 사용하는 검증 함수 + const getValidateTimeValue = (value: string) => { + let num = parseInt(value, 10); + if (isNaN(num)) { + num = 0; } - }, [ - isNormalTimer, - stance, - speechType, - predefinedSpeechOptions, - initData?.speechType, - ]); + + return Math.max(0, Math.min(59, num)); + }; return ( -
-
-
-
- {/** 타이머 이미지 */} - {isNormalTimer ? ( - normal-timer - ) : useSpeakerTime ? ( - timebased-per-speaking-timer +
+ {/* 헤더 */} +
+ {/* 제목 */} + +

+ {timerType === 'NORMAL' ? '일반 타이머' : '자유토론 타이머'} +

+

+ {timerType === 'NORMAL' ? ( + '한 팀의 발언 시간이 세팅된 일반적인 타이머' ) : ( - timebased-timer + <> + {'팀별 발언 시간과 1회당 발언 시간이 세팅된 타이머'} +
+ {'1회당 발언 시간이 지나면, 상대 팀으로 발언권이 넘어감'} + )} -

- -
- {/** boxType 라디오버튼 */} -
- -
-
-
- {/** 발언유형 */} -
- - - {isNormalTimer && ( - - )} - {isCustomSpeech && ( - { - setSpeechType(e.target.value); - }} - placeholder={isNormalTimer ? '예) 보충 질의' : '자유토론'} - /> - )} -
- {/** 팀 */} - {isNormalTimer && ( -
- - -
- )} - {/* 시간 */} - {isNormalTimer && ( -
- -
-
- setMinutes(validateTime(e.target.value))} - /> - -
-
- setSeconds(validateTime(e.target.value))} + ); + + // 팀당 발언 시간 (시간 총량제 타이머) + case 'TIME_PER_TEAM': + return ( + + ); + + // 발언 유형 (시간 총량제 타이머) + case 'SPEECH_TYPE_TIME_BASED': + return ( + + setSpeechTypeTextValue(e.target.value)} + onClear={() => setSpeechTypeTextValue('')} + placeholder="주도권 토론 등" /> - -
-
-
- )} - {/** 팀당 총 발언시간 */} - {!isNormalTimer && ( - <> -
- -
-
- - setTeamMinutes(validateTime(e.target.value)) - } + + - -
-
- - setTeamSeconds(validateTime(e.target.value)) - } - /> - -
-
-
- {/** 1회당 발언시간 */} -
-
- - 1회당
발언 시간 - - } - checked={useSpeakerTime} - onChange={() => setUseSpeakerTime((prev) => !prev)} + + {currentSpeechType === 'CUSTOM' && ( + + setSpeechTypeTextValue(e.target.value) + } + onClear={() => setSpeechTypeTextValue('')} + placeholder="입론, 반론, 작전 시간 등" + /> + )} + + + ); + + // 팀 + case 'TEAM': + return ( + + -
+ + ); + + case 'BELL': + return (
-
- - setSpeakerMinutes(validateTime(e.target.value)) - } - disabled={!useSpeakerTime} - /> - -
-
- - setSpeakerSeconds(validateTime(e.target.value)) + {/* 제목 및 종소리 횟수 배지 */} +
+
+

+ 종소리 설정 +

+ + +
+ +
+ + {/* 종소리 설정 영역 */} + {isBellExpanded && ( +
+ {/* 입력부 */} + + {/* 벨 유형 */} + { + setBellInput((prev) => ({ + ...prev, + type: value, + })); + }} + /> + + + {/* 분, 초, 타종 횟수 */} + , + ) => { + const safeValue = e.target.value.replace( + /[^0-9]/g, + '', + ); + + setBellInput((prev) => ({ + ...prev, + min: getValidateTimeValue(safeValue), + })); + }} + placeholder="분" + /> + + + , + ) => { + const safeValue = e.target.value.replace( + /[^0-9]/g, + '', + ); + + setBellInput((prev) => ({ + ...prev, + sec: getValidateTimeValue(safeValue), + })); + }} + placeholder="초" + /> + + + + +

x

+ + + + +
+ + {/* 벨 리스트 */} + + {bells.map((bell, idx) => ( + +
+

+ {BellTypeToString[bell.type]} +

+

+ {bell.min}분 {bell.sec}초 +

+ + + + + x {bell.count} + +
+ + +
+ ))} +
+
+ )}
-
- - )} + ); + default: + return null; + } + })} + + - {/** 발언자 */} - {isNormalTimer && ( -
- - { - if (stance === 'NEUTRAL') { - setSpeaker(''); - } else { - setSpeaker(e.target.value); - } - }} - placeholder="1번" - disabled={stance === 'NEUTRAL'} - /> - 토론자 -
- )} -
-
- -
+ {/* 제출 버튼 */} +
); } + +function bellInputConfigToBellConfig(input: BellInputConfig): BellConfig { + let time = input.min * 60 + input.sec; + if (input.type === 'AFTER_END') time = -time; + return { + time, + count: input.count, + type: input.type, + }; +} + +function bellConfigToBellInputConfig(data: BellConfig): BellInputConfig { + const { type, time, count } = data; + const { minutes, seconds } = Formatting.formatSecondsToMinutes(time); + const converted = { type, min: minutes, sec: seconds, count }; + return converted; +} diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContentMenuItem.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContentMenuItem.tsx new file mode 100644 index 00000000..b935ba27 --- /dev/null +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContentMenuItem.tsx @@ -0,0 +1,25 @@ +import clsx from 'clsx'; +import { PropsWithChildren } from 'react'; + +interface TimerCreationContentItemProps extends PropsWithChildren { + title: string; + className?: string; +} + +export default function TimerCreationContentItem({ + title, + children, + className = '', +}: TimerCreationContentItemProps) { + return ( +
+

{title}

+ {children} +
+ ); +} diff --git a/src/page/TableComposition/hook/useTableFrom.tsx b/src/page/TableComposition/hook/useTableFrom.tsx index 1bebeae5..a82bffd5 100644 --- a/src/page/TableComposition/hook/useTableFrom.tsx +++ b/src/page/TableComposition/hook/useTableFrom.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useNavigate, useNavigationType } from 'react-router-dom'; -import { TableCompositionStep } from '../TableComposition'; +import { TableCompositionStep } from '../TableCompositionPage'; import useBrowserStorage from '../../../hooks/useBrowserStorage'; import { DebateInfo, DebateTableData, TimeBoxInfo } from '../../../type/type'; import useAddDebateTable from '../../../hooks/mutations/useAddDebateTable'; @@ -24,8 +24,6 @@ const useTableFrom = ( agenda: '', prosTeamName: '', consTeamName: '', - warningBell: true, - finishBell: true, }, table: [], }, @@ -54,8 +52,6 @@ const useTableFrom = ( const debateInfo: DebateInfo = { name: newInfo.name, agenda: newInfo.agenda, - warningBell: newInfo.warningBell, - finishBell: newInfo.finishBell, prosTeamName: newInfo.prosTeamName, consTeamName: newInfo.consTeamName, }; @@ -80,28 +76,31 @@ const useTableFrom = ( }); }; - const { mutate: onAddTable } = useAddDebateTable((tableId) => { - removeValue(); - navigate(`/overview/customize/${tableId}`); - }); - - const { mutate: onModifyTable } = usePutDebateTable((tableId) => { - removeValue(); - if (isGuestFlow()) { - navigate(`/overview/customize/guest`); - } else { + const { mutate: onAddTable, isPending: isAddingTable } = useAddDebateTable( + (tableId) => { + removeValue(); navigate(`/overview/customize/${tableId}`); - } - }); + }, + ); + + const { mutate: onModifyTable, isPending: isModifyingTable } = + usePutDebateTable((tableId) => { + removeValue(); + if (isGuestFlow()) { + navigate(`/overview/customize/guest`); + } else { + navigate(`/overview/customize/${tableId}`); + } + }); - const AddTable = () => { + const addTable = () => { onAddTable({ info: formData.info, table: formData.table as TimeBoxInfo[], }); }; - const EditTable = (tableId: number) => { + const editTable = (tableId: number) => { onModifyTable({ tableId, info: formData.info, @@ -113,8 +112,10 @@ const useTableFrom = ( formData, updateInfo, updateTable, - AddTable, - EditTable, + addTable, + editTable, + isAddingTable, + isModifyingTable, }; }; diff --git a/src/page/TableListPage/TableListPage.test.tsx b/src/page/TableListPage/TableListPage.test.tsx index ed806d62..5ec29b74 100644 --- a/src/page/TableListPage/TableListPage.test.tsx +++ b/src/page/TableListPage/TableListPage.test.tsx @@ -5,6 +5,7 @@ import { describe, expect } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import TableListPage from './TableListPage'; import userEvent from '@testing-library/user-event'; +import { Suspense } from 'react'; function TestWrapper({ children }: { children: React.ReactNode }) { const queryClient = new QueryClient(); @@ -21,7 +22,9 @@ describe('TableListPage', () => { const renderTableListPage = () => { return render( - + + + , ); }; diff --git a/src/page/TableListPage/TableListPage.tsx b/src/page/TableListPage/TableListPage.tsx index 15ba4e5e..6dc2bb18 100644 --- a/src/page/TableListPage/TableListPage.tsx +++ b/src/page/TableListPage/TableListPage.tsx @@ -1,27 +1,10 @@ -import { useNavigate } from 'react-router-dom'; -import { useDeleteDebateTable } from '../../hooks/mutations/useDeleteDebateTable'; -import { useGetDebateTableList } from '../../hooks/query/useGetDebateTableList'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; -import { DebateTable } from '../../type/type'; -import Table from './components/Table'; import HeaderTitle from '../../components/HeaderTitle/HeaderTitle'; +import { Suspense } from 'react'; +import LoadingIndicator from '../../components/LoadingIndicator/LoadingIndicator'; +import TableListPageContent from './components/TableListPageContent'; export default function TableListPage() { - const { data } = useGetDebateTableList(); - const { mutate: deleteCustomizeTable } = useDeleteDebateTable(); - const navigate = useNavigate(); - // TODO: have to delete the query param 'type' - const onEdit = (tableId: number) => { - navigate(`/composition?mode=edit&tableId=${tableId}&type=CUSTOMIZE`); - }; - // TODO: have to delete the string 'customize' from the URL - const onClick = (tableId: number) => { - navigate(`/overview/customize/${tableId}`); - }; - const onDelete = (tableId: number) => { - deleteCustomizeTable({ tableId }); - }; - return ( @@ -33,29 +16,9 @@ export default function TableListPage() { -
- {/** Button that adds new table */} - - - {/** All tables */} - {data && - data.tables.map((table: DebateTable, idx: number) => ( - onDelete(table.id)} - onEdit={() => onEdit(table.id)} - onClick={() => onClick(table.id)} - /> - ))} - + }> + + ); diff --git a/src/page/TableListPage/components/Table.tsx b/src/page/TableListPage/components/Table.tsx index 4e400a7b..e50a858e 100644 --- a/src/page/TableListPage/components/Table.tsx +++ b/src/page/TableListPage/components/Table.tsx @@ -1,10 +1,13 @@ import { useState } from 'react'; import { DebateTable } from '../../../type/type'; -import { IoArrowForward, IoShareOutline } from 'react-icons/io5'; -import { RiDeleteBinFill, RiEditFill } from 'react-icons/ri'; +import { IoArrowForward } from 'react-icons/io5'; import { useModal } from '../../../hooks/useModal'; import DialogModal from '../../../components/DialogModal/DialogModal'; import { useTableShare } from '../../../hooks/useTableShare'; +import SmallIconButtonContainer from '../../../components/SmallIconContainer/SmallIconContainer'; +import DTEdit from '../../../components/icons/Edit'; +import DTDelete from '../../../components/icons/Delete'; +import DTShare from '../../../components/icons/Share'; interface TableProps extends DebateTable { onEdit: () => void; @@ -25,16 +28,17 @@ export default function Table({ const { openModal, closeModal, ModalWrapper } = useModal({ isCloseButtonExist: false, }); - const bgColor = isHovered ? 'bg-brand-sub1' : 'bg-brand-main'; - const squareColor = isHovered ? 'bg-neutral-0' : 'bg-brand-sub1'; - const textBodyColor = isHovered ? 'text-neutral-0' : 'text-neutral-600'; - const textTitleColor = isHovered ? 'text-neutral-0' : 'text-neutral-1000'; + const bgColor = isHovered ? 'bg-brand' : 'bg-brand/70'; const psClass = isHovered ? 'ps-12' : 'ps-0'; return ( <> -
- +
+
{/* Right component (fills remaining space) */}

{name}

주제 | {agenda}

diff --git a/src/page/TableListPage/components/TableListPageContent.tsx b/src/page/TableListPage/components/TableListPageContent.tsx new file mode 100644 index 00000000..96a376a4 --- /dev/null +++ b/src/page/TableListPage/components/TableListPageContent.tsx @@ -0,0 +1,48 @@ +import { useNavigate } from 'react-router-dom'; +import { useDeleteDebateTable } from '../../../hooks/mutations/useDeleteDebateTable'; +import { useGetDebateTableList } from '../../../hooks/query/useGetDebateTableList'; +import { DebateTable } from '../../../type/type'; +import Table from './Table'; + +export default function TableListPageContent() { + const { data } = useGetDebateTableList(); + const { mutate: deleteCustomizeTable } = useDeleteDebateTable(); + const navigate = useNavigate(); + // TODO: have to delete the query param 'type' + const onEdit = (tableId: number) => { + navigate(`/composition?mode=edit&tableId=${tableId}&type=CUSTOMIZE`); + }; + // TODO: have to delete the string 'customize' from the URL + const onClick = (tableId: number) => { + navigate(`/overview/customize/${tableId}`); + }; + const onDelete = (tableId: number) => { + deleteCustomizeTable({ tableId }); + }; + + return ( +
+ {/** Button that adds new table */} + + + {/** All tables */} + {data && + data.tables.map((table: DebateTable) => ( +
onDelete(table.id)} + onEdit={() => onEdit(table.id)} + onClick={() => onClick(table.id)} + /> + ))} + + ); +} diff --git a/src/page/TableOverviewPage/TableOverview.tsx b/src/page/TableOverviewPage/TableOverview.tsx deleted file mode 100644 index e1d6b687..00000000 --- a/src/page/TableOverviewPage/TableOverview.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; -import PropsAndConsTitle from '../../components/ProsAndConsTitle/PropsAndConsTitle'; -import { useNavigate, useParams } from 'react-router-dom'; -import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo'; -import HeaderTitle from '../../components/HeaderTitle/HeaderTitle'; -import { RiEditFill, RiSpeakFill } from 'react-icons/ri'; -import usePatchDebateTable from '../../hooks/mutations/usePatchDebateTable'; -import { useGetDebateTableData } from '../../hooks/query/useGetDebateTableData'; -import DebatePanel from '../TableComposition/components/DebatePanel/DebatePanel'; -import { useTableShare } from '../../hooks/useTableShare'; -import { MdOutlineIosShare } from 'react-icons/md'; -import { StanceToString } from '../../type/type'; -import { isGuestFlow } from '../../util/sessionStorage'; - -export default function TableOverview() { - const { id } = useParams(); - const tableId = Number(id); - const navigate = useNavigate(); - - // Only uses hooks related with customize due to the removal of parliamentary - const { data } = useGetDebateTableData(tableId); - const onModifyCustomizeTableData = usePatchDebateTable((tableId) => { - navigate(`/table/customize/${tableId}`); - }); - - // Hook for sharing tables - const { openShareModal, TableShareModal } = useTableShare(tableId); - - return ( - <> - - - - - - - - - - - - -
- - -
- {data?.table.map((info, index) => ( - - ))} -
-
-
- - -
- -
- - - -
-
-
-
- - - - ); -} diff --git a/src/page/TableOverviewPage/TableOverview.stories.tsx b/src/page/TableOverviewPage/TableOverviewPage.stories.tsx similarity index 81% rename from src/page/TableOverviewPage/TableOverview.stories.tsx rename to src/page/TableOverviewPage/TableOverviewPage.stories.tsx index e15172a2..3e5b252c 100644 --- a/src/page/TableOverviewPage/TableOverview.stories.tsx +++ b/src/page/TableOverviewPage/TableOverviewPage.stories.tsx @@ -1,20 +1,20 @@ import { Meta, StoryObj } from '@storybook/react'; -import TableOverview from './TableOverview'; +import TableOverviewPage from './TableOverviewPage'; import { // ParliamentaryTimeBoxInfo, TimeBoxInfo, } from '../../type/type'; // 1) 메타 설정 -const meta: Meta = { - title: 'page/TableOverview', - component: TableOverview, +const meta: Meta = { + title: 'page/TableOverviewPage', + component: TableOverviewPage, tags: ['autodocs'], }; export default meta; -type Story = StoryObj; +type Story = StoryObj; // 2) 스토리 정의 export const Default: Story = { diff --git a/src/page/TableOverviewPage/TableOverviewPage.tsx b/src/page/TableOverviewPage/TableOverviewPage.tsx new file mode 100644 index 00000000..577ed899 --- /dev/null +++ b/src/page/TableOverviewPage/TableOverviewPage.tsx @@ -0,0 +1,212 @@ +import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; +import PropsAndConsTitle from '../../components/ProsAndConsTitle/PropsAndConsTitle'; +import { useNavigate, useParams } from 'react-router-dom'; +import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo'; +import HeaderTitle from '../../components/HeaderTitle/HeaderTitle'; +import usePatchDebateTable from '../../hooks/mutations/usePatchDebateTable'; +import { useGetDebateTableData } from '../../hooks/query/useGetDebateTableData'; +import TimeBox from '../TableComposition/components/TimeBox/TimeBox'; +import { useTableShare } from '../../hooks/useTableShare'; +import { CoinState, StanceToString } from '../../type/type'; +import { isGuestFlow } from '../../util/sessionStorage'; +import DTShare from '../../components/icons/Share'; +import DTDebate from '../../components/icons/Debate'; +import DTEdit from '../../components/icons/Edit'; +import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; +import LoadingIndicator from '../../components/LoadingIndicator/LoadingIndicator'; +import Coins from '../../assets/teamSelection/coins.png'; +import TeamSelectionModal from './components/TeamSelectionModal/TeamSelectionModal'; +import { useModal } from '../../hooks/useModal'; +import clsx from 'clsx'; +import { useState, useCallback } from 'react'; + +export default function TableOverviewPage() { + const { id } = useParams(); + const tableId = Number(id); + const navigate = useNavigate(); + const { openModal, closeModal, ModalWrapper } = useModal(); + const [modalCoinState, setModalCoinState] = useState('initial'); + + const handleOpenModal = () => { + setModalCoinState('initial'); + openModal(); + }; + + const handleCoinStateChange = useCallback((newState: CoinState) => { + setModalCoinState(newState); + }, []); + + // Only uses hooks related with customize due to the removal of parliamentary + const { + data, + isLoading: isFetching, + isError: isFetchError, + refetch, + isRefetching, + isRefetchError, + } = useGetDebateTableData(tableId); + const onModifyCustomizeTableData = usePatchDebateTable((tableId) => { + navigate(`/table/customize/${tableId}`); + }); + + // Hook for sharing tables + const { openShareModal, TableShareModal } = useTableShare(tableId); + const isLoading = isFetching || isRefetching; + const isError = isFetchError || isRefetchError; + + // 토론 시작하기 핸들러 + const handleStartDebate = () => { + if (isGuestFlow()) { + navigate('/table/customize/guest'); + } else { + onModifyCustomizeTableData.mutate({ tableId }); + } + }; + + // 토론 수정하기 핸들러 + const handleEdit = () => { + if (isGuestFlow()) { + navigate(`/composition?mode=edit&type=CUSTOMIZE`, { + state: { step: 'NameAndType' }, + }); + } else { + navigate(`/composition?mode=edit&tableId=${tableId}&type=CUSTOMIZE`, { + state: { step: 'NameAndType' }, + }); + } + }; + + if (isError) { + return ( + + + refetch()} /> + + + ); + } + + return ( + <> + + + + {!isLoading && } + + + {!isLoading && } + + + + + + {isLoading && } + {!isLoading && ( +
+ + +
+ {data?.table.map((info, index) => ( + + ))} +
+
+ )} + {!isLoading && ( + + )} +
+ + +
+ + +
+ +
+
+
+
+ + + + + + + ); +} diff --git a/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx b/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx new file mode 100644 index 00000000..676f7315 --- /dev/null +++ b/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx @@ -0,0 +1,179 @@ +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import Cointoss from '../../../../assets/teamSelection/cointoss.png'; +import CoinFront from '../../../../assets/teamSelection/coinfront.png'; +import CoinBack from '../../../../assets/teamSelection/coinback.png'; +import { CoinState } from '../../../../type/type'; + +interface TeamSelectionModalProps { + onClose: () => void; + onStartDebate: () => void; + onEdit: () => void; + initialCoinState: CoinState; + onCoinStateChange: (state: CoinState) => void; +} + +export default function TeamSelectionModal({ + onClose, + onStartDebate, + onEdit, + initialCoinState, + onCoinStateChange, +}: TeamSelectionModalProps) { + const [coinState, setCoinState] = useState(initialCoinState); + const hasResultSoundPlayedRef = useRef(false); + + const updateCoinState = useCallback( + (newState: CoinState) => { + setCoinState(newState); + onCoinStateChange(newState); + }, + [onCoinStateChange], + ); + + // 효과음 객체 + const coinTossSound = useMemo(() => new Audio('/sounds/cointoss.mp3'), []); + const coinResultSound = useMemo( + () => new Audio('/sounds/cointoss-result.mp3'), + [], + ); + + // 동전 던지는 소리 및 결과 처리 + useEffect(() => { + if (coinState === 'tossing') { + // 동전 던질 때 false로 초기화 + hasResultSoundPlayedRef.current = false; + + coinTossSound.currentTime = 0; + coinTossSound.play(); + + const timer = setTimeout(() => { + // 다음 화면 상태 전환 직전 사운드 명시적 정지 + coinTossSound.pause(); + coinTossSound.currentTime = 0; + + // 결과 결정 및 상태 업데이트 + const result = Math.random() < 0.5 ? 'front' : 'back'; + updateCoinState(result); + + // 결과 소리 바로 재생 + setTimeout(() => { + if (!hasResultSoundPlayedRef.current) { + coinResultSound.currentTime = 0; + coinResultSound.play(); + hasResultSoundPlayedRef.current = true; + } + }, 100); // 약간의 딜레이로 자연스럽게 연결 + }, 2000); + + return () => { + clearTimeout(timer); + coinTossSound.pause(); + coinTossSound.currentTime = 0; + }; + } + }, [coinState, coinTossSound, coinResultSound, updateCoinState]); + + // 초기 상태에서 결과 상태로 직접 진입한 경우 (탭 전환 등) + useEffect(() => { + if ( + (coinState === 'front' || coinState === 'back') && + initialCoinState === coinState + ) { + // 초기값과 현재값이 같다면 이미 결과가 나온 상태이므로 소리 재생하지 않음 + hasResultSoundPlayedRef.current = true; + } + }, [coinState, initialCoinState]); + + const handleStart = () => { + onClose(); + onStartDebate(); + }; + + const handleEdit = () => { + onClose(); + onEdit(); + }; + + return ( +
+
+ {coinState === 'initial' && ( +
+

+ 팀별로
동전의 앞 / 뒷면 중
하나를 선택해 주세요. +

+
+ )} + + {coinState === 'tossing' && ( + <> +
+
+ 동전 +
+
+
+ + 동전 던지는 중... + +
+ + )} + + {(coinState === 'front' || coinState === 'back') && ( +
+
+
+ 동전 +
+
+ {coinState === 'front' ? '앞' : '뒤'} +
+
+
+ )} +
+ + {/* 모달의 콘텐츠 영역과 분리하기 위해 별도 작성 */} +
+ {coinState === 'initial' && ( + + )} + {(coinState === 'front' || coinState === 'back') && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/src/page/TimerPage/FeedbackTimerPage.tsx b/src/page/TimerPage/FeedbackTimerPage.tsx new file mode 100644 index 00000000..9e986b7a --- /dev/null +++ b/src/page/TimerPage/FeedbackTimerPage.tsx @@ -0,0 +1,29 @@ +import { useEffect } from 'react'; +import { useFeedbackTimer } from './hooks/useFeedbackTimer'; +import FeedbackTimer from './components/FeedbackTimer'; +import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; +import GoToHomeButton from '../../components/GoToHomeButton/GoToHomeButton'; + +const INITIAL_TIME = 0; + +export default function FeedbackTimerPage() { + const feedbackTimerInstance = useFeedbackTimer(); + const { setTimer, setDefaultTimer } = feedbackTimerInstance; + + useEffect(() => { + // 페이지가 로드될 때 타이머의 초기 시간을 설정 + setTimer(INITIAL_TIME); + setDefaultTimer(INITIAL_TIME); + }, [setTimer, setDefaultTimer]); + + return ( + + +
+ + +
+
+
+ ); +} diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index 1ac7e786..7772fd6a 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -1,841 +1,123 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; -import TimeBasedTimer from './components/TimeBasedTimer'; -import { useNavigate, useParams } from 'react-router-dom'; -import FirstUseToolTip from './components/FirstUseToolTip'; import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo'; import HeaderTitle from '../../components/HeaderTitle/HeaderTitle'; -import IconButton from '../../components/IconButton/IconButton'; -import { IoHelpCircle } from 'react-icons/io5'; -import { useCustomTimer } from './hooks/useCustomTimer'; -import { useGetDebateTableData } from '../../hooks/query/useGetDebateTableData'; -import { FaExchangeAlt } from 'react-icons/fa'; -import NormalTimer from './components/NormalTimer'; -import { useNormalTimer } from './hooks/useNormalTimer'; -import RoundControlButton from '../../components/RoundControlButton/RoundControlButton'; -import { useModal } from '../../hooks/useModal'; -import { isGuestFlow } from '../../util/sessionStorage'; -import { oAuthLogin } from '../../util/googleAuth'; -import DialogModal from '../../components/DialogModal/DialogModal'; - -type TimerState = 'default' | 'warning' | 'danger' | 'expired'; -const bgColorMap: Record = { - default: '', - warning: 'bg-brand-main', // 30초 ~ 11초 - danger: 'bg-brand-sub3', // 10초 이하 - expired: 'bg-neutral-700', // 0초 이하 -}; +import { useTimerPageState } from './hooks/useTimerPageState'; +import { useTimerHotkey } from './hooks/useTimerHotkey'; +import RoundControlRow from './components/RoundControlRow'; +import TimerView from './components/TimerView'; +import { FirstUseToolTipModal } from './components/FirstUseToolTipModal'; +import { LoginAndStoreModal } from './components/LoginAndStoreModal'; +import { useTimerPageModal } from './hooks/useTimerPageModal'; +import { bgColorMap } from '../../type/type'; +import DTHelp from '../../components/icons/Help'; +import clsx from 'clsx'; +import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; +import LoadingIndicator from '../../components/LoadingIndicator/LoadingIndicator'; export default function TimerPage() { - // ########## DECLARATION AREA ########## - // Load sounds and prepare for bell-related constants - const warningBellRef = useRef(null); - const finishBellRef = useRef(null); - const [isWarningBellOn, setWarningBell] = useState(false); - const [isFinishBellOn, setFinishBell] = useState(false); - - // Parse params const pathParams = useParams(); const tableId = Number(pathParams.id); - const navigate = useNavigate(); - - // Get query - const { data } = useGetDebateTableData(tableId); - - // Prepare for tooltip-related constants - const [isFirst, setIsFirst] = useState(false); - const IS_FIRST = 'isFirst'; - const TRUE = 'true'; - const FALSE = 'false'; - const { - openModal: openUseTooltipModal, - closeModal: closeUseTooltipModal, - ModalWrapper: UseToolTipWrapper, - } = useModal({ - onClose: () => { - setIsFirst(false); - localStorage.setItem(IS_FIRST, FALSE); - }, - isCloseButtonExist: false, - }); const { - openModal: openLoginAndStoreModal, - closeModal: closeLoginAndStoreModal, - ModalWrapper: LoginAndStoreModalWrapper, - } = useModal(); - - // Prepare for changing background - const [bg, setBg] = useState('default'); - - // Prepare for additional timer - const [isAdditionalTimerOn, setIsAdditionalTimerOn] = useState(false); - const [savedTimer, saveTimer] = useState(0); - const [isTimerChangeable, setIsTimerChangeable] = useState(true); - - // Prepare for index-related constants - const [index, setIndex] = useState(0); - - // Prepare for timer hook - const timer1 = useCustomTimer({}); - const timer2 = useCustomTimer({}); - const normalTimer = useNormalTimer(); - const [prosConsSelected, setProsConsSelected] = useState<'pros' | 'cons'>( - 'pros', - ); - - // 타이머의 이전상태를 저장(타종 31->30초인 상황에서만 타종하기위한 로직) - const prevTimer1Ref = useRef<{ - speakingTimer: number | null; - totalTimer: number | null; - }>({ - speakingTimer: null, - totalTimer: null, - }); - - const prevTimer2Ref = useRef<{ - speakingTimer: number | null; - totalTimer: number | null; - }>({ - speakingTimer: null, - totalTimer: null, - }); - - const prevNormalTimerRef = useRef(null); - - // 이전 또는 다음 차례로 이동하는 함수 - const goToOtherItem = useCallback( - (isPrev: boolean) => { - if (isPrev) { - if (index > 0) { - setIndex((prev) => prev - 1); - } - } else { - if (data && index < data.table.length - 1) { - setIndex((prev) => prev + 1); - } - } - }, - [index, data], - ); - - // 발언 진영(pros/cons) 전환 함수 (ENTER 버튼 등에서 사용) - const switchCamp = useCallback(() => { - if (prosConsSelected === 'pros') { - if (timer2.isDone) return; - if (timer1.isRunning) { - timer1.pauseTimer(); - timer2.startTimer(); - setProsConsSelected('cons'); - } else { - timer1.pauseTimer(); - setProsConsSelected('cons'); - } - } else if (prosConsSelected === 'cons') { - if (timer1.isDone) return; - if (timer2.isRunning) { - if (timer1.isDone) return; - timer2.pauseTimer(); - timer1.startTimer(); - setProsConsSelected('pros'); - } else { - timer2.pauseTimer(); - setProsConsSelected('pros'); - } - } - }, [prosConsSelected, timer1, timer2]); - - // ########### useEffect AREA ########### - // 로컬스토리지에 저장된 "최초 사용 여부" 확인 → 툴팁 띄울지 결정 - useEffect(() => { - const storedIsFirst = localStorage.getItem(IS_FIRST); - - if (storedIsFirst === null) { - setIsFirst(true); - } else { - setIsFirst(storedIsFirst.trim() === TRUE ? true : false); - } - - if (isFirst) { - openUseTooltipModal(); - } - }, [isFirst, openUseTooltipModal]); - - // 타이머 상태에 따라 배경색(bg) 상태 설정 - useEffect(() => { - const getBgStatus = () => { - const boxType = data?.table[index].boxType; - - const getTimerStatus = ( - speakingTimer: number | null, - totalTimer: number | null, - ) => { - const activeTimer = speakingTimer !== null ? speakingTimer : totalTimer; - if (activeTimer !== null) { - if (activeTimer > 10 && activeTimer <= 30) return 'warning'; - if (activeTimer >= 0 && activeTimer <= 10) return 'danger'; - } - return 'default'; - }; - - if (boxType === 'NORMAL') { - if (!normalTimer.isRunning) return 'default'; - - if (normalTimer.timer !== null) { - if (normalTimer.timer > 10 && normalTimer.timer <= 30) - return 'warning'; - if (normalTimer.timer >= 0 && normalTimer.timer <= 10) - return 'danger'; - if (normalTimer.timer < 0) return 'expired'; - return 'default'; - } - } - - if (boxType === 'TIME_BASED') { - if (prosConsSelected === 'pros' && timer1.isRunning) { - return getTimerStatus(timer1.speakingTimer, timer1.totalTimer); - } - if (prosConsSelected === 'cons' && timer2.isRunning) { - return getTimerStatus(timer2.speakingTimer, timer2.totalTimer); - } - } - - return 'default'; - }; - - setBg(getBgStatus()); - }, [ - normalTimer.isRunning, - normalTimer.timer, - timer1.isRunning, - timer1.totalTimer, - timer1.speakingTimer, - timer2.isRunning, - timer2.totalTimer, - timer2.speakingTimer, - prosConsSelected, - index, - data, - ]); - - // 벨 소리 재생 - useEffect(() => { - const shouldPlayWarningBell = () => { - const isAnyTimerRunning = - timer1.isRunning || timer2.isRunning || normalTimer.isRunning; - if (!warningBellRef.current || !isAnyTimerRunning || !isWarningBellOn) - return false; - const waringTime = 30; - const timerJustReached = ( - prevTime: number | null, - currentTime: number | null, - defaultTime: number | null, - ) => { - return ( - prevTime !== null && - prevTime > waringTime && - currentTime === waringTime && - defaultTime !== waringTime - ); - }; - - const isTimer1WarningTime = - timer1.isRunning && - (timerJustReached( - prevTimer1Ref.current.speakingTimer, - timer1.speakingTimer, - timer1.defaultTime.defaultSpeakingTimer, - ) || - (timer1.speakingTimer === null && - timerJustReached( - prevTimer1Ref.current.totalTimer, - timer1.totalTimer, - timer1.defaultTime.defaultTotalTimer, - ))); - - const isTimer2WarningTime = - timer2.isRunning && - (timerJustReached( - prevTimer2Ref.current.speakingTimer, - timer2.speakingTimer, - timer2.defaultTime.defaultSpeakingTimer, - ) || - (timer2.speakingTimer === null && - timerJustReached( - prevTimer2Ref.current.totalTimer, - timer2.totalTimer, - timer2.defaultTime.defaultTotalTimer, - ))); - - const isNormalTimerWarningTime = - normalTimer.isRunning && - prevNormalTimerRef.current !== null && - prevNormalTimerRef.current > waringTime && - normalTimer.timer === waringTime && - normalTimer.defaultTimer !== waringTime; - - return ( - isTimer1WarningTime || isTimer2WarningTime || isNormalTimerWarningTime - ); - }; - - // 사용 - if (warningBellRef.current && shouldPlayWarningBell()) { - warningBellRef.current.play(); - } - - const shouldPlayFinishBell = () => { - const isTimer1Finished = - timer1.isRunning && - (timer1.speakingTimer === 0 || timer1.totalTimer === 0); - - const isTimer2Finished = - timer2.isRunning && - (timer2.speakingTimer === 0 || timer2.totalTimer === 0); - - const isNormalTimerFinished = - normalTimer.isRunning && normalTimer.timer === 0; - - const isAnyTimerRunning = - timer1.isRunning || timer2.isRunning || normalTimer.isRunning; - return ( - isAnyTimerRunning && - isFinishBellOn && - (isTimer1Finished || isTimer2Finished || isNormalTimerFinished) - ); - }; - - // 사용 - if (finishBellRef.current && shouldPlayFinishBell()) { - finishBellRef.current.play(); - } - - prevTimer1Ref.current = { - speakingTimer: timer1.speakingTimer, - totalTimer: timer1.totalTimer, - }; - prevTimer2Ref.current = { - speakingTimer: timer2.speakingTimer, - totalTimer: timer2.totalTimer, - }; - prevNormalTimerRef.current = normalTimer.timer; - }, [ - isFinishBellOn, - isWarningBellOn, - timer1.isRunning, - timer2.isRunning, - normalTimer.isRunning, - timer1.speakingTimer, - timer1.totalTimer, - timer1.defaultTime.defaultTotalTimer, - timer1.defaultTime.defaultSpeakingTimer, - timer2.speakingTimer, - timer2.totalTimer, - timer2.defaultTime.defaultSpeakingTimer, - normalTimer.timer, - normalTimer.defaultTimer, - timer2.defaultTime.defaultTotalTimer, - ]); - - // 새로운 index(차례)로 이동했을 때 → 타이머 초기화 및 세팅 - useEffect(() => { - if (!data) return; - - const currentBox = data.table[index]; - const { warningBell, finishBell } = data.info; - - setWarningBell(warningBell); - setFinishBell(finishBell); - timer1.clearTimer(); - timer2.clearTimer(); - normalTimer.clearTimer(); - - if (currentBox.boxType === 'NORMAL') { - const defaultTime = currentBox.time ?? 0; - normalTimer.setDefaultTimer(defaultTime); - normalTimer.setTimer(defaultTime); - } else if (currentBox.boxType === 'TIME_BASED') { - normalTimer.clearTimer(); - - const defaultTotalTimer = currentBox.timePerTeam; - const defaultSpeakingTimer = currentBox.timePerSpeaking; - - [timer1, timer2].forEach((timer) => { - timer.setDefaultTime({ defaultTotalTimer, defaultSpeakingTimer }); - timer.setTimers(defaultTotalTimer, defaultSpeakingTimer); - timer.setIsSpeakingTimer(true); - timer.setIsDone(false); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - data, - index, - timer1.setDefaultTime, - timer1.setTimers, - timer2.setDefaultTime, - timer2.setTimers, - normalTimer.setDefaultTimer, - normalTimer.setTimer, - ]); - - // 키보드 단축키 제어 - useEffect(() => { - const boxType = data?.table[index].boxType; - const handleKeyDown = (event: KeyboardEvent) => { - const keysToDisable = [ - 'Space', - 'ArrowLeft', - 'ArrowRight', - 'KeyR', - 'KeyA', - 'KeyL', - 'Enter', - ]; - - if (keysToDisable.includes(event.key)) { - event.preventDefault(); - } - if (event.target instanceof HTMLElement) { - event.target.blur(); - } - - const toggleTimer = (timer: typeof timer1 | typeof timer2) => { - if (timer.isRunning) { - timer.pauseTimer(); - } else { - timer.startTimer(); - } - }; - - switch (event.code) { - case 'Space': - if (boxType === 'NORMAL') { - if (normalTimer.isRunning) { - normalTimer.pauseTimer(); - } else { - normalTimer.startTimer(); - } - } else { - if (prosConsSelected === 'pros') { - toggleTimer(timer1); - } else if (prosConsSelected === 'cons') { - toggleTimer(timer2); - } - } - break; - case 'ArrowLeft': - goToOtherItem(true); - break; - case 'ArrowRight': - goToOtherItem(false); - break; - case 'KeyR': - if (boxType === 'NORMAL') { - normalTimer.resetTimer(); - } else { - if (prosConsSelected === 'pros') { - timer1.resetCurrentTimer(); - } else { - timer2.resetCurrentTimer(); - } - } - break; - case 'KeyA': - if (!timer1.isDone) { - setProsConsSelected('pros'); - if (timer2.isRunning) timer2.pauseTimer(); - } - break; - case 'KeyL': - if (!timer2.isDone) { - setProsConsSelected('cons'); - if (timer1.isRunning) timer1.pauseTimer(); - } - break; - case 'Enter': - switchCamp(); - break; - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - goToOtherItem, - prosConsSelected, - timer1, - timer2, - setProsConsSelected, - switchCamp, - ]); - - // 테이블에 작전시간 발언이 있는 경우 작전시간 타이머 변경 비활성화 - useEffect(() => { - if (data) { - data.table.forEach((value) => { - if (value.speechType === '작전 시간') { - setIsTimerChangeable(false); - } - }); - } - }); - - // 작전시간 타이머가 켜져 있고, 시간이 0이 되었을 때 → 저장된 시간으로 되돌림 - useEffect(() => { - if ( - isAdditionalTimerOn && - normalTimer.timer === 0 && - normalTimer.isRunning - ) { - normalTimer.pauseTimer(); - normalTimer.setTimer(savedTimer); - setIsAdditionalTimerOn(!isAdditionalTimerOn); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - isAdditionalTimerOn, - normalTimer.timer, - savedTimer, - normalTimer.pauseTimer, - setIsAdditionalTimerOn, - normalTimer.setTimer, - normalTimer.isRunning, - ]); - - //진영(pros/cons)이 바뀌면 → 상대 타이머 초기화 - useEffect(() => { - if (prosConsSelected === 'cons') { - if (timer1.speakingTimer === null) return; - timer1.resetTimerForNextPhase(); - } else if (prosConsSelected === 'pros') { - if (timer2.speakingTimer === null) return; - timer2.resetTimerForNextPhase(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [prosConsSelected]); - - //타이머가 0초가 되면 자동으로 일시정지 - useEffect(() => { - if (timer1.speakingTimer === 0 || timer1.totalTimer === 0) { - timer1.pauseTimer(); - } else if (timer2.speakingTimer === 0 || timer2.totalTimer === 0) { - timer2.pauseTimer(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - timer1.speakingTimer, - timer1.totalTimer, - timer2.speakingTimer, - timer2.totalTimer, - ]); - - //speakingTimer or totalTimer가 0초면 → 타이머 종료 처리 (isDone = true) - useEffect(() => { - if (prosConsSelected === 'pros') { - if (timer1.speakingTimer === null) { - if (timer1.totalTimer === 0) { - timer1.setIsDone(true); - } - } else { - if (timer1.speakingTimer === 0) { - timer1.setIsDone(true); - } - } - } else if (prosConsSelected === 'cons') { - if (timer2.speakingTimer === null) { - if (timer2.totalTimer === 0) { - timer2.setIsDone(true); - } - } else { - if (timer2.speakingTimer === 0) { - timer2.setIsDone(true); - } - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - prosConsSelected, - timer1.totalTimer, - timer1.speakingTimer, - timer2.totalTimer, - timer2.speakingTimer, - ]); - // ########### COMPONENT AREA ########### - if (!data) { - return; + openUseTooltipModal, + UseToolTipWrapper, + closeUseTooltipModal, + LoginAndStoreModalWrapper, + closeLoginAndStoreModal, + openLoginAndStoreModalOrGoToDebateEndPage, + } = useTimerPageModal(tableId); + + const state = useTimerPageState(tableId); + + useTimerHotkey(state); + const { data, bg, index, goToOtherItem, isLoading, isError, refetch } = state; + + // If error, print error message and let user be able to retry + if (isError) { + return ( + + + refetch()} /> + + + ); } + + // If no error or on loading, print contents return ( <> -