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 && ( + + {title} + + ); +} 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 */} right.onClick()} > = { + title: 'components/DropdownMenu', + component: DropdownMenu, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +const booleanOptions: DropdownMenuItem[] = [ + { value: true, label: '참' }, + { value: false, label: '거짓' }, +]; + +export const Default: Story = { + args: { + disabled: false, + onSelect: () => {}, + options: booleanOptions, + placeholder: '선택', + selectedValue: true, + }, +}; + +export const OnSelected: Story = { + args: { + disabled: false, + onSelect: () => {}, + options: booleanOptions, + placeholder: '선택', + selectedValue: true, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + onSelect: () => {}, + options: booleanOptions, + placeholder: '선택', + selectedValue: false, + }, +}; diff --git a/src/components/DropdownMenu/DropdownMenu.tsx b/src/components/DropdownMenu/DropdownMenu.tsx new file mode 100644 index 00000000..827674e6 --- /dev/null +++ b/src/components/DropdownMenu/DropdownMenu.tsx @@ -0,0 +1,129 @@ +import { useState, useRef, useEffect } from 'react'; +import DTExpand from '../icons/Expand'; +import clsx from 'clsx'; + +export interface DropdownMenuItem { + value: T; + label: string; +} + +interface DropdownMenuProps { + options: DropdownMenuItem[]; + selectedValue: T; + onSelect: (value: T) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export default function DropdownMenu({ + options, + selectedValue, + onSelect, + placeholder = '선택', + disabled, + className = '', +}: DropdownMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const selectedOptionLabel = + options.find((option) => option.value === selectedValue)?.label || + placeholder; + + // 드롭다운 외부 클릭 시 닫히도록 처리 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const handleToggle = () => { + if (!disabled) { + setIsOpen((prev) => !prev); + } + }; + + const handleOptionClick = (value: T) => { + onSelect(value); + setIsOpen(false); + }; + + const buttonClasses = clsx( + 'relative flex w-full items-center justify-between rounded-md border bg-default-white px-4 py-2 text-left transition-all duration-200 ease-in-out', + 'focus:outline-none', + { + 'border-default-disabled/hover text-default-disabled/hover cursor-not-allowed': + disabled, + 'border-semantic-material ring-semantic-material/30 ring-4': + isOpen && !disabled, + 'border-default-border text-default-black hover:bg-default-disabled/hover': + !disabled, + }, + ); + + const menuClasses = clsx( + 'absolute z-10 mt-1 w-full overflow-hidden rounded-lg bg-default-white shadow-lg transition-all duration-200 ease-out', + { + 'max-h-60 opacity-100 transform scale-y-100 origin-top': isOpen, + 'max-h-0 opacity-0 transform scale-y-95 origin-top pointer-events-none': + !isOpen, + }, + ); + + const optionItemClasses = (optionValue: T) => + clsx( + 'cursor-pointer px-4 py-2 text-default-black transition-colors duration-150 ', + 'hover:bg-default-disabled/hover', + { + 'bg-semantic-material/30 font-bold': optionValue === selectedValue, + }, + ); + + return ( + + + {selectedOptionLabel} + + + + + {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 && ( + onClickRetry()} + className="small-button enabled px-8 py-1" + > + 다시 시도하기 + + )} + + ); +} 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 ( + + {children} + + ); +} 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 ( {icon} diff --git a/src/components/LabledCheckBox/LabeledCheckbox.stories.tsx b/src/components/LabeledCheckBox/LabeledCheckBox.stories.tsx similarity index 61% rename from src/components/LabledCheckBox/LabeledCheckbox.stories.tsx rename to src/components/LabeledCheckBox/LabeledCheckBox.stories.tsx index e2baa067..cd579cfc 100644 --- a/src/components/LabledCheckBox/LabeledCheckbox.stories.tsx +++ b/src/components/LabeledCheckBox/LabeledCheckBox.stories.tsx @@ -1,10 +1,10 @@ // LabeledCheckbox.stories.tsx import { Meta, StoryObj } from '@storybook/react'; -import LabeledCheckbox from './LabeledCheckbox'; +import LabeledCheckBox from './LabeledCheckBox'; -const meta: Meta = { - title: 'Components/LabeledCheckbox', - component: LabeledCheckbox, +const meta: Meta = { + title: 'Components/LabeledCheckBox', + component: LabeledCheckBox, tags: ['autodocs'], argTypes: { onChange: { action: 'changed' }, @@ -12,7 +12,7 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; // 기본 스토리 export const Default: Story = { @@ -37,3 +37,12 @@ export const Unchecked: Story = { checked: false, }, }; + +// 비활성화 상태 +export const Disabled: Story = { + args: { + label: '체크박스 라벨 (Disabled)', + checked: false, + disabled: true, + }, +}; diff --git a/src/components/LabledCheckBox/LabeledCheckbox.tsx b/src/components/LabeledCheckBox/LabeledCheckBox.tsx similarity index 55% rename from src/components/LabledCheckBox/LabeledCheckbox.tsx rename to src/components/LabeledCheckBox/LabeledCheckBox.tsx index 7b80bfc4..db24f1c2 100644 --- a/src/components/LabledCheckBox/LabeledCheckbox.tsx +++ b/src/components/LabeledCheckBox/LabeledCheckBox.tsx @@ -1,46 +1,52 @@ import { InputHTMLAttributes, ReactNode } from 'react'; -interface LabeledCheckboxProps extends InputHTMLAttributes { +interface LabeledCheckBoxProps extends InputHTMLAttributes { label: ReactNode; checked: boolean; + disabled?: boolean; } -export default function LabeledCheckbox({ +export default function LabeledCheckBox({ label, checked, + disabled = false, ...rest -}: LabeledCheckboxProps) { - // 체크 안 된 상태일 때 라벨 색을 회색으로 - const labelColorClass = checked ? '' : 'text-neutral-400'; +}: LabeledCheckBoxProps) { + // Set label text color to... + // - Black when checkbox is enabled + // - Gray when checkbox is disabled return ( {label} diff --git a/src/components/LabeledRadioButton/LabeledRadioButton.stories.tsx b/src/components/LabeledRadioButton/LabeledRadioButton.stories.tsx new file mode 100644 index 00000000..8fb0940c --- /dev/null +++ b/src/components/LabeledRadioButton/LabeledRadioButton.stories.tsx @@ -0,0 +1,45 @@ +import { Meta, StoryObj } from '@storybook/react'; +import LabeledRadioButton from './LabeledRadioButton'; + +const meta: Meta = { + title: 'Components/LabeledRadioButton', + component: LabeledRadioButton, + tags: ['autodocs'], + argTypes: { + onChange: { action: 'changed' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Checked: Story = { + args: { + label: '체크박스 라벨 (Default)', + checked: true, + }, +}; + +export const Unchecked: Story = { + args: { + label: '체크박스 라벨 (Default)', + checked: false, + }, +}; + +export const Disabled: Story = { + args: { + label: '체크박스 라벨 (Default)', + checked: false, + disabled: true, + }, +}; + +function LabeledRadioButtonTestPage() { + return ; +} + +export const SampleCode: Story = { + render: () => , +}; diff --git a/src/components/LabeledRadioButton/LabeledRadioButton.tsx b/src/components/LabeledRadioButton/LabeledRadioButton.tsx new file mode 100644 index 00000000..fe2bbf9a --- /dev/null +++ b/src/components/LabeledRadioButton/LabeledRadioButton.tsx @@ -0,0 +1,62 @@ +interface LabeledRadioButtonProps { + id: string; + name: string; + value: string; + label?: string; + checked: boolean; + onChange: (e: React.ChangeEvent) => void; + disabled?: boolean; +} + +export default function LabeledRadioButton({ + id, + name, + value, + label, + checked, + onChange, + disabled = false, +}: LabeledRadioButtonProps) { + const radioSize = 'size-[20px]'; + + const checkedColorClass = 'bg-semantic-material border-semantic-material'; + const uncheckedColorClass = + 'bg-default-disabled/hover border-default-disabled/hover'; + + const containerClasses = ` + flex items-center cursor-pointer select-none + ${disabled ? 'opacity-50 cursor-not-allowed' : ''} + `; + const outerRingClasses = ` + relative flex items-center justify-center rounded-full transition-all duration-200 ease-in-out border-2 + ${radioSize} + ${checked ? 'border-semantic-material' : 'border-default-disabled/hover'} + `; + const innerDotClasses = ` + rounded-full transition-all duration-200 ease-in-out w-2.5 h-2.5 + ${checked ? checkedColorClass : uncheckedColorClass} + `; + + return ( + + + + + + + + {label && ( + {label} + )} + + ); +} diff --git a/src/components/LoadingIndicator/LoadingIndicator.stories.tsx b/src/components/LoadingIndicator/LoadingIndicator.stories.tsx new file mode 100644 index 00000000..e08b5230 --- /dev/null +++ b/src/components/LoadingIndicator/LoadingIndicator.stories.tsx @@ -0,0 +1,18 @@ +import { Meta, StoryObj } from '@storybook/react'; +import LoadingIndicator from './LoadingIndicator'; + +const meta: Meta = { + title: 'Components/LoadingIndicator', + component: LoadingIndicator, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: '로딩 중...', + }, +}; diff --git a/src/components/LoadingIndicator/LoadingIndicator.tsx b/src/components/LoadingIndicator/LoadingIndicator.tsx new file mode 100644 index 00000000..83457713 --- /dev/null +++ b/src/components/LoadingIndicator/LoadingIndicator.tsx @@ -0,0 +1,13 @@ +import { PropsWithChildren } from 'react'; +import LoadingSpinner from '../LoadingSpinner'; + +export default function LoadingIndicator({ + children = '데이터를 불러오고 있습니다...', +}: PropsWithChildren) { + return ( + + + {children} + + ); +} diff --git a/src/components/NotificationBadge/NotificationBadge.stories.tsx b/src/components/NotificationBadge/NotificationBadge.stories.tsx new file mode 100644 index 00000000..f2938371 --- /dev/null +++ b/src/components/NotificationBadge/NotificationBadge.stories.tsx @@ -0,0 +1,33 @@ +import { Meta, StoryObj } from '@storybook/react'; +import NotificationBadge from './NotificationBadge'; + +const meta: Meta = { + title: 'Components/NotificationBadge', + component: NotificationBadge, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const WhenNoNotification: Story = { + args: { + count: 0, + }, +}; +export const When1Notification: Story = { + args: { + count: 1, + }, +}; +export const WhenMoreThan99Notification: Story = { + args: { + count: 100, + }, +}; +export const Default: Story = { + args: { + count: 14, + }, +}; diff --git a/src/components/NotificationBadge/NotificationBadge.tsx b/src/components/NotificationBadge/NotificationBadge.tsx new file mode 100644 index 00000000..3e2307fb --- /dev/null +++ b/src/components/NotificationBadge/NotificationBadge.tsx @@ -0,0 +1,32 @@ +import clsx from 'clsx'; + +interface NotificationBadgeProps { + count: number; + className?: string; +} + +export default function NotificationBadge({ + count, + className = '', +}: NotificationBadgeProps) { + // 음수, NaN 등 의도하지 않은 값 확인 + const safeCount = Number.isFinite(count) ? Math.max(0, count) : 0; + if (safeCount === 0) { + return null; + } + + const displayCount = safeCount > 99 ? '99+' : safeCount; + + return ( + + {displayCount} + + ); +} diff --git a/src/components/ProsAndConsTitle/PropsAndConsTitle.tsx b/src/components/ProsAndConsTitle/PropsAndConsTitle.tsx index 382c116a..5b53cd05 100644 --- a/src/components/ProsAndConsTitle/PropsAndConsTitle.tsx +++ b/src/components/ProsAndConsTitle/PropsAndConsTitle.tsx @@ -8,7 +8,7 @@ export default function PropsAndConsTitle({ consTeamName = '반대', }: PropsAndConsTitleProps) { return ( - + {prosTeamName} diff --git a/src/components/RoundControlButton/RoundControlButton.tsx b/src/components/RoundControlButton/RoundControlButton.tsx index 949d61c3..69fd0052 100644 --- a/src/components/RoundControlButton/RoundControlButton.tsx +++ b/src/components/RoundControlButton/RoundControlButton.tsx @@ -1,4 +1,5 @@ -import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'; +import DTLeftArrow from '../icons/LeftArrow'; +import DTRightArrow from '../icons/RightArrow'; type RoundControlButtonTypes = 'PREV' | 'NEXT' | 'DONE'; @@ -13,12 +14,12 @@ export default function RoundControlButton({ }: RoundControlButtonProps) { return ( onClick()} > {type === 'PREV' && ( <> - + 이전 차례 @@ -29,7 +30,7 @@ export default function RoundControlButton({ 다음 차례 - + > )} {type === 'DONE' && ( diff --git a/src/components/ShareModal/ShareModal.stories.tsx b/src/components/ShareModal/ShareModal.stories.tsx index b358280c..62b0871c 100644 --- a/src/components/ShareModal/ShareModal.stories.tsx +++ b/src/components/ShareModal/ShareModal.stories.tsx @@ -17,8 +17,6 @@ const shareUrl = createTableShareUrl('https://localhost:6006', { agenda: '토론 주제', prosTeamName: '짜장', consTeamName: '짬뽕', - finishBell: true, - warningBell: false, name: '테이블 이름', }, table: [ @@ -30,6 +28,7 @@ const shareUrl = createTableShareUrl('https://localhost:6006', { time: 60, timePerSpeaking: null, timePerTeam: null, + bell: null, }, { stance: 'CONS', @@ -39,6 +38,7 @@ const shareUrl = createTableShareUrl('https://localhost:6006', { time: 60, timePerSpeaking: null, timePerTeam: null, + bell: null, }, { stance: 'NEUTRAL', @@ -48,6 +48,7 @@ const shareUrl = createTableShareUrl('https://localhost:6006', { time: null, timePerSpeaking: 60, timePerTeam: 120, + bell: null, }, ], }); @@ -57,8 +58,10 @@ export const OnQRCodeReady: Story = { args: { shareUrl: shareUrl, copyState: false, - isUrlReady: true, - onClick: () => { + isLoading: false, + isError: false, + onRefetch: () => {}, + onCopyClicked: () => { navigator.clipboard.writeText(shareUrl); }, }, @@ -69,7 +72,21 @@ export const OnLoadingData: Story = { args: { shareUrl: '', copyState: false, - isUrlReady: false, - onClick: () => {}, + isLoading: true, + isError: false, + onRefetch: () => {}, + onCopyClicked: () => {}, + }, +}; + +// When failed to process share URL +export const OnFailure: Story = { + args: { + shareUrl: '', + copyState: false, + isLoading: false, + isError: true, + onRefetch: () => {}, + onCopyClicked: () => {}, }, }; diff --git a/src/components/ShareModal/ShareModal.tsx b/src/components/ShareModal/ShareModal.tsx index 271b36a2..62df8790 100644 --- a/src/components/ShareModal/ShareModal.tsx +++ b/src/components/ShareModal/ShareModal.tsx @@ -1,32 +1,47 @@ import { QRCodeSVG } from 'qrcode.react'; import { IoLinkOutline, IoShareOutline } from 'react-icons/io5'; import LoadingSpinner from '../LoadingSpinner'; +import ErrorIndicator from '../ErrorIndicator/ErrorIndicator'; +import clsx from 'clsx'; interface ShareModalProps { shareUrl: string; copyState: boolean; - isUrlReady: boolean; - onClick: () => void; + isLoading: boolean; + isError: boolean; + onRefetch: () => void; + onCopyClicked: () => void; } export default function ShareModal({ shareUrl, copyState, - isUrlReady, - onClick, + isLoading, + isError, + onRefetch, + onCopyClicked, }: ShareModalProps) { + // If error, print error message and let user be able to retry + if (isError) { + return ( + + onRefetch()}> + QR 코드를 불러오지 못했어요...다시 시도하시겠어요? + + + ); + } + + // If no error or on loading, print modal contents return ( - + {/* This component appears to tell the user that URL is succefully copied to clipboard. */} {/* It will disappear after 3 seconds. */} {copyState && ( - - + + 링크가 클립보드에 복사됨 @@ -38,34 +53,43 @@ export default function ShareModal({ {/* QR code is here. */} {/* If QR code is not prepared because response is not arrived, spinner will be shown. */} - {isUrlReady && ( + {isLoading && ( + + )} + {!isLoading && ( )} - {!isUrlReady && ( - - )} {/* Button that copies URL to the user's clipboard. */} { - onClick(); + if (!isLoading) { + onCopyClicked(); + } }} > - - 공유 링크 복사 - - + + {isLoading ? '링크 준비 중' : '공유 링크 복사'} ); 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 ( + + {children} + + ); +} 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 ( -
{title}
대제목
제목
부제목
세부사항
내용
= { + title: 'components/DropdownMenu', + component: DropdownMenu, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +const booleanOptions: DropdownMenuItem[] = [ + { value: true, label: '참' }, + { value: false, label: '거짓' }, +]; + +export const Default: Story = { + args: { + disabled: false, + onSelect: () => {}, + options: booleanOptions, + placeholder: '선택', + selectedValue: true, + }, +}; + +export const OnSelected: Story = { + args: { + disabled: false, + onSelect: () => {}, + options: booleanOptions, + placeholder: '선택', + selectedValue: true, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + onSelect: () => {}, + options: booleanOptions, + placeholder: '선택', + selectedValue: false, + }, +}; diff --git a/src/components/DropdownMenu/DropdownMenu.tsx b/src/components/DropdownMenu/DropdownMenu.tsx new file mode 100644 index 00000000..827674e6 --- /dev/null +++ b/src/components/DropdownMenu/DropdownMenu.tsx @@ -0,0 +1,129 @@ +import { useState, useRef, useEffect } from 'react'; +import DTExpand from '../icons/Expand'; +import clsx from 'clsx'; + +export interface DropdownMenuItem { + value: T; + label: string; +} + +interface DropdownMenuProps { + options: DropdownMenuItem[]; + selectedValue: T; + onSelect: (value: T) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export default function DropdownMenu({ + options, + selectedValue, + onSelect, + placeholder = '선택', + disabled, + className = '', +}: DropdownMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const selectedOptionLabel = + options.find((option) => option.value === selectedValue)?.label || + placeholder; + + // 드롭다운 외부 클릭 시 닫히도록 처리 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const handleToggle = () => { + if (!disabled) { + setIsOpen((prev) => !prev); + } + }; + + const handleOptionClick = (value: T) => { + onSelect(value); + setIsOpen(false); + }; + + const buttonClasses = clsx( + 'relative flex w-full items-center justify-between rounded-md border bg-default-white px-4 py-2 text-left transition-all duration-200 ease-in-out', + 'focus:outline-none', + { + 'border-default-disabled/hover text-default-disabled/hover cursor-not-allowed': + disabled, + 'border-semantic-material ring-semantic-material/30 ring-4': + isOpen && !disabled, + 'border-default-border text-default-black hover:bg-default-disabled/hover': + !disabled, + }, + ); + + const menuClasses = clsx( + 'absolute z-10 mt-1 w-full overflow-hidden rounded-lg bg-default-white shadow-lg transition-all duration-200 ease-out', + { + 'max-h-60 opacity-100 transform scale-y-100 origin-top': isOpen, + 'max-h-0 opacity-0 transform scale-y-95 origin-top pointer-events-none': + !isOpen, + }, + ); + + const optionItemClasses = (optionValue: T) => + clsx( + 'cursor-pointer px-4 py-2 text-default-black transition-colors duration-150 ', + 'hover:bg-default-disabled/hover', + { + 'bg-semantic-material/30 font-bold': optionValue === selectedValue, + }, + ); + + return ( + + + {selectedOptionLabel} + + + + + {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 && ( + onClickRetry()} + className="small-button enabled px-8 py-1" + > + 다시 시도하기 + + )} + + ); +} 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 ( + + {children} + + ); +} 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 ( {icon} diff --git a/src/components/LabledCheckBox/LabeledCheckbox.stories.tsx b/src/components/LabeledCheckBox/LabeledCheckBox.stories.tsx similarity index 61% rename from src/components/LabledCheckBox/LabeledCheckbox.stories.tsx rename to src/components/LabeledCheckBox/LabeledCheckBox.stories.tsx index e2baa067..cd579cfc 100644 --- a/src/components/LabledCheckBox/LabeledCheckbox.stories.tsx +++ b/src/components/LabeledCheckBox/LabeledCheckBox.stories.tsx @@ -1,10 +1,10 @@ // LabeledCheckbox.stories.tsx import { Meta, StoryObj } from '@storybook/react'; -import LabeledCheckbox from './LabeledCheckbox'; +import LabeledCheckBox from './LabeledCheckBox'; -const meta: Meta = { - title: 'Components/LabeledCheckbox', - component: LabeledCheckbox, +const meta: Meta = { + title: 'Components/LabeledCheckBox', + component: LabeledCheckBox, tags: ['autodocs'], argTypes: { onChange: { action: 'changed' }, @@ -12,7 +12,7 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; // 기본 스토리 export const Default: Story = { @@ -37,3 +37,12 @@ export const Unchecked: Story = { checked: false, }, }; + +// 비활성화 상태 +export const Disabled: Story = { + args: { + label: '체크박스 라벨 (Disabled)', + checked: false, + disabled: true, + }, +}; diff --git a/src/components/LabledCheckBox/LabeledCheckbox.tsx b/src/components/LabeledCheckBox/LabeledCheckBox.tsx similarity index 55% rename from src/components/LabledCheckBox/LabeledCheckbox.tsx rename to src/components/LabeledCheckBox/LabeledCheckBox.tsx index 7b80bfc4..db24f1c2 100644 --- a/src/components/LabledCheckBox/LabeledCheckbox.tsx +++ b/src/components/LabeledCheckBox/LabeledCheckBox.tsx @@ -1,46 +1,52 @@ import { InputHTMLAttributes, ReactNode } from 'react'; -interface LabeledCheckboxProps extends InputHTMLAttributes { +interface LabeledCheckBoxProps extends InputHTMLAttributes { label: ReactNode; checked: boolean; + disabled?: boolean; } -export default function LabeledCheckbox({ +export default function LabeledCheckBox({ label, checked, + disabled = false, ...rest -}: LabeledCheckboxProps) { - // 체크 안 된 상태일 때 라벨 색을 회색으로 - const labelColorClass = checked ? '' : 'text-neutral-400'; +}: LabeledCheckBoxProps) { + // Set label text color to... + // - Black when checkbox is enabled + // - Gray when checkbox is disabled return ( {label} diff --git a/src/components/LabeledRadioButton/LabeledRadioButton.stories.tsx b/src/components/LabeledRadioButton/LabeledRadioButton.stories.tsx new file mode 100644 index 00000000..8fb0940c --- /dev/null +++ b/src/components/LabeledRadioButton/LabeledRadioButton.stories.tsx @@ -0,0 +1,45 @@ +import { Meta, StoryObj } from '@storybook/react'; +import LabeledRadioButton from './LabeledRadioButton'; + +const meta: Meta = { + title: 'Components/LabeledRadioButton', + component: LabeledRadioButton, + tags: ['autodocs'], + argTypes: { + onChange: { action: 'changed' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Checked: Story = { + args: { + label: '체크박스 라벨 (Default)', + checked: true, + }, +}; + +export const Unchecked: Story = { + args: { + label: '체크박스 라벨 (Default)', + checked: false, + }, +}; + +export const Disabled: Story = { + args: { + label: '체크박스 라벨 (Default)', + checked: false, + disabled: true, + }, +}; + +function LabeledRadioButtonTestPage() { + return ; +} + +export const SampleCode: Story = { + render: () => , +}; diff --git a/src/components/LabeledRadioButton/LabeledRadioButton.tsx b/src/components/LabeledRadioButton/LabeledRadioButton.tsx new file mode 100644 index 00000000..fe2bbf9a --- /dev/null +++ b/src/components/LabeledRadioButton/LabeledRadioButton.tsx @@ -0,0 +1,62 @@ +interface LabeledRadioButtonProps { + id: string; + name: string; + value: string; + label?: string; + checked: boolean; + onChange: (e: React.ChangeEvent) => void; + disabled?: boolean; +} + +export default function LabeledRadioButton({ + id, + name, + value, + label, + checked, + onChange, + disabled = false, +}: LabeledRadioButtonProps) { + const radioSize = 'size-[20px]'; + + const checkedColorClass = 'bg-semantic-material border-semantic-material'; + const uncheckedColorClass = + 'bg-default-disabled/hover border-default-disabled/hover'; + + const containerClasses = ` + flex items-center cursor-pointer select-none + ${disabled ? 'opacity-50 cursor-not-allowed' : ''} + `; + const outerRingClasses = ` + relative flex items-center justify-center rounded-full transition-all duration-200 ease-in-out border-2 + ${radioSize} + ${checked ? 'border-semantic-material' : 'border-default-disabled/hover'} + `; + const innerDotClasses = ` + rounded-full transition-all duration-200 ease-in-out w-2.5 h-2.5 + ${checked ? checkedColorClass : uncheckedColorClass} + `; + + return ( + + + + + + + + {label && ( + {label} + )} + + ); +} diff --git a/src/components/LoadingIndicator/LoadingIndicator.stories.tsx b/src/components/LoadingIndicator/LoadingIndicator.stories.tsx new file mode 100644 index 00000000..e08b5230 --- /dev/null +++ b/src/components/LoadingIndicator/LoadingIndicator.stories.tsx @@ -0,0 +1,18 @@ +import { Meta, StoryObj } from '@storybook/react'; +import LoadingIndicator from './LoadingIndicator'; + +const meta: Meta = { + title: 'Components/LoadingIndicator', + component: LoadingIndicator, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: '로딩 중...', + }, +}; diff --git a/src/components/LoadingIndicator/LoadingIndicator.tsx b/src/components/LoadingIndicator/LoadingIndicator.tsx new file mode 100644 index 00000000..83457713 --- /dev/null +++ b/src/components/LoadingIndicator/LoadingIndicator.tsx @@ -0,0 +1,13 @@ +import { PropsWithChildren } from 'react'; +import LoadingSpinner from '../LoadingSpinner'; + +export default function LoadingIndicator({ + children = '데이터를 불러오고 있습니다...', +}: PropsWithChildren) { + return ( + + + {children} + + ); +} diff --git a/src/components/NotificationBadge/NotificationBadge.stories.tsx b/src/components/NotificationBadge/NotificationBadge.stories.tsx new file mode 100644 index 00000000..f2938371 --- /dev/null +++ b/src/components/NotificationBadge/NotificationBadge.stories.tsx @@ -0,0 +1,33 @@ +import { Meta, StoryObj } from '@storybook/react'; +import NotificationBadge from './NotificationBadge'; + +const meta: Meta = { + title: 'Components/NotificationBadge', + component: NotificationBadge, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const WhenNoNotification: Story = { + args: { + count: 0, + }, +}; +export const When1Notification: Story = { + args: { + count: 1, + }, +}; +export const WhenMoreThan99Notification: Story = { + args: { + count: 100, + }, +}; +export const Default: Story = { + args: { + count: 14, + }, +}; diff --git a/src/components/NotificationBadge/NotificationBadge.tsx b/src/components/NotificationBadge/NotificationBadge.tsx new file mode 100644 index 00000000..3e2307fb --- /dev/null +++ b/src/components/NotificationBadge/NotificationBadge.tsx @@ -0,0 +1,32 @@ +import clsx from 'clsx'; + +interface NotificationBadgeProps { + count: number; + className?: string; +} + +export default function NotificationBadge({ + count, + className = '', +}: NotificationBadgeProps) { + // 음수, NaN 등 의도하지 않은 값 확인 + const safeCount = Number.isFinite(count) ? Math.max(0, count) : 0; + if (safeCount === 0) { + return null; + } + + const displayCount = safeCount > 99 ? '99+' : safeCount; + + return ( + + {displayCount} + + ); +} diff --git a/src/components/ProsAndConsTitle/PropsAndConsTitle.tsx b/src/components/ProsAndConsTitle/PropsAndConsTitle.tsx index 382c116a..5b53cd05 100644 --- a/src/components/ProsAndConsTitle/PropsAndConsTitle.tsx +++ b/src/components/ProsAndConsTitle/PropsAndConsTitle.tsx @@ -8,7 +8,7 @@ export default function PropsAndConsTitle({ consTeamName = '반대', }: PropsAndConsTitleProps) { return ( - + {prosTeamName} diff --git a/src/components/RoundControlButton/RoundControlButton.tsx b/src/components/RoundControlButton/RoundControlButton.tsx index 949d61c3..69fd0052 100644 --- a/src/components/RoundControlButton/RoundControlButton.tsx +++ b/src/components/RoundControlButton/RoundControlButton.tsx @@ -1,4 +1,5 @@ -import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'; +import DTLeftArrow from '../icons/LeftArrow'; +import DTRightArrow from '../icons/RightArrow'; type RoundControlButtonTypes = 'PREV' | 'NEXT' | 'DONE'; @@ -13,12 +14,12 @@ export default function RoundControlButton({ }: RoundControlButtonProps) { return ( onClick()} > {type === 'PREV' && ( <> - + 이전 차례 @@ -29,7 +30,7 @@ export default function RoundControlButton({ 다음 차례 - + > )} {type === 'DONE' && ( diff --git a/src/components/ShareModal/ShareModal.stories.tsx b/src/components/ShareModal/ShareModal.stories.tsx index b358280c..62b0871c 100644 --- a/src/components/ShareModal/ShareModal.stories.tsx +++ b/src/components/ShareModal/ShareModal.stories.tsx @@ -17,8 +17,6 @@ const shareUrl = createTableShareUrl('https://localhost:6006', { agenda: '토론 주제', prosTeamName: '짜장', consTeamName: '짬뽕', - finishBell: true, - warningBell: false, name: '테이블 이름', }, table: [ @@ -30,6 +28,7 @@ const shareUrl = createTableShareUrl('https://localhost:6006', { time: 60, timePerSpeaking: null, timePerTeam: null, + bell: null, }, { stance: 'CONS', @@ -39,6 +38,7 @@ const shareUrl = createTableShareUrl('https://localhost:6006', { time: 60, timePerSpeaking: null, timePerTeam: null, + bell: null, }, { stance: 'NEUTRAL', @@ -48,6 +48,7 @@ const shareUrl = createTableShareUrl('https://localhost:6006', { time: null, timePerSpeaking: 60, timePerTeam: 120, + bell: null, }, ], }); @@ -57,8 +58,10 @@ export const OnQRCodeReady: Story = { args: { shareUrl: shareUrl, copyState: false, - isUrlReady: true, - onClick: () => { + isLoading: false, + isError: false, + onRefetch: () => {}, + onCopyClicked: () => { navigator.clipboard.writeText(shareUrl); }, }, @@ -69,7 +72,21 @@ export const OnLoadingData: Story = { args: { shareUrl: '', copyState: false, - isUrlReady: false, - onClick: () => {}, + isLoading: true, + isError: false, + onRefetch: () => {}, + onCopyClicked: () => {}, + }, +}; + +// When failed to process share URL +export const OnFailure: Story = { + args: { + shareUrl: '', + copyState: false, + isLoading: false, + isError: true, + onRefetch: () => {}, + onCopyClicked: () => {}, }, }; diff --git a/src/components/ShareModal/ShareModal.tsx b/src/components/ShareModal/ShareModal.tsx index 271b36a2..62df8790 100644 --- a/src/components/ShareModal/ShareModal.tsx +++ b/src/components/ShareModal/ShareModal.tsx @@ -1,32 +1,47 @@ import { QRCodeSVG } from 'qrcode.react'; import { IoLinkOutline, IoShareOutline } from 'react-icons/io5'; import LoadingSpinner from '../LoadingSpinner'; +import ErrorIndicator from '../ErrorIndicator/ErrorIndicator'; +import clsx from 'clsx'; interface ShareModalProps { shareUrl: string; copyState: boolean; - isUrlReady: boolean; - onClick: () => void; + isLoading: boolean; + isError: boolean; + onRefetch: () => void; + onCopyClicked: () => void; } export default function ShareModal({ shareUrl, copyState, - isUrlReady, - onClick, + isLoading, + isError, + onRefetch, + onCopyClicked, }: ShareModalProps) { + // If error, print error message and let user be able to retry + if (isError) { + return ( + + onRefetch()}> + QR 코드를 불러오지 못했어요...다시 시도하시겠어요? + + + ); + } + + // If no error or on loading, print modal contents return ( - + {/* This component appears to tell the user that URL is succefully copied to clipboard. */} {/* It will disappear after 3 seconds. */} {copyState && ( - - + + 링크가 클립보드에 복사됨 @@ -38,34 +53,43 @@ export default function ShareModal({ {/* QR code is here. */} {/* If QR code is not prepared because response is not arrived, spinner will be shown. */} - {isUrlReady && ( + {isLoading && ( + + )} + {!isLoading && ( )} - {!isUrlReady && ( - - )} {/* Button that copies URL to the user's clipboard. */} { - onClick(); + if (!isLoading) { + onCopyClicked(); + } }} > - - 공유 링크 복사 - - + + {isLoading ? '링크 준비 중' : '공유 링크 복사'} ); 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 ( + + {children} + + ); +} 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 ( -
{message}
{error.message}
{children}
추가하기
링크가 클립보드에 복사됨 @@ -38,34 +53,43 @@ export default function ShareModal({ {/* QR code is here. */} {/* If QR code is not prepared because response is not arrived, spinner will be shown. */}
- 공유 링크 복사 -
{isLoading ? '링크 준비 중' : '공유 링크 복사'}
로그인
홈
도움말
닫기
확인
확장 (더보기)
토론
우측 화살표
좌측 화살표
드래그
복사
삭제
수정
시작/재생
초기화
공유
교체
타종