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
336
src/renderer/components/meeting/MeetingDetailTabs.tsx
Normal file
336
src/renderer/components/meeting/MeetingDetailTabs.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
// src/renderer/components/meeting/MeetingDetailTabs.tsx
|
||||
// Phase 14.5: 풀스크린 탭 전환 컨테이너 — 전사 + 동적 문서 탭
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Tabs,
|
||||
Tab,
|
||||
IconButton,
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from '@mui/material'
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import CheckIcon from '@mui/icons-material/Check'
|
||||
import { TranscriptTab } from './TranscriptTab'
|
||||
import { DocumentTab } from './DocumentTab'
|
||||
import { AddDocumentDialog } from './AddDocumentDialog'
|
||||
import { PhosphorText } from '../ds/PhosphorText'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme'
|
||||
import { useI18n } from '../../i18n'
|
||||
import type {
|
||||
MeetingSessionDetail,
|
||||
MeetingDocument,
|
||||
MeetingDocTemplate,
|
||||
MeetingExportFormat,
|
||||
} from '@shared/types'
|
||||
|
||||
interface MeetingDetailTabsProps {
|
||||
detail: MeetingSessionDetail
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const AUTOSAVE_DELAY = 500
|
||||
|
||||
// 세그먼트 파싱: rawTranscript를 줄 단위로 분리
|
||||
function parseSegments(
|
||||
rawTranscript: string | null,
|
||||
editedTranscript: string | null,
|
||||
): Array<{ id: string; timestamp: number; text: string; edited: boolean }> {
|
||||
const source = rawTranscript ?? ''
|
||||
const edited = editedTranscript ?? ''
|
||||
// [MM:SS] 텍스트 패턴 파싱 시도
|
||||
const regex = /^\[(\d{2}):(\d{2})\]\s*(.+)$/
|
||||
return source
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line, idx) => {
|
||||
const m = regex.exec(line.trim())
|
||||
if (m) {
|
||||
const min = parseInt(m[1], 10)
|
||||
const sec = parseInt(m[2], 10)
|
||||
const text = m[3]
|
||||
// 수정본이 있으면 edited 판단
|
||||
const editedLine = edited.split('\n')[idx]?.trim() ?? ''
|
||||
return {
|
||||
id: `seg-${idx}`,
|
||||
timestamp: (min * 60 + sec) * 1000,
|
||||
text,
|
||||
edited: Boolean(editedLine) && editedLine !== line.trim(),
|
||||
}
|
||||
}
|
||||
// 타임스탬프 없는 라인: 인덱스 기반
|
||||
return {
|
||||
id: `seg-${idx}`,
|
||||
timestamp: idx * 5000,
|
||||
text: line.trim(),
|
||||
edited: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function MeetingDetailTabs({
|
||||
detail: initialDetail,
|
||||
onBack,
|
||||
}: MeetingDetailTabsProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [detail, setDetail] = useState<MeetingSessionDetail>(initialDetail)
|
||||
const [documents, setDocuments] = useState<MeetingDocument[]>(initialDetail.documents)
|
||||
const [templates, setTemplates] = useState<MeetingDocTemplate[]>([])
|
||||
const [tabIndex, setTabIndex] = useState(0)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleDraft, setTitleDraft] = useState(detail.title ?? '')
|
||||
const autoSaveTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
|
||||
// 세그먼트 파싱 (rawTranscript 기반)
|
||||
const segments = parseSegments(detail.rawTranscript, detail.editedTranscript)
|
||||
|
||||
// 템플릿 로드
|
||||
useEffect(() => {
|
||||
window.electronAPI.meetingDocTemplate.getAll().then((resp) => {
|
||||
if (resp.success) setTemplates(resp.data)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── 제목 저장 ──
|
||||
const handleSaveTitle = useCallback(async () => {
|
||||
if (!titleDraft.trim()) return
|
||||
await window.electronAPI.meetingMode.updateTitle({
|
||||
sessionId: detail.id,
|
||||
title: titleDraft.trim(),
|
||||
})
|
||||
setDetail((prev) => ({ ...prev, title: titleDraft.trim() }))
|
||||
setEditingTitle(false)
|
||||
}, [detail.id, titleDraft])
|
||||
|
||||
// ── 세그먼트 편집 ──
|
||||
const handleEditSegment = useCallback(
|
||||
async (segmentId: string, newText: string) => {
|
||||
await window.electronAPI.meetingMode.editSegment({
|
||||
sessionId: detail.id,
|
||||
segmentId,
|
||||
text: newText,
|
||||
})
|
||||
// 로컬 editedTranscript 갱신 (단순 반영)
|
||||
setDetail((prev) => ({ ...prev, editedTranscript: prev.rawTranscript }))
|
||||
},
|
||||
[detail.id],
|
||||
)
|
||||
|
||||
// ── 전사 저장 ──
|
||||
const handleSaveTranscript = useCallback(
|
||||
async (editedTranscript: string) => {
|
||||
await window.electronAPI.meetingMode.updateTranscript({
|
||||
sessionId: detail.id,
|
||||
editedTranscript,
|
||||
})
|
||||
setDetail((prev) => ({ ...prev, editedTranscript }))
|
||||
},
|
||||
[detail.id],
|
||||
)
|
||||
|
||||
// ── 문서 내용 자동저장 ──
|
||||
const handleDocContentChange = useCallback(
|
||||
(docId: string, content: string) => {
|
||||
if (autoSaveTimers.current[docId]) clearTimeout(autoSaveTimers.current[docId])
|
||||
autoSaveTimers.current[docId] = setTimeout(async () => {
|
||||
await window.electronAPI.meetingMode.updateDocument({
|
||||
documentId: docId,
|
||||
content,
|
||||
})
|
||||
setDocuments((prev) =>
|
||||
prev.map((d) => (d.id === docId ? { ...d, content } : d)),
|
||||
)
|
||||
}, AUTOSAVE_DELAY)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// ── 문서 내보내기 ──
|
||||
const handleExportDocument = useCallback(
|
||||
async (docId: string, format: MeetingExportFormat) => {
|
||||
await window.electronAPI.meetingMode.exportDocument({
|
||||
documentId: docId,
|
||||
format,
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// ── 문서 삭제 ──
|
||||
const handleDeleteDocument = useCallback(
|
||||
async (docId: string) => {
|
||||
await window.electronAPI.meetingMode.deleteDocument({ documentId: docId })
|
||||
setDocuments((prev) => {
|
||||
const docTabIndex = prev.findIndex((d) => d.id === docId) + 1 // +1: 전사 탭
|
||||
setTabIndex((currentTab) => {
|
||||
if (currentTab === docTabIndex) return 0
|
||||
if (currentTab > docTabIndex) return currentTab - 1
|
||||
return currentTab
|
||||
})
|
||||
return prev.filter((d) => d.id !== docId)
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// ── 문서 생성 ──
|
||||
const handleGenerate = useCallback(
|
||||
async (templateId: string, customPrompt?: string, customTitle?: string) => {
|
||||
setGenerating(true)
|
||||
const resp = await window.electronAPI.meetingMode.generateDocument({
|
||||
sessionId: detail.id,
|
||||
templateId,
|
||||
customPrompt,
|
||||
customTitle,
|
||||
})
|
||||
setGenerating(false)
|
||||
if (resp.success) {
|
||||
const newDoc = resp.data.document
|
||||
setDocuments((prev) => {
|
||||
// 새 문서 탭으로 이동: 전사(0) + 기존문서수 + 1
|
||||
setTabIndex(prev.length + 1)
|
||||
return [...prev, newDoc]
|
||||
})
|
||||
setDialogOpen(false)
|
||||
}
|
||||
},
|
||||
[detail.id],
|
||||
)
|
||||
|
||||
// 탭 수: 전사(1) + 문서들 + [+] 버튼(1)
|
||||
const totalTabCount = 1 + documents.length + 1
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* 헤더 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconButton size="small" onClick={onBack}>
|
||||
<ArrowBackIcon sx={{ fontSize: 18, color: d3roPalette.text.secondary }} />
|
||||
</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,
|
||||
'& .MuiInputBase-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size },
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<IconButton size="small" onClick={handleSaveTitle}>
|
||||
<CheckIcon sx={{ fontSize: 16, color: d3roPalette.accent.amber }} />
|
||||
</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: 15, color: d3roPalette.text.inactive }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 탭 바 */}
|
||||
<Box sx={{ borderBottom: `1px solid ${d3roPalette.border.subtle}`, flexShrink: 0 }}>
|
||||
<Tabs
|
||||
value={tabIndex}
|
||||
onChange={(_e, v: number) => {
|
||||
// 마지막 탭([+])은 다이얼로그 열기
|
||||
if (v === totalTabCount - 1) {
|
||||
setDialogOpen(true)
|
||||
return
|
||||
}
|
||||
setTabIndex(v)
|
||||
}}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
'& .MuiTab-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
minHeight: 40,
|
||||
py: 0,
|
||||
color: d3roPalette.text.inactive,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1px',
|
||||
'&.Mui-selected': { color: d3roPalette.accent.amber },
|
||||
},
|
||||
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.amber, height: 2 },
|
||||
}}
|
||||
>
|
||||
<Tab label={t('meeting.transcriptTab')} />
|
||||
{documents.map((doc) => (
|
||||
<Tab key={doc.id} label={doc.title} />
|
||||
))}
|
||||
<Tab
|
||||
icon={<AddIcon sx={{ fontSize: 16 }} />}
|
||||
label={t('meeting.addDocument')}
|
||||
iconPosition="start"
|
||||
sx={{ minWidth: 120 }}
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 2, minHeight: 0 }}>
|
||||
{tabIndex === 0 && (
|
||||
<TranscriptTab
|
||||
sessionId={detail.id}
|
||||
segments={segments}
|
||||
rawTranscript={detail.rawTranscript}
|
||||
editedTranscript={detail.editedTranscript}
|
||||
memos={detail.memos}
|
||||
onEditSegment={handleEditSegment}
|
||||
onSaveTranscript={handleSaveTranscript}
|
||||
/>
|
||||
)}
|
||||
{documents.map((doc, idx) =>
|
||||
tabIndex === idx + 1 ? (
|
||||
<DocumentTab
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
onContentChange={(content) => handleDocContentChange(doc.id, content)}
|
||||
onExport={(format) => handleExportDocument(doc.id, format)}
|
||||
onDelete={() => handleDeleteDocument(doc.id)}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 문서 생성 다이얼로그 */}
|
||||
<AddDocumentDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onGenerate={handleGenerate}
|
||||
templates={templates}
|
||||
generating={generating}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue