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:
parent
3a160b9032
commit
45a580878a
178 changed files with 214 additions and 0 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
89
apps/desktop/src/renderer/components/meeting/DocumentTab.tsx
Normal file
89
apps/desktop/src/renderer/components/meeting/DocumentTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
204
apps/desktop/src/renderer/components/meeting/EditableSegment.tsx
Normal file
204
apps/desktop/src/renderer/components/meeting/EditableSegment.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
apps/desktop/src/renderer/components/meeting/ExportMenu.tsx
Normal file
113
apps/desktop/src/renderer/components/meeting/ExportMenu.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
239
apps/desktop/src/renderer/components/meeting/TranscriptTab.tsx
Normal file
239
apps/desktop/src/renderer/components/meeting/TranscriptTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue