Skip to content

Commit

Permalink
발표 연습 페이지 헤더 이벤트 처리 및 조회 API 연동 (#39)
Browse files Browse the repository at this point in the history
* feat: 발표 연습 상세 조회 API 연동

* refact: CloseIcon 색상 값으로 분기하도록 수정

* feat: PracticeNav 컴포넌트 내 버튼 로직 구현

- PracticeNav layout에서 제거
- 일시정지 여부에 맞춰서 헤더 컬러 및 버튼 노출 여부 변경되도록 구현
- 버튼 클릭 시 이벤트 바인딩
- 그 외 데이터 바인딩
  • Loading branch information
Joie-Kim authored Mar 1, 2024
1 parent 6a89ab6 commit 1e7d2b4
Show file tree
Hide file tree
Showing 16 changed files with 421 additions and 178 deletions.
10 changes: 6 additions & 4 deletions src/app/(afterlogin)/practice/[id]/page.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
@import '@/styles/mixins';

.container {
display: flex;
justify-content: center;
position: relative;
width: 100%;
padding: 60px;
Expand All @@ -15,8 +17,8 @@
.presentation {
width: 900px;
height: 100%;
border: 1px solid $gray-1;
border-radius: 15px;
background: $purple-1;

&__box {
display: flex;
Expand Down Expand Up @@ -58,8 +60,8 @@
width: 100%;
height: 100%;
margin-top: 12px;
border: 1px solid $gray-1;
border-radius: 15px;
background: $purple-1;
}

&__box {
Expand All @@ -86,6 +88,6 @@

.bubble {
position: absolute;
top: 8px;
left: 30%;
top: 75px;
left: 35%;
}
153 changes: 119 additions & 34 deletions src/app/(afterlogin)/practice/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,63 +7,140 @@ import { useEffect, useState } from 'react';
import useRecorder from '../_hooks/useRecorder';
import Alert from '@/app/_components/_modules/_modal/Alert';
import SpeechBubble from '@/app/_components/_modules/SpeechBubble';
import { useQuery } from '@tanstack/react-query';
import { PracticeDetail } from '@/types/service';
import { PracticeService } from '@/services/client/practice';
import Image from 'next/image';
import PracticeNav from '../_components/PracticeNav';
import Confirm from '@/app/_components/_modules/_modal/Confirm';

export default function Page({ params }: { params: { id: string } }) {
const id = Number(params.id);

// #region state
const [slideIdx, setSlideIdx] = useState(0);
const modal = useToggle();
const confirm = useToggle();
const bubble = useToggle();
const recorder = useRecorder();
// #endregion

//
const cx = classNames.bind(styles);

// #region query
const { isLoading, data } = useQuery<PracticeDetail>({
queryKey: ['practice', id],
queryFn: () => PracticeService.getPracticeDetail(Number(id)),
});

/** 발표 이름 */
const title = data?.title ?? '';

/** 슬라이드 페이징 문자열 */
const slidePaging = `${slideIdx}/${data?.slides.length ?? 0}`;

/** 마지막 슬라이드 여부 */
const isLastSlide = data?.slides.length === slideIdx + 1;
// #endregion

useEffect(() => {
modal.onOpen();
recorder.getMedia();
recorder.processPermission();
}, []);

// #region event-handler
const handleModalAction = () => {
recorder.startRecording();
modal.onClose();
bubble.onOpen();
};

const handleNextSlide = () => {
console.log('go to next slide');
};

const handleRecordingPause = () => {
if (recorder.isRecording) recorder.pauseRecording();
else recorder.resumeRecording();
};

const handleClose = () => {
console.log('close ... ');
confirm.onOpen();
};
// #endregion

// #region function
const goToPresentationsPage = () => {
console.log('go to presentation page...');
confirm.onClose();
};
// #endregion

return (
<div className={styles.container}>
<div className={styles.contents}>
<section className={styles.presentation__box}>
<article className={styles.presentation}>img...</article>
<section className={styles.helper__box}>
<article className={styles.helper}>
<h4 className={styles.helper__title}>
다음 슬라이드
<span className={cx(['helper__subtitle', 'helper__subtitle--next'])}>2/15</span>
</h4>
<div className={styles.helper__item}>img...</div>
</article>
<article className={styles.helper}>
<h4 className={styles.helper__title}>
메모하기
<span className={cx(['helper__subtitle', 'helper__subtitle--memo'])}>
발표 연습 중 메모를 입력하면 녹음이 일시정지돼요.
</span>
</h4>
<textarea
className={styles.helper__item}
placeholder="ex. 발표문 수정 사항, 목소리 크기 등에 대한 메모를 작성해 주세요."
<>
<PracticeNav
title={title}
isRecording={recorder.isRecording}
goToNext={handleNextSlide}
handleRecording={handleRecordingPause}
onCloseClick={handleClose}
/>
<div className={styles.container}>
<div className={styles.contents}>
<section className={styles.presentation__box}>
<article className={styles.presentation}>
<Image
src={`${process.env.NEXT_PUBLIC_BASE_URL_CDN}/${data?.slides[0].imageFilePath}`}
alt={`slide-${0}`}
width={900}
height={510}
style={{ objectFit: 'contain', borderRadius: '16px' }}
/>
</article>
<section className={styles.helper__box}>
<article className={styles.helper}>
<h4 className={styles.helper__title}>
다음 슬라이드
<span className={cx(['helper__subtitle', 'helper__subtitle--next'])}>
{slidePaging}
</span>
</h4>
<div className={styles.helper__item}>
{isLastSlide ? (
<div>last ... </div>
) : (
<Image
src={`${process.env.NEXT_PUBLIC_BASE_URL_CDN}/${data?.slides[0].imageFilePath}`}
alt={`slide-${0}`}
width={370}
height={200}
style={{ objectFit: 'contain', borderRadius: '16px' }}
/>
)}
</div>
</article>
<article className={styles.helper}>
<h4 className={styles.helper__title}>
메모하기
<span className={cx(['helper__subtitle', 'helper__subtitle--memo'])}>
발표 연습 중 메모를 입력하면 녹음이 일시정지돼요.
</span>
</h4>
<textarea
className={styles.helper__item}
placeholder="ex. 발표문 수정 사항, 목소리 크기 등에 대한 메모를 작성해 주세요."
defaultValue={data?.slides[0].memo ?? ''}
/>
</article>
</section>
</section>
</section>
<article className={styles.script__box}>
<p className={styles.script}>
발표 내용 발표 내용 ... 발표 내용 발표 내용 ... 발표 내용 발표 내용 ... 발표 내용 발표
내용 ... 발표 내용 발표 내용 ... 발표 내용 발표 내용 ... 발표 내용 발표 내용 ... 발표
내용 발표 내용 ... 발표 내용 발표 내용 ...발표 내용 발표 내용 ... 발표 내용 발표 내용
... 발표 내용 발표 내용 ... 발표 내용 발표 내용 ... 발표 내용 발표 내용 ... 발표 내용
발표 내용 ... 발표 내용 발표 내용 ... 발표 내용 발표 내용 ... 발표 내용 발표 내용 ...
발표 내용 발표 내용 ... 발표 내용 발표 내용 ...
</p>
</article>
<article className={styles.script__box}>
<p className={styles.script}>{data?.slides[0].script ?? ''}</p>
</article>
</div>
</div>
<Alert
context={modal}
Expand All @@ -73,13 +150,21 @@ export default function Page({ params }: { params: { id: string } }) {
isDisabled={!recorder.isPermitted}
onActionClick={handleModalAction}
/>
<Confirm
context={confirm}
title="연습을 중단하시겠어요?"
message={`연습을 중단하시면\n이번 연습에 대한 피드백을 받을 수 없어요.`}
okayText="중단하기"
cancelText="계속 연습하기"
onOkayClick={goToPresentationsPage}
/>
<div className={styles.bubble}>
<SpeechBubble
context={bubble}
message="녹음 버튼을 누르면 일시정지할 수 있어요."
hasCloseBtn
/>
</div>
</div>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@
justify-content: center;
width: 100%;
height: 68px;
background: $primary;
min-width: 1320px;

&--record-on {
background: $primary;
}

&--record-off {
background: $pink-4;
}
}

.contents {
Expand Down Expand Up @@ -38,6 +45,10 @@
top: 12px;
right: 50%;

.recorder {
@include pure-button;
}

.division {
width: 10px;
height: 10px;
Expand Down
39 changes: 30 additions & 9 deletions src/app/(afterlogin)/practice/_components/PracticeNav.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,51 @@
import classNames from 'classnames/bind';

import LogoIcon from '@/app/_svgs/LogoIcon';
import RecordOnIcon from '../_svgs/RecordOnIcon';
import CloseIconWhite from '../../../_svgs/CloseIconWhite';
import RecordIcon from '../_svgs/RecordIcon';
import CloseIcon from '../../../_svgs/CloseIcon';

import styles from './PracticeNav.module.scss';

const PracticeNav = () => {
interface Props {
/** 발표 제목 */
title: string;
/** 녹음 진행 여부 */
isRecording: boolean;
/** 다음 페이지 이동 함수 */
goToNext: () => void;
/** 녹음 핸들러 */
handleRecording: () => void;
/** 닫기 버튼 클릭 이벤트 */
onCloseClick: () => void;
}

const PracticeNav = ({ title, isRecording, goToNext, handleRecording, onCloseClick }: Props) => {
const cx = classNames.bind(styles);

return (
<nav className={styles.container}>
<nav
className={cx('container', isRecording ? 'container--record-on' : 'container--record-off')}
>
<div className={styles.contents__box}>
<div className={cx(['contents', 'contents--left'])}>
<LogoIcon />
<h3 className={styles.title}>발표이름 발표이름 발표이름 발표이름 발표이름 발표이름</h3>
<h3 className={styles.title}>{title}</h3>
</div>
<div className={cx(['contents', 'contents--center'])}>
<RecordOnIcon />
<button className={styles.recorder} onClick={handleRecording}>
<RecordIcon isRecording={isRecording} />
</button>
<em className={styles.division}></em>
<h2>15:00</h2>
</div>
<div className={cx(['contents', 'contents--right'])}>
<button className={cx('action--next')}>다음 페이지</button>
<button className={cx('action--close')}>
<CloseIconWhite />
{isRecording && (
<button className={cx('action--next')} onClick={goToNext}>
다음 페이지
</button>
)}
<button className={cx('action--close')} onClick={onCloseClick}>
<CloseIcon color="white" />
</button>
</div>
</div>
Expand Down
53 changes: 1 addition & 52 deletions src/app/(afterlogin)/practice/_components/Recorder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,6 @@

import React, { useState, useRef } from 'react';

const Recorder = () => {
const [isRecording, setIsRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunks: Blob[] = [];

const startRecording = () => {
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
const mediaRecorder = new MediaRecorder(stream);

mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
audioChunks.push(e.data);
}
};

mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/mp3' }); // 오디오 파일 형식 확인 필요
setAudioBlob(audioBlob);
};

mediaRecorderRef.current = mediaRecorder;
mediaRecorder.start();
setIsRecording(true);
})
.catch((error) => {
console.error('Error accessing microphone:', error);
});
};

const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
}
};

return (
<div>
<button onClick={isRecording ? stopRecording : startRecording}>
{isRecording ? 'Stop Recording' : 'Start Recording'}
</button>
{audioBlob && (
<audio controls>
<source src={URL.createObjectURL(audioBlob)} type="audio/mp3" />
</audio>
)}
</div>
);
};
const Recorder = () => {};

export default Recorder;
Loading

0 comments on commit 1e7d2b4

Please sign in to comment.