From f0e221058d29bf0024204c6f87709879a0963368 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 8 Apr 2026 11:34:50 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20/simplify=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=E2=80=94=20UX/=EC=BD=94=EB=93=9C=20=ED=92=88=EC=A7=88=2010?= =?UTF-8?q?=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditableSegment: readOnly prop 추가, hover 배경색 효과 - MeetingDetailTabs: useMemo(parseSegments), 이중 debounce 제거, cleanup - MeetingChatPanel: sessionId deps, 에러 표시, key=timestamp - ExportMenu: i18n 위반 수정 (하드코딩 → t()) - MeetingModePage: 에러 Snackbar 피드백 (startRecording, viewDetail) - TranscriptTab: polish 실패 피드백 - 12개 locale i18n 키 5개 추가 --- .../components/meeting/EditableSegment.tsx | 20 +++++++--- .../components/meeting/ExportMenu.tsx | 4 +- .../components/meeting/MeetingChatPanel.tsx | 36 ++++++++++++++++-- .../components/meeting/MeetingDetailTabs.tsx | 37 +++++++++---------- .../components/meeting/TranscriptTab.tsx | 18 ++++++++- src/renderer/i18n/de.json | 7 +++- src/renderer/i18n/en.json | 7 +++- src/renderer/i18n/es.json | 7 +++- src/renderer/i18n/fr.json | 7 +++- src/renderer/i18n/ja.json | 7 +++- src/renderer/i18n/ko.json | 7 +++- src/renderer/i18n/pt.json | 7 +++- src/renderer/i18n/ru.json | 7 +++- src/renderer/i18n/th.json | 7 +++- src/renderer/i18n/vi.json | 7 +++- src/renderer/i18n/zh-TW.json | 7 +++- src/renderer/i18n/zh.json | 7 +++- src/renderer/pages/MeetingModePage.tsx | 29 +++++++++++++-- 18 files changed, 179 insertions(+), 49 deletions(-) diff --git a/src/renderer/components/meeting/EditableSegment.tsx b/src/renderer/components/meeting/EditableSegment.tsx index ebafb5b..e79d924 100644 --- a/src/renderer/components/meeting/EditableSegment.tsx +++ b/src/renderer/components/meeting/EditableSegment.tsx @@ -12,6 +12,7 @@ interface EditableSegmentProps { text: string edited: boolean // 수정 여부 onEdit: (segmentId: string, newText: string) => void + readOnly?: boolean } function formatTimestamp(ms: number): string { @@ -27,6 +28,7 @@ export function EditableSegment({ text, edited, onEdit, + readOnly = false, }: EditableSegmentProps): React.ReactElement { const { t } = useI18n() const [editing, setEditing] = useState(false) @@ -34,9 +36,10 @@ export function EditableSegment({ const inputRef = useRef(null) const handleClick = useCallback(() => { + if (readOnly) return setDraft(text) setEditing(true) - }, [text]) + }, [readOnly, text]) const handleCommit = useCallback(() => { const trimmed = draft.trim() @@ -104,12 +107,17 @@ export function EditableSegment({ fontFamily: d3roFontMono, fontSize: 13, color: d3roPalette.text.primary, - cursor: 'text', + cursor: readOnly ? 'default' : 'text', borderBottom: edited ? `1px dashed ${d3roPalette.text.inactive}` : 'none', - '&:hover': { - color: d3roPalette.text.primary, - borderBottomColor: edited ? d3roPalette.accent.amber : undefined, - }, + 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} diff --git a/src/renderer/components/meeting/ExportMenu.tsx b/src/renderer/components/meeting/ExportMenu.tsx index c95bbda..e364497 100644 --- a/src/renderer/components/meeting/ExportMenu.tsx +++ b/src/renderer/components/meeting/ExportMenu.tsx @@ -41,9 +41,9 @@ export function ExportMenu({ onExport, onCopyToClipboard }: ExportMenuProps): Re ) const formats: FormatItem[] = [ - { format: 'md', label: 'Markdown (.md)' }, + { format: 'md', label: t('meeting.exportMd') }, { format: 'txt', label: t('meeting.exportTxt') }, - { format: 'pdf', label: 'PDF (.pdf)' }, + { format: 'pdf', label: t('meeting.exportPdfFmt') }, { format: 'docx', label: t('meeting.exportDocx') }, ] diff --git a/src/renderer/components/meeting/MeetingChatPanel.tsx b/src/renderer/components/meeting/MeetingChatPanel.tsx index aa078d7..e283644 100644 --- a/src/renderer/components/meeting/MeetingChatPanel.tsx +++ b/src/renderer/components/meeting/MeetingChatPanel.tsx @@ -52,6 +52,7 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re const [streaming, setStreaming] = useState(false) const [streamingContent, setStreamingContent] = useState('') const [collapsed, setCollapsed] = useState(false) + const [error, setError] = useState(null) const scrollRef = useRef(null) const inputRef = useRef(null) @@ -79,9 +80,10 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re setStreamingContent('') }) - const unsubError = window.electronAPI.meetingChat.onError((_err) => { + const unsubError = window.electronAPI.meetingChat.onError((err) => { setStreaming(false) setStreamingContent('') + setError(err.message) }) return () => { @@ -89,7 +91,7 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re unsubMessage() unsubError() } - }, []) + }, [sessionId]) const handleSend = useCallback(() => { const text = inputValue.trim() @@ -197,9 +199,9 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re minHeight: 0, }} > - {messages.map((msg, idx) => ( + {messages.map((msg) => ( ))} + {/* 에러 메시지 */} + {error && ( + + setError(null)} + > + {error} + + + )} + {/* 스트리밍 중 어시스턴트 메시지 */} {streaming && ( diff --git a/src/renderer/components/meeting/MeetingDetailTabs.tsx b/src/renderer/components/meeting/MeetingDetailTabs.tsx index 7664b1e..e24288f 100644 --- a/src/renderer/components/meeting/MeetingDetailTabs.tsx +++ b/src/renderer/components/meeting/MeetingDetailTabs.tsx @@ -1,7 +1,7 @@ // src/renderer/components/meeting/MeetingDetailTabs.tsx // Phase 14.5: 풀스크린 탭 전환 컨테이너 — 전사 + 동적 문서 탭 -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { Box, Tabs, @@ -33,8 +33,6 @@ interface MeetingDetailTabsProps { onBack: () => void } -const AUTOSAVE_DELAY = 500 - // 세그먼트 파싱: rawTranscript를 줄 단위로 분리 function parseSegments( rawTranscript: string | null, @@ -78,17 +76,19 @@ export function MeetingDetailTabs({ }: MeetingDetailTabsProps): React.ReactElement { const { t } = useI18n() const [detail, setDetail] = useState(initialDetail) - const [documents, setDocuments] = useState(initialDetail.documents ?? []) + const [documents, setDocuments] = useState((initialDetail.documents ?? []).filter(Boolean)) const [templates, setTemplates] = useState([]) const [tabIndex, setTabIndex] = useState(0) const [dialogOpen, setDialogOpen] = useState(false) const [generating, setGenerating] = useState(false) const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState(detail.title ?? '') - const autoSaveTimers = useRef>>({}) // 세그먼트 파싱 (rawTranscript 기반) - const segments = parseSegments(detail.rawTranscript, detail.editedTranscript) + const segments = useMemo( + () => parseSegments(detail.rawTranscript, detail.editedTranscript), + [detail.rawTranscript, detail.editedTranscript], + ) // 템플릿 로드 useEffect(() => { @@ -134,19 +134,16 @@ export function MeetingDetailTabs({ [detail.id], ) - // ── 문서 내용 자동저장 ── + // ── 문서 내용 자동저장 (DocumentTab 내부에 debounce 있음, 이중 래핑 제거) ── const handleDocContentChange = useCallback( - (docId: string, content: string) => { - if (autoSaveTimers.current[docId]) clearTimeout(autoSaveTimers.current[docId]) - autoSaveTimers.current[docId] = setTimeout(async () => { - await window.electronAPI.meetingMode.updateDocument({ - documentId: docId, - content, - }) - setDocuments((prev) => - prev.map((d) => (d.id === docId ? { ...d, content } : d)), - ) - }, AUTOSAVE_DELAY) + async (docId: string, content: string) => { + await window.electronAPI.meetingMode.updateDocument({ + documentId: docId, + content, + }) + setDocuments((prev) => + prev.map((d) => (d.id === docId ? { ...d, content } : d)), + ) }, [], ) @@ -286,8 +283,8 @@ export function MeetingDetailTabs({ }} > - {documents.map((doc) => ( - + {documents.filter(Boolean).map((doc) => ( + ))} } diff --git a/src/renderer/components/meeting/TranscriptTab.tsx b/src/renderer/components/meeting/TranscriptTab.tsx index fa78bb9..09020f2 100644 --- a/src/renderer/components/meeting/TranscriptTab.tsx +++ b/src/renderer/components/meeting/TranscriptTab.tsx @@ -2,7 +2,7 @@ // Phase 14.5: 전사 편집 탭 (상세 페이지용) import { useState, useCallback } from 'react' -import { Box, Switch, FormControlLabel, LinearProgress } from '@mui/material' +import { Box, Switch, FormControlLabel, LinearProgress, Snackbar, Alert } from '@mui/material' import { EditableSegment } from './EditableSegment' import { PhysicalButton } from '../ds/PhysicalButton' import { PhosphorText } from '../ds/PhosphorText' @@ -37,15 +37,19 @@ export function TranscriptTab({ const { t } = useI18n() const [showEdited, setShowEdited] = useState(true) const [polishing, setPolishing] = useState(false) + const [polishError, setPolishError] = useState(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]) + }, [sessionId, onSaveTranscript, t]) const handleDownloadTxt = useCallback(() => { const content = showEdited && editedTranscript ? editedTranscript : (rawTranscript ?? '') @@ -77,6 +81,16 @@ export function TranscriptTab({ return ( + setPolishError(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setPolishError(null)} sx={{ width: '100%' }}> + {polishError} + + {/* 상단 컨트롤 */} (null) + const [snackbarMsg, setSnackbarMsg] = useState(null) // ── 세션 목록 로드 ── const loadSessions = useCallback(async () => { @@ -140,8 +143,10 @@ export function MeetingModePage(): React.ReactElement { setElapsedMs(0) setProgress(null) setView('recording') + } else { + setSnackbarMsg(t('meeting.startRecordingError')) } - }, []) + }, [t]) const handleStopRecording = useCallback(async () => { await window.electronAPI.meetingMode.stopRecording() @@ -171,8 +176,10 @@ export function MeetingModePage(): React.ReactElement { if (resp.success) { setDetail(resp.data) setView('detail') + } else { + setSnackbarMsg(t('meeting.viewDetailError')) } - }, []) + }, [t]) // ── 유틸 ── @@ -198,6 +205,19 @@ export function MeetingModePage(): React.ReactElement { } } + const snackbar = ( + setSnackbarMsg(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnackbarMsg(null)} sx={{ width: '100%' }}> + {snackbarMsg} + + + ) + // ── 렌더: 목록 뷰 ── if (view === 'list') { return ( @@ -263,6 +283,7 @@ export function MeetingModePage(): React.ReactElement { ))} )} + {snackbar} ) } @@ -328,7 +349,8 @@ export function MeetingModePage(): React.ReactElement { timestamp={Math.max(0, seg.timestamp - (recordingStartRef.current || seg.timestamp))} text={seg.text} edited={false} - onEdit={() => { /* 녹음 중 편집 비활성 */ }} + onEdit={() => { /* no-op: readOnly=true */ }} + readOnly /> ))} @@ -403,6 +425,7 @@ export function MeetingModePage(): React.ReactElement { )} + {snackbar} ) }