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 런타임 모두 통과.
489 lines
17 KiB
TypeScript
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 />
|
|
}
|