Bug 10 fix(MeetingModePage 인라인 가드)를 isImeComposingEvent helper로 추출하고, 한글 위험도 있는 나머지 7개 Enter 핸들러에 일괄 적용. 총 8곳이 이제 동일 helper 경유. 신규: - apps/desktop/src/renderer/utils/keyboard.ts — isImeComposingEvent(e) JSDoc에 Bug 10 원리(Chromium이 IME 조합 중 Enter를 2번 발화) + 권장 사용 패턴 포함 적용 8곳: - pages/MeetingModePage.tsx:164 회의 메모 (기존 인라인 가드 4줄 교체) - pages/KnowledgeBasePage.tsx:84 RAG 쿼리 - pages/VoiceConversationPage.tsx:113 텍스트 채팅 - pages/CommandsPage.tsx:336 키워드 추가 (Enter+Esc) - components/meeting/MeetingChatPanel.tsx:111 미팅 챗 - components/meeting/EditableSegment.tsx:70 전사 세그먼트 편집 (Enter+Esc) - components/meeting/MeetingDetailTabs.tsx:242 미팅 타이틀 (인라인 arrow → 블록) - components/shared/HistoryEntryCard.tsx:64 태그 추가 (Enter+Esc) /simplify 패스 품질 리뷰: - Phase 3.3 CloudSyncService.pushOne 훅 8곳은 이미 fire-and-forget 1줄로 일관. 내부 try-catch가 에러 삼켜 로컬 write 차단 금지 철학 준수 → 수정 없음, 현 상태가 최적. 검증: - desktop tsc --noEmit EXIT=0 - Vite HMR로 dev 프로세스 자동 반영 (재기동 없음) - 한글 Enter 시연은 사용자 실측 대기 (VoiceConversationPage / MeetingDetailTabs 대표 2곳)
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
// src/renderer/components/shared/HistoryEntryCard.tsx
|
|
// 공유: 히스토리 항목 카드 (Dashboard + HistoryPage에서 재사용)
|
|
// Phase 10: 태그 표시/추가/삭제 기능 통합
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { Box, IconButton, Tooltip, Chip } from '@mui/material'
|
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
|
import DeleteIcon from '@mui/icons-material/Delete'
|
|
import LocalOfferIcon from '@mui/icons-material/LocalOffer'
|
|
import CloseIcon from '@mui/icons-material/Close'
|
|
import SummarizeIcon from '@mui/icons-material/Summarize'
|
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
|
import { MetalCard, Led } from '@d3ro/ui/components/ds'
|
|
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
|
|
import { useI18n } from '@d3ro/i18n'
|
|
import { formatDuration } from '../../utils/formatters'
|
|
import { isImeComposingEvent } from '../../utils/keyboard'
|
|
import type { HistoryEntry, MemoTag, MeetingSummaryResult } from '@d3ro/core/types'
|
|
|
|
interface HistoryEntryCardProps {
|
|
entry: HistoryEntry
|
|
onCopy?: (text: string) => void
|
|
onDelete?: (id: string) => void
|
|
showTags?: boolean
|
|
onTagClick?: (tag: string) => void
|
|
}
|
|
|
|
export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, onTagClick }: HistoryEntryCardProps): React.ReactElement {
|
|
const { t, formatTime } = useI18n()
|
|
const displayText = entry.polishedText || entry.originalText
|
|
const [tags, setTags] = useState<MemoTag[]>([])
|
|
const [tagInput, setTagInput] = useState('')
|
|
const [showTagInput, setShowTagInput] = useState(false)
|
|
const [summaryExpanded, setSummaryExpanded] = useState(false)
|
|
const [summary, setSummary] = useState<MeetingSummaryResult | null>(null)
|
|
const [summaryLoading, setSummaryLoading] = useState(false)
|
|
const hasSummary = !!entry.summaryText
|
|
|
|
const loadTags = useCallback(async () => {
|
|
if (!showTags) return
|
|
const result = await window.electronAPI.memo.getTags(entry.id)
|
|
if (result.success) setTags(result.data)
|
|
}, [entry.id, showTags])
|
|
|
|
useEffect(() => { loadTags() }, [loadTags])
|
|
|
|
const handleAddTag = useCallback(async () => {
|
|
const trimmed = tagInput.trim()
|
|
if (!trimmed) return
|
|
const result = await window.electronAPI.memo.addTag(entry.id, trimmed)
|
|
if (result.success) {
|
|
setTags(prev => [...prev, result.data])
|
|
setTagInput('')
|
|
setShowTagInput(false)
|
|
}
|
|
}, [entry.id, tagInput])
|
|
|
|
const handleRemoveTag = useCallback(async (tag: string) => {
|
|
const result = await window.electronAPI.memo.removeTag(entry.id, tag)
|
|
if (result.success) {
|
|
setTags(prev => prev.filter(t => t.tag !== tag))
|
|
}
|
|
}, [entry.id])
|
|
|
|
const handleTagKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (isImeComposingEvent(e)) return
|
|
if (e.key === 'Enter') { e.preventDefault(); handleAddTag() }
|
|
if (e.key === 'Escape') { setShowTagInput(false); setTagInput('') }
|
|
}, [handleAddTag])
|
|
|
|
const handleToggleSummary = useCallback(async () => {
|
|
if (summaryExpanded) {
|
|
setSummaryExpanded(false)
|
|
return
|
|
}
|
|
setSummaryExpanded(true)
|
|
if (!summary) {
|
|
setSummaryLoading(true)
|
|
const result = await window.electronAPI.meetingSummary.getSummary({ historyId: entry.id })
|
|
if (result.success && result.data) {
|
|
setSummary(result.data)
|
|
}
|
|
setSummaryLoading(false)
|
|
}
|
|
}, [summaryExpanded, summary, entry.id])
|
|
|
|
const handleGenerateSummary = useCallback(async () => {
|
|
setSummaryLoading(true)
|
|
const result = await window.electronAPI.meetingSummary.summarize({ historyId: entry.id })
|
|
if (result.success) {
|
|
setSummary(result.data)
|
|
}
|
|
setSummaryLoading(false)
|
|
}, [entry.id])
|
|
|
|
const handleExportSummary = useCallback(async () => {
|
|
await window.electronAPI.meetingSummary.exportMarkdown({ historyId: entry.id })
|
|
}, [entry.id])
|
|
|
|
return (
|
|
<MetalCard>
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
|
<Led color={entry.status === 'completed' ? 'green' : 'red'} size={6} />
|
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
{/* 타이틀 */}
|
|
<Box
|
|
sx={{
|
|
fontSize: d3roTypo.compact.size,
|
|
fontWeight: 600,
|
|
color: d3roPalette.text.primary,
|
|
lineHeight: d3roTypo.compact.line,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
mb: 0.5,
|
|
}}
|
|
>
|
|
{entry.title ?? displayText.slice(0, 60)}
|
|
</Box>
|
|
{/* 내용 미리보기 */}
|
|
<Box
|
|
sx={{
|
|
fontSize: d3roTypo.small.size,
|
|
color: d3roPalette.text.secondary,
|
|
lineHeight: 1.5,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: 'vertical',
|
|
}}
|
|
>
|
|
{displayText}
|
|
</Box>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
gap: 2,
|
|
mt: 1,
|
|
fontFamily: d3roFontMono,
|
|
fontSize: d3roTypo.label.size,
|
|
color: d3roPalette.text.dimLabel,
|
|
letterSpacing: d3roTypo.label.spacing,
|
|
}}
|
|
>
|
|
<span>{formatTime(entry.createdAt)}</span>
|
|
<span>{formatDuration(entry.duration)}</span>
|
|
{entry.detectedLanguage && <span>{entry.detectedLanguage.toUpperCase()}</span>}
|
|
<span>{entry.mode.toUpperCase()}</span>
|
|
{hasSummary && (
|
|
<Chip
|
|
icon={<SummarizeIcon sx={{ fontSize: '12px !important' }} />}
|
|
label={t('meetingSummary.title')}
|
|
size="small"
|
|
onClick={handleToggleSummary}
|
|
sx={{
|
|
height: 18,
|
|
fontFamily: d3roFontMono,
|
|
fontSize: d3roTypo.micro.size,
|
|
bgcolor: d3roPalette.tag.greenBg,
|
|
color: d3roPalette.tag.green,
|
|
borderRadius: d3roRadius.small,
|
|
cursor: 'pointer',
|
|
'& .MuiChip-icon': { color: d3roPalette.tag.green },
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
{/* 태그 영역 */}
|
|
{showTags && (
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1, alignItems: 'center' }}>
|
|
{tags.map(tag => (
|
|
<Chip
|
|
key={tag.id}
|
|
label={`#${tag.tag}`}
|
|
size="small"
|
|
onClick={() => onTagClick?.(tag.tag)}
|
|
onDelete={() => handleRemoveTag(tag.tag)}
|
|
deleteIcon={<CloseIcon sx={{ fontSize: '12px !important' }} />}
|
|
sx={{
|
|
height: 20,
|
|
fontFamily: d3roFontMono,
|
|
fontSize: d3roTypo.micro.size,
|
|
bgcolor: d3roPalette.tag.purpleBg,
|
|
color: d3roPalette.tag.purple,
|
|
borderRadius: d3roRadius.small,
|
|
'& .MuiChip-deleteIcon': { color: d3roPalette.tag.purple, fontSize: 12 },
|
|
'&:hover': { bgcolor: d3roPalette.tag.purple, color: d3roPalette.bg.card },
|
|
}}
|
|
/>
|
|
))}
|
|
{showTagInput ? (
|
|
<Box
|
|
component="input"
|
|
value={tagInput}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTagInput(e.target.value)}
|
|
onKeyDown={handleTagKeyDown}
|
|
onBlur={() => { if (!tagInput.trim()) setShowTagInput(false) }}
|
|
autoFocus
|
|
placeholder={t('memo.tagPlaceholder')}
|
|
sx={{
|
|
border: `1px solid ${d3roPalette.border.subtle}`,
|
|
bgcolor: d3roPalette.bg.input,
|
|
color: d3roPalette.text.primary,
|
|
fontFamily: d3roFontMono,
|
|
fontSize: d3roTypo.micro.size,
|
|
px: 1,
|
|
py: 0.25,
|
|
borderRadius: d3roRadius.xs,
|
|
outline: 'none',
|
|
width: 100,
|
|
'&:focus': { borderColor: d3roPalette.accent.amber },
|
|
}}
|
|
/>
|
|
) : (
|
|
<Tooltip title={t('memo.addTag')} arrow>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => setShowTagInput(true)}
|
|
sx={{ p: 0.25, color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.amber } }}
|
|
>
|
|
<LocalOfferIcon sx={{ fontSize: 14 }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
|
{onCopy && (
|
|
<Tooltip title={t('common.copy')} arrow>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => onCopy(displayText)}
|
|
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
|
|
>
|
|
<ContentCopyIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
{onDelete && (
|
|
<Tooltip title={t('common.delete')} arrow>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => onDelete(entry.id)}
|
|
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
|
|
>
|
|
<DeleteIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Phase 12.2: 회의록 요약 확장 뷰 */}
|
|
{summaryExpanded && (
|
|
<Box sx={{ mt: 2, pt: 2, borderTop: `1px solid ${d3roPalette.border.subtle}` }}>
|
|
{summaryLoading ? (
|
|
<PhosphorText variant="dim">{t('meetingSummary.generating')}</PhosphorText>
|
|
) : summary ? (
|
|
<Box sx={{ fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary, lineHeight: 1.6 }}>
|
|
{summary.summary && (
|
|
<Box sx={{ mb: 1.5 }}>
|
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
|
|
{t('meetingSummary.summary').toUpperCase()}
|
|
</PhosphorText>
|
|
<Box sx={{ whiteSpace: 'pre-wrap' }}>{summary.summary}</Box>
|
|
</Box>
|
|
)}
|
|
{summary.decisions.length > 0 && (
|
|
<Box sx={{ mb: 1.5 }}>
|
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
|
|
{t('meetingSummary.decisions').toUpperCase()}
|
|
</PhosphorText>
|
|
{summary.decisions.map((d, i) => (
|
|
<Box key={i} sx={{ pl: 1.5 }}>• {d}</Box>
|
|
))}
|
|
</Box>
|
|
)}
|
|
{summary.actionItems.length > 0 && (
|
|
<Box sx={{ mb: 1 }}>
|
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
|
|
{t('meetingSummary.actionItems').toUpperCase()}
|
|
</PhosphorText>
|
|
{summary.actionItems.map((a, i) => (
|
|
<Box key={i} sx={{ pl: 1.5 }}>☐ {a}</Box>
|
|
))}
|
|
</Box>
|
|
)}
|
|
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
|
<Tooltip title={t('meetingSummary.exportMarkdown')} arrow>
|
|
<IconButton size="small" onClick={handleExportSummary} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
|
|
<SummarizeIcon sx={{ fontSize: 14 }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ textAlign: 'center' }}>
|
|
<PhosphorText variant="dim" sx={{ mb: 1 }}>{t('meetingSummary.noSummary')}</PhosphorText>
|
|
{(entry.mode === 'caption' || entry.mode === 'file-transcription') && (
|
|
<Chip
|
|
label={t('meetingSummary.generate')}
|
|
size="small"
|
|
onClick={handleGenerateSummary}
|
|
sx={{
|
|
fontFamily: d3roFontMono,
|
|
fontSize: d3roTypo.micro.size,
|
|
bgcolor: d3roPalette.accent.amber,
|
|
color: d3roPalette.bg.chassis,
|
|
cursor: 'pointer',
|
|
'&:hover': { opacity: 0.85 },
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* 요약 토글 버튼 (caption/file-transcription 모드만) */}
|
|
{(entry.mode === 'caption' || entry.mode === 'file-transcription') && !summaryExpanded && !hasSummary && (
|
|
<Box
|
|
sx={{ mt: 1, textAlign: 'center', cursor: 'pointer', color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.amber } }}
|
|
onClick={handleToggleSummary}
|
|
>
|
|
<ExpandMoreIcon sx={{ fontSize: 16 }} />
|
|
</Box>
|
|
)}
|
|
</MetalCard>
|
|
)
|
|
}
|