// src/renderer/pages/MeetingModePage.tsx // Phase 14: Meeting Mode — 실시간 녹음 + 메모 + 회의록 UI import { useState, useEffect, useCallback, useRef } from 'react' import { Box, TextField, IconButton, Tooltip, LinearProgress, Typography, } from '@mui/material' import AddIcon from '@mui/icons-material/Add' import StopIcon from '@mui/icons-material/Stop' import ArrowBackIcon from '@mui/icons-material/ArrowBack' import DeleteIcon from '@mui/icons-material/Delete' import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf' import DescriptionIcon from '@mui/icons-material/Description' import EditIcon from '@mui/icons-material/Edit' import CheckIcon from '@mui/icons-material/Check' import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord' import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '../components/ds' import { PageHeader, EmptyStateCard } from '../components/shared' import { d3roPalette, d3roFontMono, d3roTypo } from '../theme' import { useI18n } from '../i18n' import type { MeetingSessionSummary, MeetingSessionDetail, MeetingModeStateInfo, MeetingProcessingProgress, CaptionSegment, MeetingMemo, } from '@shared/types' type MeetingView = 'list' | 'recording' | 'detail' export function MeetingModePage(): React.ReactElement { const { t } = useI18n() const [view, setView] = useState('list') const [sessions, setSessions] = useState([]) const [totalSessions, setTotalSessions] = useState(0) const [loading, setLoading] = useState(true) const [page, setPage] = useState(1) const pageSize = 20 // 녹음 뷰 상태 const [stateInfo, setStateInfo] = useState(null) const [segments, setSegments] = useState([]) const [memos, setMemos] = useState([]) const [memoInput, setMemoInput] = useState('') const [elapsedMs, setElapsedMs] = useState(0) const [progress, setProgress] = useState(null) const transcriptRef = useRef(null) const elapsedTimerRef = useRef | null>(null) const recordingStartRef = useRef(0) // 상세 뷰 상태 const [detail, setDetail] = useState(null) const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState('') // ── 세션 목록 로드 ── const loadSessions = useCallback(async () => { setLoading(true) const resp = await window.electronAPI.meetingMode.getSessions({ page, pageSize }) if (resp.success) { setSessions(resp.data.sessions) setTotalSessions(resp.data.total) } setLoading(false) }, [page]) useEffect(() => { if (view === 'list') loadSessions() }, [view, loadSessions]) // ── IPC 이벤트 구독 ── useEffect(() => { const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => { setStateInfo(info) if (info.state === 'idle' && view === 'recording') { // 후처리 완료 → 목록으로 이동 if (!progress) { setView('list') } } }) const unsubSegment = window.electronAPI.meetingMode.onSegment((seg) => { setSegments((prev) => [...prev, seg]) // 자동 스크롤 setTimeout(() => { if (transcriptRef.current) { transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight } }, 50) }) const unsubProgress = window.electronAPI.meetingMode.onProcessingProgress((p) => { setProgress(p) }) const unsubCompleted = window.electronAPI.meetingMode.onSessionCompleted(() => { setProgress(null) setView('list') }) const unsubError = window.electronAPI.meetingMode.onError(() => { setProgress(null) setView('list') }) return () => { unsubState() unsubSegment() unsubProgress() unsubCompleted() unsubError() } }, [view, progress]) // ── 경과 시간 타이머 ── useEffect(() => { if (view === 'recording') { recordingStartRef.current = Date.now() elapsedTimerRef.current = setInterval(() => { setElapsedMs(Date.now() - recordingStartRef.current) }, 1000) } return () => { if (elapsedTimerRef.current) { clearInterval(elapsedTimerRef.current) elapsedTimerRef.current = null } } }, [view]) // ── 핸들러 ── const handleStartRecording = useCallback(async () => { const resp = await window.electronAPI.meetingMode.startRecording() if (resp.success) { setSegments([]) setMemos([]) setElapsedMs(0) setProgress(null) setView('recording') } }, []) const handleStopRecording = useCallback(async () => { await window.electronAPI.meetingMode.stopRecording() }, []) const handleAddMemo = useCallback(async () => { if (!memoInput.trim()) return const resp = await window.electronAPI.meetingMode.addMemo({ content: memoInput.trim() }) if (resp.success) { setMemos((prev) => [...prev, resp.data]) setMemoInput('') } }, [memoInput]) const handleMemoKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleAddMemo() } }, [handleAddMemo], ) const handleViewDetail = useCallback(async (sessionId: string) => { const resp = await window.electronAPI.meetingMode.getSession({ sessionId }) if (resp.success) { setDetail(resp.data) setView('detail') } }, []) const handleDelete = useCallback(async () => { if (!detail) return await window.electronAPI.meetingMode.deleteSession({ sessionId: detail.id }) setDetail(null) setView('list') }, [detail]) const handleExportPdf = useCallback(async () => { if (!detail) return await window.electronAPI.meetingMode.exportPdf({ sessionId: detail.id }) }, [detail]) const handleExportMarkdown = useCallback(async () => { if (!detail) return await window.electronAPI.meetingMode.exportMarkdown({ sessionId: detail.id }) }, [detail]) const handleSaveTitle = useCallback(async () => { if (!detail || !titleDraft.trim()) return await window.electronAPI.meetingMode.updateTitle({ sessionId: detail.id, title: titleDraft.trim(), }) setDetail({ ...detail, title: titleDraft.trim() }) setEditingTitle(false) }, [detail, titleDraft]) // ── 유틸 ── const formatTime = (ms: number): string => { const totalSec = Math.floor(ms / 1000) const min = Math.floor(totalSec / 60) const sec = totalSec % 60 return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}` } const formatDate = (ts: number): string => { const d = new Date(ts) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` } const statusColor = (status: string): 'green' | 'amber' | 'red' | 'blue' => { switch (status) { case 'completed': return 'green' case 'recording': return 'red' case 'processing': return 'amber' case 'error': return 'red' default: return 'blue' } } // ── 렌더: 목록 뷰 ── if (view === 'list') { return ( 0 ? t('meeting.sessions', { count: totalSessions }) : undefined} action={ {t('meeting.newMeeting')} } /> {loading ? ( ) : sessions.length === 0 ? ( ) : ( {sessions.map((session) => ( handleViewDetail(session.id)} > {session.title ?? t('meeting.untitled')} {session.durationMs != null ? t('meeting.duration', { minutes: Math.round(session.durationMs / 60000) }) : '—'} {' · '} {t('meeting.memos')}: {session.memoCount} {formatDate(session.startedAt)} {/* 프로세싱 중인 세션 표시 */} {session.status === 'processing' && progress && progress.sessionId === session.id && ( {t(`meeting.processingStep.${progress.step}` as Parameters[0])} )} ))} )} ) } // ── 렌더: 녹음 뷰 ── if (view === 'recording') { const isProcessing = stateInfo?.state === 'processing' return ( {/* 상단 바 */} {formatTime(elapsedMs)} {isProcessing ? t('meeting.processing') : t('meeting.recording')} {!isProcessing && ( {t('meeting.stopRecording')} )} {/* 프로세싱 진행률 */} {isProcessing && progress && ( {t(`meeting.processingStep.${progress.step}` as Parameters[0])} ({progress.percent}%) )} {/* 메인 패널: 전사 + 메모 */} {/* 좌: 실시간 전사 */} {t('meeting.transcript')} {segments.map((seg) => ( [{formatTime(seg.timestamp)}] {seg.text} ))} {/* 우: 메모 입력 */} {t('meeting.memos')} {memos.map((memo) => ( {formatTime(memo.timestampMs)} {memo.content} ))} {!isProcessing && ( setMemoInput(e.target.value)} onKeyDown={handleMemoKeyDown} sx={{ '& .MuiInputBase-root': { fontFamily: d3roFontMono, fontSize: 13, }, }} /> )} ) } // ── 렌더: 상세 뷰 ── if (view === 'detail' && detail) { const durationMin = detail.durationMs ? Math.round(detail.durationMs / 60000) : 0 return ( {/* 헤더 */} { setDetail(null); setView('list') }}> {editingTitle ? ( setTitleDraft(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()} sx={{ flex: 1 }} autoFocus /> ) : ( <> {detail.title ?? t('meeting.untitled')} { setEditingTitle(true); setTitleDraft(detail.title ?? '') }} > )} {/* 메타 */} {formatDate(detail.startedAt)} {detail.endedAt ? ` ~ ${new Date(detail.endedAt).getHours()}:${String(new Date(detail.endedAt).getMinutes()).padStart(2, '0')}` : ''} {' · '} {t('meeting.duration', { minutes: durationMin })} {/* 회의록 본문 */} {detail.minutes && ( <> {/* 요약 */} {t('meeting.summary')} {detail.minutes.summary} {/* 결정사항 */} {detail.minutes.decisions.length > 0 && ( {t('meeting.decisions')} {detail.minutes.decisions.map((d, i) => ( {'• '}{d} ))} )} {/* 할 일 */} {detail.minutes.actionItems.length > 0 && ( {t('meeting.actionItems')} {detail.minutes.actionItems.map((item, i) => ( {'☐ '}{item.assignee ? `${item.assignee}: ` : ''}{item.task} {item.deadline ? ` (${item.deadline})` : ''} ))} )} {/* 타임라인 */} {detail.minutes.timeline.length > 0 && ( {t('meeting.timeline')} {detail.minutes.timeline.map((tl, i) => ( {tl.time} {tl.type === 'memo' ? '📝 ' : ''}{tl.content} ))} )} )} {/* 에러 메시지 */} {detail.status === 'error' && detail.errorMessage && ( {t('meeting.error')} {detail.errorMessage} )} {/* 메모 */} {detail.memos.length > 0 && ( {t('meeting.memos')} {detail.memos.map((m) => ( {formatTime(m.timestampMs)} {m.content} ))} )} {/* 액션 버튼 */} {t('meeting.exportPdf')} {t('meeting.exportMarkdown')} {t('meeting.delete')} ) } return }