From 210c2fb4fad327f1a1f1a94af865d81503c62296 Mon Sep 17 00:00:00 2001 From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> Date: Thu, 3 Jul 2025 00:57:33 +0900 Subject: [PATCH 01/31] =?UTF-8?q?[CHORE]=20=ED=8C=80=20=EA=B5=AC=EC=84=B1?= =?UTF-8?q?=EC=9B=90=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?Auto=20PR=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#299)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Auto PR 스크립트 수정 * chore: 주석 추가 --- .github/workflows/Auto_PR_Setting.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 288e661fbb111598c6dcbae452d275dc72ebf641 Mon Sep 17 00:00:00 2001 From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> Date: Sat, 5 Jul 2025 10:21:59 +0900 Subject: [PATCH 02/31] =?UTF-8?q?[CHORE]=20GitHub=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EC=9D=B4=EC=8A=88=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20?= =?UTF-8?q?Notion=EC=9C=BC=EB=A1=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=20(#303)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Implemented Python code * chore: Implemented GitHub Actions script * fix: Typo * fix: Fixed location of Python file * chore: Applied CodeRabbit's suggestions * fix: Changed location of Python code --- .github/workflows/Notion_Sync.yml | 32 +++++ notion_sync.py | 230 ++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 .github/workflows/Notion_Sync.yml create mode 100644 notion_sync.py 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/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 From 31335178e0a7c9052afa262402d1ee01dc018c4f Mon Sep 17 00:00:00 2001 From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> Date: Mon, 7 Jul 2025 22:56:43 +0900 Subject: [PATCH 03/31] =?UTF-8?q?[DESIGN]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8/?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=B3=80=EA=B2=BD=20(#300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * design: 로그인/로그아웃 버튼 디자인 및 위치 변경 * chore: 주석 추가 * feat: 홈 버튼 목적지 변경에 따라 LandingPage에 수정 사항 반영 * refactor: CodeRabbit 제안 사항 반영 * fix: Let Axios remove access token when it is expired * chore: 불필요한 주석 제거 * refactor: Completely separated buisness logics from UI * refactor: Refactored functions with useCallback for optimization * refactor: Applied naming suggestions --- src/apis/axiosInstance.ts | 7 +- .../header/StickyTriSectionHeader.tsx | 91 ++++++++----------- src/page/LandingPage/LandingPage.tsx | 26 +++--- src/page/LandingPage/components/Header.tsx | 9 +- .../LandingPage/components/MainSection.tsx | 11 ++- .../hooks/useLandingPageHandlers.ts | 48 ++++++++++ 6 files changed, 119 insertions(+), 73 deletions(-) create mode 100644 src/page/LandingPage/hooks/useLandingPageHandlers.ts diff --git a/src/apis/axiosInstance.ts b/src/apis/axiosInstance.ts index 6723086d..b65260db 100644 --- a/src/apis/axiosInstance.ts +++ b/src/apis/axiosInstance.ts @@ -1,5 +1,9 @@ import axios from 'axios'; -import { getAccessToken, setAccessToken } from '../util/accessToken'; +import { + getAccessToken, + removeAccessToken, + setAccessToken, +} from '../util/accessToken'; axios.defaults.withCredentials = true; export const axiosInstance = axios.create({ @@ -57,6 +61,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/layout/components/header/StickyTriSectionHeader.tsx b/src/layout/components/header/StickyTriSectionHeader.tsx index 116afc70..9c574460 100644 --- a/src/layout/components/header/StickyTriSectionHeader.tsx +++ b/src/layout/components/header/StickyTriSectionHeader.tsx @@ -2,7 +2,6 @@ import { PropsWithChildren } from 'react'; import { IoMdHome } from 'react-icons/io'; import { useNavigate } from 'react-router-dom'; import useLogout from '../../../hooks/mutations/useLogout'; -import { IoLogIn, IoLogOut } from 'react-icons/io5'; import IconButton from '../../../components/IconButton/IconButton'; import { isLoggedIn } from '../../../util/accessToken'; import { @@ -13,6 +12,9 @@ import { oAuthLogin } from '../../../util/googleAuth'; import { useModal } from '../../../hooks/useModal'; import DialogModal from '../../../components/DialogModal/DialogModal'; +// The type of header icons will be declared here. +type HeaderIcons = 'home'; + function StickyTriSectionHeader(props: PropsWithChildren) { const { children } = props; @@ -35,45 +37,52 @@ StickyTriSectionHeader.Center = function Center(props: PropsWithChildren) { return
{children}
; }; -type HeaderIcons = 'home' | 'logout' | 'guest' | 'auth'; - StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { - const { children } = props; + const { children: buttons } = props; const navigate = useNavigate(); const { mutate: logoutMutate } = useLogout(() => navigate('/home')); const { openModal, closeModal, ModalWrapper } = useModal({}); - const defaultIcons: HeaderIcons[] = []; - - if (isGuestFlow()) { - defaultIcons.push('guest'); - } - - if (isLoggedIn()) { - defaultIcons.push('home', 'auth'); - } else { - defaultIcons.push('auth'); - } + const defaultIcons: HeaderIcons[] = ['home']; // Icons that will be displayed on all pages are added here return ( <>
- {children && ( - <> - {children} -
- - )} + {/* Auth related header items */} + <> + {/* Guest mode indicator */} + {isGuestFlow() && ( +
+ 비회원 모드 +
+ )} + + {/* Login and logout button */} + {isLoggedIn() && ( + + )} + {!isLoggedIn() && ( + + )} +
+ + + {/* Buttons given as an argument */} + {buttons} + + {/* Normal buttons */} {defaultIcons.map((iconName, index) => { switch (iconName) { - case 'guest': - return ( -
-
- 비회원 모드 -
-
- ); case 'home': return (
@@ -83,33 +92,11 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { if (isGuestFlow()) { deleteSessionCustomizeTableData(); } - navigate('/'); + navigate('/home'); }} />
); - case 'auth': - if (isLoggedIn()) { - return ( -
- } - onClick={() => logoutMutate()} - title="로그아웃" - /> -
- ); - } else { - return ( -
- } - onClick={() => openModal()} - title="로그인" - /> -
- ); - } default: return null; } diff --git a/src/page/LandingPage/LandingPage.tsx b/src/page/LandingPage/LandingPage.tsx index 188766c3..350a8f54 100644 --- a/src/page/LandingPage/LandingPage.tsx +++ b/src/page/LandingPage/LandingPage.tsx @@ -5,29 +5,29 @@ import TimerSection from './components/TimerSection'; import TableSection from './components/TableSection'; import ReviewSection from './components/ReviewSection'; import ReportSection from './components/ReportSection'; -import { oAuthLogin } from '../../util/googleAuth'; -import { createTableShareUrl } from '../../util/arrayEncoding'; -import { SAMPLE_TABLE_DATA } from '../../constants/sample_table'; +import useLandingPageHandlers from './hooks/useLandingPageHandlers'; export default function LandingPage() { - const handleStartWithoutLogin = () => { - // window.location.href = LANDING_URLS.START_WITHOUT_LOGIN_URL; - window.location.href = createTableShareUrl( - import.meta.env.VITE_SHARE_BASE_URL, - SAMPLE_TABLE_DATA, - ); - }; + const { + handleStartWithoutLogin, + handleDashboardButtonClick, + handleHeaderLoginButtonClick, + handleTableSectionLoginButtonClick, + } = useLandingPageHandlers(); return (
{/* 헤더 */} -
oAuthLogin()} /> +
{/* 흰색 배경 */}
{/* 메인 화면 */} - + {/* 시간표 설정화면 */}
@@ -41,7 +41,7 @@ export default function LandingPage() { {/* 흰색 배경 */}
{/* 홈 설정 */} - oAuthLogin()} /> + {/* 리뷰 */} {/* 버그 및 불편사항 제보 */} diff --git a/src/page/LandingPage/components/Header.tsx b/src/page/LandingPage/components/Header.tsx index 548d6a88..df4de7d9 100644 --- a/src/page/LandingPage/components/Header.tsx +++ b/src/page/LandingPage/components/Header.tsx @@ -1,10 +1,11 @@ import { useState, useEffect } from 'react'; +import { isLoggedIn } from '../../../util/accessToken'; interface HeaderProps { - onLogin: () => void; + onLoginButtonClicked: () => void; } -export default function Header({ onLogin }: HeaderProps) { +export default function Header({ onLoginButtonClicked }: HeaderProps) { const [isScrolled, setIsScrolled] = useState(false); useEffect(() => { @@ -28,9 +29,9 @@ export default function Header({ onLogin }: HeaderProps) {
diff --git a/src/page/LandingPage/components/MainSection.tsx b/src/page/LandingPage/components/MainSection.tsx index ded9ee28..763466d3 100644 --- a/src/page/LandingPage/components/MainSection.tsx +++ b/src/page/LandingPage/components/MainSection.tsx @@ -1,10 +1,15 @@ import preview from '../../../assets/landing/preview.webm'; +import { isLoggedIn } from '../../../util/accessToken'; interface MainSectionProps { onStartWithoutLogin: () => void; + onDashboardButtonClicked: () => void; } -export default function MainSection({ onStartWithoutLogin }: MainSectionProps) { +export default function MainSection({ + onStartWithoutLogin, + onDashboardButtonClicked, +}: MainSectionProps) { return (
); diff --git a/src/page/LandingPage/hooks/useLandingPageHandlers.ts b/src/page/LandingPage/hooks/useLandingPageHandlers.ts new file mode 100644 index 00000000..80b94b0a --- /dev/null +++ b/src/page/LandingPage/hooks/useLandingPageHandlers.ts @@ -0,0 +1,48 @@ +import { useNavigate } from 'react-router-dom'; +import { isLoggedIn } from '../../../util/accessToken'; +import { oAuthLogin } from '../../../util/googleAuth'; +import useLogout from '../../../hooks/mutations/useLogout'; +import { createTableShareUrl } from '../../../util/arrayEncoding'; +import { SAMPLE_TABLE_DATA } from '../../../constants/sample_table'; +import { useCallback } from 'react'; + +const useLandingPageHandlers = () => { + // Prepare dependencies + const navigate = useNavigate(); + const { mutate: logoutMutate } = useLogout(() => navigate('/home')); + + // Declare functions that represent business logics + const handleStartWithoutLogin = useCallback(() => { + // window.location.href = LANDING_URLS.START_WITHOUT_LOGIN_URL; + window.location.href = createTableShareUrl( + import.meta.env.VITE_SHARE_BASE_URL, + SAMPLE_TABLE_DATA, + ); + }, []); + const handleTableSectionLoginButtonClick = useCallback(() => { + if (!isLoggedIn()) { + oAuthLogin(); + } else { + navigate('/'); + } + }, [navigate]); + const handleDashboardButtonClick = useCallback(() => { + navigate('/'); + }, [navigate]); + const handleHeaderLoginButtonClick = useCallback(() => { + if (!isLoggedIn()) { + oAuthLogin(); + } else { + logoutMutate(); + } + }, [logoutMutate]); + + return { + handleStartWithoutLogin, + handleTableSectionLoginButtonClick, + handleDashboardButtonClick, + handleHeaderLoginButtonClick, + }; +}; + +export default useLandingPageHandlers; From c1e6d9ea8e7e8b3612372dabad95f71e17d8acec Mon Sep 17 00:00:00 2001 From: jaeml06 Date: Fri, 11 Jul 2025 15:49:15 +0900 Subject: [PATCH 04/31] =?UTF-8?q?[REFACTOR]=20TimerPage=20=EB=B9=84?= =?UTF-8?q?=EC=A6=88=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(#304)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: TimerPage의 상태관리를 해당 관심사 훅으로 분리 * refactor: NormalTimer, TimeBasedTimer 불필요한 Props제거 * refactor: useTimerPageState에서 모달 관련 훅을 분리 * refactor: 타이머 페이지 사용 훅에 로직을 설명하는 주석 추가 * refactor: 변경사항에 따른 storybook코드 수정 * refactor: 작전시간 상태를 NormalTimer컴포너트에서 useNormalTimer로 변경 * refactor: useBellSound 분기 조건을 함수로 분리 * refactor: 작전시간 종료 시, IsAdditionalTimerOn가 false가 되게 변경 * refactor: 변수명에 오타 수정 * refactor: 의존성 배열에 ref값 제거 * refactor: switchCamp 함수의 중복 검사 제거 * refactor: switchCamp 로직 개선 Co-Authored-By: Yuseon Kim(썬데이) <74897720+useon@users.noreply.github.com> * refactor: 각 진영의 타이머가 완전히 끝난 경우 분기 처리 개선 Co-authored-by: Yuseon Kim(썬데이) <74897720+useon@users.noreply.github.com> * refactor: 들여쓰기 변경 Co-Authored-By: Yuseon Kim(썬데이) <74897720+useon@users.noreply.github.com> * refactor: 타입명 변경 Co-Authored-By: Yuseon Kim(썬데이) <74897720+useon@users.noreply.github.com> * refactor: 분리되어 있던 테스트 컴포넌트를 스토리북 파일로 이동 Co-Authored-By: Yuseon Kim(썬데이) <74897720+useon@users.noreply.github.com> * refactor: 리턴 타입명 변경 * refactor: 모달 관련 컴포넌트 로직 useTimerPageModal로 이동 * refactor: 자유토론 타이머 활성화 로직을 useTimerPageState로 이동 * fix: 초기상태에서 reset버튼 클릭 시, 타이머가 0이되는 문제 수정 * refactor: Timer컴포넌트 Props를 timer인스턴스를 직접 받도록 변경 * refactor: TiemrPage의 UI 컴포넌트 분리 * fix: 불필요한 타입 선언 삭제 * refactor: 불필요한 빈태그 제거 * refactor: TimeBasedStance 타입 추가 * refactor: useCustomeTimer명을 useTimeBasedTimer로 변경 * refactor: 조건 함수명 prefix를 check로 변경 * refactor: useTimerPageModal의 UI로직을 별도의 컴포넌트로 분리 * refactor: NormalTimer, TimeBasedTimer의 Instance를 필요 값만 받도록 변경 * refactor: 리턴 타입을 명시적인 타입선언으로 변경 * refactor: 훅에 반환 타입 명시 * refactor: 로직 간소화 * refactor: 남은 시간에 따른 배경색 변경 로직을 별도의 훅으로 분리 * refactor: 배경색 훅명 변경 * refactor: 배경색 타입을 type파일로 분리 --------- Co-authored-by: Yuseon Kim(썬데이) <74897720+useon@users.noreply.github.com> --- src/page/TimerPage/TimerPage.tsx | 817 +----------------- .../components/FirstUseToolTipModal.tsx | 15 + .../components/LoginAndStoreModal.tsx | 40 + src/page/TimerPage/components/NormalTimer.tsx | 73 +- .../components/NormalTimerTestPage.tsx | 65 -- .../TimerPage/components/RoundControlRow.tsx | 32 + .../TimerPage/components/TimeBasedTimer.tsx | 65 +- .../TimerPage/components/TimerController.tsx | 8 +- src/page/TimerPage/components/TimerView.tsx | 91 ++ src/page/TimerPage/hooks/useBellSound.ts | 195 +++++ src/page/TimerPage/hooks/useNormalTimer.ts | 105 ++- ...useCustomTimer.ts => useTimeBasedTimer.ts} | 109 ++- .../TimerPage/hooks/useTimerBackground.ts | 97 +++ src/page/TimerPage/hooks/useTimerHotkey.ts | 136 +++ src/page/TimerPage/hooks/useTimerPageModal.ts | 60 ++ src/page/TimerPage/hooks/useTimerPageState.ts | 258 ++++++ .../TimerPage/stories/NormalTimer.stories.tsx | 132 ++- .../stories/NormalTimerTestPage.stories.tsx | 63 +- .../stories/TimeBasedTimer.stories.tsx | 119 +-- .../stories/TimerController.stories.tsx | 3 - src/type/type.ts | 10 + 21 files changed, 1383 insertions(+), 1110 deletions(-) create mode 100644 src/page/TimerPage/components/FirstUseToolTipModal.tsx create mode 100644 src/page/TimerPage/components/LoginAndStoreModal.tsx delete mode 100644 src/page/TimerPage/components/NormalTimerTestPage.tsx create mode 100644 src/page/TimerPage/components/RoundControlRow.tsx create mode 100644 src/page/TimerPage/components/TimerView.tsx create mode 100644 src/page/TimerPage/hooks/useBellSound.ts rename src/page/TimerPage/hooks/{useCustomTimer.ts => useTimeBasedTimer.ts} (58%) create mode 100644 src/page/TimerPage/hooks/useTimerBackground.ts create mode 100644 src/page/TimerPage/hooks/useTimerHotkey.ts create mode 100644 src/page/TimerPage/hooks/useTimerPageModal.ts create mode 100644 src/page/TimerPage/hooks/useTimerPageState.ts diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index 1ac7e786..7c8c101e 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -1,577 +1,46 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; -import TimeBasedTimer from './components/TimeBasedTimer'; -import { useNavigate, useParams } from 'react-router-dom'; -import FirstUseToolTip from './components/FirstUseToolTip'; import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo'; import HeaderTitle from '../../components/HeaderTitle/HeaderTitle'; import IconButton from '../../components/IconButton/IconButton'; import { IoHelpCircle } from 'react-icons/io5'; -import { useCustomTimer } from './hooks/useCustomTimer'; -import { useGetDebateTableData } from '../../hooks/query/useGetDebateTableData'; -import { FaExchangeAlt } from 'react-icons/fa'; -import NormalTimer from './components/NormalTimer'; -import { useNormalTimer } from './hooks/useNormalTimer'; -import RoundControlButton from '../../components/RoundControlButton/RoundControlButton'; -import { useModal } from '../../hooks/useModal'; -import { isGuestFlow } from '../../util/sessionStorage'; -import { oAuthLogin } from '../../util/googleAuth'; -import DialogModal from '../../components/DialogModal/DialogModal'; - -type TimerState = 'default' | 'warning' | 'danger' | 'expired'; -const bgColorMap: Record = { - default: '', - warning: 'bg-brand-main', // 30초 ~ 11초 - danger: 'bg-brand-sub3', // 10초 이하 - expired: 'bg-neutral-700', // 0초 이하 -}; +import { useTimerPageState } from './hooks/useTimerPageState'; +import { useTimerHotkey } from './hooks/useTimerHotkey'; +import RoundControlRow from './components/RoundControlRow'; +import TimerView from './components/TimerView'; +import { FirstUseToolTipModal } from './components/FirstUseToolTipModal'; +import { LoginAndStoreModal } from './components/LoginAndStoreModal'; +import { useTimerPageModal } from './hooks/useTimerPageModal'; +import { bgColorMap } from '../../type/type'; export default function TimerPage() { - // ########## DECLARATION AREA ########## - // Load sounds and prepare for bell-related constants - const warningBellRef = useRef(null); - const finishBellRef = useRef(null); - const [isWarningBellOn, setWarningBell] = useState(false); - const [isFinishBellOn, setFinishBell] = useState(false); - - // Parse params const pathParams = useParams(); const tableId = Number(pathParams.id); - const navigate = useNavigate(); - - // Get query - const { data } = useGetDebateTableData(tableId); - - // Prepare for tooltip-related constants - const [isFirst, setIsFirst] = useState(false); - const IS_FIRST = 'isFirst'; - const TRUE = 'true'; - const FALSE = 'false'; const { - openModal: openUseTooltipModal, - closeModal: closeUseTooltipModal, - ModalWrapper: UseToolTipWrapper, - } = useModal({ - onClose: () => { - setIsFirst(false); - localStorage.setItem(IS_FIRST, FALSE); - }, - isCloseButtonExist: false, - }); - const { - openModal: openLoginAndStoreModal, - closeModal: closeLoginAndStoreModal, - ModalWrapper: LoginAndStoreModalWrapper, - } = useModal(); - - // Prepare for changing background - const [bg, setBg] = useState('default'); - - // Prepare for additional timer - const [isAdditionalTimerOn, setIsAdditionalTimerOn] = useState(false); - const [savedTimer, saveTimer] = useState(0); - const [isTimerChangeable, setIsTimerChangeable] = useState(true); - - // Prepare for index-related constants - const [index, setIndex] = useState(0); - - // Prepare for timer hook - const timer1 = useCustomTimer({}); - const timer2 = useCustomTimer({}); - const normalTimer = useNormalTimer(); - const [prosConsSelected, setProsConsSelected] = useState<'pros' | 'cons'>( - 'pros', - ); - - // 타이머의 이전상태를 저장(타종 31->30초인 상황에서만 타종하기위한 로직) - const prevTimer1Ref = useRef<{ - speakingTimer: number | null; - totalTimer: number | null; - }>({ - speakingTimer: null, - totalTimer: null, - }); - - const prevTimer2Ref = useRef<{ - speakingTimer: number | null; - totalTimer: number | null; - }>({ - speakingTimer: null, - totalTimer: null, - }); - - const prevNormalTimerRef = useRef(null); - - // 이전 또는 다음 차례로 이동하는 함수 - const goToOtherItem = useCallback( - (isPrev: boolean) => { - if (isPrev) { - if (index > 0) { - setIndex((prev) => prev - 1); - } - } else { - if (data && index < data.table.length - 1) { - setIndex((prev) => prev + 1); - } - } - }, - [index, data], - ); - - // 발언 진영(pros/cons) 전환 함수 (ENTER 버튼 등에서 사용) - const switchCamp = useCallback(() => { - if (prosConsSelected === 'pros') { - if (timer2.isDone) return; - if (timer1.isRunning) { - timer1.pauseTimer(); - timer2.startTimer(); - setProsConsSelected('cons'); - } else { - timer1.pauseTimer(); - setProsConsSelected('cons'); - } - } else if (prosConsSelected === 'cons') { - if (timer1.isDone) return; - if (timer2.isRunning) { - if (timer1.isDone) return; - timer2.pauseTimer(); - timer1.startTimer(); - setProsConsSelected('pros'); - } else { - timer2.pauseTimer(); - setProsConsSelected('pros'); - } - } - }, [prosConsSelected, timer1, timer2]); - - // ########### useEffect AREA ########### - // 로컬스토리지에 저장된 "최초 사용 여부" 확인 → 툴팁 띄울지 결정 - useEffect(() => { - const storedIsFirst = localStorage.getItem(IS_FIRST); - - if (storedIsFirst === null) { - setIsFirst(true); - } else { - setIsFirst(storedIsFirst.trim() === TRUE ? true : false); - } - - if (isFirst) { - openUseTooltipModal(); - } - }, [isFirst, openUseTooltipModal]); - - // 타이머 상태에 따라 배경색(bg) 상태 설정 - useEffect(() => { - const getBgStatus = () => { - const boxType = data?.table[index].boxType; - - const getTimerStatus = ( - speakingTimer: number | null, - totalTimer: number | null, - ) => { - const activeTimer = speakingTimer !== null ? speakingTimer : totalTimer; - if (activeTimer !== null) { - if (activeTimer > 10 && activeTimer <= 30) return 'warning'; - if (activeTimer >= 0 && activeTimer <= 10) return 'danger'; - } - return 'default'; - }; - - if (boxType === 'NORMAL') { - if (!normalTimer.isRunning) return 'default'; - - if (normalTimer.timer !== null) { - if (normalTimer.timer > 10 && normalTimer.timer <= 30) - return 'warning'; - if (normalTimer.timer >= 0 && normalTimer.timer <= 10) - return 'danger'; - if (normalTimer.timer < 0) return 'expired'; - return 'default'; - } - } - - if (boxType === 'TIME_BASED') { - if (prosConsSelected === 'pros' && timer1.isRunning) { - return getTimerStatus(timer1.speakingTimer, timer1.totalTimer); - } - if (prosConsSelected === 'cons' && timer2.isRunning) { - return getTimerStatus(timer2.speakingTimer, timer2.totalTimer); - } - } - - return 'default'; - }; - - setBg(getBgStatus()); - }, [ - normalTimer.isRunning, - normalTimer.timer, - timer1.isRunning, - timer1.totalTimer, - timer1.speakingTimer, - timer2.isRunning, - timer2.totalTimer, - timer2.speakingTimer, - prosConsSelected, - index, - data, - ]); - - // 벨 소리 재생 - useEffect(() => { - const shouldPlayWarningBell = () => { - const isAnyTimerRunning = - timer1.isRunning || timer2.isRunning || normalTimer.isRunning; - if (!warningBellRef.current || !isAnyTimerRunning || !isWarningBellOn) - return false; - const waringTime = 30; - const timerJustReached = ( - prevTime: number | null, - currentTime: number | null, - defaultTime: number | null, - ) => { - return ( - prevTime !== null && - prevTime > waringTime && - currentTime === waringTime && - defaultTime !== waringTime - ); - }; - - const isTimer1WarningTime = - timer1.isRunning && - (timerJustReached( - prevTimer1Ref.current.speakingTimer, - timer1.speakingTimer, - timer1.defaultTime.defaultSpeakingTimer, - ) || - (timer1.speakingTimer === null && - timerJustReached( - prevTimer1Ref.current.totalTimer, - timer1.totalTimer, - timer1.defaultTime.defaultTotalTimer, - ))); - - const isTimer2WarningTime = - timer2.isRunning && - (timerJustReached( - prevTimer2Ref.current.speakingTimer, - timer2.speakingTimer, - timer2.defaultTime.defaultSpeakingTimer, - ) || - (timer2.speakingTimer === null && - timerJustReached( - prevTimer2Ref.current.totalTimer, - timer2.totalTimer, - timer2.defaultTime.defaultTotalTimer, - ))); - - const isNormalTimerWarningTime = - normalTimer.isRunning && - prevNormalTimerRef.current !== null && - prevNormalTimerRef.current > waringTime && - normalTimer.timer === waringTime && - normalTimer.defaultTimer !== waringTime; - - return ( - isTimer1WarningTime || isTimer2WarningTime || isNormalTimerWarningTime - ); - }; - - // 사용 - if (warningBellRef.current && shouldPlayWarningBell()) { - warningBellRef.current.play(); - } + openUseTooltipModal, + UseToolTipWrapper, + closeUseTooltipModal, + LoginAndStoreModalWrapper, + closeLoginAndStoreModal, + openLoginAndStoreModalOrGoToOverviewPage, + } = useTimerPageModal(tableId); - const shouldPlayFinishBell = () => { - const isTimer1Finished = - timer1.isRunning && - (timer1.speakingTimer === 0 || timer1.totalTimer === 0); + const state = useTimerPageState(tableId); - const isTimer2Finished = - timer2.isRunning && - (timer2.speakingTimer === 0 || timer2.totalTimer === 0); + useTimerHotkey(state); + const { warningBellRef, finishBellRef, data, bg, index, goToOtherItem } = + state; - const isNormalTimerFinished = - normalTimer.isRunning && normalTimer.timer === 0; - - const isAnyTimerRunning = - timer1.isRunning || timer2.isRunning || normalTimer.isRunning; - return ( - isAnyTimerRunning && - isFinishBellOn && - (isTimer1Finished || isTimer2Finished || isNormalTimerFinished) - ); - }; - - // 사용 - if (finishBellRef.current && shouldPlayFinishBell()) { - finishBellRef.current.play(); - } - - prevTimer1Ref.current = { - speakingTimer: timer1.speakingTimer, - totalTimer: timer1.totalTimer, - }; - prevTimer2Ref.current = { - speakingTimer: timer2.speakingTimer, - totalTimer: timer2.totalTimer, - }; - prevNormalTimerRef.current = normalTimer.timer; - }, [ - isFinishBellOn, - isWarningBellOn, - timer1.isRunning, - timer2.isRunning, - normalTimer.isRunning, - timer1.speakingTimer, - timer1.totalTimer, - timer1.defaultTime.defaultTotalTimer, - timer1.defaultTime.defaultSpeakingTimer, - timer2.speakingTimer, - timer2.totalTimer, - timer2.defaultTime.defaultSpeakingTimer, - normalTimer.timer, - normalTimer.defaultTimer, - timer2.defaultTime.defaultTotalTimer, - ]); - - // 새로운 index(차례)로 이동했을 때 → 타이머 초기화 및 세팅 - useEffect(() => { - if (!data) return; - - const currentBox = data.table[index]; - const { warningBell, finishBell } = data.info; - - setWarningBell(warningBell); - setFinishBell(finishBell); - timer1.clearTimer(); - timer2.clearTimer(); - normalTimer.clearTimer(); - - if (currentBox.boxType === 'NORMAL') { - const defaultTime = currentBox.time ?? 0; - normalTimer.setDefaultTimer(defaultTime); - normalTimer.setTimer(defaultTime); - } else if (currentBox.boxType === 'TIME_BASED') { - normalTimer.clearTimer(); - - const defaultTotalTimer = currentBox.timePerTeam; - const defaultSpeakingTimer = currentBox.timePerSpeaking; - - [timer1, timer2].forEach((timer) => { - timer.setDefaultTime({ defaultTotalTimer, defaultSpeakingTimer }); - timer.setTimers(defaultTotalTimer, defaultSpeakingTimer); - timer.setIsSpeakingTimer(true); - timer.setIsDone(false); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - data, - index, - timer1.setDefaultTime, - timer1.setTimers, - timer2.setDefaultTime, - timer2.setTimers, - normalTimer.setDefaultTimer, - normalTimer.setTimer, - ]); - - // 키보드 단축키 제어 - useEffect(() => { - const boxType = data?.table[index].boxType; - const handleKeyDown = (event: KeyboardEvent) => { - const keysToDisable = [ - 'Space', - 'ArrowLeft', - 'ArrowRight', - 'KeyR', - 'KeyA', - 'KeyL', - 'Enter', - ]; - - if (keysToDisable.includes(event.key)) { - event.preventDefault(); - } - if (event.target instanceof HTMLElement) { - event.target.blur(); - } - - const toggleTimer = (timer: typeof timer1 | typeof timer2) => { - if (timer.isRunning) { - timer.pauseTimer(); - } else { - timer.startTimer(); - } - }; - - switch (event.code) { - case 'Space': - if (boxType === 'NORMAL') { - if (normalTimer.isRunning) { - normalTimer.pauseTimer(); - } else { - normalTimer.startTimer(); - } - } else { - if (prosConsSelected === 'pros') { - toggleTimer(timer1); - } else if (prosConsSelected === 'cons') { - toggleTimer(timer2); - } - } - break; - case 'ArrowLeft': - goToOtherItem(true); - break; - case 'ArrowRight': - goToOtherItem(false); - break; - case 'KeyR': - if (boxType === 'NORMAL') { - normalTimer.resetTimer(); - } else { - if (prosConsSelected === 'pros') { - timer1.resetCurrentTimer(); - } else { - timer2.resetCurrentTimer(); - } - } - break; - case 'KeyA': - if (!timer1.isDone) { - setProsConsSelected('pros'); - if (timer2.isRunning) timer2.pauseTimer(); - } - break; - case 'KeyL': - if (!timer2.isDone) { - setProsConsSelected('cons'); - if (timer1.isRunning) timer1.pauseTimer(); - } - break; - case 'Enter': - switchCamp(); - break; - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - goToOtherItem, - prosConsSelected, - timer1, - timer2, - setProsConsSelected, - switchCamp, - ]); - - // 테이블에 작전시간 발언이 있는 경우 작전시간 타이머 변경 비활성화 - useEffect(() => { - if (data) { - data.table.forEach((value) => { - if (value.speechType === '작전 시간') { - setIsTimerChangeable(false); - } - }); - } - }); - - // 작전시간 타이머가 켜져 있고, 시간이 0이 되었을 때 → 저장된 시간으로 되돌림 - useEffect(() => { - if ( - isAdditionalTimerOn && - normalTimer.timer === 0 && - normalTimer.isRunning - ) { - normalTimer.pauseTimer(); - normalTimer.setTimer(savedTimer); - setIsAdditionalTimerOn(!isAdditionalTimerOn); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - isAdditionalTimerOn, - normalTimer.timer, - savedTimer, - normalTimer.pauseTimer, - setIsAdditionalTimerOn, - normalTimer.setTimer, - normalTimer.isRunning, - ]); - - //진영(pros/cons)이 바뀌면 → 상대 타이머 초기화 - useEffect(() => { - if (prosConsSelected === 'cons') { - if (timer1.speakingTimer === null) return; - timer1.resetTimerForNextPhase(); - } else if (prosConsSelected === 'pros') { - if (timer2.speakingTimer === null) return; - timer2.resetTimerForNextPhase(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [prosConsSelected]); - - //타이머가 0초가 되면 자동으로 일시정지 - useEffect(() => { - if (timer1.speakingTimer === 0 || timer1.totalTimer === 0) { - timer1.pauseTimer(); - } else if (timer2.speakingTimer === 0 || timer2.totalTimer === 0) { - timer2.pauseTimer(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - timer1.speakingTimer, - timer1.totalTimer, - timer2.speakingTimer, - timer2.totalTimer, - ]); - - //speakingTimer or totalTimer가 0초면 → 타이머 종료 처리 (isDone = true) - useEffect(() => { - if (prosConsSelected === 'pros') { - if (timer1.speakingTimer === null) { - if (timer1.totalTimer === 0) { - timer1.setIsDone(true); - } - } else { - if (timer1.speakingTimer === 0) { - timer1.setIsDone(true); - } - } - } else if (prosConsSelected === 'cons') { - if (timer2.speakingTimer === null) { - if (timer2.totalTimer === 0) { - timer2.setIsDone(true); - } - } else { - if (timer2.speakingTimer === 0) { - timer2.setIsDone(true); - } - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - prosConsSelected, - timer1.totalTimer, - timer1.speakingTimer, - timer2.totalTimer, - timer2.speakingTimer, - ]); - // ########### COMPONENT AREA ########### if (!data) { - return; + return null; } + return ( <>
- - {/** Tooltip */} - - { - closeUseTooltipModal(); - setIsFirst(false); - localStorage.setItem(IS_FIRST, FALSE); - }} - /> - - {/** Login And DataStore*/} - - { - closeLoginAndStoreModal(); - }, - }} - right={{ - text: '네', - onClick: () => { - closeLoginAndStoreModal(); - oAuthLogin(); - }, - isBold: true, - }} - > -
- 토론을 끝내셨군요!
- 지금까지의 시간표를 로그인하고 저장할까요? -
-
-
+ + ); } diff --git a/src/page/TimerPage/components/FirstUseToolTipModal.tsx b/src/page/TimerPage/components/FirstUseToolTipModal.tsx new file mode 100644 index 00000000..d0b9c9ee --- /dev/null +++ b/src/page/TimerPage/components/FirstUseToolTipModal.tsx @@ -0,0 +1,15 @@ +// components/FirstUseToolTipModal.tsx +import { ComponentType, ReactNode } from 'react'; +import FirstUseToolTip from '../components/FirstUseToolTip'; + +interface Props { + Wrapper: ComponentType<{ children: ReactNode }>; + onClose: () => void; +} +export function FirstUseToolTipModal({ Wrapper, onClose }: Props) { + return ( + + + + ); +} diff --git a/src/page/TimerPage/components/LoginAndStoreModal.tsx b/src/page/TimerPage/components/LoginAndStoreModal.tsx new file mode 100644 index 00000000..5be6f851 --- /dev/null +++ b/src/page/TimerPage/components/LoginAndStoreModal.tsx @@ -0,0 +1,40 @@ +import { ComponentType, ReactNode } from 'react'; +import DialogModal from '../../../components/DialogModal/DialogModal'; +import { oAuthLogin } from '../../../util/googleAuth'; + +interface LoginAndStoreModalProps { + Wrapper: ComponentType<{ + children: ReactNode; + closeButtonColor?: string; + }>; + onClose: () => void; +} + +export function LoginAndStoreModal({ + Wrapper, + onClose, +}: LoginAndStoreModalProps) { + return ( + + { + onClose(); + oAuthLogin(); + }, + isBold: true, + }} + > +
+ 토론을 끝내셨군요!
+ 지금까지의 시간표를 로그인하고 저장할까요? +
+
+
+ ); +} diff --git a/src/page/TimerPage/components/NormalTimer.tsx b/src/page/TimerPage/components/NormalTimer.tsx index f5378c0c..06dd6748 100644 --- a/src/page/TimerPage/components/NormalTimer.tsx +++ b/src/page/TimerPage/components/NormalTimer.tsx @@ -4,39 +4,44 @@ import { Formatting } from '../../../util/formatting'; import AdditionalTimerController from './AdditionalTimerController'; import { IoCloseOutline } from 'react-icons/io5'; import { MdRecordVoiceOver } from 'react-icons/md'; - -interface NormalTimerProps { - onStart: () => void; - onPause: () => void; - onReset: () => void; - addOnTimer: (delta: number) => void; - onChangingTimer: () => void; - goToOtherItem: (isPrev: boolean) => void; - timer: number; +type NormalTimerInstance = { + timer: number | null; isAdditionalTimerOn: boolean; - isTimerChangeable: boolean; isRunning: boolean; - isLastItem: boolean; - isFirstItem: boolean; + handleChangeAdditionalTimer: () => void; + startTimer: () => void; + pauseTimer: () => void; + resetTimer: () => void; + setTimer: (val: number) => void; +}; +interface NormalTimerProps { + normalTimerInstance: NormalTimerInstance; + isAdditionalTimerAvailable: boolean; item: TimeBoxInfo; teamName: string | null; } export default function NormalTimer({ - onStart, - onPause, - onReset, - addOnTimer, - onChangingTimer, - timer, - isAdditionalTimerOn, - isTimerChangeable, - isRunning, + normalTimerInstance, + isAdditionalTimerAvailable, item, teamName, }: NormalTimerProps) { - const minute = Formatting.formatTwoDigits(Math.floor(Math.abs(timer) / 60)); - const second = Formatting.formatTwoDigits(Math.abs(timer % 60)); + const { + timer, + isAdditionalTimerOn, + isRunning, + handleChangeAdditionalTimer, + startTimer, + pauseTimer, + resetTimer, + setTimer, + } = normalTimerInstance; + const totalTime = timer ?? 0; + const minute = Formatting.formatTwoDigits( + Math.floor(Math.abs(totalTime) / 60), + ); + const second = Formatting.formatTwoDigits(Math.abs(totalTime % 60)); const bgColorClass = item.stance === 'NEUTRAL' || isAdditionalTimerOn ? 'bg-neutral-500' @@ -75,7 +80,7 @@ export default function NormalTimer({ {isAdditionalTimerOn && ( @@ -85,7 +90,7 @@ export default function NormalTimer({ {/* Speaker's number, if necessary */}
- {item.stance !== 'NEUTRAL' && !isAdditionalTimerOn && ( + {item.stance !== 'NEUTRAL' && isAdditionalTimerOn && ( <>

@@ -101,7 +106,7 @@ export default function NormalTimer({
- {timer < 0 &&

-

} + {totalTime < 0 &&

-

}

{minute}

:

{second}

@@ -112,20 +117,20 @@ export default function NormalTimer({ {!isAdditionalTimerOn && ( onChangingTimer()} - onStart={() => onStart()} - onPause={() => onPause()} - onReset={() => onReset()} + isAdditionalTimerAvailable={isAdditionalTimerAvailable} + onChangingTimer={handleChangeAdditionalTimer} + onStart={startTimer} + onPause={pauseTimer} + onReset={resetTimer} /> )} {isAdditionalTimerOn && ( onStart()} - onPause={() => onPause()} - addOnTimer={(delta: number) => addOnTimer(delta)} + onStart={startTimer} + onPause={pauseTimer} + addOnTimer={(delta: number) => setTimer(totalTime + delta)} /> )}
diff --git a/src/page/TimerPage/components/NormalTimerTestPage.tsx b/src/page/TimerPage/components/NormalTimerTestPage.tsx deleted file mode 100644 index b5c4382f..00000000 --- a/src/page/TimerPage/components/NormalTimerTestPage.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import DefaultLayout from '../../../layout/defaultLayout/DefaultLayout'; -import NormalTimer from './NormalTimer'; -import { TimeBoxInfo } from '../../../type/type'; -import HeaderTableInfo from '../../../components/HeaderTableInfo/HeaderTableInfo'; -import HeaderTitle from '../../../components/HeaderTitle/HeaderTitle'; -import RoundControlButton from '../../../components/RoundControlButton/RoundControlButton'; - -export default function NormalTimerTestPage() { - const item: TimeBoxInfo = { - boxType: 'NORMAL', - speechType: '입론', - stance: 'PROS', - speaker: '케이티', - time: 120, - timePerTeam: null, - timePerSpeaking: null, - }; - - return ( - - {/* Header */} - - - - - - - - - - - {/* Content */} - -
- {}} - onStart={() => {}} - onPause={() => {}} - onReset={() => {}} - goToOtherItem={() => {}} - addOnTimer={() => {}} - isFirstItem={true} - isLastItem={false} - item={item} - teamName="찬성" - /> - - {/* NEXT 버튼만 하단에 표시 */} -
-
- alert('이전')} /> -
-
- alert('다음')} /> -
-
-
-
-
- ); -} diff --git a/src/page/TimerPage/components/RoundControlRow.tsx b/src/page/TimerPage/components/RoundControlRow.tsx new file mode 100644 index 00000000..4579ebea --- /dev/null +++ b/src/page/TimerPage/components/RoundControlRow.tsx @@ -0,0 +1,32 @@ +import RoundControlButton from '../../../components/RoundControlButton/RoundControlButton'; +import { TimeBoxInfo } from '../../../type/type'; + +interface RoundControlRowProps { + table: TimeBoxInfo[]; + index: number; + goToOtherItem: (isPrev: boolean) => void; + openDoneModal: () => void; +} + +export default function RoundControlRow(props: RoundControlRowProps) { + const { table, index, goToOtherItem, openDoneModal } = props; + return ( +
+
+ {index !== 0 && ( + goToOtherItem(true)} /> + )} +
+
+ {index === table.length - 1 ? ( + + ) : ( + goToOtherItem(false)} + /> + )} +
+
+ ); +} diff --git a/src/page/TimerPage/components/TimeBasedTimer.tsx b/src/page/TimerPage/components/TimeBasedTimer.tsx index 2ee5810c..7c4f7d07 100644 --- a/src/page/TimerPage/components/TimeBasedTimer.tsx +++ b/src/page/TimerPage/components/TimeBasedTimer.tsx @@ -1,50 +1,45 @@ -import { TimeBoxInfo } from '../../../type/type'; import TimerController from './TimerController'; import { Formatting } from '../../../util/formatting'; import KeyboardKeyA from '../../../assets/keyboard/keyboard_key_A.png'; import KeyboardKeyL from '../../../assets/keyboard/keyboard_key_l.png'; +import { TimeBasedStance } from '../../../type/type'; -interface TimeBasedTimerProps { - onStart: () => void; - onPause: () => void; - onReset: () => void; - addOnTimer: (delta: number) => void; - onChangingTimer: () => void; - goToOtherItem: (isPrev: boolean) => void; - timer: number; - isTimerChangeable: boolean; +type TimeBasedTimerInstance = { + totalTimer: number | null; + speakingTimer: number | null; isRunning: boolean; - isLastItem: boolean; - isFirstItem: boolean; - item: TimeBoxInfo; - - /** 🚩 추가된 Props */ - speakingTimer: number | null; // 발언시간용 타이머 추가 + startTimer: () => void; + pauseTimer: () => void; + resetCurrentTimer: () => void; +}; +interface TimeBasedTimerProps { + timeBasedTimerInstance: TimeBasedTimerInstance; isSelected: boolean; onActivate?: () => void; - prosCons: 'pros' | 'cons'; + prosCons: TimeBasedStance; teamName: string; } export default function TimeBasedTimer({ - onStart, - onPause, - onReset, - onChangingTimer, - timer, - speakingTimer, - isRunning, + timeBasedTimerInstance, isSelected, onActivate, prosCons, teamName, }: TimeBasedTimerProps) { + const { + totalTimer, + speakingTimer, + isRunning, + startTimer, + pauseTimer, + resetCurrentTimer, + } = timeBasedTimerInstance; const minute = Formatting.formatTwoDigits( - Math.floor(Math.abs(timer ?? 0) / 60), + Math.floor(Math.abs(totalTimer ?? 0) / 60), ); - const second = Formatting.formatTwoDigits(Math.abs((timer ?? 0) % 60)); + const second = Formatting.formatTwoDigits(Math.abs((totalTimer ?? 0) % 60)); - /** 🚩 추가된 코드: 발언시간 표시 처리 */ const speakingMinute = Formatting.formatTwoDigits( Math.floor(Math.abs((speakingTimer ?? 0) / 60)), ); @@ -53,12 +48,12 @@ export default function TimeBasedTimer({ ); const boxShadow = isRunning - ? prosCons === 'pros' + ? prosCons === 'PROS' ? 'shadow-camp-blue' : 'shadow-camp-red' : ''; - const bgColorClass = prosCons === 'pros' ? 'bg-camp-blue' : 'bg-camp-red'; + const bgColorClass = prosCons === 'PROS' ? 'bg-camp-blue' : 'bg-camp-red'; return (
{teamName} {prosCons

@@ -143,11 +138,9 @@ export default function TimeBasedTimer({
diff --git a/src/page/TimerPage/components/TimerController.tsx b/src/page/TimerPage/components/TimerController.tsx index e91b7151..a78753f9 100644 --- a/src/page/TimerPage/components/TimerController.tsx +++ b/src/page/TimerPage/components/TimerController.tsx @@ -6,8 +6,8 @@ interface TimerControllerProps { onStart: () => void; onPause: () => void; onReset: () => void; - onChangingTimer: () => void; - isTimerChangeable: boolean; + onChangingTimer?: () => void; + isAdditionalTimerAvailable?: boolean; isRunning: boolean; } @@ -16,7 +16,7 @@ export default function TimerController({ onPause, onReset, onChangingTimer, - isTimerChangeable, + isAdditionalTimerAvailable, isRunning, }: TimerControllerProps) { return ( @@ -52,7 +52,7 @@ export default function TimerController({
- {isTimerChangeable && ( + {isAdditionalTimerAvailable && onChangingTimer && ( +
+ ); + } + return null; +} diff --git a/src/page/TimerPage/hooks/useBellSound.ts b/src/page/TimerPage/hooks/useBellSound.ts new file mode 100644 index 00000000..551f2c07 --- /dev/null +++ b/src/page/TimerPage/hooks/useBellSound.ts @@ -0,0 +1,195 @@ +import { useEffect, useRef, useState } from 'react'; +import { TimeBasedTimerLogics } from './useTimeBasedTimer'; +import { NormalTimerLogics } from './useNormalTimer'; + +interface UseBellSoundProps { + timer1: TimeBasedTimerLogics; + timer2: TimeBasedTimerLogics; + normalTimer: NormalTimerLogics; + isWarningBell?: boolean; + isFinishBell?: boolean; +} + +/** + * 토론 타이머에서 경고/종료 벨 사운드를 자동 재생해주는 커스텀 훅 + * - 타이머 상태 변화(30초, 0초 등)에 따라 지정된 벨 사운드가 한 번씩 재생됨 + */ +export function useBellSound({ + timer1, + timer2, + normalTimer, + isWarningBell = false, + isFinishBell = false, +}: UseBellSoundProps) { + // 오디오 태그를 참조하기 위한 ref (컴포넌트에서
+ + {/* Modal for users who have not used this timer */} + + {/* Modal that asks users whether they want to store the timetable in their account */} Date: Mon, 14 Jul 2025 23:23:55 +0900 Subject: [PATCH 06/31] =?UTF-8?q?[FEAT]=20=ED=83=80=EC=9E=84=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EB=B3=B5=EC=82=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 타임 박스에 복사하기 버튼 추가 * refactor: TimeBoxManageButtons으로 컴포넌트 명 변경 * refactor: TimeBox로 컴포넌트 명 변경 * test: 컴포넌트 명 변경에 따른 파일경로 재설정 * feat: TimeBoxManageButtons에서 이벤트 핸들러를 옵셔널로 변경 * fix: TableOverView에서 드래그 UI가 표시되는 문제 수정 --- .../TableComposition.test.tsx | 2 +- .../EditDeleteButtons/EditDeleteButtons.tsx | 77 ------------- .../TimeBox.stories.tsx} | 10 +- .../DebatePanel.tsx => TimeBox/TimeBox.tsx} | 90 +++++++++------- .../TimeBoxManageButtons.stories.tsx} | 23 ++-- .../TimeBoxManageButtons.tsx | 101 ++++++++++++++++++ .../components/TimeBoxStep/TimeBoxStep.tsx | 23 +++- src/page/TableOverviewPage/TableOverview.tsx | 4 +- 8 files changed, 193 insertions(+), 137 deletions(-) delete mode 100644 src/page/TableComposition/components/EditDeleteButtons/EditDeleteButtons.tsx rename src/page/TableComposition/components/{DebatePanel/DebatePanel.stories.tsx => TimeBox/TimeBox.stories.tsx} (87%) rename src/page/TableComposition/components/{DebatePanel/DebatePanel.tsx => TimeBox/TimeBox.tsx} (71%) rename src/page/TableComposition/components/{EditDeleteButtons/EditDeleteButtons.stories.tsx => TimeBoxManageButtons/TimeBoxManageButtons.stories.tsx} (61%) create mode 100644 src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.tsx diff --git a/src/page/TableComposition/TableComposition.test.tsx b/src/page/TableComposition/TableComposition.test.tsx index 87997fdc..38869174 100644 --- a/src/page/TableComposition/TableComposition.test.tsx +++ b/src/page/TableComposition/TableComposition.test.tsx @@ -37,7 +37,7 @@ function TestWrapper({ ); } -vi.mock('./components/DebatePanel/DebatePanel', () => { +vi.mock('./components/TimeBox/TimeBox', () => { const DebatePanel = ({ children }: { children: React.ReactNode }) => (
{children}
); diff --git a/src/page/TableComposition/components/EditDeleteButtons/EditDeleteButtons.tsx b/src/page/TableComposition/components/EditDeleteButtons/EditDeleteButtons.tsx deleted file mode 100644 index a7cfba20..00000000 --- a/src/page/TableComposition/components/EditDeleteButtons/EditDeleteButtons.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { RiEditFill, RiDeleteBinFill } from 'react-icons/ri'; -import { TimeBoxInfo } from '../../../../type/type'; -import { useModal } from '../../../../hooks/useModal'; -import TimerCreationContent from '../TimerCreationContent/TimerCreationContent'; -import DialogModal from '../../../../components/DialogModal/DialogModal'; - -interface EditDeleteButtonsProps { - info: TimeBoxInfo; - prosTeamName?: string; - consTeamName?: string; - onSubmitEdit: (updatedInfo: TimeBoxInfo) => void; - onSubmitDelete: () => void; -} - -export default function EditDeleteButtons(props: EditDeleteButtonsProps) { - const { - openModal: openEditModal, - closeModal: closeEditModal, - ModalWrapper: EditModalWrapper, - } = useModal({ isCloseButtonExist: false }); - const { - openModal: openDeleteModal, - closeModal: closeDeleteModal, - ModalWrapper: DeleteModalWrapper, - } = useModal({ isCloseButtonExist: false }); - const { info, onSubmitEdit, onSubmitDelete } = props; - - return ( - <> -
- - -
- - { - onSubmitEdit(newInfo); - }} - onClose={closeEditModal} - /> - - - - closeDeleteModal() }} - right={{ - text: '삭제', - onClick: () => { - onSubmitDelete(); - closeDeleteModal(); - }, - isBold: true, - }} - > -

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

-
-
- - ); -} diff --git a/src/page/TableComposition/components/DebatePanel/DebatePanel.stories.tsx b/src/page/TableComposition/components/TimeBox/TimeBox.stories.tsx similarity index 87% rename from src/page/TableComposition/components/DebatePanel/DebatePanel.stories.tsx rename to src/page/TableComposition/components/TimeBox/TimeBox.stories.tsx index 2cdd7273..9218cdb5 100644 --- a/src/page/TableComposition/components/DebatePanel/DebatePanel.stories.tsx +++ b/src/page/TableComposition/components/TimeBox/TimeBox.stories.tsx @@ -1,9 +1,9 @@ import { Meta, StoryObj } from '@storybook/react'; -import DebatePanel from './DebatePanel'; +import TimeBox from './TimeBox'; -const meta: Meta = { - title: 'page/TableSetup/Components/DebatePanel', - component: DebatePanel, +const meta: Meta = { + title: 'page/TableSetup/Components/TimeBox', + component: TimeBox, tags: ['autodocs'], args: { info: { @@ -19,7 +19,7 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; // 찬성 입론 export const ProsOpening: Story = { diff --git a/src/page/TableComposition/components/DebatePanel/DebatePanel.tsx b/src/page/TableComposition/components/TimeBox/TimeBox.tsx similarity index 71% rename from src/page/TableComposition/components/DebatePanel/DebatePanel.tsx rename to src/page/TableComposition/components/TimeBox/TimeBox.tsx index b6a2ae09..a3ce72bb 100644 --- a/src/page/TableComposition/components/DebatePanel/DebatePanel.tsx +++ b/src/page/TableComposition/components/TimeBox/TimeBox.tsx @@ -1,18 +1,23 @@ import { HTMLAttributes } from 'react'; -import EditDeleteButtons from '../EditDeleteButtons/EditDeleteButtons'; +import TimeBoxManageButtons from '../TimeBoxManageButtons/TimeBoxManageButtons'; import { TimeBoxInfo } from '../../../../type/type'; import { Formatting } from '../../../../util/formatting'; import { LuArrowUpDown } from 'react-icons/lu'; -interface DebatePanelProps extends HTMLAttributes { +interface TimeBoxEventHandlers { + onSubmitEdit?: (updatedInfo: TimeBoxInfo) => void; + onSubmitDelete?: () => void; + onSubmitCopy?: () => void; + onMouseDown?: () => void; +} +interface TimeBoxProps extends HTMLAttributes { info: TimeBoxInfo; prosTeamName: string; consTeamName: string; - onSubmitEdit?: (updatedInfo: TimeBoxInfo) => void; - onSubmitDelete?: () => void; + eventHandlers?: TimeBoxEventHandlers; } -export default function DebatePanel(props: DebatePanelProps) { +export default function TimeBox(props: TimeBoxProps) { const { stance, speechType, @@ -22,9 +27,12 @@ export default function DebatePanel(props: DebatePanelProps) { timePerSpeaking, speaker, } = props.info; - const { onSubmitEdit, onSubmitDelete, onMouseDown } = props; - - // 타이머 시간 문자열 처리 + const { eventHandlers } = props; + const onSubmitEdit = eventHandlers?.onSubmitEdit; + const onSubmitDelete = eventHandlers?.onSubmitDelete; + const onSubmitCopy = eventHandlers?.onSubmitCopy; + const onMouseDown = eventHandlers?.onMouseDown; + const isModifiable = !!eventHandlers; let timeStr = ''; let timePerSpeakingStr = ''; @@ -74,37 +82,41 @@ export default function DebatePanel(props: DebatePanelProps) { isPros ? 'bg-camp-blue' : 'bg-camp-red' } h-20 select-none p-2 font-bold text-neutral-0`} > - {onSubmitEdit && onSubmitDelete && ( - <> - {isPros ? ( + {isPros + ? isModifiable && ( <>
-
{renderDragHandle()} - ) : ( + ) + : isModifiable && ( <> {renderDragHandle()}
-
)} - - )}
{speechType} {speaker && `| ${speaker} 토론자`}
@@ -114,20 +126,19 @@ export default function DebatePanel(props: DebatePanelProps) { const renderNeutralTimeoutPanel = () => (
- {onSubmitEdit && onSubmitDelete && ( - <> - {renderDragHandle()} -
- -
- - )} + {renderDragHandle()} +
+ +
{speechType} {timeStr}
@@ -135,16 +146,19 @@ export default function DebatePanel(props: DebatePanelProps) { const renderNeutralCustomPanel = () => (
- {onSubmitEdit && onSubmitDelete && ( + {isModifiable && ( <> {renderDragHandle()}
-
diff --git a/src/page/TableComposition/components/EditDeleteButtons/EditDeleteButtons.stories.tsx b/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.stories.tsx similarity index 61% rename from src/page/TableComposition/components/EditDeleteButtons/EditDeleteButtons.stories.tsx rename to src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.stories.tsx index 4e807d6a..118679fb 100644 --- a/src/page/TableComposition/components/EditDeleteButtons/EditDeleteButtons.stories.tsx +++ b/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.stories.tsx @@ -1,16 +1,16 @@ import { Meta, StoryObj } from '@storybook/react'; -import EditDeleteButtons from './EditDeleteButtons'; +import TimeBoxManageButtons from './TimeBoxManageButtons'; import { TimeBoxInfo } from '../../../../type/type'; -const meta: Meta = { - title: 'page/TableSetup/components/EditDeleteButtons', - component: EditDeleteButtons, +const meta: Meta = { + title: 'page/TableSetup/components/TimeBoxManageButtons', + component: TimeBoxManageButtons, tags: ['autodocs'], }; export default meta; -type Story = StoryObj; +type Story = StoryObj; const normalTimeBoxInfo: TimeBoxInfo = { stance: 'PROS', @@ -25,8 +25,11 @@ const normalTimeBoxInfo: TimeBoxInfo = { export const NormalTimeBox: Story = { args: { info: normalTimeBoxInfo, - onSubmitEdit: () => {}, - onSubmitDelete: () => {}, + eventHandlers: { + onSubmitEdit: () => {}, + onSubmitDelete: () => {}, + onSubmitCopy: () => {}, + }, }, }; @@ -43,7 +46,9 @@ const timeBasedTimeBoxInfo: TimeBoxInfo = { export const TimeBasedTimeBox: Story = { args: { info: timeBasedTimeBoxInfo, - onSubmitEdit: () => {}, - onSubmitDelete: () => {}, + eventHandlers: { + onSubmitEdit: () => {}, + onSubmitDelete: () => {}, + }, }, }; diff --git a/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.tsx b/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.tsx new file mode 100644 index 00000000..73df5a7c --- /dev/null +++ b/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.tsx @@ -0,0 +1,101 @@ +import { RiEditFill, RiDeleteBinFill } from 'react-icons/ri'; +import { TimeBoxInfo } from '../../../../type/type'; +import { useModal } from '../../../../hooks/useModal'; +import TimerCreationContent from '../TimerCreationContent/TimerCreationContent'; +import DialogModal from '../../../../components/DialogModal/DialogModal'; +import { FaPaste } from 'react-icons/fa'; +interface TimeBoxManageButtonsEventHandlers { + onSubmitEdit?: (updatedInfo: TimeBoxInfo) => void; + onSubmitDelete?: () => void; + onSubmitCopy?: () => void; +} +interface TimeBoxManageButtonsProps { + info: TimeBoxInfo; + prosTeamName: string; + consTeamName: string; + eventHandlers?: TimeBoxManageButtonsEventHandlers; +} + +export default function TimeBoxManageButtons(props: TimeBoxManageButtonsProps) { + const { + openModal: openEditModal, + closeModal: closeEditModal, + ModalWrapper: EditModalWrapper, + } = useModal({ isCloseButtonExist: false }); + const { + openModal: openDeleteModal, + closeModal: closeDeleteModal, + ModalWrapper: DeleteModalWrapper, + } = useModal({ isCloseButtonExist: false }); + const { info, eventHandlers } = props; + const onSubmitEdit = eventHandlers?.onSubmitEdit; + const onSubmitDelete = eventHandlers?.onSubmitDelete; + const onSubmitCopy = eventHandlers?.onSubmitCopy; + + return ( + <> +
+ {onSubmitCopy && ( + + )} + {onSubmitEdit && ( + + )} + {onSubmitDelete && ( + + )} +
+ {onSubmitEdit && ( + + { + onSubmitEdit(newInfo); + }} + onClose={closeEditModal} + /> + + )} + + {onSubmitDelete && ( + + closeDeleteModal() }} + right={{ + text: '삭제', + onClick: () => { + onSubmitDelete(); + closeDeleteModal(); + }, + isBold: true, + }} + > +

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

+
+
+ )} + + ); +} diff --git a/src/page/TableComposition/components/TimeBoxStep/TimeBoxStep.tsx b/src/page/TableComposition/components/TimeBoxStep/TimeBoxStep.tsx index 932206ec..eea272e2 100644 --- a/src/page/TableComposition/components/TimeBoxStep/TimeBoxStep.tsx +++ b/src/page/TableComposition/components/TimeBoxStep/TimeBoxStep.tsx @@ -1,4 +1,4 @@ -import DebatePanel from '../DebatePanel/DebatePanel'; +import TimeBox from '../TimeBox/TimeBox'; import TimerCreationButton from '../TimerCreationButton/TimerCreationButton'; import { useModal } from '../../../../hooks/useModal'; import { useDragAndDrop } from '../../../../hooks/useDragAndDrop'; @@ -49,18 +49,31 @@ export default function TimeBoxStep(props: TimeBoxStepProps) { ); }; + const handleCopy = (indexToCopy: number) => { + onTimeBoxChange((prevData) => { + const toCopy = prevData[indexToCopy]; + if (!toCopy) return prevData; + const copyItem = { ...toCopy }; + return [...prevData, copyItem]; + }); + }; + const isAbledSummitButton = initTimeBox.length !== 0; const renderTimeBoxItem = (info: TimeBoxInfo, index: number) => { return ( - handleSubmitEdit(index, updatedInfo)} prosTeamName={initData.info.prosTeamName} consTeamName={initData.info.consTeamName} - onSubmitDelete={() => handleSubmitDelete(index)} - onMouseDown={() => handleMouseDown(index)} + eventHandlers={{ + onSubmitEdit: (updatedInfo: TimeBoxInfo) => + handleSubmitEdit(index, updatedInfo), + onSubmitDelete: () => handleSubmitDelete(index), + onSubmitCopy: () => handleCopy(index), + onMouseDown: () => handleMouseDown(index), + }} /> ); }; diff --git a/src/page/TableOverviewPage/TableOverview.tsx b/src/page/TableOverviewPage/TableOverview.tsx index e1d6b687..54c490ea 100644 --- a/src/page/TableOverviewPage/TableOverview.tsx +++ b/src/page/TableOverviewPage/TableOverview.tsx @@ -6,7 +6,7 @@ import HeaderTitle from '../../components/HeaderTitle/HeaderTitle'; import { RiEditFill, RiSpeakFill } from 'react-icons/ri'; import usePatchDebateTable from '../../hooks/mutations/usePatchDebateTable'; import { useGetDebateTableData } from '../../hooks/query/useGetDebateTableData'; -import DebatePanel from '../TableComposition/components/DebatePanel/DebatePanel'; +import TimeBox from '../TableComposition/components/TimeBox/TimeBox'; import { useTableShare } from '../../hooks/useTableShare'; import { MdOutlineIosShare } from 'react-icons/md'; import { StanceToString } from '../../type/type'; @@ -56,7 +56,7 @@ export default function TableOverview() {
{data?.table.map((info, index) => ( - Date: Tue, 22 Jul 2025 19:26:21 +0900 Subject: [PATCH 07/31] =?UTF-8?q?[REFACTOR]=20Axios=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EB=B0=8F=20API=20=EC=98=A4=EB=A5=98=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95=20(#324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Polished Axios codes * refactor: Refined ErrorBoundary codes * chore: Added delay function * refactor: Applied suggestions from PR reviews --- src/apis/axiosInstance.ts | 18 ++++++--- src/apis/primitives.ts | 37 +++++++++++++------ .../ErrorBoundary/ErrorBoundary.tsx | 21 +++-------- .../ErrorBoundary/ErrorPage.stories.tsx | 10 ++++- src/components/ErrorBoundary/ErrorPage.tsx | 20 +++++++--- src/constants/errors.ts | 10 +++++ src/util/delay.ts | 3 ++ vite.config.ts | 1 + 8 files changed, 82 insertions(+), 38 deletions(-) create mode 100644 src/constants/errors.ts create mode 100644 src/util/delay.ts diff --git a/src/apis/axiosInstance.ts b/src/apis/axiosInstance.ts index b65260db..89e9bede 100644 --- a/src/apis/axiosInstance.ts +++ b/src/apis/axiosInstance.ts @@ -5,28 +5,34 @@ import { 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) => { 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/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/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/util/delay.ts b/src/util/delay.ts new file mode 100644 index 00000000..c896eb15 --- /dev/null +++ b/src/util/delay.ts @@ -0,0 +1,3 @@ +export default function delay(ms: number) { + return new Promise((res) => setTimeout(res, ms)); +} diff --git a/vite.config.ts b/vite.config.ts index 71ce1c1b..ce11360d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,7 @@ const viteConfig = defineViteConfig(({ mode }) => { ws: true, }, }, + port: 3000, }, }; }); From 8be1711355c23b2d76f6baaa0e8fc9c58935378a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuseon=20Kim=28=EC=8D=AC=EB=8D=B0=EC=9D=B4=29?= <74897720+useon@users.noreply.github.com> Date: Tue, 22 Jul 2025 20:41:51 +0900 Subject: [PATCH 08/31] =?UTF-8?q?[FIX]=20=EB=B2=84=ED=8A=BC=20"=EB=94=B0?= =?UTF-8?q?=EB=8B=A5"=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=ED=98=B8=EC=B6=9C=EB=A1=9C=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=ED=91=9C=EA=B0=80=20=EC=97=AC=EB=9F=AC=20=EA=B0=9C=20=EC=83=9D?= =?UTF-8?q?=EA=B8=B0=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?(#321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 함수 네이밍 카멜 케이스로 수정 * fix: dev환경에서 api호출 오료 해결 * feat: useMutation에 중복 호출 방지 로직 래핑한 커스텀 훅 구현 * fix: 테이블 추가 및 수정시 중복 생성 문제 해결 * refactor: useMutation의 onSettled를 활용하여 중복 방지 로직 개선 * feat: isPending을 통해 수정하기 및 생성하기 버튼 비활성화 * docs: 코드 이해를 돕기 위한 주석 추가 * refactor: 불필요한 코드 삭제 * refactor: usePreventDuplicateMutation의 기본 에러 타입을 DefaultError로 변경 --- src/hooks/mutations/useAddDebateTable.ts | 4 +- .../mutations/usePreventDuplicateMutation.ts | 42 +++++++++++++++++++ src/hooks/mutations/usePutDebateTable.ts | 8 +++- src/main.tsx | 9 ---- .../TableComposition/TableComposition.tsx | 16 +++++-- .../components/TimeBoxStep/TimeBoxStep.tsx | 4 +- .../TableComposition/hook/useTableFrom.tsx | 37 +++++++++------- 7 files changed, 86 insertions(+), 34 deletions(-) create mode 100644 src/hooks/mutations/usePreventDuplicateMutation.ts 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..f26e873c --- /dev/null +++ b/src/hooks/mutations/usePreventDuplicateMutation.ts @@ -0,0 +1,42 @@ +import { useRef, useCallback } from 'react'; +import { type DefaultError, useMutation, type UseMutationOptions, type UseMutationResult } from '@tanstack/react-query'; + +export function usePreventDuplicateMutation( + 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/main.tsx b/src/main.tsx index d2278b0a..a263a75f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,15 +7,6 @@ import router from './routes/routes.tsx'; import './index.css'; import { setupGoogleAnalytics } from './util/setupGoogleAnalytics.tsx'; -// console.log(`# URL = ${import.meta.env.VITE_API_BASE_URL}`); -if (import.meta.env.DEV && !localStorage.getItem('accessToken')) { - localStorage.setItem('accessToken', 'mock-token'); - console.log( - '# Fake access token has set to %{}', - localStorage.getItem('accessToken'), - ); -} - // Functions that calls msw mocking worker if (import.meta.env.VITE_MOCK_API === 'true') { console.log('[msw] Mocking enabled.'); diff --git a/src/page/TableComposition/TableComposition.tsx b/src/page/TableComposition/TableComposition.tsx index c149a713..d1639a3b 100644 --- a/src/page/TableComposition/TableComposition.tsx +++ b/src/page/TableComposition/TableComposition.tsx @@ -39,8 +39,15 @@ export default function TableComposition() { return undefined; }, [mode, data]); - const { formData, updateInfo, updateTable, AddTable, EditTable } = - useTableFrom(currentStep, initData); + const { + formData, + updateInfo, + updateTable, + addTable, + editTable, + isAddingTable, + isModifyingTable, + } = useTableFrom(currentStep, initData); const handleButtonClick = () => { const patchedInfo = { @@ -52,9 +59,9 @@ export default function TableComposition() { updateInfo(patchedInfo); if (mode === 'edit') { - EditTable(tableId); + editTable(tableId); } else { - AddTable(); + addTable(); } }; @@ -77,6 +84,7 @@ export default function TableComposition() { onTimeBoxChange={updateTable} onFinishButtonClick={handleButtonClick} onEditTableInfoButtonClick={() => goToStep('NameAndType')} + isSubmitting={mode === 'edit' ? isModifyingTable : isAddingTable} /> ), }} diff --git a/src/page/TableComposition/components/TimeBoxStep/TimeBoxStep.tsx b/src/page/TableComposition/components/TimeBoxStep/TimeBoxStep.tsx index eea272e2..bcdbb555 100644 --- a/src/page/TableComposition/components/TimeBoxStep/TimeBoxStep.tsx +++ b/src/page/TableComposition/components/TimeBoxStep/TimeBoxStep.tsx @@ -15,6 +15,7 @@ interface TimeBoxStepProps { onFinishButtonClick: () => void; onEditTableInfoButtonClick: () => void; isEdit?: boolean; + isSubmitting?: boolean; } export default function TimeBoxStep(props: TimeBoxStepProps) { @@ -24,6 +25,7 @@ export default function TimeBoxStep(props: TimeBoxStepProps) { onFinishButtonClick, onEditTableInfoButtonClick, isEdit = false, + isSubmitting = false, } = props; const initTimeBox = initData.table; const { openModal, closeModal, ModalWrapper } = useModal(); @@ -124,7 +126,7 @@ export default function TimeBoxStep(props: TimeBoxStepProps) { className={`h-16 w-full ${ isAbledSummitButton ? 'button enabled' : 'button disabled' }`} - disabled={!isAbledSummitButton} + disabled={!isAbledSummitButton || isSubmitting} > {isEdit ? '수정 완료' : '추가하기'} diff --git a/src/page/TableComposition/hook/useTableFrom.tsx b/src/page/TableComposition/hook/useTableFrom.tsx index 1bebeae5..3981e576 100644 --- a/src/page/TableComposition/hook/useTableFrom.tsx +++ b/src/page/TableComposition/hook/useTableFrom.tsx @@ -80,28 +80,31 @@ const useTableFrom = ( }); }; - const { mutate: onAddTable } = useAddDebateTable((tableId) => { - removeValue(); - navigate(`/overview/customize/${tableId}`); - }); - - const { mutate: onModifyTable } = usePutDebateTable((tableId) => { - removeValue(); - if (isGuestFlow()) { - navigate(`/overview/customize/guest`); - } else { + const { mutate: onAddTable, isPending: isAddingTable } = useAddDebateTable( + (tableId) => { + removeValue(); navigate(`/overview/customize/${tableId}`); - } - }); + }, + ); + + const { mutate: onModifyTable, isPending: isModifyingTable } = + usePutDebateTable((tableId) => { + removeValue(); + if (isGuestFlow()) { + navigate(`/overview/customize/guest`); + } else { + navigate(`/overview/customize/${tableId}`); + } + }); - const AddTable = () => { + const addTable = () => { onAddTable({ info: formData.info, table: formData.table as TimeBoxInfo[], }); }; - const EditTable = (tableId: number) => { + const editTable = (tableId: number) => { onModifyTable({ tableId, info: formData.info, @@ -113,8 +116,10 @@ const useTableFrom = ( formData, updateInfo, updateTable, - AddTable, - EditTable, + addTable, + editTable, + isAddingTable, + isModifyingTable, }; }; From e43e20c7d61d46a0eb288ee18ad77349c1de6edd Mon Sep 17 00:00:00 2001 From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> Date: Thu, 24 Jul 2025 14:06:05 +0900 Subject: [PATCH 09/31] =?UTF-8?q?[FIX]=20=ED=83=80=EC=9D=B4=EB=A8=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=88=AB=EC=9E=90=20=ED=82=A4=ED=8C=A8?= =?UTF-8?q?=EB=93=9C=20Enter=EA=B0=80=20=EB=8F=99=EC=9E=91=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#328)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 키 목록에 숫자 키패드 Enter 추가 * fix: 진영 전환이 숫자 키패드 Enter에도 동작하게 수정 * refactor: 키 목록을 리스트에서 집합으로 변경 --- src/page/TimerPage/hooks/useTimerHotkey.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/page/TimerPage/hooks/useTimerHotkey.ts b/src/page/TimerPage/hooks/useTimerHotkey.ts index bc0c8aff..5751239e 100644 --- a/src/page/TimerPage/hooks/useTimerHotkey.ts +++ b/src/page/TimerPage/hooks/useTimerHotkey.ts @@ -7,7 +7,7 @@ import { TimerPageLogics } from './useTimerPageState'; * - ArrowLeft/ArrowRight: 이전/다음 라운드 이동 * - KeyR: 타이머 리셋 * - KeyA/KeyL: 각각 찬/반 진영 타이머 활성화 - * - Enter: 진영 전환 + * - Enter, NumpadEnter: 진영 전환 */ export function useTimerHotkey(state: TimerPageLogics) { const { @@ -32,7 +32,7 @@ export function useTimerHotkey(state: TimerPageLogics) { */ const handleKeyDown = (event: KeyboardEvent) => { // 핫키로 쓸 키 목록 - const keysToDisable = [ + const keysToDisable = new Set([ 'Space', 'ArrowLeft', 'ArrowRight', @@ -40,10 +40,11 @@ export function useTimerHotkey(state: TimerPageLogics) { 'KeyA', 'KeyL', 'Enter', - ]; + 'NumpadEnter', + ]); // 핫키 입력시, 기본 동작(스크롤, 폼 전송 등) 막음 - if (keysToDisable.includes(event.code)) { + if (keysToDisable.has(event.code)) { event.preventDefault(); } // 입력 포커스 해제(특히 input/select 사용 중일 때) @@ -112,6 +113,7 @@ export function useTimerHotkey(state: TimerPageLogics) { } break; case 'Enter': + case 'NumpadEnter': // 진영 전환 switchCamp(); break; From faf0b2cd4dd89aa4fa70078f6394f696bdf2883e Mon Sep 17 00:00:00 2001 From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> Date: Thu, 24 Jul 2025 14:52:00 +0900 Subject: [PATCH 10/31] =?UTF-8?q?[DESIGN]=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B4=80=EB=A0=A8=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EC=84=A0=20(#325)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * design: Enhanced some components to support `disabled` * design: Enhanced header components to support skeleton UI * design: Moved async-related components from Windows repo * design: Fixed warning "Invalid Tailwind CSS classnames order" * refactor: Applied suggestions from PR reviews * refactor: Corrected file location * chore: 체크박스 비활성화 시 포인터 안 보이게 변경 --- .../ClearableInput/ClearableInput.stories.tsx | 9 +++++++ .../ClearableInput/ClearableInput.tsx | 10 +++++-- .../ErrorIndicator/ErrorIndicator.stories.tsx | 25 +++++++++++++++++ .../ErrorIndicator/ErrorIndicator.tsx | 27 +++++++++++++++++++ .../HeaderTableInfo/HeaderTableInfo.tsx | 23 ++++++++++++---- src/components/HeaderTitle/HeaderTitle.tsx | 21 ++++++++++++--- .../LabeledCheckBox.stories.tsx} | 19 +++++++++---- .../LabeledCheckBox.tsx} | 24 ++++++++++------- .../LoadingIndicator.stories.tsx | 18 +++++++++++++ .../LoadingIndicator/LoadingIndicator.tsx | 13 +++++++++ src/components/Skeleton/Skeleton.stories.tsx | 16 +++++++++++ src/components/Skeleton/Skeleton.tsx | 20 ++++++++++++++ .../TableNameAndType/TableNameAndType.tsx | 16 +++++------ .../TimerCreationContent.tsx | 4 +-- .../TimerPage/components/FirstUseToolTip.tsx | 6 ++--- 15 files changed, 213 insertions(+), 38 deletions(-) create mode 100644 src/components/ErrorIndicator/ErrorIndicator.stories.tsx create mode 100644 src/components/ErrorIndicator/ErrorIndicator.tsx rename src/components/{LabledCheckBox/LabeledCheckbox.stories.tsx => LabeledCheckBox/LabeledCheckBox.stories.tsx} (61%) rename src/components/{LabledCheckBox/LabeledCheckbox.tsx => LabeledCheckBox/LabeledCheckBox.tsx} (62%) create mode 100644 src/components/LoadingIndicator/LoadingIndicator.stories.tsx create mode 100644 src/components/LoadingIndicator/LoadingIndicator.tsx create mode 100644 src/components/Skeleton/Skeleton.stories.tsx create mode 100644 src/components/Skeleton/Skeleton.tsx 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..bdffcc46 100644 --- a/src/components/ClearableInput/ClearableInput.tsx +++ b/src/components/ClearableInput/ClearableInput.tsx @@ -3,12 +3,14 @@ import { IoMdCloseCircle } from 'react-icons/io'; interface ClearableInputProps extends InputHTMLAttributes { value: string; + disabled?: boolean; onClear: () => void; } export default function ClearableInput({ value, onClear, + disabled = false, ...rest }: ClearableInputProps) { return ( @@ -16,9 +18,13 @@ export default function ClearableInput({ - {value && ( + {value && !disabled && ( + )} +
+ ); +} diff --git a/src/components/HeaderTableInfo/HeaderTableInfo.tsx b/src/components/HeaderTableInfo/HeaderTableInfo.tsx index e69dcf95..8b41f5aa 100644 --- a/src/components/HeaderTableInfo/HeaderTableInfo.tsx +++ b/src/components/HeaderTableInfo/HeaderTableInfo.tsx @@ -1,15 +1,28 @@ +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..2bf999f2 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/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 62% rename from src/components/LabledCheckBox/LabeledCheckbox.tsx rename to src/components/LabeledCheckBox/LabeledCheckBox.tsx index 7b80bfc4..5438acea 100644 --- a/src/components/LabledCheckBox/LabeledCheckbox.tsx +++ b/src/components/LabeledCheckBox/LabeledCheckBox.tsx @@ -1,27 +1,34 @@ 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 ( 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/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/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx b/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx index 246b02f8..7fcfc651 100644 --- a/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx +++ b/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx @@ -1,6 +1,6 @@ import ClearableInput from '../../../../components/ClearableInput/ClearableInput'; import HeaderTitle from '../../../../components/HeaderTitle/HeaderTitle'; -import LabeledCheckbox from '../../../../components/LabledCheckBox/LabeledCheckbox'; +import LabeledCheckBox from '../../../../components/LabeledCheckBox/LabeledCheckBox'; import DefaultLayout from '../../../../layout/defaultLayout/DefaultLayout'; import { DebateInfo, StanceToString } from '../../../../type/type'; @@ -51,8 +51,8 @@ export default function TableNameAndType(props: TableNameAndTypeProps) { -
-