340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
// 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 { MeetingChatPanel } from './MeetingChatPanel'
|
|
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>
|
|
|
|
{/* AI 채팅 패널 */}
|
|
<MeetingChatPanel sessionId={detail.id} />
|
|
|
|
{/* 문서 생성 다이얼로그 */}
|
|
<AddDocumentDialog
|
|
open={dialogOpen}
|
|
onClose={() => setDialogOpen(false)}
|
|
onGenerate={handleGenerate}
|
|
templates={templates}
|
|
generating={generating}
|
|
/>
|
|
</Box>
|
|
)
|
|
}
|