+
스페이스 바로 타이머를 시작 및 일시정지
R 키로 타이머 초기화
좌우 방향키로 이전/다음 차례로 이동
From 32bbe17bb5baa9973bc82833dc291db9690464b9 Mon Sep 17 00:00:00 2001
From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com>
Date: Sun, 27 Jul 2025 12:44:28 +0900
Subject: [PATCH 11/31] =?UTF-8?q?[FIX]=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?=
=?UTF-8?q?=EC=A2=8C=EC=9A=B0=20=EB=B0=A9=ED=96=A5=ED=82=A4=20=EC=A1=B0?=
=?UTF-8?q?=EC=9E=91=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20(#333)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: 좌우 방향키 비활성화
* chore: 관련 주석 제거
---
src/page/TimerPage/hooks/useTimerHotkey.ts | 11 -----------
1 file changed, 11 deletions(-)
diff --git a/src/page/TimerPage/hooks/useTimerHotkey.ts b/src/page/TimerPage/hooks/useTimerHotkey.ts
index 5751239e..6c2c2098 100644
--- a/src/page/TimerPage/hooks/useTimerHotkey.ts
+++ b/src/page/TimerPage/hooks/useTimerHotkey.ts
@@ -4,7 +4,6 @@ import { TimerPageLogics } from './useTimerPageState';
/**
* 타이머 페이지에서 키보드 단축키(핫키) 기능을 제공하는 커스텀 훅입니다.
* - Space: 타이머 시작/일시정지
- * - ArrowLeft/ArrowRight: 이전/다음 라운드 이동
* - KeyR: 타이머 리셋
* - KeyA/KeyL: 각각 찬/반 진영 타이머 활성화
* - Enter, NumpadEnter: 진영 전환
@@ -34,8 +33,6 @@ export function useTimerHotkey(state: TimerPageLogics) {
// 핫키로 쓸 키 목록
const keysToDisable = new Set([
'Space',
- 'ArrowLeft',
- 'ArrowRight',
'KeyR',
'KeyA',
'KeyL',
@@ -78,14 +75,6 @@ export function useTimerHotkey(state: TimerPageLogics) {
}
}
break;
- case 'ArrowLeft':
- // 이전 라운드 이동
- goToOtherItem(true);
- break;
- case 'ArrowRight':
- // 다음 라운드 이동
- goToOtherItem(false);
- break;
case 'KeyR':
// 타이머 리셋
if (boxType === 'NORMAL') {
From bd32663c614a11c3791a7e65abaede3506d5f950 Mon Sep 17 00:00:00 2001
From: jaeml06
Date: Fri, 1 Aug 2025 16:50:47 +0900
Subject: [PATCH 12/31] =?UTF-8?q?[FEAT]=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?=
=?UTF-8?q?=ED=83=80=EC=A2=85=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=20?=
=?UTF-8?q?(#326)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: 커스텀 종소리를 위한 타입 추가
* feat: 커스텀 종소리 input UI추가
* feat: 커스텀 종소리 타종 시점 select 추가
* feat: 커스텀 벨소리에 맞게 useBellSound내부 로직 변경
* refactor: bell 필드 추가에 따른 데이터 수정
* refactor: 전연적인 벨소리 제거에 따른 변경사항 반영
* fix: 0분 0초에도 타종 설정이 가능하도록 변경
* feat: 타이머 새로고침시, 타종이 되지 않는 문제 수정
* refactor: 불필요한 key값 삭제
* refactor: bellInput 초기 상태값 상수화
* refactor: bell 상태 변경함수 분리
* refactor: 종소리 유형을 문자열로 바꾸는 타입 컨버터 추가
* feat: autio.play에러시, console추가
* refactor: 수정사항에 맞게 주성 수정
* fix: 불필요한 import 삭제
* fix: count가 음수일 경우, formatting처리 추가
* fix: 이전 타임 박스 상태에 따른, 타종 상태값 추가
---
src/apis/apis/debateTable.ts | 4 -
.../ShareModal/ShareModal.stories.tsx | 5 +-
src/constants/sample_table.ts | 9 +-
src/mocks/handlers/customize.ts | 19 +-
.../TableNameAndType/TableNameAndType.tsx | 21 --
.../components/TimeBox/TimeBox.stories.tsx | 5 +
.../TimeBoxManageButtons.stories.tsx | 2 +
.../TimerCreationContent.tsx | 208 +++++++++++++++-
.../TableComposition/hook/useTableFrom.tsx | 4 -
src/page/TimerPage/TimerPage.tsx | 6 +-
src/page/TimerPage/hooks/useBellSound.ts | 222 +++---------------
src/page/TimerPage/hooks/useTimerPageState.ts | 14 +-
.../TimerPage/stories/NormalTimer.stories.tsx | 6 +
.../stories/NormalTimerTestPage.stories.tsx | 1 +
src/type/type.ts | 16 +-
src/util/arrayEncoding.test.ts | 9 +-
src/util/formatting.ts | 5 +-
17 files changed, 303 insertions(+), 253 deletions(-)
diff --git a/src/apis/apis/debateTable.ts b/src/apis/apis/debateTable.ts
index dd038843..4a7ec83e 100644
--- a/src/apis/apis/debateTable.ts
+++ b/src/apis/apis/debateTable.ts
@@ -56,8 +56,6 @@ export async function postDebateTableData({
agenda: info.agenda,
prosTeamName: info.prosTeamName,
consTeamName: info.consTeamName,
- warningBell: info.warningBell,
- finishBell: info.finishBell,
},
table,
},
@@ -82,8 +80,6 @@ export async function putDebateTableData({
agenda: info.agenda,
prosTeamName: info.prosTeamName,
consTeamName: info.consTeamName,
- warningBell: info.warningBell,
- finishBell: info.finishBell,
},
table,
},
diff --git a/src/components/ShareModal/ShareModal.stories.tsx b/src/components/ShareModal/ShareModal.stories.tsx
index b358280c..dc13dcc3 100644
--- a/src/components/ShareModal/ShareModal.stories.tsx
+++ b/src/components/ShareModal/ShareModal.stories.tsx
@@ -17,8 +17,6 @@ const shareUrl = createTableShareUrl('https://localhost:6006', {
agenda: '토론 주제',
prosTeamName: '짜장',
consTeamName: '짬뽕',
- finishBell: true,
- warningBell: false,
name: '테이블 이름',
},
table: [
@@ -30,6 +28,7 @@ const shareUrl = createTableShareUrl('https://localhost:6006', {
time: 60,
timePerSpeaking: null,
timePerTeam: null,
+ bell: null,
},
{
stance: 'CONS',
@@ -39,6 +38,7 @@ const shareUrl = createTableShareUrl('https://localhost:6006', {
time: 60,
timePerSpeaking: null,
timePerTeam: null,
+ bell: null,
},
{
stance: 'NEUTRAL',
@@ -48,6 +48,7 @@ const shareUrl = createTableShareUrl('https://localhost:6006', {
time: null,
timePerSpeaking: 60,
timePerTeam: 120,
+ bell: null,
},
],
});
diff --git a/src/constants/sample_table.ts b/src/constants/sample_table.ts
index 7e72ae87..cf7ad335 100644
--- a/src/constants/sample_table.ts
+++ b/src/constants/sample_table.ts
@@ -6,8 +6,6 @@ export const SAMPLE_TABLE_DATA: DebateTableData = {
name: '나의 시간표',
prosTeamName: '찬성',
consTeamName: '반대',
- finishBell: true,
- warningBell: false,
},
table: [
{
@@ -18,6 +16,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = {
timePerSpeaking: null,
timePerTeam: null,
speaker: '1번',
+ bell: [{ type: 'BEFORE_END', time: 0, count: 2 }],
},
{
boxType: 'NORMAL',
@@ -27,6 +26,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = {
timePerSpeaking: null,
timePerTeam: null,
speaker: '1번',
+ bell: [{ type: 'BEFORE_END', time: 0, count: 2 }],
},
{
boxType: 'NORMAL',
@@ -36,6 +36,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = {
timePerSpeaking: null,
timePerTeam: null,
speaker: '2번',
+ bell: [{ type: 'BEFORE_END', time: 0, count: 2 }],
},
{
boxType: 'NORMAL',
@@ -45,6 +46,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = {
timePerSpeaking: null,
timePerTeam: null,
speaker: '2번',
+ bell: [{ type: 'BEFORE_END', time: 0, count: 2 }],
},
{
boxType: 'TIME_BASED',
@@ -54,6 +56,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = {
timePerSpeaking: 120,
timePerTeam: 420,
speaker: '2번',
+ bell: [{ type: 'BEFORE_END', time: 0, count: 2 }],
},
{
boxType: 'NORMAL',
@@ -63,6 +66,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = {
timePerSpeaking: null,
timePerTeam: null,
speaker: '3번',
+ bell: [{ type: 'BEFORE_END', time: 0, count: 2 }],
},
{
boxType: 'NORMAL',
@@ -72,6 +76,7 @@ export const SAMPLE_TABLE_DATA: DebateTableData = {
timePerSpeaking: null,
timePerTeam: null,
speaker: '3번',
+ bell: [{ type: 'BEFORE_END', time: 0, count: 2 }],
},
],
} as const;
diff --git a/src/mocks/handlers/customize.ts b/src/mocks/handlers/customize.ts
index dab7c434..c0172fc7 100644
--- a/src/mocks/handlers/customize.ts
+++ b/src/mocks/handlers/customize.ts
@@ -19,8 +19,6 @@ export const customizeHandlers = [
agenda: '토론 주제',
prosTeamName: '찬성',
consTeamName: '반대',
- warningBell: true,
- finishBell: true,
},
table: [
{
@@ -49,6 +47,10 @@ export const customizeHandlers = [
timePerTeam: null,
timePerSpeaking: null,
speaker: '발언자 1',
+ bell: [
+ { type: 'BEFORE_END', time: 0, count: 2 },
+ { type: 'AFTER_START', time: 5, count: 1 },
+ ],
},
{
stance: 'PROS',
@@ -58,6 +60,10 @@ export const customizeHandlers = [
timePerTeam: null,
timePerSpeaking: null,
speaker: '발언자 1',
+ bell: [
+ { type: 'BEFORE_END', time: 0, count: 2 },
+ { type: 'AFTER_START', time: 7, count: 3 },
+ ],
},
{
stance: 'NEUTRAL',
@@ -67,6 +73,7 @@ export const customizeHandlers = [
timePerTeam: null,
timePerSpeaking: null,
speaker: null,
+ bell: [{ type: 'BEFORE_END', time: 0, count: 2 }],
},
{
stance: 'CONS',
@@ -76,6 +83,7 @@ export const customizeHandlers = [
timePerTeam: null,
timePerSpeaking: null,
speaker: '발언자 2',
+ bell: [{ type: 'BEFORE_END', time: 0, count: 2 }],
},
{
stance: 'PROS',
@@ -85,6 +93,7 @@ export const customizeHandlers = [
timePerTeam: null,
timePerSpeaking: null,
speaker: '발언자 2',
+ bell: [{ type: 'BEFORE_END', time: 0, count: 2 }],
},
],
});
@@ -105,8 +114,6 @@ export const customizeHandlers = [
agenda: '토론 주제',
prosTeamName: '찬성',
consTeamName: '반대',
- warningBell: true,
- finishBell: true,
},
table: [
{
@@ -182,8 +189,6 @@ export const customizeHandlers = [
agenda: '토론 주제',
prosTeamName: '찬성',
consTeamName: '반대',
- warningBell: true,
- finishBell: true,
},
table: [
{
@@ -270,8 +275,6 @@ export const customizeHandlers = [
agenda: '토론 주제',
prosTeamName: '찬성',
consTeamName: '반대',
- warningBell: true,
- finishBell: true,
},
table: [
{
diff --git a/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx b/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx
index 7fcfc651..fe8fc9c7 100644
--- a/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx
+++ b/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx
@@ -1,6 +1,5 @@
import ClearableInput from '../../../../components/ClearableInput/ClearableInput';
import HeaderTitle from '../../../../components/HeaderTitle/HeaderTitle';
-import LabeledCheckBox from '../../../../components/LabeledCheckBox/LabeledCheckBox';
import DefaultLayout from '../../../../layout/defaultLayout/DefaultLayout';
import { DebateInfo, StanceToString } from '../../../../type/type';
@@ -101,26 +100,6 @@ export default function TableNameAndType(props: TableNameAndTypeProps) {
/>
>
-
-
-
-
- handleFieldChange('warningBell', e.target.checked)
- }
- />
-
- handleFieldChange('finishBell', e.target.checked)
- }
- />
-
diff --git a/src/page/TableComposition/components/TimeBox/TimeBox.stories.tsx b/src/page/TableComposition/components/TimeBox/TimeBox.stories.tsx
index 9218cdb5..3b97c0ea 100644
--- a/src/page/TableComposition/components/TimeBox/TimeBox.stories.tsx
+++ b/src/page/TableComposition/components/TimeBox/TimeBox.stories.tsx
@@ -14,6 +14,7 @@ const meta: Meta
= {
timePerTeam: null,
timePerSpeaking: null,
speaker: '1번',
+ bell: [],
},
},
};
@@ -32,6 +33,7 @@ export const ProsOpening: Story = {
timePerTeam: null,
timePerSpeaking: null,
speaker: '1번',
+ bell: [],
},
},
};
@@ -47,6 +49,7 @@ export const ConsRebuttal: Story = {
timePerTeam: null,
timePerSpeaking: null,
speaker: '1번',
+ bell: [],
},
},
};
@@ -62,6 +65,7 @@ export const NeutralTimeout: Story = {
timePerTeam: null,
timePerSpeaking: null,
speaker: '1번',
+ bell: [],
},
},
};
@@ -77,6 +81,7 @@ export const NeutralCustom: Story = {
timePerTeam: 120,
timePerSpeaking: 60,
speaker: null,
+ bell: [],
},
},
};
diff --git a/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.stories.tsx b/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.stories.tsx
index 118679fb..37054081 100644
--- a/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.stories.tsx
+++ b/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.stories.tsx
@@ -20,6 +20,7 @@ const normalTimeBoxInfo: TimeBoxInfo = {
speaker: '김철수 토론자',
timePerSpeaking: null,
timePerTeam: null,
+ bell: [],
};
export const NormalTimeBox: Story = {
@@ -41,6 +42,7 @@ const timeBasedTimeBoxInfo: TimeBoxInfo = {
speaker: null,
timePerSpeaking: 120,
timePerTeam: 480,
+ bell: [],
};
export const TimeBasedTimeBox: Story = {
diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx
index 6d74c76d..010933d7 100644
--- a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx
+++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx
@@ -1,5 +1,12 @@
import { useState, useEffect, useMemo } from 'react';
-import { TimeBoxInfo, Stance, TimeBoxType } from '../../../../type/type';
+import {
+ TimeBoxInfo,
+ Stance,
+ TimeBoxType,
+ BellType,
+ BellConfig,
+ BellTypeToString,
+} from '../../../../type/type';
import { Formatting } from '../../../../util/formatting';
import normalTimer from '../../../../assets/timer/normal_timer.png';
import timeBasedTimer from '../../../../assets/timer/timebased_timer.png';
@@ -15,6 +22,13 @@ interface TimerCreationContentProps {
onClose: () => void;
}
+interface BellInputConfig {
+ type: BellType;
+ min: number;
+ sec: number;
+ count: number;
+}
+
export default function TimerCreationContent({
beforeData,
initData,
@@ -41,6 +55,15 @@ export default function TimerCreationContent({
[],
);
+ const initBellInput: BellInputConfig = useMemo(() => {
+ return {
+ type: 'BEFORE_END', // 기본값: 종료 전
+ min: 0,
+ sec: 0,
+ count: 1,
+ };
+ }, []);
+
const initSpeechType =
beforeData?.speechType ?? initData?.speechType ?? '입론';
const [speechType, setSpeechType] = useState(initSpeechType);
@@ -80,6 +103,46 @@ export default function TimerCreationContent({
beforeData?.speaker ?? initData?.speaker ?? '',
);
+ // 종소리 input 상태
+ const [bellInput, setBellInput] = useState(initBellInput);
+
+ // bell의 time(초)은: before => 양수, after => 음수로 변환
+ const getInitialBells = (): BellInputConfig[] => {
+ if (beforeData?.bell && beforeData.bell.length >= 0) {
+ return beforeData.bell.map(bellConfigToBellInputConfig);
+ }
+ if (initData) {
+ const initBell = initData.bell === null ? [] : initData.bell;
+ return initBell.map(bellConfigToBellInputConfig);
+ }
+ return [
+ { type: 'BEFORE_END', min: 0, sec: 30, count: 1 },
+ { type: 'BEFORE_END', min: 0, sec: 0, count: 2 },
+ ];
+ };
+ const [bells, setBells] = useState(getInitialBells);
+ const isBellAddEnabled =
+ (bellInput.min >= 0 || bellInput.sec >= 0) &&
+ bellInput.count >= 1 &&
+ bellInput.count <= 3;
+
+ const handleAddBell = () => {
+ if (!isBellAddEnabled) return;
+ setBells([
+ ...bells,
+ {
+ type: bellInput.type,
+ min: bellInput.min,
+ sec: bellInput.sec,
+ count: bellInput.count,
+ },
+ ]);
+ setBellInput(initBellInput);
+ };
+
+ const handleDeleteBell = (idx: number) => {
+ setBells(bells.filter((_, i) => i !== idx));
+ };
const handleSubmit = () => {
const totalTime = minutes * 60 + seconds;
const totalTimePerTeam = teamMinutes * 60 + teamSeconds;
@@ -110,6 +173,7 @@ export default function TimerCreationContent({
return;
}
+ const bell = isNormalTimer ? bells.map(bellInputConfigToBellConfig) : null;
if (boxType === 'NORMAL') {
onSubmit({
stance,
@@ -119,6 +183,7 @@ export default function TimerCreationContent({
timePerTeam: null,
timePerSpeaking: null,
speaker,
+ bell,
});
} else {
// TIME_BASED
@@ -130,6 +195,7 @@ export default function TimerCreationContent({
timePerTeam: totalTimePerTeam,
timePerSpeaking: useSpeakerTime ? totalTimePerSpeaking : null,
speaker: null,
+ bell: null,
});
}
onClose();
@@ -168,8 +234,8 @@ export default function TimerCreationContent({
return (
-
-
+
+
{/** 타이머 이미지 */}
{isNormalTimer ? (
![]()
-
+
{/** boxType 라디오버튼 */}
)}
+
+ {isNormalTimer && (
+
+
+ {/* 입력부 */}
+
+ {/* direction 드롭다운 */}
+
+
+ setBellInput((prev) => ({
+ ...prev,
+ min: Math.max(0, Math.min(59, Number(e.target.value))),
+ }))
+ }
+ placeholder="분"
+ />
+ 분
+
+ setBellInput((prev) => ({
+ ...prev,
+ sec: Math.max(0, Math.min(59, Number(e.target.value))),
+ }))
+ }
+ placeholder="초"
+ />
+ 초
+
+ setBellInput((prev) => ({
+ ...prev,
+ count: Math.max(1, Math.min(3, Number(e.target.value))),
+ }))
+ }
+ placeholder="횟수"
+ />
+
+ 🔔
+
+ x {bellInput.count}
+
+
+ {/* 벨 리스트 */}
+
+ {bells.map((bell, idx) => (
+
+
+
+ {BellTypeToString[bell.type]}
+
+
+ {bell.min}분 {bell.sec}초
+
+
+ 🔔
+
+ x{bell.count}
+
+
+
+ ))}
+
+
+ )}
);
}
+
+function bellInputConfigToBellConfig(input: BellInputConfig): BellConfig {
+ let time = input.min * 60 + input.sec;
+ if (input.type === 'AFTER_END') time = -time;
+ return {
+ time,
+ count: input.count,
+ type: input.type,
+ };
+}
+
+function bellConfigToBellInputConfig(data: BellConfig): BellInputConfig {
+ const { type, time, count } = data;
+ const { minutes, seconds } = Formatting.formatSecondsToMinutes(time);
+ const converted = { type, min: minutes, sec: seconds, count };
+ return converted;
+}
diff --git a/src/page/TableComposition/hook/useTableFrom.tsx b/src/page/TableComposition/hook/useTableFrom.tsx
index 3981e576..9a5655d5 100644
--- a/src/page/TableComposition/hook/useTableFrom.tsx
+++ b/src/page/TableComposition/hook/useTableFrom.tsx
@@ -24,8 +24,6 @@ const useTableFrom = (
agenda: '',
prosTeamName: '',
consTeamName: '',
- warningBell: true,
- finishBell: true,
},
table: [],
},
@@ -54,8 +52,6 @@ const useTableFrom = (
const debateInfo: DebateInfo = {
name: newInfo.name,
agenda: newInfo.agenda,
- warningBell: newInfo.warningBell,
- finishBell: newInfo.finishBell,
prosTeamName: newInfo.prosTeamName,
consTeamName: newInfo.consTeamName,
};
diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx
index 8cea6d76..a15e460e 100644
--- a/src/page/TimerPage/TimerPage.tsx
+++ b/src/page/TimerPage/TimerPage.tsx
@@ -28,8 +28,7 @@ export default function TimerPage() {
const state = useTimerPageState(tableId);
useTimerHotkey(state);
- const { warningBellRef, finishBellRef, data, bg, index, goToOtherItem } =
- state;
+ const { data, bg, index, goToOtherItem } = state;
if (!data) {
return null;
@@ -37,9 +36,6 @@ export default function TimerPage() {
return (
<>
-
-
-
diff --git a/src/page/TimerPage/hooks/useBellSound.ts b/src/page/TimerPage/hooks/useBellSound.ts
index 551f2c07..00ec7c83 100644
--- a/src/page/TimerPage/hooks/useBellSound.ts
+++ b/src/page/TimerPage/hooks/useBellSound.ts
@@ -1,195 +1,47 @@
-import { useEffect, useRef, useState } from 'react';
-import { TimeBasedTimerLogics } from './useTimeBasedTimer';
+import { useEffect } from 'react';
import { NormalTimerLogics } from './useNormalTimer';
+import { BellConfig } from '../../../type/type';
interface UseBellSoundProps {
- timer1: TimeBasedTimerLogics;
- timer2: TimeBasedTimerLogics;
normalTimer: NormalTimerLogics;
- isWarningBell?: boolean;
- isFinishBell?: boolean;
+ bells?: BellConfig[] | null;
}
-/**
- * 토론 타이머에서 경고/종료 벨 사운드를 자동 재생해주는 커스텀 훅
- * - 타이머 상태 변화(30초, 0초 등)에 따라 지정된 벨 사운드가 한 번씩 재생됨
- */
-export function useBellSound({
- timer1,
- timer2,
- normalTimer,
- isWarningBell = false,
- isFinishBell = false,
-}: UseBellSoundProps) {
- // 오디오 태그를 참조하기 위한 ref (컴포넌트에서 로 연결)
- const warningBellRef = useRef(null);
- const finishBellRef = useRef(null);
-
- // 이전 타이머 상태를 기억하여 변화 시점을 감지하기 위한 ref(30초로 변경되는 시점 종소리를 위한)
- const prevTimer1Ref = useRef({
- speakingTimer: null as number | null,
- totalTimer: null as number | null,
- });
- const prevTimer2Ref = useRef({
- speakingTimer: null as number | null,
- totalTimer: null as number | null,
- });
- const prevNormalTimerRef = useRef(null);
-
- const [isWarningBellOn, setWarningBell] = useState(isWarningBell);
- const [isFinishBellOn, setFinishBell] = useState(isFinishBell);
- const warningTime = 30;
-
- // 30초 경고음 진입 조건 함수
- function timerJustReached(
- prevTime: number | null,
- currentTime: number | null,
- defaultTime: number | null,
- ) {
- return (
- prevTime !== null &&
- prevTime > warningTime &&
- currentTime === warningTime &&
- defaultTime !== warningTime
- );
- }
-
- // 자유토론(TimeBased) 타이머 경고음 조건 체크
- function checkTimerWarningTime(
- timer: TimeBasedTimerLogics,
- prevTimer: { speakingTimer: number | null; totalTimer: number | null },
- ) {
- return (
- timer.isRunning &&
- (timerJustReached(
- prevTimer.speakingTimer,
- timer.speakingTimer,
- timer.defaultTime.defaultSpeakingTimer,
- ) ||
- (timer.speakingTimer === null &&
- timerJustReached(
- prevTimer.totalTimer,
- timer.totalTimer,
- timer.defaultTime.defaultTotalTimer,
- )))
- );
- }
-
- // 일반타이머 경고음 조건 체크
- function checkNormalTimerWarningTime(
- timer: NormalTimerLogics,
- prevNormalTimer: number | null,
- ) {
- return (
- timer.isRunning &&
- prevNormalTimer !== null &&
- prevNormalTimer > warningTime &&
- timer.timer === warningTime &&
- timer.defaultTimer !== warningTime
- );
- }
-
- // 종료음 조건 체크
- function checkTimerFinished(timer: TimeBasedTimerLogics) {
- return (
- timer.isRunning && (timer.speakingTimer === 0 || timer.totalTimer === 0)
- );
- }
- function checkNormalTimerFinished(timer: NormalTimerLogics) {
- return timer.isRunning && timer.timer === 0;
- }
-
- // 이전값(ref) 최신화 함수
- function updatePrevTimers() {
- prevTimer1Ref.current = {
- speakingTimer: timer1.speakingTimer,
- totalTimer: timer1.totalTimer,
- };
- prevTimer2Ref.current = {
- speakingTimer: timer2.speakingTimer,
- totalTimer: timer2.totalTimer,
- };
- prevNormalTimerRef.current = normalTimer.timer;
- }
-
- // 벨 재생 함수
- function playWarningBell() {
- if (warningBellRef.current) warningBellRef.current.play();
- }
- function playFinishBell() {
- if (finishBellRef.current) finishBellRef.current.play();
- }
-
- // ===== useEffect 메인 =====
- useEffect(() => {
- const isAnyTimerRunning =
- timer1.isRunning || timer2.isRunning || normalTimer.isRunning;
-
- const isTimer1WarningTime = checkTimerWarningTime(
- timer1,
- prevTimer1Ref.current,
- );
- const isTimer2WarningTime = checkTimerWarningTime(
- timer2,
- prevTimer2Ref.current,
- );
- const isNormalWarningTime = checkNormalTimerWarningTime(
- normalTimer,
- prevNormalTimerRef.current,
- );
-
- // ------ 경고음(warningBell) ------
- if (
- isAnyTimerRunning &&
- isWarningBellOn &&
- (isTimer1WarningTime || isTimer2WarningTime || isNormalWarningTime)
- ) {
- playWarningBell();
+export function useBellSound({ normalTimer, bells }: UseBellSoundProps) {
+ // 종소리 여러 번 - 새로운 Audio로 재생
+ function playBell(count: number) {
+ for (let i = 0; i < count; i++) {
+ setTimeout(() => {
+ const audio = new Audio('/sounds/bell-warning.mp3');
+ audio.play().catch((err) => {
+ console.warn('audio.play() 실패:', err);
+ });
+ }, i * 500);
}
+ }
- // ------ 종료음(finishBell) ------
- const isTimer1Finished = checkTimerFinished(timer1);
- const isTimer2Finished = checkTimerFinished(timer2);
- const isNormalTimerFinhed = checkNormalTimerFinished(normalTimer);
- const isAnyTimerFinished =
- isTimer1Finished || isTimer2Finished || isNormalTimerFinhed;
-
- if (isAnyTimerRunning && isFinishBellOn && isAnyTimerFinished) {
- playFinishBell();
- }
-
- updatePrevTimers();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [
- isWarningBellOn,
- isFinishBellOn,
- timer1.isRunning,
- timer2.isRunning,
- normalTimer.isRunning,
- timer1.speakingTimer,
- timer1.totalTimer,
- timer1.defaultTime.defaultTotalTimer,
- timer1.defaultTime.defaultSpeakingTimer,
- timer2.speakingTimer,
- timer2.totalTimer,
- timer2.defaultTime.defaultTotalTimer,
- timer2.defaultTime.defaultSpeakingTimer,
- normalTimer.timer,
- normalTimer.defaultTimer,
- ]);
-
- // 외부 상태/props에서 벨 활성화 여부를 받아옴 (처음 or 값 변경 시 반영)
useEffect(() => {
- setWarningBell(isWarningBell);
- setFinishBell(isFinishBell);
- }, [isWarningBell, isFinishBell]);
-
- return {
- warningBellRef,
- finishBellRef,
- isWarningBellOn,
- setWarningBell,
- isFinishBellOn,
- setFinishBell,
- };
+ const timerVal = normalTimer.timer;
+ const defaultTime = normalTimer.defaultTimer;
+ if (!bells || timerVal == null) return;
+
+ bells.forEach((bell) => {
+ let trigger = false;
+
+ if (bell.type === 'BEFORE_END') {
+ // timerVal이 남은 시간 === bell.time일 때
+ trigger = timerVal === bell.time;
+ } else if (bell.type === 'AFTER_END') {
+ // timerVal이 0보다 작아진 후, timerVal === bell.time (bell.time < 0)
+ trigger = timerVal === bell.time;
+ } else if (bell.type === 'AFTER_START') {
+ // 시작 후 N초: timerVal === defaultTime - bell.time
+ trigger = timerVal === defaultTime - bell.time;
+ }
+
+ if (trigger) {
+ playBell(bell.count);
+ }
+ });
+ }, [normalTimer.timer, bells, normalTimer.defaultTimer]);
}
diff --git a/src/page/TimerPage/hooks/useTimerPageState.ts b/src/page/TimerPage/hooks/useTimerPageState.ts
index 9413cd98..013b395f 100644
--- a/src/page/TimerPage/hooks/useTimerPageState.ts
+++ b/src/page/TimerPage/hooks/useTimerPageState.ts
@@ -1,6 +1,5 @@
import {
Dispatch,
- RefObject,
SetStateAction,
useCallback,
useEffect,
@@ -44,13 +43,10 @@ export function useTimerPageState(tableId: number): TimerPageLogics {
const [prosConsSelected, setProsConsSelected] =
useState('PROS');
- // 벨 사운드 관련 훅 (벨 ref 제공)
- const { warningBellRef, finishBellRef } = useBellSound({
- timer1,
- timer2,
+ // 벨 사운드 관련 훅
+ useBellSound({
normalTimer,
- isWarningBell: data?.info.warningBell,
- isFinishBell: data?.info.finishBell,
+ bells: data?.table[index].bell,
});
const { bg, setBg } = useTimerBackground({
@@ -217,8 +213,6 @@ export function useTimerPageState(tableId: number): TimerPageLogics {
]);
return {
- warningBellRef,
- finishBellRef,
data,
bg,
setBg,
@@ -238,8 +232,6 @@ export function useTimerPageState(tableId: number): TimerPageLogics {
}
export interface TimerPageLogics {
- warningBellRef: RefObject;
- finishBellRef: RefObject;
data: DebateTableData | undefined;
bg: TimerBGState;
setBg: Dispatch>;
diff --git a/src/page/TimerPage/stories/NormalTimer.stories.tsx b/src/page/TimerPage/stories/NormalTimer.stories.tsx
index 10a04a4a..bebe75d9 100644
--- a/src/page/TimerPage/stories/NormalTimer.stories.tsx
+++ b/src/page/TimerPage/stories/NormalTimer.stories.tsx
@@ -31,6 +31,7 @@ export const OnPros: Story = {
timePerTeam: null,
timePerSpeaking: null,
speaker: '발언자',
+ bell: null,
},
},
};
@@ -55,6 +56,7 @@ export const OnCons: Story = {
timePerTeam: null,
timePerSpeaking: null,
speaker: '발언자',
+ bell: null,
},
},
};
@@ -79,6 +81,7 @@ export const OnNeutral: Story = {
timePerTeam: null,
timePerSpeaking: null,
speaker: '홍길동',
+ bell: null,
},
},
};
@@ -103,6 +106,7 @@ export const OnRunning: Story = {
timePerTeam: null,
timePerSpeaking: null,
speaker: '홍길동',
+ bell: null,
},
},
};
@@ -127,6 +131,7 @@ export const WhenAdditionalTimerAvailable: Story = {
timePerTeam: null,
timePerSpeaking: null,
speaker: '홍길동',
+ bell: null,
},
},
};
@@ -151,6 +156,7 @@ export const OnAdditionalTimerEnabled: Story = {
timePerTeam: null,
timePerSpeaking: null,
speaker: '발언자',
+ bell: null,
},
},
};
diff --git a/src/page/TimerPage/stories/NormalTimerTestPage.stories.tsx b/src/page/TimerPage/stories/NormalTimerTestPage.stories.tsx
index b47d187e..241e79f7 100644
--- a/src/page/TimerPage/stories/NormalTimerTestPage.stories.tsx
+++ b/src/page/TimerPage/stories/NormalTimerTestPage.stories.tsx
@@ -14,6 +14,7 @@ const item: TimeBoxInfo = {
time: 120,
timePerTeam: null,
timePerSpeaking: null,
+ bell: null,
};
function NormalTimerTestPage() {
diff --git a/src/type/type.ts b/src/type/type.ts
index ef35ca28..56d810f2 100644
--- a/src/type/type.ts
+++ b/src/type/type.ts
@@ -3,6 +3,13 @@ export type Stance = 'PROS' | 'CONS' | 'NEUTRAL';
export type TimeBasedStance = Exclude;
export type TimeBoxType = 'NORMAL' | 'TIME_BASED';
+export type BellType = 'BEFORE_END' | 'AFTER_END' | 'AFTER_START';
+export type BellConfig = {
+ type: BellType;
+ time: number;
+ count: number;
+};
+
// Type converters
export const StanceToString: Record = {
PROS: '찬성',
@@ -15,6 +22,12 @@ export const TimeBoxTypeToString: Record = {
TIME_BASED: '자유토론 타이머',
};
+export const BellTypeToString: Record = {
+ BEFORE_END: '종료 전',
+ AFTER_END: '종료 후',
+ AFTER_START: '시작 후',
+};
+
// Interfaces
export interface User {
id: string;
@@ -24,6 +37,7 @@ export interface User {
export interface TimeBoxInfo {
stance: Stance;
speechType: string;
+ bell: BellConfig[] | null;
boxType: TimeBoxType;
time: number | null;
timePerTeam: number | null;
@@ -36,8 +50,6 @@ export interface DebateInfo {
agenda: string;
prosTeamName: string;
consTeamName: string;
- warningBell: boolean;
- finishBell: boolean;
}
export interface DebateTable {
diff --git a/src/util/arrayEncoding.test.ts b/src/util/arrayEncoding.test.ts
index 2af7a9e8..cdb4e985 100644
--- a/src/util/arrayEncoding.test.ts
+++ b/src/util/arrayEncoding.test.ts
@@ -13,8 +13,6 @@ describe('토론 테이블 인코딩 유틸리티', () => {
agenda: '토론 주제',
prosTeamName: '찬성',
consTeamName: '반대',
- warningBell: true,
- finishBell: true,
},
table: [
{
@@ -25,6 +23,7 @@ describe('토론 테이블 인코딩 유틸리티', () => {
timePerTeam: 60,
timePerSpeaking: 33,
speaker: null,
+ bell: null,
},
{
stance: 'NEUTRAL',
@@ -34,6 +33,7 @@ describe('토론 테이블 인코딩 유틸리티', () => {
timePerTeam: 35,
timePerSpeaking: null,
speaker: null,
+ bell: null,
},
{
stance: 'PROS',
@@ -43,6 +43,7 @@ describe('토론 테이블 인코딩 유틸리티', () => {
timePerTeam: null,
timePerSpeaking: null,
speaker: '발언자 1',
+ bell: null,
},
{
stance: 'PROS',
@@ -52,6 +53,7 @@ describe('토론 테이블 인코딩 유틸리티', () => {
timePerTeam: null,
timePerSpeaking: null,
speaker: '발언자 1',
+ bell: null,
},
{
stance: 'NEUTRAL',
@@ -61,6 +63,7 @@ describe('토론 테이블 인코딩 유틸리티', () => {
timePerTeam: null,
timePerSpeaking: null,
speaker: null,
+ bell: null,
},
{
stance: 'CONS',
@@ -70,6 +73,7 @@ describe('토론 테이블 인코딩 유틸리티', () => {
timePerTeam: null,
timePerSpeaking: null,
speaker: '발언자 2',
+ bell: null,
},
{
stance: 'PROS',
@@ -79,6 +83,7 @@ describe('토론 테이블 인코딩 유틸리티', () => {
timePerTeam: null,
timePerSpeaking: null,
speaker: '발언자 2',
+ bell: null,
},
],
};
diff --git a/src/util/formatting.ts b/src/util/formatting.ts
index 002c9391..075731f6 100644
--- a/src/util/formatting.ts
+++ b/src/util/formatting.ts
@@ -1,7 +1,8 @@
export const Formatting = {
formatSecondsToMinutes: (time: number) => {
- const minutes = Math.floor(time / 60);
- const seconds = time % 60;
+ const positiveTime = time >= 0 ? time : -time;
+ const minutes = Math.floor(positiveTime / 60);
+ const seconds = positiveTime % 60;
return { minutes, seconds };
},
// Function that set all number to be have 2 digits (if time is 3, it will return 03)
From ac32448d20829aef2cf8e1de6fa597cc5bc99a9e Mon Sep 17 00:00:00 2001
From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com>
Date: Fri, 1 Aug 2025 17:31:37 +0900
Subject: [PATCH 13/31] =?UTF-8?q?[REFACTOR]=20=EB=B9=84=EB=8F=99=EA=B8=B0?=
=?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EB=B3=B4=EA=B0=95?=
=?UTF-8?q?=20(#329)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: 함수 네이밍 카멜 케이스로 수정
* fix: dev환경에서 api호출 오료 해결
* chore: Copied async components from Windows repo
* refactor: Polished Axions instance code
* refactor: Specified errors from API requests
* refactor: Replaced useQuery with useSuspenseQuery on GET all
* feat: useMutation에 중복 호출 방지 로직 래핑한 커스텀 훅 구현
* fix: 테이블 추가 및 수정시 중복 생성 문제 해결
* refactor: useMutation의 onSettled를 활용하여 중복 방지 로직 개선
* refactor: Polished error related codes
* design: Added padding inside of async components
* feat: Added skeleton UI component
* chore: Added delay function
* refactor: Refactored async components to accept React node
* feat: isPending을 통해 수정하기 및 생성하기 버튼 비활성화
* refactor: Refined async logic of share modal
* refactor: Refined async logic of share modal
* design: Changed color of skeleton UI
* design: Applied skeleton UI to header title and table info
* refactor: Refined async logic of TableOverviewPage
* design: Added styles applied when components' are disabled
* refactor: Refined async logic of TableCompositionPage
* chore: Modified code to make `npm run dev` work
* test: Separated baseURL of Axios by mode
* refactor: Refined async logic of TimerPage
* design: 스켈레톤 UI 제거
* chore: 컴포넌트 경로 변경에 따른 코드 수정
* fix: 잘못 감싸진 수정
* refactor: CodeRabbit 제안 사항 반영
* refactor: CodeRabbit 제안 반영
* refactor: PR 리뷰 반영
* refactor: 린팅 오류 수정
---------
Co-authored-by: useon
---
.../ErrorIndicator/ErrorIndicator.tsx | 8 +-
.../ShareModal/ShareModal.stories.tsx | 24 ++++-
src/components/ShareModal/ShareModal.tsx | 50 +++++++---
.../mutations/usePreventDuplicateMutation.ts | 14 ++-
src/hooks/query/useGetDebateTableData.ts | 1 +
src/hooks/query/useGetDebateTableList.ts | 4 +-
src/hooks/useTableShare.tsx | 29 ++++--
...s.tsx => TableCompositionPage.stories.tsx} | 8 +-
...test.tsx => TableCompositionPage.test.tsx} | 6 +-
...mposition.tsx => TableCompositionPage.tsx} | 52 +++++++++--
.../TableNameAndType/TableNameAndType.tsx | 14 ++-
.../components/TimeBoxStep/TimeBoxStep.tsx | 56 +++++++-----
.../TableComposition/hook/useTableFrom.tsx | 2 +-
src/page/TableListPage/TableListPage.test.tsx | 5 +-
src/page/TableListPage/TableListPage.tsx | 49 ++--------
.../components/TableListPageContent.tsx | 48 ++++++++++
...ries.tsx => TableOverviewPage.stories.tsx} | 10 +-
...ableOverview.tsx => TableOverviewPage.tsx} | 91 ++++++++++++-------
src/page/TimerPage/TimerPage.tsx | 81 ++++++++++-------
src/page/TimerPage/hooks/useTimerPageState.ts | 18 +++-
src/routes/routes.tsx | 8 +-
21 files changed, 391 insertions(+), 187 deletions(-)
rename src/page/TableComposition/{TableComposition.stories.tsx => TableCompositionPage.stories.tsx} (61%)
rename src/page/TableComposition/{TableComposition.test.tsx => TableCompositionPage.test.tsx} (96%)
rename src/page/TableComposition/{TableComposition.tsx => TableCompositionPage.tsx} (62%)
create mode 100644 src/page/TableListPage/components/TableListPageContent.tsx
rename src/page/TableOverviewPage/{TableOverview.stories.tsx => TableOverviewPage.stories.tsx} (81%)
rename src/page/TableOverviewPage/{TableOverview.tsx => TableOverviewPage.tsx} (57%)
diff --git a/src/components/ErrorIndicator/ErrorIndicator.tsx b/src/components/ErrorIndicator/ErrorIndicator.tsx
index 92ddbbc5..9f0b8b59 100644
--- a/src/components/ErrorIndicator/ErrorIndicator.tsx
+++ b/src/components/ErrorIndicator/ErrorIndicator.tsx
@@ -6,7 +6,13 @@ interface ErrorIndicatorProps extends PropsWithChildren {
}
export default function ErrorIndicator({
- children = '데이터를 불러오지 못했어요. 다시 시도할까요?',
+ children = (
+ <>
+ 데이터를 불러오지 못했어요.
+
+ 다시 시도할까요?
+ >
+ ),
onClickRetry,
}: ErrorIndicatorProps) {
return (
diff --git a/src/components/ShareModal/ShareModal.stories.tsx b/src/components/ShareModal/ShareModal.stories.tsx
index dc13dcc3..62b0871c 100644
--- a/src/components/ShareModal/ShareModal.stories.tsx
+++ b/src/components/ShareModal/ShareModal.stories.tsx
@@ -58,8 +58,10 @@ export const OnQRCodeReady: Story = {
args: {
shareUrl: shareUrl,
copyState: false,
- isUrlReady: true,
- onClick: () => {
+ isLoading: false,
+ isError: false,
+ onRefetch: () => {},
+ onCopyClicked: () => {
navigator.clipboard.writeText(shareUrl);
},
},
@@ -70,7 +72,21 @@ export const OnLoadingData: Story = {
args: {
shareUrl: '',
copyState: false,
- isUrlReady: false,
- onClick: () => {},
+ isLoading: true,
+ isError: false,
+ onRefetch: () => {},
+ onCopyClicked: () => {},
+ },
+};
+
+// When failed to process share URL
+export const OnFailure: Story = {
+ args: {
+ shareUrl: '',
+ copyState: false,
+ isLoading: false,
+ isError: true,
+ onRefetch: () => {},
+ onCopyClicked: () => {},
},
};
diff --git a/src/components/ShareModal/ShareModal.tsx b/src/components/ShareModal/ShareModal.tsx
index 271b36a2..fe4330c4 100644
--- a/src/components/ShareModal/ShareModal.tsx
+++ b/src/components/ShareModal/ShareModal.tsx
@@ -1,20 +1,37 @@
import { QRCodeSVG } from 'qrcode.react';
import { IoLinkOutline, IoShareOutline } from 'react-icons/io5';
import LoadingSpinner from '../LoadingSpinner';
+import ErrorIndicator from '../ErrorIndicator/ErrorIndicator';
interface ShareModalProps {
shareUrl: string;
copyState: boolean;
- isUrlReady: boolean;
- onClick: () => void;
+ isLoading: boolean;
+ isError: boolean;
+ onRefetch: () => void;
+ onCopyClicked: () => void;
}
export default function ShareModal({
shareUrl,
copyState,
- isUrlReady,
- onClick,
+ isLoading,
+ isError,
+ onRefetch,
+ onCopyClicked,
}: ShareModalProps) {
+ // If error, print error message and let user be able to retry
+ if (isError) {
+ return (
+
+ onRefetch()}>
+ QR 코드를 불러오지 못했어요...
다시 시도하시겠어요?
+
+
+ );
+ }
+
+ // If no error or on loading, print modal contents
return (
- {isUrlReady && (
-
- )}
- {!isUrlReady && (
+ {isLoading && (
)}
+ {!isLoading && (
+
+ )}
{/* Button that copies URL to the user's clipboard. */}
diff --git a/src/hooks/mutations/usePreventDuplicateMutation.ts b/src/hooks/mutations/usePreventDuplicateMutation.ts
index f26e873c..d0c2ae9c 100644
--- a/src/hooks/mutations/usePreventDuplicateMutation.ts
+++ b/src/hooks/mutations/usePreventDuplicateMutation.ts
@@ -1,7 +1,17 @@
import { useRef, useCallback } from 'react';
-import { type DefaultError, useMutation, type UseMutationOptions, type UseMutationResult } from '@tanstack/react-query';
+import {
+ type DefaultError,
+ useMutation,
+ type UseMutationOptions,
+ type UseMutationResult,
+} from '@tanstack/react-query';
-export function usePreventDuplicateMutation(
+export function usePreventDuplicateMutation<
+ TData = unknown,
+ TError = DefaultError,
+ TVariables = void,
+ TContext = unknown,
+>(
options: UseMutationOptions,
): UseMutationResult {
// useRef를 통해 요청 여부를 저장
diff --git a/src/hooks/query/useGetDebateTableData.ts b/src/hooks/query/useGetDebateTableData.ts
index b1c4635e..6d4fd45f 100644
--- a/src/hooks/query/useGetDebateTableData.ts
+++ b/src/hooks/query/useGetDebateTableData.ts
@@ -10,5 +10,6 @@ export function useGetDebateTableData(tableId: number, enabled?: boolean) {
return repo.getTable(tableId);
},
enabled,
+ throwOnError: false,
});
}
diff --git a/src/hooks/query/useGetDebateTableList.ts b/src/hooks/query/useGetDebateTableList.ts
index 7940f8f3..650e725a 100644
--- a/src/hooks/query/useGetDebateTableList.ts
+++ b/src/hooks/query/useGetDebateTableList.ts
@@ -1,8 +1,8 @@
-import { useQuery } from '@tanstack/react-query';
+import { useSuspenseQuery } from '@tanstack/react-query';
import { getDebateTableList } from '../../apis/apis/member';
export function useGetDebateTableList() {
- return useQuery({
+ return useSuspenseQuery({
queryKey: ['DebateTableList'],
queryFn: () => getDebateTableList(),
});
diff --git a/src/hooks/useTableShare.tsx b/src/hooks/useTableShare.tsx
index b52deaee..8f8f9cd4 100644
--- a/src/hooks/useTableShare.tsx
+++ b/src/hooks/useTableShare.tsx
@@ -7,7 +7,6 @@ import { createTableShareUrl } from '../util/arrayEncoding';
export function useTableShare(tableId: number) {
const { isOpen, openModal, closeModal, ModalWrapper } = useModal();
const [copyState, setCopyState] = useState(false);
- const [isUrlReady, setIsUrlReady] = useState(false);
const [shareUrl, setShareUrl] = useState('');
const baseUrl =
import.meta.env.MODE !== 'production'
@@ -21,22 +20,34 @@ export function useTableShare(tableId: number) {
console.error('Failed to copy: ', err);
}
};
- const data = useGetDebateTableData(tableId, isOpen);
+ const {
+ data,
+ isLoading: isFetching,
+ isError: isFetchError,
+ refetch,
+ isRefetching,
+ isRefetchError,
+ } = useGetDebateTableData(tableId, isOpen);
+ const isLoading = isFetching || isRefetching;
+ const isError = isFetchError || isRefetchError;
+
+ // Process URL when data is successfully fetched
useEffect(() => {
- if (data.data) {
- setShareUrl(createTableShareUrl(baseUrl, data.data));
- setIsUrlReady(true);
+ if (data) {
+ setShareUrl(createTableShareUrl(baseUrl, data));
}
}, [baseUrl, data]);
+ // Close indicator after 3 seconds
+ // which tells user that URL is copied to clipboard
useEffect(() => {
if (copyState) {
setTimeout(() => {
setCopyState(false);
}, 3000);
}
- });
+ }, [copyState]);
const TableShareModal = () =>
isOpen ? (
@@ -44,8 +55,10 @@ export function useTableShare(tableId: number) {
handleCopy()}
+ isLoading={isLoading}
+ isError={isError}
+ onRefetch={() => refetch()}
+ onCopyClicked={() => handleCopy()}
/>
) : null;
diff --git a/src/page/TableComposition/TableComposition.stories.tsx b/src/page/TableComposition/TableCompositionPage.stories.tsx
similarity index 61%
rename from src/page/TableComposition/TableComposition.stories.tsx
rename to src/page/TableComposition/TableCompositionPage.stories.tsx
index c517393e..f999aca0 100644
--- a/src/page/TableComposition/TableComposition.stories.tsx
+++ b/src/page/TableComposition/TableCompositionPage.stories.tsx
@@ -1,9 +1,9 @@
import { Meta, StoryObj } from '@storybook/react';
-import TableComposition from './TableComposition';
+import TableCompositionPage from './TableCompositionPage';
-const meta: Meta = {
+const meta: Meta = {
title: 'page/TableCompositon',
- component: TableComposition,
+ component: TableCompositionPage,
tags: ['autodocs'],
decorators: [
(Story) => {
@@ -16,6 +16,6 @@ const meta: Meta = {
export default meta;
-type Story = StoryObj;
+type Story = StoryObj;
export const Default: Story = {};
diff --git a/src/page/TableComposition/TableComposition.test.tsx b/src/page/TableComposition/TableCompositionPage.test.tsx
similarity index 96%
rename from src/page/TableComposition/TableComposition.test.tsx
rename to src/page/TableComposition/TableCompositionPage.test.tsx
index 38869174..3fad8c46 100644
--- a/src/page/TableComposition/TableComposition.test.tsx
+++ b/src/page/TableComposition/TableCompositionPage.test.tsx
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { GlobalPortal } from '../../util/GlobalPortal';
-import TableComposition from './TableComposition';
+import TableCompositionPage from './TableCompositionPage';
// ------------------
// 테스트 래퍼 (TestWrapper)
@@ -62,7 +62,7 @@ describe('TableComposition', () => {
it('Creation flow and timebox functionality test', async () => {
render(
-
+
,
);
@@ -96,7 +96,7 @@ describe('TableComposition', () => {
-
+
,
);
diff --git a/src/page/TableComposition/TableComposition.tsx b/src/page/TableComposition/TableCompositionPage.tsx
similarity index 62%
rename from src/page/TableComposition/TableComposition.tsx
rename to src/page/TableComposition/TableCompositionPage.tsx
index d1639a3b..a04c4520 100644
--- a/src/page/TableComposition/TableComposition.tsx
+++ b/src/page/TableComposition/TableCompositionPage.tsx
@@ -3,30 +3,47 @@ import TableNameAndType from './components/TableNameAndType/TableNameAndType';
import useFunnel from '../../hooks/useFunnel';
import useTableFrom from './hook/useTableFrom';
import TimeBoxStep from './components/TimeBoxStep/TimeBoxStep';
-import { useGetDebateTableData } from '../../hooks/query/useGetDebateTableData';
import { useSearchParams } from 'react-router-dom';
import { useMemo } from 'react';
import { DebateInfo, TimeBoxInfo } from '../../type/type';
+import { useGetDebateTableData } from '../../hooks/query/useGetDebateTableData';
+import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator';
export type TableCompositionStep = 'NameAndType' | 'TimeBox';
type Mode = 'edit' | 'add';
-export default function TableComposition() {
+export default function TableCompositionPage() {
// URL 등으로부터 "editMode"와 "tableId"를 추출
const [searchParams] = useSearchParams();
- const mode = searchParams.get('mode') as Mode;
- const tableId = Number(searchParams.get('tableId') || 0);
+ const rawMode = searchParams.get('mode');
+ const rawTableId = searchParams.get('tableId');
+
+ if (rawMode !== 'edit' && rawMode !== 'add') {
+ throw new Error('테이블 모드가 올바르지 않습니다.');
+ }
+ const mode = rawMode as Mode;
+
+ if (mode === 'edit' && (rawTableId === null || isNaN(Number(rawTableId)))) {
+ throw new Error('테이블 ID가 올바르지 않습니다.');
+ }
+ const tableId = rawTableId ? Number(rawTableId) : 0;
- // Print different funnel page by mode (edit a existing table or add a new table)
const initialMode: TableCompositionStep =
mode !== 'edit' ? 'NameAndType' : 'TimeBox';
const { Funnel, currentStep, goToStep } =
useFunnel(initialMode);
// edit 모드일 때만 서버에서 initData를 가져옴
- // 테이블 데이터 패칭 분기
- const { data } = useGetDebateTableData(tableId, mode === 'edit');
+ const {
+ data,
+ isError: isFetchError,
+ isRefetchError,
+ isLoading: isFetching,
+ isRefetching,
+ refetch,
+ } = useGetDebateTableData(tableId, mode === 'edit');
+ // 테이블 데이터 패칭 분기
const initData = useMemo(() => {
if (mode === 'edit' && data) {
const info = data.info as DebateInfo;
@@ -39,6 +56,10 @@ export default function TableComposition() {
return undefined;
}, [mode, data]);
+ // Declare constants to handle async request
+ const isError = mode === 'add' ? false : isFetchError || isRefetchError;
+ const isLoading = mode === 'add' ? false : isFetching || isRefetching;
+
const {
formData,
updateInfo,
@@ -65,6 +86,21 @@ export default function TableComposition() {
}
};
+ // If error, print error message and let user be able to retry
+ if (isError) {
+ return (
+
+
+ refetch()}>
+ 시간표 정보를 불러오지 못했어요...
다시 시도할까요?
+
+
+
+ );
+ }
+
+ // If no error or on loading, print contents
+ // Only pass isLoading because isError is used right above this code line
return (
goToStep('TimeBox')}
@@ -80,6 +117,7 @@ export default function TableComposition() {
TimeBox: (
void;
onButtonClick: () => void;
}
export default function TableNameAndType(props: TableNameAndTypeProps) {
- const { info, isEdit = false, onInfoChange, onButtonClick } = props;
+ const {
+ info,
+ isEdit = false,
+ onInfoChange,
+ isLoading,
+ onButtonClick,
+ } = props;
const handleFieldChange = (
field: K,
@@ -59,6 +66,7 @@ export default function TableNameAndType(props: TableNameAndTypeProps) {
onChange={(e) => handleFieldChange('name', e.target.value)}
onClear={() => clearField('name')}
placeholder="시간표 1"
+ disabled={isLoading}
/>
>
@@ -106,6 +117,7 @@ export default function TableNameAndType(props: TableNameAndTypeProps) {