Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,6 @@
"링크 복사됨": "Link Copied",
"링크 복사 실패": "Copy Failed",
"분석 보고서를 만들지 못했어요": "We couldn't create the analysis report",
"{{count}}문장": "{{count}} sentences",
"{{label}}: 속기록에서 보기": "{{label}}: view in transcript",
"{{side}} 우세": "{{side}} ahead",
"근거 없음": "No evidence",
Expand Down Expand Up @@ -349,8 +348,6 @@
"타이머를 시작하면 기록해요": "Records while the timer runs",
"토론 기록": "Debate record",
"토론 기록 ID가 올바르지 않습니다.": "The debate record ID is invalid.",
"토론 기록 끄기": "Turn off debate recording",
"토론 기록 켜기": "Turn on debate recording",
"토론 기록을 보내고 있어요": "Sending the debate record",
"토론 기록을 보내지 못했어요": "We couldn't send the debate record",
"토론 음성 기록에 동의하시나요?": "Do you agree to record the debate audio?",
Expand All @@ -361,7 +358,6 @@
"음성 인식 방식": "Speech recognition mode",
"발언 구간이 끝날 때마다 녹음한 오디오를 서버로 보내 Soniox로 텍스트로 바꿔요. 토론이 끝나면 속기록을 서버로 보내 AI가 분석 보고서를 만들어요.": "Each time a speech segment ends, its recorded audio is sent to our server and converted to text with Soniox. When the debate ends, the transcript is sent to our server and AI writes an analysis report.",
"이 브라우저에서는 토론 기록을 사용할 수 없어요": "Debate recording is not available in this browser",
"녹음 중": "Recording",
"{{count}}개 구간 전사 중": "Transcribing {{count}} segment(s)",
"{{count}}개 구간 전사 실패": "{{count}} segment(s) failed to transcribe",
"발언을 텍스트로 바꾸고 있어요 ({{count}}개 구간 남음)": "Converting speech to text ({{count}} segment(s) left)",
Expand All @@ -381,5 +377,9 @@
"주제를 알 수 없어요": "Unknown topic",
"아직 토론 기록이 없어요": "No debate records yet",
"타이머 화면에서 녹음을 켜고 토론을 진행해 보세요.": "Turn on recording on the timer screen and run a debate.",
"타이머로 진행한 토론이 끝날 때마다 속기록과 분석 보고서가 자동으로 쌓여요.": "Each debate you run with the timer adds a transcript and an analysis report here automatically."
"타이머로 진행한 토론이 끝날 때마다 속기록과 분석 보고서가 자동으로 쌓여요.": "Each debate you run with the timer adds a transcript and an analysis report here automatically.",
"토론 녹화하기": "Record debate",
"토론 녹음 중": "Recording debate",
"토론 녹음 끄기": "Stop recording debate",
"{{count}}문장 기록": "{{count}} sentences recorded"
}
10 changes: 5 additions & 5 deletions public/locales/ko/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,6 @@
"링크 복사됨": "링크 복사됨",
"링크 복사 실패": "링크 복사 실패",
"분석 보고서를 만들지 못했어요": "분석 보고서를 만들지 못했어요",
"{{count}}문장": "{{count}}문장",
"{{label}}: 속기록에서 보기": "{{label}}: 속기록에서 보기",
"{{side}} 우세": "{{side}} 우세",
"근거 없음": "근거 없음",
Expand Down Expand Up @@ -349,8 +348,6 @@
"타이머를 시작하면 기록해요": "타이머를 시작하면 기록해요",
"토론 기록": "토론 기록",
"토론 기록 ID가 올바르지 않습니다.": "토론 기록 ID가 올바르지 않습니다.",
"토론 기록 끄기": "토론 기록 끄기",
"토론 기록 켜기": "토론 기록 켜기",
"토론 기록을 보내고 있어요": "토론 기록을 보내고 있어요",
"토론 기록을 보내지 못했어요": "토론 기록을 보내지 못했어요",
"토론 음성 기록에 동의하시나요?": "토론 음성 기록에 동의하시나요?",
Expand All @@ -361,7 +358,6 @@
"음성 인식 방식": "음성 인식 방식",
"발언 구간이 끝날 때마다 녹음한 오디오를 서버로 보내 Soniox로 텍스트로 바꿔요. 토론이 끝나면 속기록을 서버로 보내 AI가 분석 보고서를 만들어요.": "발언 구간이 끝날 때마다 녹음한 오디오를 서버로 보내 Soniox로 텍스트로 바꿔요. 토론이 끝나면 속기록을 서버로 보내 AI가 분석 보고서를 만들어요.",
"이 브라우저에서는 토론 기록을 사용할 수 없어요": "이 브라우저에서는 토론 기록을 사용할 수 없어요",
"녹음 중": "녹음 중",
"{{count}}개 구간 전사 중": "{{count}}개 구간 전사 중",
"{{count}}개 구간 전사 실패": "{{count}}개 구간 전사 실패",
"발언을 텍스트로 바꾸고 있어요 ({{count}}개 구간 남음)": "발언을 텍스트로 바꾸고 있어요 ({{count}}개 구간 남음)",
Expand All @@ -381,5 +377,9 @@
"주제를 알 수 없어요": "주제를 알 수 없어요",
"아직 토론 기록이 없어요": "아직 토론 기록이 없어요",
"타이머 화면에서 녹음을 켜고 토론을 진행해 보세요.": "타이머 화면에서 녹음을 켜고 토론을 진행해 보세요.",
"타이머로 진행한 토론이 끝날 때마다 속기록과 분석 보고서가 자동으로 쌓여요.": "타이머로 진행한 토론이 끝날 때마다 속기록과 분석 보고서가 자동으로 쌓여요."
"타이머로 진행한 토론이 끝날 때마다 속기록과 분석 보고서가 자동으로 쌓여요.": "타이머로 진행한 토론이 끝날 때마다 속기록과 분석 보고서가 자동으로 쌓여요.",
"토론 녹화하기": "토론 녹화하기",
"토론 녹음 중": "토론 녹음 중",
"토론 녹음 끄기": "토론 녹음 끄기",
"{{count}}문장 기록": "{{count}}문장 기록"
}
10 changes: 10 additions & 0 deletions src/page/TableListPage/components/TranscriptListContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,14 @@ describe('대시보드 토론 기록 목록', () => {
await screen.findByText('아직 토론 기록이 없어요'),
).toBeInTheDocument();
});

it('주제 길이나 유무와 상관없이 모든 카드를 시간표 카드와 같은 고정 크기로 보여준다', async () => {
renderList();

const cards = await screen.findAllByRole('link');

cards.forEach((card) => {
expect(card).toHaveClass('h-[220px]', 'w-[340px]');
});
});
});
13 changes: 7 additions & 6 deletions src/page/TableListPage/components/TranscriptListContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {

// 노란 시간표 카드와 구분되도록 기본블랙(차콜) 카드에 테마 노랑으로 강조한다.
const CARD_CLASS =
'flex h-[200px] flex-col gap-3 rounded-[28px] border-2 p-6 text-left text-default-white shadow-lg transition-all duration-300 hover:-translate-y-1';
'm-5 flex h-[220px] w-[340px] flex-col gap-3 rounded-[28px] border-2 p-6 text-left text-default-white shadow-lg transition-all duration-300 hover:-translate-y-1';
const GENERATING_CHIP_CLASS =
'flex flex-none items-center gap-1.5 rounded-full bg-brand px-2.5 py-1 text-xs font-bold text-default-black';

Expand Down Expand Up @@ -48,26 +48,27 @@ export default function TranscriptListContent() {
}

return (
<div className="mx-auto w-full max-w-[1140px] px-5 pt-5 pb-16">
<p className="text-xl font-semibold md:text-subtitle-raw">
<div className="pb-16">
<p className="m-5 mb-0 text-xl font-semibold md:text-subtitle-raw">
{t('토론 기록')}
</p>
<p className="mt-1 mb-6 text-sm text-default-border md:text-base">
<p className="mx-5 mt-1 text-sm text-default-border md:text-base">
{t(
'타이머로 진행한 토론이 끝날 때마다 속기록과 분석 보고서가 자동으로 쌓여요.',
)}
</p>

{data.transcripts.length === 0 && (
<div className="flex flex-col items-center gap-2 rounded-[28px] bg-default-dim2 p-12 text-center">
<div className="m-5 flex h-[220px] w-[340px] flex-col items-center justify-center gap-2 rounded-[28px] bg-default-dim2 p-6 text-center">
<p className="font-semibold">{t('아직 토론 기록이 없어요')}</p>
<p className="text-sm text-default-border">
{t('타이머 화면에서 녹음을 켜고 토론을 진행해 보세요.')}
</p>
</div>
)}

<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
{/* 시간표 탭처럼 주제 길이와 상관없이 같은 크기의 카드를 줄 바꿈으로 놓는다. */}
<div className="flex max-w-[1140px] flex-wrap justify-start">
{data.transcripts.map((transcript) => {
const generating = isGenerating(transcript);
return (
Expand Down
33 changes: 21 additions & 12 deletions src/page/TimerPage/components/TranscribeControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('토론 기록 켜기 컨트롤', () => {
const user = userEvent.setup();
const props = renderControl();

await user.click(screen.getByRole('button', { name: '토론 기록 켜기' }));
await user.click(screen.getByRole('button', { name: '토론 녹화하기' }));
expect(props.onEnable).not.toHaveBeenCalled();
expect(
screen.getByText('토론 음성 기록에 동의하시나요?'),
Expand All @@ -53,7 +53,7 @@ describe('토론 기록 켜기 컨트롤', () => {
const user = userEvent.setup();
const props = renderControl();

await user.click(screen.getByRole('button', { name: '토론 기록 켜기' }));
await user.click(screen.getByRole('button', { name: '토론 녹화하기' }));
await user.click(
screen.getByRole('radio', { name: /발언 후 일괄 인식 \(Soniox\)/ }),
);
Expand All @@ -69,7 +69,7 @@ describe('토론 기록 켜기 컨트롤', () => {
const user = userEvent.setup();
renderControl({ supportedModes: { RPC: false, SONIOX: true } });

await user.click(screen.getByRole('button', { name: '토론 기록 켜기' }));
await user.click(screen.getByRole('button', { name: '토론 녹화하기' }));

expect(
screen.getByRole('radio', { name: /실시간 인식 \(크롬\)/ }),
Expand All @@ -83,7 +83,7 @@ describe('토론 기록 켜기 컨트롤', () => {
const user = userEvent.setup();
const props = renderControl({ sttMode: 'SONIOX', isModeLocked: true });

await user.click(screen.getByRole('button', { name: '토론 기록 켜기' }));
await user.click(screen.getByRole('button', { name: '토론 녹화하기' }));

expect(screen.queryByRole('radio')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '동의하고 기록하기' }));
Expand All @@ -94,33 +94,42 @@ describe('토론 기록 켜기 컨트롤', () => {
const user = userEvent.setup();
const props = renderControl();

await user.click(screen.getByRole('button', { name: '토론 기록 켜기' }));
await user.click(screen.getByRole('button', { name: '토론 녹화하기' }));
await user.click(screen.getByRole('button', { name: '취소' }));

expect(props.onEnable).not.toHaveBeenCalled();
});

it('기록 중에 누르면 기록을 끈다', async () => {
it('녹음하지 않을 때는 토론 녹화하기 버튼을 보여준다', () => {
renderControl();

const button = screen.getByRole('button', { name: '토론 녹화하기' });
expect(button).toHaveTextContent('토론 녹화하기');
expect(button).toHaveAttribute('aria-pressed', 'false');
});

it('녹음 중에는 토론 녹음 중 태그를 보여주고, 누르면 녹음을 끈다', async () => {
const user = userEvent.setup();
const props = renderControl({ isEnabled: true });

const button = screen.getByRole('button', { name: '토론 기록 끄기' });
const button = screen.getByRole('button', { name: '토론 녹음 끄기' });
expect(button).toHaveTextContent('토론 녹음 중');
expect(button).toHaveAttribute('aria-pressed', 'true');
await user.click(button);

expect(props.onDisable).toHaveBeenCalledTimes(1);
});

it('크롬 실시간 인식도 인식 중인 문장 대신 녹음 중 상태와 기록한 문장 수만 보여준다', () => {
it('크롬 실시간 인식도 인식 중인 문장 대신 녹음 중 태그와 기록한 문장 수만 보여준다', () => {
renderControl({
sttMode: 'RPC',
isEnabled: true,
isListening: true,
utteranceCount: 3,
});

expect(screen.getByText('녹음 중')).toBeInTheDocument();
expect(screen.getByText('3문장')).toBeInTheDocument();
expect(screen.getByText('토론 녹음 중')).toBeInTheDocument();
expect(screen.getByText('3문장 기록')).toBeInTheDocument();
});

it('타이머 화면에 처음 들어오면 녹음할지 묻는 동의 창을 연다', async () => {
Expand Down Expand Up @@ -152,7 +161,7 @@ describe('토론 기록 켜기 컨트롤', () => {
failedCount: 1,
});

expect(screen.getByText('녹음 중')).toBeInTheDocument();
expect(screen.getByText('토론 녹음 중')).toBeInTheDocument();
expect(screen.getByText('2개 구간 전사 중')).toBeInTheDocument();
expect(screen.getByText('1개 구간 전사 실패')).toBeInTheDocument();
});
Expand All @@ -161,7 +170,7 @@ describe('토론 기록 켜기 컨트롤', () => {
renderControl({ supportedModes: { RPC: false, SONIOX: false } });

expect(
screen.queryByRole('button', { name: '토론 기록 켜기' }),
screen.queryByRole('button', { name: '토론 녹화하기' }),
).not.toBeInTheDocument();
expect(
screen.getByText('이 브라우저에서는 토론 기록을 사용할 수 없어요'),
Expand Down
86 changes: 43 additions & 43 deletions src/page/TimerPage/components/TranscribeControl.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RiMicFill, RiMicOffFill } from 'react-icons/ri';
import { RiMicFill } from 'react-icons/ri';
import clsx from 'clsx';
import { useModal } from '../../../hooks/useModal';
import DialogModal from '../../../components/DialogModal/DialogModal';
Expand All @@ -26,9 +26,9 @@ export interface TranscribeControlProps {

const STT_MODES: SttMode[] = ['RPC', 'SONIOX'];

// 바로 위 라이브 공유 버튼과 같은 크기·모양으로 맞춘다.
const MIC_BUTTON_CLASS =
'relative flex size-[88px] items-center justify-center rounded-full border-[2px] p-[22px] shadow-lg transition-colors';
// 녹음하지 않을 때는 "토론 녹화하기" 버튼, 녹음 중에는 "토론 녹음 중" 태그로 보이는 타원형 버튼
const RECORD_BUTTON_CLASS =
'flex items-center gap-2 rounded-full border-2 px-5 py-2.5 text-base font-bold whitespace-nowrap shadow-lg transition-colors';
const STATUS_PILL_CLASS =
'flex w-fit items-center gap-2 rounded-full bg-default-black/80 px-3 py-1.5 text-sm font-semibold text-default-white';
const MODE_OPTION_CLASS =
Expand Down Expand Up @@ -105,7 +105,7 @@ export default function TranscribeControl(props: TranscribeControlProps) {
}

const consentMode = isModeLocked ? sttMode : selectedMode;
const label = isEnabled ? t('토론 기록 끄기') : t('토론 기록 켜기');
const label = isEnabled ? t('토론 녹음 끄기') : t('토론 녹화하기');

const handleClick = () => {
if (isEnabled) {
Expand All @@ -124,62 +124,62 @@ export default function TranscribeControl(props: TranscribeControlProps) {

return (
<>
{/* 오른쪽 위 공유 버튼 밑에 두므로 상태 표시는 버튼 왼쪽에 오른쪽 정렬한다. */}
<div className="flex flex-row-reverse items-start gap-3">
{/* 오른쪽 위 공유 버튼 밑에 두므로 버튼과 상태 표시를 오른쪽 정렬한다. */}
<div className="flex max-w-[360px] flex-col items-end gap-2">
<button
type="button"
onClick={handleClick}
aria-label={label}
aria-pressed={isEnabled}
title={label}
className={clsx(
MIC_BUTTON_CLASS,
RECORD_BUTTON_CLASS,
isEnabled
? 'border-semantic-error bg-semantic-error text-default-white'
: 'border-default-black2 bg-default-white text-default-black',
: 'border-default-black bg-default-white text-default-black hover:bg-default-dim2',
)}
>
{isEnabled ? (
<RiMicFill className="size-full" />
) : (
<RiMicOffFill className="size-full" />
)}
</button>

<div className="flex max-w-[360px] flex-col items-end gap-2 pt-2">
{isEnabled && (
<p className={STATUS_PILL_CLASS} aria-live="polite">
<>
<span
aria-hidden="true"
className={clsx(
'size-2.5 rounded-full',
isListening
? 'animate-pulse bg-semantic-error'
: 'bg-default-neutral',
'size-2.5 rounded-full bg-default-white',
isListening && 'animate-pulse',
)}
/>
{isListening ? t('녹음 중') : t('타이머를 시작하면 기록해요')}
<span className="font-normal text-default-white/70">
{t('{{count}}문장', { count: utteranceCount })}
</span>
</p>
)}
{isEnabled && pendingCount > 0 && (
<p className={STATUS_PILL_CLASS} aria-live="polite">
{t('{{count}}개 구간 전사 중', { count: pendingCount })}
</p>
)}
{failedCount > 0 && (
<p className={clsx(STATUS_PILL_CLASS, 'bg-semantic-error/90')}>
{t('{{count}}개 구간 전사 실패', { count: failedCount })}
</p>
)}
{error === 'not-allowed' && (
<p className={STATUS_PILL_CLASS}>
{t('마이크 권한을 허용해야 토론을 기록할 수 있어요')}
</p>
{t('토론 녹음 중')}
</>
) : (
<>
<RiMicFill aria-hidden="true" className="size-5" />
{t('토론 녹화하기')}
</>
)}
</div>
</button>

{isEnabled && (
<p className={STATUS_PILL_CLASS} aria-live="polite">
{isListening
? t('{{count}}문장 기록', { count: utteranceCount })
: t('타이머를 시작하면 기록해요')}
</p>
)}
{isEnabled && pendingCount > 0 && (
<p className={STATUS_PILL_CLASS} aria-live="polite">
{t('{{count}}개 구간 전사 중', { count: pendingCount })}
</p>
)}
{failedCount > 0 && (
<p className={clsx(STATUS_PILL_CLASS, 'bg-semantic-error/90')}>
{t('{{count}}개 구간 전사 실패', { count: failedCount })}
</p>
)}
{error === 'not-allowed' && (
<p className={STATUS_PILL_CLASS}>
{t('마이크 권한을 허용해야 토론을 기록할 수 있어요')}
</p>
)}
</div>

<ModalWrapper>
Expand Down
Loading
Loading