feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동

- npm workspaces 루트 (apps/*, packages/*) 세팅
- V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar,
  scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts,
  tsconfig.node.json, tsconfig.web.json)
- apps/desktop/package.json 신규 (name=@d3ro/desktop)
- productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로
  %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장)
- 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지
  (typescript, eslint, prettier)
- turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase)
- memory/project_status.md 생성 (규칙 13)

검증:
- npm run typecheck 통과
- npm run build 통과 (electron-vite main+preload+renderer)
- npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
yunchan8804 2026-04-08 14:04:41 +09:00
parent 3a160b9032
commit 45a580878a
178 changed files with 214 additions and 0 deletions

View file

@ -0,0 +1,193 @@
// 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) => (
<Box
key={tp.id}
onClick={() => !generating && setSelectedId(tp.id)}
sx={{
cursor: generating ? 'default' : 'pointer',
opacity: generating ? 0.6 : 1,
border:
selectedId === tp.id
? `2px solid ${d3roPalette.accent.amber}`
: `2px solid transparent`,
borderRadius: d3roRadius.inner,
transition: 'border-color 0.15s',
'&:hover': {
borderColor: generating ? undefined : d3roPalette.accent.amberDim,
},
}}
>
<MetalCard>
<PhosphorText variant="compact">{tp.name}</PhosphorText>
<PhosphorText variant="dim" sx={{ fontSize: 11, mt: 0.25 }}>
{tp.description}
</PhosphorText>
</MetalCard>
</Box>
))}
</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,204 @@
// src/renderer/components/meeting/EditableSegment.tsx
// Phase 14.5: 전사 세그먼트 인라인 편집 컴포넌트
import { useState, useRef, useCallback } from 'react'
import { Box, TextField, Tooltip, Chip } from '@mui/material'
import { d3roPalette, d3roFontMono } from '../../theme'
import { useI18n } from '../../i18n'
// Phase 15.5: 화자별 색상 매핑 (d3roPalette SSOT)
const SPEAKER_COLORS = [
d3roPalette.tag.purple,
d3roPalette.tag.green,
d3roPalette.tag.orange,
d3roPalette.tag.red,
'#3b82f6',
]
function getSpeakerColor(speaker: string): string {
// "화자 1" → 0, "화자 2" → 1, ...
const match = /(\d+)$/.exec(speaker)
const idx = match ? (parseInt(match[1], 10) - 1) : 0
return SPEAKER_COLORS[idx % SPEAKER_COLORS.length]
}
interface EditableSegmentProps {
segmentId: string
timestamp: number // ms, 상대 시간
text: string
edited: boolean // 수정 여부
onEdit: (segmentId: string, newText: string) => void
readOnly?: boolean
speaker?: string
}
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,
readOnly = false,
speaker,
}: EditableSegmentProps): React.ReactElement {
const { t } = useI18n()
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(text)
const inputRef = useRef<HTMLInputElement>(null)
const handleClick = useCallback(() => {
if (readOnly) return
setDraft(text)
setEditing(true)
}, [readOnly, 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],
)
const speakerColor = speaker ? getSpeakerColor(speaker) : undefined
if (editing) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1,
mb: 0.5,
borderLeft: speakerColor ? `4px solid ${speakerColor}` : undefined,
pl: speakerColor ? 0.75 : 0,
}}
>
<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: readOnly ? 'default' : 'text',
borderBottom: edited ? `1px dashed ${d3roPalette.text.inactive}` : 'none',
borderRadius: '2px',
px: readOnly ? 0 : '2px',
'&:hover': readOnly
? {}
: {
color: d3roPalette.text.primary,
borderBottomColor: edited ? d3roPalette.accent.amber : undefined,
bgcolor: edited ? undefined : d3roPalette.bg.elevated,
},
}}
>
{text}
</Box>
)
return (
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1,
mb: 0.5,
borderLeft: speakerColor ? `4px solid ${speakerColor}` : undefined,
pl: speakerColor ? 0.75 : 0,
}}
>
<Box
component="span"
sx={{
fontFamily: d3roFontMono,
fontSize: 11,
color: d3roPalette.text.inactive,
pt: '2px',
flexShrink: 0,
userSelect: 'none',
}}
>
{formatTimestamp(timestamp)}
</Box>
{speaker && (
<Chip
label={speaker}
size="small"
sx={{
fontSize: 10,
height: 18,
flexShrink: 0,
bgcolor: `${speakerColor}22`,
color: speakerColor,
border: `1px solid ${speakerColor}55`,
fontFamily: d3roFontMono,
}}
/>
)}
{edited ? (
<Tooltip title={t('meeting.modified')} placement="top">
{textNode}
</Tooltip>
) : (
textNode
)}
</Box>
)
}

View file

@ -0,0 +1,113 @@
// 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
onCopyToClipboard?: () => void
}
interface FormatItem {
format: MeetingExportFormat
label: string
}
export function ExportMenu({ onExport, onCopyToClipboard }: 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: t('meeting.exportMd') },
{ format: 'txt', label: t('meeting.exportTxt') },
{ format: 'pdf', label: t('meeting.exportPdfFmt') },
{ 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>
))}
{onCopyToClipboard && (
<MenuItem
onClick={() => { onCopyToClipboard(); handleClose() }}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
color: d3roPalette.text.primary,
'&:hover': {
bgcolor: d3roPalette.bg.cardHover,
color: d3roPalette.accent.amber,
},
}}
>
{t('meeting.copyToClipboard')}
</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,353 @@
// src/renderer/components/meeting/MeetingChatPanel.tsx
// Phase 15: 회의 상세 페이지 하단 AI 채팅 패널
import { useState, useCallback, useEffect, useRef } from 'react'
import { Box, TextField, IconButton, LinearProgress, Tooltip } from '@mui/material'
import SendIcon from '@mui/icons-material/Send'
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { PhosphorText } from '../ds/PhosphorText'
import { PhysicalButton } from '../ds/PhysicalButton'
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow } from '../../theme'
import { useI18n } from '../../i18n'
import type { MeetingChatMessage } from '@shared/types'
interface MeetingChatPanelProps {
sessionId: string
}
const COLLAPSED_HEIGHT = 40
const EXPANDED_HEIGHT = 240
// 타이핑 인디케이터 점 3개 애니메이션
function TypingIndicator(): React.ReactElement {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: '3px', px: 1, py: 0.5 }}>
{[0, 1, 2].map((i) => (
<Box
key={i}
sx={{
width: 5,
height: 5,
borderRadius: '50%',
bgcolor: d3roPalette.accent.amber,
animation: 'typing-dot 1.2s infinite',
animationDelay: `${i * 0.2}s`,
'@keyframes typing-dot': {
'0%, 80%, 100%': { opacity: 0.3, transform: 'scale(0.8)' },
'40%': { opacity: 1, transform: 'scale(1)' },
},
}}
/>
))}
</Box>
)
}
export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.ReactElement {
const { t } = useI18n()
const [messages, setMessages] = useState<MeetingChatMessage[]>([])
const [inputValue, setInputValue] = useState('')
const [streaming, setStreaming] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const [collapsed, setCollapsed] = useState(false)
const [error, setError] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// 스크롤 하단 유지
const scrollToBottom = useCallback(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [])
useEffect(() => {
scrollToBottom()
}, [messages, streamingContent, scrollToBottom])
// 이벤트 구독
useEffect(() => {
const unsubDelta = window.electronAPI.meetingChat.onDelta((data) => {
setStreaming(true)
setStreamingContent((prev) => prev + data.token)
})
const unsubMessage = window.electronAPI.meetingChat.onMessage((msg) => {
setMessages((prev) => [...prev, msg])
setStreaming(false)
setStreamingContent('')
})
const unsubError = window.electronAPI.meetingChat.onError((err) => {
setStreaming(false)
setStreamingContent('')
setError(err.message)
})
return () => {
unsubDelta()
unsubMessage()
unsubError()
}
}, [sessionId])
const handleSend = useCallback(() => {
const text = inputValue.trim()
if (!text || streaming) return
const userMsg: MeetingChatMessage = {
role: 'user',
content: text,
timestamp: Date.now(),
}
setMessages((prev) => [...prev, userMsg])
setInputValue('')
window.electronAPI.meetingChat.send({ sessionId, message: text })
inputRef.current?.focus()
}, [inputValue, streaming, sessionId])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
},
[handleSend],
)
const handleClear = useCallback(() => {
setMessages([])
setStreamingContent('')
setStreaming(false)
window.electronAPI.meetingChat.clear({ sessionId })
}, [sessionId])
const handleToggleCollapse = useCallback(() => {
setCollapsed((prev) => !prev)
}, [])
return (
<Box
sx={{
flexShrink: 0,
borderTop: `1px solid ${d3roPalette.border.default}`,
bgcolor: d3roPalette.bg.card,
display: 'flex',
flexDirection: 'column',
height: collapsed ? COLLAPSED_HEIGHT : EXPANDED_HEIGHT,
transition: 'height 0.2s ease',
overflow: 'hidden',
boxShadow: d3roShadow.inset,
}}
>
{/* 헤더 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
px: 1.5,
height: COLLAPSED_HEIGHT,
flexShrink: 0,
borderBottom: collapsed ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
gap: 1,
}}
>
<PhosphorText
variant="dim"
sx={{ fontSize: d3roTypo.meta.size, letterSpacing: d3roTypo.meta.spacing, flex: 1 }}
>
{t('meeting.chat')}
</PhosphorText>
<Tooltip title={t('meeting.chatClear')}>
<IconButton
size="small"
onClick={handleClear}
disabled={messages.length === 0 && !streaming}
>
<DeleteSweepIcon sx={{ fontSize: 15, color: d3roPalette.text.inactive }} />
</IconButton>
</Tooltip>
<Tooltip title={collapsed ? t('meeting.chatExpand') : t('meeting.chatCollapse')}>
<IconButton size="small" onClick={handleToggleCollapse}>
{collapsed ? (
<ExpandLessIcon sx={{ fontSize: 15, color: d3roPalette.text.inactive }} />
) : (
<ExpandMoreIcon sx={{ fontSize: 15, color: d3roPalette.text.inactive }} />
)}
</IconButton>
</Tooltip>
</Box>
{!collapsed && (
<>
{/* 메시지 히스토리 */}
<Box
ref={scrollRef}
sx={{
flex: 1,
overflowY: 'auto',
px: 1.5,
py: 1,
display: 'flex',
flexDirection: 'column',
gap: 0.75,
minHeight: 0,
}}
>
{messages.map((msg) => (
<Box
key={`${msg.role}-${msg.timestamp}`}
sx={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}
>
<Box
sx={{
maxWidth: '80%',
px: 1.25,
py: 0.5,
borderRadius: '6px',
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
lineHeight: 1.5,
bgcolor:
msg.role === 'user'
? d3roPalette.accent.amberDim
: d3roPalette.bg.elevated,
color: d3roPalette.text.primary,
border: `1px solid ${
msg.role === 'user'
? d3roPalette.accent.amber
: d3roPalette.border.subtle
}`,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
>
{msg.content}
</Box>
</Box>
))}
{/* 에러 메시지 */}
{error && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
<Box
sx={{
maxWidth: '80%',
px: 1.25,
py: 0.5,
borderRadius: '6px',
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
lineHeight: 1.5,
bgcolor: d3roPalette.bg.elevated,
color: d3roPalette.tag.red,
border: `1px solid ${d3roPalette.tag.red}`,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
cursor: 'pointer',
}}
onClick={() => setError(null)}
>
{error}
</Box>
</Box>
)}
{/* 스트리밍 중 어시스턴트 메시지 */}
{streaming && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
<Box
sx={{
maxWidth: '80%',
px: 1.25,
py: 0.5,
borderRadius: '6px',
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
lineHeight: 1.5,
bgcolor: d3roPalette.bg.elevated,
color: d3roPalette.text.primary,
border: `1px solid ${d3roPalette.border.subtle}`,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
>
{streamingContent || <TypingIndicator />}
</Box>
</Box>
)}
</Box>
{/* 스트리밍 진행 표시 */}
{streaming && (
<LinearProgress
sx={{
height: 1,
flexShrink: 0,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.amber },
}}
/>
)}
{/* 입력 영역 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 0.75,
flexShrink: 0,
borderTop: `1px solid ${d3roPalette.border.subtle}`,
}}
>
<TextField
inputRef={inputRef}
size="small"
fullWidth
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('meeting.chatPlaceholder')}
disabled={streaming}
sx={{
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
bgcolor: d3roPalette.bg.input,
borderRadius: '4px',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: d3roPalette.border.default,
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: d3roPalette.border.strong,
},
'& .Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: d3roPalette.accent.amber,
},
}}
/>
<PhysicalButton
size="small"
onClick={handleSend}
disabled={!inputValue.trim() || streaming}
sx={{ flexShrink: 0, height: 36, minWidth: 36, px: 1 }}
>
<SendIcon sx={{ fontSize: 14 }} />
</PhysicalButton>
</Box>
</>
)}
</Box>
)
}

View file

@ -0,0 +1,352 @@
// src/renderer/components/meeting/MeetingDetailTabs.tsx
// Phase 14.5: 풀스크린 탭 전환 컨테이너 — 전사 + 동적 문서 탭
import { useState, useEffect, useCallback, useMemo } 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
}
// 세그먼트 파싱: 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 ?? []).filter(Boolean))
const [templates, setTemplates] = useState<MeetingDocTemplate[]>([])
const [tabIndex, setTabIndex] = useState(0)
const [dialogOpen, setDialogOpen] = useState(false)
const [generating, setGenerating] = useState(false)
const [diarizing, setDiarizing] = useState(false)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState(detail.title ?? '')
// 세그먼트 파싱 (rawTranscript 기반)
const segments = useMemo(
() => parseSegments(detail.rawTranscript, detail.editedTranscript),
[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],
)
// ── 문서 내용 자동저장 (DocumentTab 내부에 debounce 있음, 이중 래핑 제거) ──
const handleDocContentChange = useCallback(
async (docId: string, content: string) => {
await window.electronAPI.meetingMode.updateDocument({
documentId: docId,
content,
})
setDocuments((prev) =>
prev.map((d) => (d.id === docId ? { ...d, content } : d)),
)
},
[],
)
// ── 문서 내보내기 ──
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 handleDiarize = useCallback(async () => {
setDiarizing(true)
const resp = await window.electronAPI.meetingMode.diarize({ sessionId: detail.id })
if (resp.success) {
const updated = await window.electronAPI.meetingMode.getSession({ sessionId: detail.id })
if (updated.success) setDetail(updated.data)
}
setDiarizing(false)
}, [detail.id])
// ── 문서 생성 ──
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
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.filter(Boolean).map((doc) => (
<Tab key={doc.id} label={doc.title ?? t('meeting.untitled')} />
))}
<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}
onDiarize={handleDiarize}
diarizing={diarizing}
/>
)}
{documents.filter(Boolean).map((doc, idx) => {
if (!doc || tabIndex !== idx + 1) return null
return (
<DocumentTab
key={doc.id}
document={doc}
onContentChange={(content) => handleDocContentChange(doc.id, content)}
onExport={(format) => handleExportDocument(doc.id, format)}
onDelete={() => handleDeleteDocument(doc.id)}
/>
)
})}
</Box>
{/* AI 채팅 패널 */}
<MeetingChatPanel sessionId={detail.id} />
{/* 문서 생성 다이얼로그 */}
<AddDocumentDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onGenerate={handleGenerate}
templates={templates}
generating={generating}
/>
</Box>
)
}

View file

@ -0,0 +1,239 @@
// src/renderer/components/meeting/TranscriptTab.tsx
// Phase 14.5: 전사 편집 탭 (상세 페이지용)
import { useState, useCallback } from 'react'
import { Box, Switch, FormControlLabel, LinearProgress, Snackbar, Alert } 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'
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'
interface TranscriptTabProps {
sessionId: string
segments: Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }>
rawTranscript: string | null
editedTranscript: string | null
memos: Array<{ id: string; timestampMs: number; content: string }>
onEditSegment: (segmentId: string, newText: string) => void
onSaveTranscript: (editedTranscript: string) => void
onDiarize?: () => Promise<void>
diarizing?: boolean
}
type TimelineItem =
| { kind: 'segment'; id: string; timestamp: number; text: string; edited: boolean; speaker?: string }
| { kind: 'memo'; id: string; timestamp: number; content: string }
export function TranscriptTab({
sessionId,
segments,
rawTranscript,
editedTranscript,
memos,
onEditSegment,
onSaveTranscript,
onDiarize,
diarizing = false,
}: TranscriptTabProps): React.ReactElement {
const { t } = useI18n()
const [showEdited, setShowEdited] = useState(true)
const [polishing, setPolishing] = useState(false)
const [polishError, setPolishError] = useState<string | null>(null)
const handlePolish = useCallback(async () => {
setPolishing(true)
setPolishError(null)
const resp = await window.electronAPI.meetingMode.polishTranscript({ sessionId })
if (resp.success) {
onSaveTranscript(resp.data)
} else {
setPolishError(t('meeting.polishFailed'))
}
setPolishing(false)
}, [sessionId, onSaveTranscript, t])
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,
speaker: s.speaker,
})),
...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%' }}>
<Snackbar
open={polishError !== null}
autoHideDuration={4000}
onClose={() => setPolishError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setPolishError(null)} sx={{ width: '100%' }}>
{polishError}
</Alert>
</Snackbar>
{/* 상단 컨트롤 */}
<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>
}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<PhysicalButton
size="small"
onClick={handlePolish}
disabled={polishing || diarizing}
sx={{ height: 30, fontSize: d3roTypo.engrave.size }}
>
<AutoFixHighIcon sx={{ fontSize: 13, mr: 0.5 }} />
{polishing ? t('meeting.polishing') : t('meeting.polish')}
</PhysicalButton>
{onDiarize && (
<PhysicalButton
size="small"
onClick={onDiarize}
disabled={polishing || diarizing}
sx={{ height: 30, fontSize: d3roTypo.engrave.size }}
>
<RecordVoiceOverIcon sx={{ fontSize: 13, mr: 0.5 }} />
{diarizing ? t('meeting.diarizing') : t('meeting.diarize')}
</PhysicalButton>
)}
<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>
{/* 폴리싱 진행 표시 */}
{polishing && (
<LinearProgress
sx={{
height: 2,
mb: 1,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.amber },
}}
/>
)}
{/* 타임라인 스크롤 영역 */}
<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}
speaker={item.speaker}
/>
)
}
// 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>
)
}