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:
Yun Chan 2026-04-08 09:53:54 +09:00
parent 6197ceb132
commit d4928ffa60
38 changed files with 4791 additions and 438 deletions

View file

@ -0,0 +1,190 @@
// src/renderer/components/meeting/AddDocumentDialog.tsx
// Phase 14.5: 문서 생성 다이얼로그 — 템플릿 선택 + 커스텀 프롬프트
import { useState, useCallback } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Box,
TextField,
LinearProgress,
} from '@mui/material'
import { MetalCard } from '../ds/MetalCard'
import { PhosphorText } from '../ds/PhosphorText'
import { PhysicalButton } from '../ds/PhysicalButton'
import { d3roPalette, d3roTypo, d3roRadius, d3roShadow } from '../../theme'
import { useI18n } from '../../i18n'
interface TemplateItem {
id: string
name: string
description: string
templateType: string
isBuiltin: boolean
}
interface AddDocumentDialogProps {
open: boolean
onClose: () => void
onGenerate: (templateId: string, customPrompt?: string, customTitle?: string) => void
templates: TemplateItem[]
generating: boolean
}
export function AddDocumentDialog({
open,
onClose,
onGenerate,
templates,
generating,
}: AddDocumentDialogProps): React.ReactElement {
const { t } = useI18n()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [customPrompt, setCustomPrompt] = useState('')
const [customTitle, setCustomTitle] = useState('')
const selectedTemplate = templates.find((tp) => tp.id === selectedId)
const isCustom = selectedTemplate?.templateType === 'custom'
const handleGenerate = useCallback(() => {
if (!selectedId) return
onGenerate(
selectedId,
isCustom && customPrompt.trim() ? customPrompt.trim() : undefined,
isCustom && customTitle.trim() ? customTitle.trim() : undefined,
)
}, [selectedId, isCustom, customPrompt, customTitle, onGenerate])
const handleClose = useCallback(() => {
if (!generating) {
setSelectedId(null)
setCustomPrompt('')
setCustomTitle('')
onClose()
}
}, [generating, onClose])
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
slotProps={{
paper: {
sx: {
bgcolor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.inner,
boxShadow: d3roShadow.tooltip,
},
},
}}
>
<DialogTitle>
<PhosphorText variant="label">{t('meeting.selectTemplate')}</PhosphorText>
</DialogTitle>
<DialogContent>
{generating && (
<Box sx={{ mb: 2 }}>
<LinearProgress
sx={{
height: 4,
borderRadius: 2,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.amber },
}}
/>
<PhosphorText variant="dim" sx={{ fontSize: 11, mt: 0.5 }}>
{t('meeting.generating')}
</PhosphorText>
</Box>
)}
{/* 템플릿 목록 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{templates.map((tp) => (
<MetalCard
key={tp.id}
onClick={() => !generating && setSelectedId(tp.id)}
sx={{
cursor: generating ? 'default' : 'pointer',
opacity: generating ? 0.6 : 1,
border:
selectedId === tp.id
? `1px solid ${d3roPalette.accent.amber}`
: `1px solid ${d3roPalette.border.subtle}`,
transition: 'border-color 0.15s',
'&:hover': {
borderColor: generating ? undefined : d3roPalette.accent.amberDim,
},
}}
>
<PhosphorText variant="compact">{tp.name}</PhosphorText>
<PhosphorText variant="dim" sx={{ fontSize: 11, mt: 0.25 }}>
{tp.description}
</PhosphorText>
</MetalCard>
))}
</Box>
{/* 커스텀 템플릿 추가 입력 */}
{isCustom && (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<TextField
label={t('meeting.templateName')}
size="small"
fullWidth
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
disabled={generating}
sx={{
'& .MuiInputBase-root': { fontSize: d3roTypo.compact.size },
}}
/>
<TextField
label={t('meeting.customPrompt')}
size="small"
fullWidth
multiline
rows={3}
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
disabled={generating}
placeholder={t('meeting.templatePrompt')}
sx={{
'& .MuiInputBase-root': { fontSize: d3roTypo.compact.size },
}}
/>
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<PhysicalButton
size="small"
onClick={handleClose}
disabled={generating}
sx={{ height: 36, fontSize: d3roTypo.engrave.size }}
>
{t('common.cancel')}
</PhysicalButton>
<PhysicalButton
size="small"
onClick={handleGenerate}
disabled={!selectedId || generating}
sx={{
height: 36,
fontSize: d3roTypo.engrave.size,
color: d3roPalette.accent.amber,
}}
>
{generating ? t('meeting.generating') : t('meeting.generate')}
</PhysicalButton>
</DialogActions>
</Dialog>
)
}

View file

@ -0,0 +1,89 @@
// src/renderer/components/meeting/DocumentTab.tsx
// Phase 14.5: 문서 탭 — MarkdownEditor + 자동저장 + 내보내기
import { useCallback, useRef } from 'react'
import { Box } from '@mui/material'
import DeleteIcon from '@mui/icons-material/Delete'
import { MarkdownEditor } from './MarkdownEditor'
import { ExportMenu } from './ExportMenu'
import { PhysicalButton } from '../ds/PhysicalButton'
import { d3roPalette, d3roTypo } from '../../theme'
import { useI18n } from '../../i18n'
import type { MeetingExportFormat } from '@shared/types'
interface DocumentTabDoc {
id: string
title: string
content: string
templateType: string
}
interface DocumentTabProps {
document: DocumentTabDoc
onContentChange: (content: string) => void
onExport: (format: MeetingExportFormat) => void
onDelete: () => void
}
const AUTOSAVE_DELAY = 500
export function DocumentTab({
document: doc,
onContentChange,
onExport,
onDelete,
}: DocumentTabProps): React.ReactElement {
const { t } = useI18n()
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleChange = useCallback(
(content: string) => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
onContentChange(content)
}, AUTOSAVE_DELAY)
},
[onContentChange],
)
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
{/* 콘텐츠 편집기 */}
<Box sx={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<MarkdownEditor
content={doc.content}
onChange={handleChange}
/>
</Box>
{/* 하단 액션 바 */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mt: 1.5,
pt: 1.5,
borderTop: `1px solid ${d3roPalette.border.subtle}`,
}}
>
<ExportMenu onExport={onExport} />
<PhysicalButton
size="small"
color="error"
onClick={onDelete}
sx={{ height: 32, fontSize: d3roTypo.engrave.size }}
>
<DeleteIcon sx={{ fontSize: 14, mr: 0.5 }} />
{t('common.delete')}
</PhysicalButton>
</Box>
</Box>
)
}

View file

@ -0,0 +1,143 @@
// src/renderer/components/meeting/EditableSegment.tsx
// Phase 14.5: 전사 세그먼트 인라인 편집 컴포넌트
import { useState, useRef, useCallback } from 'react'
import { Box, TextField, Tooltip } from '@mui/material'
import { d3roPalette, d3roFontMono } from '../../theme'
import { useI18n } from '../../i18n'
interface EditableSegmentProps {
segmentId: string
timestamp: number // ms, 상대 시간
text: string
edited: boolean // 수정 여부
onEdit: (segmentId: string, newText: string) => void
}
function formatTimestamp(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')}]`
}
export function EditableSegment({
segmentId,
timestamp,
text,
edited,
onEdit,
}: EditableSegmentProps): React.ReactElement {
const { t } = useI18n()
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(text)
const inputRef = useRef<HTMLInputElement>(null)
const handleClick = useCallback(() => {
setDraft(text)
setEditing(true)
}, [text])
const handleCommit = useCallback(() => {
const trimmed = draft.trim()
if (trimmed && trimmed !== text) {
onEdit(segmentId, trimmed)
}
setEditing(false)
}, [draft, text, segmentId, onEdit])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleCommit()
} else if (e.key === 'Escape') {
setEditing(false)
}
},
[handleCommit],
)
if (editing) {
return (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 0.5 }}>
<Box
component="span"
sx={{
fontFamily: d3roFontMono,
fontSize: 11,
color: d3roPalette.text.inactive,
pt: '7px',
flexShrink: 0,
userSelect: 'none',
}}
>
{formatTimestamp(timestamp)}
</Box>
<TextField
inputRef={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleCommit}
autoFocus
size="small"
fullWidth
multiline
sx={{
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: 13,
bgcolor: d3roPalette.bg.input,
},
}}
/>
</Box>
)
}
const textNode = (
<Box
component="span"
onClick={handleClick}
sx={{
fontFamily: d3roFontMono,
fontSize: 13,
color: d3roPalette.text.primary,
cursor: 'text',
borderBottom: edited ? `1px dashed ${d3roPalette.text.inactive}` : 'none',
'&:hover': {
color: d3roPalette.text.primary,
borderBottomColor: edited ? d3roPalette.accent.amber : undefined,
},
}}
>
{text}
</Box>
)
return (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 0.5 }}>
<Box
component="span"
sx={{
fontFamily: d3roFontMono,
fontSize: 11,
color: d3roPalette.text.inactive,
pt: '2px',
flexShrink: 0,
userSelect: 'none',
}}
>
{formatTimestamp(timestamp)}
</Box>
{edited ? (
<Tooltip title={t('meeting.modified')} placement="top">
{textNode}
</Tooltip>
) : (
textNode
)}
</Box>
)
}

View file

@ -0,0 +1,96 @@
// src/renderer/components/meeting/ExportMenu.tsx
// Phase 14.5: 다운로드 형식 선택 드롭다운
import { useState, useCallback } from 'react'
import { Menu, MenuItem } from '@mui/material'
import FileDownloadIcon from '@mui/icons-material/FileDownload'
import { PhysicalButton } from '../ds/PhysicalButton'
import { d3roPalette, d3roTypo, d3roFontMono, d3roRadius, d3roShadow } from '../../theme'
import { useI18n } from '../../i18n'
import type { MeetingExportFormat } from '@shared/types'
interface ExportMenuProps {
onExport: (format: MeetingExportFormat) => void
}
interface FormatItem {
format: MeetingExportFormat
label: string
}
export function ExportMenu({ onExport }: ExportMenuProps): React.ReactElement {
const { t } = useI18n()
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const open = Boolean(anchorEl)
const handleOpen = useCallback((e: React.MouseEvent<HTMLElement>) => {
setAnchorEl(e.currentTarget)
}, [])
const handleClose = useCallback(() => {
setAnchorEl(null)
}, [])
const handleSelect = useCallback(
(format: MeetingExportFormat) => {
onExport(format)
handleClose()
},
[onExport, handleClose],
)
const formats: FormatItem[] = [
{ format: 'md', label: 'Markdown (.md)' },
{ format: 'txt', label: t('meeting.exportTxt') },
{ format: 'pdf', label: 'PDF (.pdf)' },
{ format: 'docx', label: t('meeting.exportDocx') },
]
return (
<>
<PhysicalButton
size="small"
onClick={handleOpen}
sx={{ height: 32, fontSize: d3roTypo.engrave.size }}
>
<FileDownloadIcon sx={{ fontSize: 14, mr: 0.5 }} />
{t('meeting.exportFormat')}
</PhysicalButton>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
slotProps={{
paper: {
sx: {
bgcolor: d3roPalette.bg.elevated,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.small,
boxShadow: d3roShadow.tooltip,
minWidth: 160,
},
},
}}
>
{formats.map(({ format, label }) => (
<MenuItem
key={format}
onClick={() => handleSelect(format)}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
color: d3roPalette.text.primary,
'&:hover': {
bgcolor: d3roPalette.bg.cardHover,
color: d3roPalette.accent.amber,
},
}}
>
{label}
</MenuItem>
))}
</Menu>
</>
)
}

View file

@ -0,0 +1,83 @@
// src/renderer/components/meeting/MarkdownEditor.tsx
// Phase 14.5: 마크다운 렌더링/편집 토글 컴포넌트
import { useState } from 'react'
import { Box } from '@mui/material'
import { MarkdownRenderer } from './MarkdownRenderer'
import { PhysicalButton } from '../ds/PhysicalButton'
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '../../theme'
import { useI18n } from '../../i18n'
interface MarkdownEditorProps {
content: string
onChange: (content: string) => void
readOnly?: boolean
}
export function MarkdownEditor({
content,
onChange,
readOnly = false,
}: MarkdownEditorProps): React.ReactElement {
const { t } = useI18n()
const [preview, setPreview] = useState(true)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 상단 토글 */}
{!readOnly && (
<Box sx={{ display: 'flex', gap: 0.75, mb: 1 }}>
<PhysicalButton
size="small"
selected={preview}
onClick={() => setPreview(true)}
sx={{ height: 32, fontSize: d3roTypo.engrave.size }}
>
{t('meeting.previewMode')}
</PhysicalButton>
<PhysicalButton
size="small"
selected={!preview}
onClick={() => setPreview(false)}
sx={{ height: 32, fontSize: d3roTypo.engrave.size }}
>
{t('meeting.editMode')}
</PhysicalButton>
</Box>
)}
{/* 콘텐츠 영역 */}
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{preview || readOnly ? (
<MarkdownRenderer content={content} />
) : (
<Box
component="textarea"
value={content}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
sx={{
width: '100%',
height: '100%',
minHeight: 300,
resize: 'none',
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
color: d3roPalette.text.primary,
bgcolor: d3roPalette.bg.input,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.small,
boxShadow: d3roShadow.inset,
p: 1.5,
outline: 'none',
lineHeight: 1.7,
boxSizing: 'border-box',
'&:focus': {
borderColor: d3roPalette.accent.amber,
},
}}
/>
)}
</Box>
</Box>
)
}

View file

@ -0,0 +1,198 @@
// src/renderer/components/meeting/MarkdownRenderer.tsx
// Phase 14.5: react-markdown + remark-gfm 래퍼 — d3roPalette 기반 스타일
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Box } from '@mui/material'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../../theme'
import type { Components } from 'react-markdown'
interface MarkdownRendererProps {
content: string
}
const components: Components = {
h1: ({ children }) => (
<Box
component="h1"
sx={{
fontSize: d3roTypo.value.size,
fontWeight: 600,
color: d3roPalette.accent.amber,
mt: 2,
mb: 1,
borderBottom: `1px solid ${d3roPalette.border.default}`,
pb: 0.5,
}}
>
{children}
</Box>
),
h2: ({ children }) => (
<Box
component="h2"
sx={{
fontSize: d3roTypo.heading.size,
fontWeight: 600,
color: d3roPalette.text.primary,
mt: 1.5,
mb: 0.75,
}}
>
{children}
</Box>
),
h3: ({ children }) => (
<Box
component="h3"
sx={{
fontSize: d3roTypo.body.size,
fontWeight: 600,
color: d3roPalette.text.secondary,
mt: 1,
mb: 0.5,
}}
>
{children}
</Box>
),
p: ({ children }) => (
<Box
component="p"
sx={{
fontSize: d3roTypo.body.size,
color: d3roPalette.text.primary,
lineHeight: 1.7,
mb: 0.75,
mt: 0,
}}
>
{children}
</Box>
),
code: ({ children, className }) => {
const isBlock = className?.startsWith('language-') ?? false
if (isBlock) {
return (
<Box
component="pre"
sx={{
bgcolor: d3roPalette.bg.inset,
borderRadius: d3roRadius.small,
p: 1.5,
overflowX: 'auto',
my: 1,
border: `1px solid ${d3roPalette.border.subtle}`,
}}
>
<Box
component="code"
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
color: d3roPalette.accent.amber,
}}
>
{children}
</Box>
</Box>
)
}
return (
<Box
component="code"
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
color: d3roPalette.accent.amber,
bgcolor: d3roPalette.bg.inset,
px: 0.5,
py: 0.25,
borderRadius: d3roRadius.xs,
}}
>
{children}
</Box>
)
},
table: ({ children }) => (
<Box
component="table"
sx={{
width: '100%',
borderCollapse: 'collapse',
fontSize: d3roTypo.compact.size,
mb: 1,
'& th, & td': {
border: `1px solid ${d3roPalette.border.default}`,
p: 0.75,
textAlign: 'left',
},
'& th': {
bgcolor: d3roPalette.bg.chassis,
color: d3roPalette.text.secondary,
fontWeight: 600,
},
'& tr:nth-of-type(even)': {
bgcolor: d3roPalette.bg.inset,
},
}}
>
{children}
</Box>
),
li: ({ children }) => (
<Box
component="li"
sx={{
fontSize: d3roTypo.body.size,
color: d3roPalette.text.primary,
lineHeight: 1.7,
mb: 0.25,
}}
>
{children}
</Box>
),
ul: ({ children }) => (
<Box
component="ul"
sx={{ pl: 2.5, mb: 0.75, mt: 0 }}
>
{children}
</Box>
),
ol: ({ children }) => (
<Box
component="ol"
sx={{ pl: 2.5, mb: 0.75, mt: 0 }}
>
{children}
</Box>
),
blockquote: ({ children }) => (
<Box
component="blockquote"
sx={{
borderLeft: `3px solid ${d3roPalette.accent.amber}`,
pl: 1.5,
ml: 0,
my: 0.75,
color: d3roPalette.text.secondary,
fontStyle: 'italic',
}}
>
{children}
</Box>
),
}
export function MarkdownRenderer({ content }: MarkdownRendererProps): React.ReactElement {
return (
<Box sx={{ color: d3roPalette.text.primary, fontSize: d3roTypo.body.size }}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
</Box>
)
}

View 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>
)
}

View file

@ -0,0 +1,173 @@
// src/renderer/components/meeting/TranscriptTab.tsx
// Phase 14.5: 전사 편집 탭 (상세 페이지용)
import { useState, useCallback } from 'react'
import { Box, Switch, FormControlLabel } from '@mui/material'
import { EditableSegment } from './EditableSegment'
import { PhysicalButton } from '../ds/PhysicalButton'
import { PhosphorText } from '../ds/PhosphorText'
import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme'
import { useI18n } from '../../i18n'
import FileDownloadIcon from '@mui/icons-material/FileDownload'
interface TranscriptTabProps {
sessionId: string
segments: Array<{ id: string; timestamp: number; text: string; edited: boolean }>
rawTranscript: string | null
editedTranscript: string | null
memos: Array<{ id: string; timestampMs: number; content: string }>
onEditSegment: (segmentId: string, newText: string) => void
onSaveTranscript: (editedTranscript: string) => void
}
type TimelineItem =
| { kind: 'segment'; id: string; timestamp: number; text: string; edited: boolean }
| { kind: 'memo'; id: string; timestamp: number; content: string }
export function TranscriptTab({
sessionId: _sessionId,
segments,
rawTranscript,
editedTranscript,
memos,
onEditSegment,
onSaveTranscript,
}: TranscriptTabProps): React.ReactElement {
const { t } = useI18n()
const [showEdited, setShowEdited] = useState(true)
const handleDownloadTxt = useCallback(() => {
const content = showEdited && editedTranscript ? editedTranscript : (rawTranscript ?? '')
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'transcript.txt'
a.click()
URL.revokeObjectURL(url)
}, [showEdited, editedTranscript, rawTranscript])
// 세그먼트 + 메모를 타임스탬프 순으로 인터리브
const timeline: TimelineItem[] = [
...segments.map((s) => ({
kind: 'segment' as const,
id: s.id,
timestamp: s.timestamp,
text: s.text,
edited: s.edited,
})),
...memos.map((m) => ({
kind: 'memo' as const,
id: m.id,
timestamp: m.timestampMs,
content: m.content,
})),
].sort((a, b) => a.timestamp - b.timestamp)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 상단 컨트롤 */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<FormControlLabel
control={
<Switch
size="small"
checked={showEdited}
onChange={(e) => {
setShowEdited(e.target.checked)
if (!e.target.checked && rawTranscript) {
onSaveTranscript(rawTranscript)
}
}}
sx={{
'& .MuiSwitch-thumb': { bgcolor: d3roPalette.accent.amber },
'& .Mui-checked + .MuiSwitch-track': { bgcolor: d3roPalette.accent.amberDim },
}}
/>
}
label={
<PhosphorText variant="dim" sx={{ fontSize: 11 }}>
{showEdited ? t('meeting.editedText') : t('meeting.originalText')}
</PhosphorText>
}
/>
<PhysicalButton
size="small"
onClick={handleDownloadTxt}
sx={{ height: 30, fontSize: d3roTypo.engrave.size }}
>
<FileDownloadIcon sx={{ fontSize: 13, mr: 0.5 }} />
{t('meeting.downloadTranscript')}
</PhysicalButton>
</Box>
{/* 타임라인 스크롤 영역 */}
<Box
sx={{
flex: 1,
overflow: 'auto',
fontFamily: d3roFontMono,
lineHeight: 1.8,
}}
>
{timeline.length === 0 ? (
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>
{t('meeting.noSessions')}
</PhosphorText>
) : (
timeline.map((item) => {
if (item.kind === 'segment') {
return (
<EditableSegment
key={item.id}
segmentId={item.id}
timestamp={item.timestamp}
text={item.text}
edited={item.edited}
onEdit={onEditSegment}
/>
)
}
// memo
return (
<Box
key={item.id}
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1,
mb: 0.5,
pl: 0.5,
borderLeft: `2px solid ${d3roPalette.accent.amberDim}`,
}}
>
<Box
component="span"
sx={{
fontFamily: d3roFontMono,
fontSize: 11,
color: d3roPalette.text.inactive,
flexShrink: 0,
pt: '2px',
}}
>
{`[M ${String(Math.floor(item.timestamp / 60000)).padStart(2, '0')}:${String(Math.floor((item.timestamp % 60000) / 1000)).padStart(2, '0')}]`}
</Box>
<Box
component="span"
sx={{
fontSize: 12,
color: d3roPalette.accent.amber,
fontFamily: d3roFontMono,
}}
>
{item.content}
</Box>
</Box>
)
})
)}
</Box>
</Box>
)
}