d3ro-voice/apps/desktop/src/renderer/pages/MeetingModePage.tsx
yunchan8804 a041f1b6a9 feat(V2-1c): packages/ui 추출 — DS 컴포넌트 + theme + theme-vars 분리
packages/ui (@d3ro/ui) 신규:
- src/theme.ts (d3roPalette/d3roTypo/d3roShadow/d3roRadius SSOT)
- src/theme-vars.ts (팝업/main 프로세스용 CSS 변수 맵)
- src/components/ds/ (CrtDisplay, InstrumentPanel, Led, MetalCard,
  MetalDial, PhosphorText, PhysicalButton, ScreenPanel, ButtonGroup)
- src/index.ts barrel
- subpath exports: ./theme, ./theme-vars, ./components/ds
- React/MUI/Emotion은 peerDependencies로 선언
- @d3ro/core만 직접 의존성

apps/desktop/src/shared/ 디렉토리 완전 제거:
- theme-vars가 마지막 남은 파일이었음
- tsconfig include에서 src/shared/**/* 제거

일괄 치환 (renderer 전역):
- ../theme, ../../theme, ./theme → @d3ro/ui/theme
- ../components/ds, ../../components/ds, ./ds, ../ds → @d3ro/ui/components/ds
- ../ds/<Component>, ../../ds/<Component> → @d3ro/ui/components/ds
  (세부 파일 import는 barrel로 통합)
- @shared/theme-vars → @d3ro/ui/theme-vars (WindowManager)

apps/desktop 설정:
- package.json: @d3ro/ui: '*' dep 추가
- tsconfig.node/web.json: @shared/* paths 완전 제거, @d3ro/ui,
  @d3ro/ui/* paths 추가
- electron.vite.config.ts: @shared alias 제거, @d3ro/ui alias 추가,
  externalize exclude에 @d3ro/ui 추가
- vitest.config.ts: alias 교체

DS 컴포넌트 내부의 '../../theme' 상대 경로는 packages/ui 구조에서
동일하게 해결되어 그대로 유효.

검증: typecheck + build + dev 런타임 모두 통과.
2026-04-08 14:50:33 +09:00

489 lines
17 KiB
TypeScript

// src/renderer/pages/MeetingModePage.tsx
// Phase 14: Meeting Mode — 실시간 녹음 + 메모 + 회의록 UI
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Box,
LinearProgress,
TextField,
Snackbar,
Alert,
} from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import StopIcon from '@mui/icons-material/Stop'
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '@d3ro/ui/components/ds'
import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs'
import { EditableSegment } from '../components/meeting/EditableSegment'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '../i18n'
import type {
MeetingSessionSummary,
MeetingSessionDetail,
MeetingModeStateInfo,
MeetingProcessingProgress,
CaptionSegment,
MeetingMemo,
} from '@d3ro/core/types'
type MeetingView = 'list' | 'recording' | 'detail'
export function MeetingModePage(): React.ReactElement {
const { t } = useI18n()
const [view, setView] = useState<MeetingView>('list')
const [sessions, setSessions] = useState<MeetingSessionSummary[]>([])
const [totalSessions, setTotalSessions] = useState(0)
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const pageSize = 20
// 녹음 뷰 상태
const [stateInfo, setStateInfo] = useState<MeetingModeStateInfo | null>(null)
const [segments, setSegments] = useState<CaptionSegment[]>([])
const [memos, setMemos] = useState<MeetingMemo[]>([])
const [memoInput, setMemoInput] = useState('')
const [elapsedMs, setElapsedMs] = useState(0)
const [progress, setProgress] = useState<MeetingProcessingProgress | null>(null)
const [audioLevel, setAudioLevel] = useState(0)
const transcriptRef = useRef<HTMLDivElement>(null)
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const recordingStartRef = useRef<number>(0)
// 상세 뷰 상태
const [detail, setDetail] = useState<MeetingSessionDetail | null>(null)
const [snackbarMsg, setSnackbarMsg] = useState<string | null>(null)
// ── 세션 목록 로드 ──
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') {
// 후처리 완료 또는 에러 → 목록으로 이동 + 새로고침
setProgress(null)
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')
})
const unsubAudioLevel = window.electronAPI.meetingMode.onAudioLevel((data) => {
setAudioLevel(Math.min(data.level * 10, 1)) // RMS 정규화
})
return () => {
unsubState()
unsubSegment()
unsubProgress()
unsubCompleted()
unsubError()
unsubAudioLevel()
}
}, [view])
// ── 경과 시간 타이머 ──
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')
} else {
setSnackbarMsg(t('meeting.startRecordingError'))
}
}, [t])
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')
} else {
setSnackbarMsg(t('meeting.viewDetailError'))
}
}, [t])
// ── 유틸 ──
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'
}
}
const snackbar = (
<Snackbar
open={snackbarMsg !== null}
autoHideDuration={4000}
onClose={() => setSnackbarMsg(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setSnackbarMsg(null)} sx={{ width: '100%' }}>
{snackbarMsg}
</Alert>
</Snackbar>
)
// ── 렌더: 목록 뷰 ──
if (view === 'list') {
return (
<Box sx={{ p: 3 }}>
<PageHeader
title={t('meeting.title')}
count={totalSessions > 0 ? t('meeting.sessions', { count: totalSessions }) : undefined}
action={
<PhysicalButton size="small" onClick={handleStartRecording}>
<AddIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.newMeeting')}
</PhysicalButton>
}
/>
{loading ? (
<LinearProgress sx={{ mt: 2 }} />
) : sessions.length === 0 ? (
<EmptyStateCard message={`${t('meeting.noSessions')}\n${t('meeting.noSessionsDesc')}`} />
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
lg: 'repeat(4, 1fr)',
},
gap: 1.5,
}}
>
{sessions.map((session) => (
<Box
key={session.id}
onClick={() => handleViewDetail(session.id)}
sx={{
cursor: 'pointer',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
'&:hover': { transform: 'translateY(-2px)' },
}}
>
<MetalCard>
{/* 제목 — 여러 줄 허용 */}
<PhosphorText
variant="heading"
sx={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
mb: 1.5,
}}
>
{session.title ?? t('meeting.untitled')}
</PhosphorText>
{/* 정보 그리드 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size, fontFamily: d3roFontMono }}>
{formatDate(session.startedAt)}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size, fontFamily: d3roFontMono }}>
{session.durationMs != null
? t('meeting.duration', { minutes: Math.round(session.durationMs / 60000) })
: '—'}
</PhosphorText>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size, fontFamily: d3roFontMono }}>
{t('meeting.memos')}: {session.memoCount}
</PhosphorText>
</Box>
</Box>
{/* 하단 — 상태 표시 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pt: 1, borderTop: `1px solid ${d3roPalette.border.subtle}` }}>
<Led color={statusColor(session.status)} size={6} />
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size, textTransform: 'uppercase', letterSpacing: d3roTypo.label.spacing }}>
{session.status === 'completed' ? t('meeting.completed')
: session.status === 'error' ? t('meeting.error')
: session.status === 'processing' ? t('meeting.processing')
: t('meeting.recording')}
</PhosphorText>
</Box>
{/* 프로세싱 진행 바 */}
{session.status === 'processing' && progress && progress.sessionId === session.id && (
<Box sx={{ mt: 1 }}>
<LinearProgress
variant="determinate"
value={progress.percent}
sx={{
height: 3,
borderRadius: 2,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.amber },
}}
/>
<PhosphorText variant="dim" sx={{ mt: 0.5, fontSize: d3roTypo.nano.size }}>
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])}
</PhosphorText>
</Box>
)}
</MetalCard>
</Box>
))}
</Box>
)}
{snackbar}
</Box>
)
}
// ── 렌더: 녹음 뷰 ──
if (view === 'recording') {
const isProcessing = stateInfo?.state === 'processing'
return (
<Box sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 상단 바 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<FiberManualRecordIcon sx={{ color: d3roPalette.tag.red, fontSize: 16, animation: 'pulse 1s infinite' }} />
<PhosphorText variant="label" sx={{ fontFamily: d3roFontMono }}>
{formatTime(elapsedMs)}
</PhosphorText>
<PhosphorText variant="dim">
{isProcessing ? t('meeting.processing') : t('meeting.recording')}
</PhosphorText>
</Box>
{!isProcessing && (
<PhysicalButton size="small" color="error" onClick={handleStopRecording}>
<StopIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.stopRecording')}
</PhysicalButton>
)}
</Box>
{/* 프로세싱 진행률 */}
{isProcessing && progress && (
<Box sx={{ mb: 2 }}>
<LinearProgress variant="determinate" value={progress.percent} sx={{ height: 6, borderRadius: 3 }} />
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])} ({progress.percent}%)
</PhosphorText>
</Box>
)}
{/* 메인 패널: 전사 + 메모 */}
<Box sx={{ flex: 1, display: 'flex', gap: 2, minHeight: 0 }}>
{/* 좌: 실시간 전사 */}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<ScreenPanel>
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
{t('meeting.transcript')}
</PhosphorText>
<Box
ref={transcriptRef}
sx={{
flex: 1,
overflow: 'auto',
fontFamily: d3roFontMono,
fontSize: d3roTypo.body.size,
color: d3roPalette.text.primary,
lineHeight: 1.8,
}}
>
{segments.map((seg) => (
<EditableSegment
key={seg.id}
segmentId={seg.id}
timestamp={Math.max(0, seg.timestamp - (recordingStartRef.current || seg.timestamp))}
text={seg.text}
edited={false}
onEdit={() => { /* no-op: readOnly=true */ }}
readOnly
/>
))}
</Box>
</ScreenPanel>
</Box>
{/* 우: 메모 입력 */}
<Box sx={{ width: 300, display: 'flex', flexDirection: 'column' }}>
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
{t('meeting.memos')}
</PhosphorText>
<Box sx={{ flex: 1, overflow: 'auto', mb: 1 }}>
{memos.map((memo) => (
<Box
key={memo.id}
sx={{
mb: 0.5,
p: 1,
borderRadius: 1,
bgcolor: d3roPalette.bg.chassis,
}}
>
<PhosphorText variant="dim" sx={{ fontSize: 10, fontFamily: d3roFontMono }}>
{formatTime(memo.timestampMs)}
</PhosphorText>
<PhosphorText variant="body" sx={{ fontSize: 12 }}>
{memo.content}
</PhosphorText>
</Box>
))}
</Box>
{!isProcessing && (
<TextField
size="small"
fullWidth
placeholder={t('meeting.memoPlaceholder')}
value={memoInput}
onChange={(e) => setMemoInput(e.target.value)}
onKeyDown={handleMemoKeyDown}
sx={{
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: 13,
},
}}
/>
)}
</Box>
</Box>
{/* 오디오 레벨 미터 */}
{!isProcessing && (
<Box sx={{ mt: 1.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={audioLevel > 0.02 ? 'green' : 'amber'} size={8} />
<Box sx={{
flex: 1,
height: 6,
borderRadius: 3,
bgcolor: d3roPalette.bg.inset,
overflow: 'hidden',
}}>
<Box sx={{
height: '100%',
width: `${Math.max(audioLevel * 100, 0)}%`,
bgcolor: audioLevel > 0.7 ? d3roPalette.tag.red : audioLevel > 0.3 ? d3roPalette.tag.orange : d3roPalette.tag.green,
borderRadius: 3,
transition: 'width 0.1s ease-out',
}} />
</Box>
<PhosphorText variant="dim" sx={{ fontSize: 10, fontFamily: d3roFontMono, minWidth: 30 }}>
{Math.round(audioLevel * 100)}%
</PhosphorText>
</Box>
)}
{snackbar}
</Box>
)
}
// ── 렌더: 상세 뷰 ──
if (view === 'detail' && detail) {
return (
<MeetingDetailTabs
detail={detail}
onBack={() => { setDetail(null); setView('list') }}
/>
)
}
return <Box />
}