Phase 14.5 구현: 회의 모드 고도화 — 다중 문서 생성/편집/내보내기
PLAUD 수준의 기능 확장: - 전사 편집: 인라인 편집 + 원본 보존 + 수정됨 표시(점선 밑줄) - 다중 문서 생성: 회의록/보고서/아이디어노트/커스텀 템플릿 - MD 에디터: 렌더링/편집 토글 (react-markdown + remark-gfm) - 상세 페이지 리디자인: 풀스크린 탭 전환 구조 - 내보내기: MD, PDF, TXT, DOCX 4종 (docx 패키지) - 커스텀 프롬프트 템플릿 저장/재사용 (electron-store) - SSOT: 마크다운 파싱 유틸 추출, 후처리에서 자동 문서 생성 제거 DB: meeting_documents 테이블 + edited_transcript 컬럼 IPC: 8+4 채널, 서비스 2개(MeetingModeService 확장 + MeetingDocTemplateService 신규) UI: 8개 신규 컴포넌트 (meeting/ 디렉토리), 12개 locale i18n
This commit is contained in:
parent
6197ceb132
commit
d4928ffa60
38 changed files with 4791 additions and 438 deletions
|
|
@ -4,22 +4,15 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
TextField,
|
||||
} 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 { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs'
|
||||
import { EditableSegment } from '../components/meeting/EditableSegment'
|
||||
import { PageHeader, EmptyStateCard } from '../components/shared'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
|
|
@ -57,8 +50,6 @@ export function MeetingModePage(): React.ReactElement {
|
|||
|
||||
// 상세 뷰 상태
|
||||
const [detail, setDetail] = useState<MeetingSessionDetail | null>(null)
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleDraft, setTitleDraft] = useState('')
|
||||
|
||||
// ── 세션 목록 로드 ──
|
||||
const loadSessions = useCallback(async () => {
|
||||
|
|
@ -183,32 +174,6 @@ export function MeetingModePage(): React.ReactElement {
|
|||
}
|
||||
}, [])
|
||||
|
||||
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 => {
|
||||
|
|
@ -355,17 +320,14 @@ export function MeetingModePage(): React.ReactElement {
|
|||
}}
|
||||
>
|
||||
{segments.map((seg) => (
|
||||
<Box key={seg.id} sx={{ mb: 0.5 }}>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ color: d3roPalette.text.inactive, fontSize: 11, mr: 1, fontFamily: d3roFontMono }}
|
||||
>
|
||||
[{formatTime(seg.timestamp - (recordingStartRef.current || seg.timestamp))}]
|
||||
</Typography>
|
||||
<Typography component="span" sx={{ fontSize: 13, fontFamily: d3roFontMono }}>
|
||||
{seg.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
<EditableSegment
|
||||
key={seg.id}
|
||||
segmentId={seg.id}
|
||||
timestamp={Math.max(0, seg.timestamp - (recordingStartRef.current || seg.timestamp))}
|
||||
text={seg.text}
|
||||
edited={false}
|
||||
onEdit={() => { /* 녹음 중 편집 비활성 */ }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</ScreenPanel>
|
||||
|
|
@ -445,156 +407,11 @@ export function MeetingModePage(): React.ReactElement {
|
|||
|
||||
// ── 렌더: 상세 뷰 ──
|
||||
if (view === 'detail' && detail) {
|
||||
const durationMin = detail.durationMs ? Math.round(detail.durationMs / 60000) : 0
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, overflow: 'auto' }}>
|
||||
{/* 헤더 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<IconButton size="small" onClick={() => { setDetail(null); setView('list') }}>
|
||||
<ArrowBackIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{editingTitle ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()}
|
||||
sx={{ flex: 1 }}
|
||||
autoFocus
|
||||
/>
|
||||
<IconButton size="small" onClick={handleSaveTitle}>
|
||||
<CheckIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<PhosphorText variant="label" sx={{ flex: 1 }}>
|
||||
{detail.title ?? t('meeting.untitled')}
|
||||
</PhosphorText>
|
||||
<Tooltip title={t('meeting.editTitle')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => { setEditingTitle(true); setTitleDraft(detail.title ?? '') }}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 메타 */}
|
||||
<PhosphorText variant="dim" sx={{ mb: 3 }}>
|
||||
{formatDate(detail.startedAt)}
|
||||
{detail.endedAt ? ` ~ ${new Date(detail.endedAt).getHours()}:${String(new Date(detail.endedAt).getMinutes()).padStart(2, '0')}` : ''}
|
||||
{' · '}
|
||||
{t('meeting.duration', { minutes: durationMin })}
|
||||
</PhosphorText>
|
||||
|
||||
{/* 회의록 본문 */}
|
||||
{detail.minutes && (
|
||||
<>
|
||||
{/* 요약 */}
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.summary')}</PhosphorText>
|
||||
<PhosphorText variant="body" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{detail.minutes.summary}
|
||||
</PhosphorText>
|
||||
</MetalCard>
|
||||
|
||||
{/* 결정사항 */}
|
||||
{detail.minutes.decisions.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.decisions')}</PhosphorText>
|
||||
{detail.minutes.decisions.map((d, i) => (
|
||||
<PhosphorText key={i} variant="body" sx={{ mb: 0.5 }}>
|
||||
{'• '}{d}
|
||||
</PhosphorText>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 할 일 */}
|
||||
{detail.minutes.actionItems.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.actionItems')}</PhosphorText>
|
||||
{detail.minutes.actionItems.map((item, i) => (
|
||||
<PhosphorText key={i} variant="body" sx={{ mb: 0.5 }}>
|
||||
{'☐ '}{item.assignee ? `${item.assignee}: ` : ''}{item.task}
|
||||
{item.deadline ? ` (${item.deadline})` : ''}
|
||||
</PhosphorText>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 타임라인 */}
|
||||
{detail.minutes.timeline.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.timeline')}</PhosphorText>
|
||||
{detail.minutes.timeline.map((tl, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 2, mb: 0.5 }}>
|
||||
<PhosphorText variant="dim" sx={{ fontFamily: d3roFontMono, minWidth: 50 }}>
|
||||
{tl.time}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="body" sx={{ flex: 1 }}>
|
||||
{tl.type === 'memo' ? '📝 ' : ''}{tl.content}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{detail.status === 'error' && detail.errorMessage && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.tag.red, mb: 1 }}>
|
||||
{t('meeting.error')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="body">{detail.errorMessage}</PhosphorText>
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 메모 */}
|
||||
{detail.memos.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.memos')}</PhosphorText>
|
||||
{detail.memos.map((m) => (
|
||||
<Box key={m.id} sx={{ display: 'flex', gap: 2, mb: 0.5 }}>
|
||||
<PhosphorText variant="dim" sx={{ fontFamily: d3roFontMono, minWidth: 50 }}>
|
||||
{formatTime(m.timestampMs)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="body">{m.content}</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 3 }}>
|
||||
<Tooltip title={t('meeting.exportPdf')}>
|
||||
<PhysicalButton size="small" onClick={handleExportPdf}>
|
||||
<PictureAsPdfIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.exportPdf')}
|
||||
</PhysicalButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('meeting.exportMarkdown')}>
|
||||
<PhysicalButton size="small" onClick={handleExportMarkdown}>
|
||||
<DescriptionIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.exportMarkdown')}
|
||||
</PhysicalButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('meeting.delete')}>
|
||||
<PhysicalButton size="small" color="error" onClick={handleDelete}>
|
||||
<DeleteIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.delete')}
|
||||
</PhysicalButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<MeetingDetailTabs
|
||||
detail={detail}
|
||||
onBack={() => { setDetail(null); setView('list') }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue