feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
This commit is contained in:
parent
5cd1de6859
commit
708e20f747
406 changed files with 42464 additions and 6199 deletions
|
|
@ -1,7 +1,7 @@
|
|||
// src/renderer/components/meeting/AddDocumentDialog.tsx
|
||||
// Phase 14.5: 문서 생성 다이얼로그 — 템플릿 선택 + 커스텀 프롬프트
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
|
|
@ -44,18 +44,26 @@ export function AddDocumentDialog({
|
|||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [customPrompt, setCustomPrompt] = useState('')
|
||||
const [customTitle, setCustomTitle] = useState('')
|
||||
const isGeneratingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !generating) {
|
||||
isGeneratingRef.current = false
|
||||
}
|
||||
}, [open, generating])
|
||||
|
||||
const selectedTemplate = templates.find((tp) => tp.id === selectedId)
|
||||
const isCustom = selectedTemplate?.templateType === 'custom'
|
||||
|
||||
const handleGenerate = useCallback(() => {
|
||||
if (!selectedId) return
|
||||
if (!selectedId || generating || isGeneratingRef.current) return
|
||||
isGeneratingRef.current = true
|
||||
onGenerate(
|
||||
selectedId,
|
||||
isCustom && customPrompt.trim() ? customPrompt.trim() : undefined,
|
||||
isCustom && customTitle.trim() ? customTitle.trim() : undefined,
|
||||
)
|
||||
}, [selectedId, isCustom, customPrompt, customTitle, onGenerate])
|
||||
}, [selectedId, generating, isCustom, customPrompt, customTitle, onGenerate])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!generating) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Send, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
|||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { PhysicalButton } from '@d3ro/ui/components/ds'
|
||||
import { isImeComposingEvent } from '../../utils/keyboard'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type { MeetingChatMessage } from '@d3ro/core/types'
|
||||
|
||||
|
|
@ -85,12 +85,14 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
|
|||
setMessages((prev) => [...prev, msg])
|
||||
setStreaming(false)
|
||||
setStreamingContent('')
|
||||
isSendingChatRef.current = false
|
||||
})
|
||||
|
||||
const unsubError = window.electronAPI.meetingChat.onError((err) => {
|
||||
setStreaming(false)
|
||||
setStreamingContent('')
|
||||
setError(err.message)
|
||||
isSendingChatRef.current = false
|
||||
})
|
||||
|
||||
return () => {
|
||||
|
|
@ -100,9 +102,12 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
|
|||
}
|
||||
}, [sessionId])
|
||||
|
||||
const isSendingChatRef = useRef(false)
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const text = inputValue.trim()
|
||||
if (!text || streaming) return
|
||||
if (!text || streaming || isSendingChatRef.current) return
|
||||
isSendingChatRef.current = true
|
||||
|
||||
const userMsg: MeetingChatMessage = {
|
||||
role: 'user',
|
||||
|
|
@ -111,8 +116,12 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
|
|||
}
|
||||
setMessages((prev) => [...prev, userMsg])
|
||||
setInputValue('')
|
||||
setStreaming(true)
|
||||
window.electronAPI.meetingChat.send({ sessionId, message: text })
|
||||
inputRef.current?.focus()
|
||||
setTimeout(() => {
|
||||
isSendingChatRef.current = false
|
||||
}, 500)
|
||||
}, [inputValue, streaming, sessionId])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
|
|
@ -179,13 +188,15 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
|
|||
</PhosphorText>
|
||||
|
||||
<Tooltip title={t('meeting.chatClear')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleClear}
|
||||
disabled={messages.length === 0 && !streaming}
|
||||
>
|
||||
<Trash2 size={15} style={{ color: d3roPalette.text.inactive }} />
|
||||
</IconButton>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleClear}
|
||||
disabled={messages.length === 0 && !streaming}
|
||||
>
|
||||
<Trash2 size={15} style={{ color: d3roPalette.text.inactive }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={collapsed ? t('meeting.chatExpand') : t('meeting.chatCollapse')}>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// src/renderer/components/meeting/MeetingDetailTabs.tsx
|
||||
// Phase 14.5: 풀스크린 탭 전환 컨테이너 — 전사 + 동적 문서 탭
|
||||
// Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad)
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Tabs,
|
||||
|
|
@ -9,15 +9,31 @@ import {
|
|||
IconButton,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Checkbox,
|
||||
} from '@mui/material'
|
||||
import { ArrowLeft, Plus, Pencil, Check } from 'lucide-react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Pencil,
|
||||
Check,
|
||||
Users,
|
||||
Copy,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { TranscriptTab } from './TranscriptTab'
|
||||
import { DocumentTab } from './DocumentTab'
|
||||
import { AddDocumentDialog } from './AddDocumentDialog'
|
||||
import { MeetingChatPanel } from './MeetingChatPanel'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import {
|
||||
PhosphorText,
|
||||
PhysicalButton,
|
||||
SegmentControl,
|
||||
TactileBadge,
|
||||
MetalCard,
|
||||
} from '@d3ro/ui/components/ds'
|
||||
import { isImeComposingEvent } from '../../utils/keyboard'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type {
|
||||
MeetingSessionDetail,
|
||||
|
|
@ -31,15 +47,15 @@ interface MeetingDetailTabsProps {
|
|||
onBack: () => void
|
||||
}
|
||||
|
||||
type ViewMode = 'split' | 'transcript' | 'doc'
|
||||
|
||||
// 세그먼트 파싱: 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*(.+)$/
|
||||
): Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }> {
|
||||
const source = editedTranscript || rawTranscript || ''
|
||||
const regex = /^\[(\d{2}):(\d{2})\]\s*(?:\[([^\]]+)\]\s*)?(.+)$/
|
||||
return source
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
|
|
@ -48,26 +64,43 @@ function parseSegments(
|
|||
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() ?? ''
|
||||
const speaker = m[3] || undefined
|
||||
const text = m[4]
|
||||
return {
|
||||
id: `seg-${idx}`,
|
||||
timestamp: (min * 60 + sec) * 1000,
|
||||
text,
|
||||
edited: Boolean(editedLine) && editedLine !== line.trim(),
|
||||
edited: false,
|
||||
speaker,
|
||||
}
|
||||
}
|
||||
// 타임스탬프 없는 라인: 인덱스 기반
|
||||
return {
|
||||
id: `seg-${idx}`,
|
||||
timestamp: idx * 5000,
|
||||
timestamp: idx * 4000,
|
||||
text: line.trim(),
|
||||
edited: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Extract Action items from documents
|
||||
function extractActionItems(content: string): Array<{ id: string; text: string; done: boolean }> {
|
||||
const lines = content.split('\n')
|
||||
const items: Array<{ id: string; text: string; done: boolean }> = []
|
||||
let idx = 0
|
||||
for (const line of lines) {
|
||||
const checkMatch = /^\s*[-*]\s*\[([ xX])\]\s*(.+)$/.exec(line)
|
||||
if (checkMatch) {
|
||||
items.push({
|
||||
id: `todo-${idx++}`,
|
||||
done: checkMatch[1].toLowerCase() === 'x',
|
||||
text: checkMatch[2].trim(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export function MeetingDetailTabs({
|
||||
detail: initialDetail,
|
||||
onBack,
|
||||
|
|
@ -76,14 +109,26 @@ export function MeetingDetailTabs({
|
|||
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)
|
||||
|
||||
// Tab key string state (e.g. `doc-${id}`, 'notepad', 'action-items')
|
||||
const [activeTabKey, setActiveTabKey] = useState<string>(() => {
|
||||
const docs = (initialDetail.documents ?? []).filter(Boolean)
|
||||
return docs.length > 0 ? `doc-${docs[0].id}` : 'notepad'
|
||||
})
|
||||
|
||||
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 ?? '')
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||
|
||||
// 세그먼트 파싱 (rawTranscript 기반)
|
||||
// Granola-style Scratchpad & Action Items State
|
||||
const [scratchpadNotes, setScratchpadNotes] = useState('')
|
||||
const [actionItems, setActionItems] = useState<Array<{ id: string; text: string; done: boolean }>>([])
|
||||
const [enhancingNotes, setEnhancingNotes] = useState(false)
|
||||
|
||||
// 세그먼트 파싱 (rawTranscript / editedTranscript 기반)
|
||||
const segments = useMemo(
|
||||
() => parseSegments(detail.rawTranscript, detail.editedTranscript),
|
||||
[detail.rawTranscript, detail.editedTranscript],
|
||||
|
|
@ -96,6 +141,30 @@ export function MeetingDetailTabs({
|
|||
})
|
||||
}, [])
|
||||
|
||||
// Sync action items from primary document
|
||||
useEffect(() => {
|
||||
const primaryDoc = documents[0]
|
||||
if (primaryDoc) {
|
||||
const extracted = extractActionItems(primaryDoc.content)
|
||||
if (extracted.length > 0) {
|
||||
setActionItems(extracted)
|
||||
}
|
||||
}
|
||||
}, [documents])
|
||||
|
||||
// If documents change, ensure activeTabKey is valid
|
||||
useEffect(() => {
|
||||
if (activeTabKey.startsWith('doc-')) {
|
||||
const targetId = activeTabKey.replace('doc-', '')
|
||||
const exists = documents.some((d) => d.id === targetId)
|
||||
if (!exists && documents.length > 0) {
|
||||
setActiveTabKey(`doc-${documents[0].id}`)
|
||||
} else if (!exists) {
|
||||
setActiveTabKey('notepad')
|
||||
}
|
||||
}
|
||||
}, [documents, activeTabKey])
|
||||
|
||||
// ── 제목 저장 ──
|
||||
const handleSaveTitle = useCallback(async () => {
|
||||
if (!titleDraft.trim()) return
|
||||
|
|
@ -115,7 +184,6 @@ export function MeetingDetailTabs({
|
|||
segmentId,
|
||||
text: newText,
|
||||
})
|
||||
// 로컬 editedTranscript 갱신 (단순 반영)
|
||||
setDetail((prev) => ({ ...prev, editedTranscript: prev.rawTranscript }))
|
||||
},
|
||||
[detail.id],
|
||||
|
|
@ -133,7 +201,7 @@ export function MeetingDetailTabs({
|
|||
[detail.id],
|
||||
)
|
||||
|
||||
// ── 문서 내용 자동저장 (DocumentTab 내부에 debounce 있음, 이중 래핑 제거) ──
|
||||
// ── 문서 내용 자동저장 ──
|
||||
const handleDocContentChange = useCallback(
|
||||
async (docId: string, content: string) => {
|
||||
await window.electronAPI.meetingMode.updateDocument({
|
||||
|
|
@ -147,13 +215,22 @@ export function MeetingDetailTabs({
|
|||
[],
|
||||
)
|
||||
|
||||
const isExportingRef = useRef(false)
|
||||
const isDeletingDocRef = useRef(false)
|
||||
|
||||
// ── 문서 내보내기 ──
|
||||
const handleExportDocument = useCallback(
|
||||
async (docId: string, format: MeetingExportFormat) => {
|
||||
await window.electronAPI.meetingMode.exportDocument({
|
||||
documentId: docId,
|
||||
format,
|
||||
})
|
||||
if (isExportingRef.current) return
|
||||
isExportingRef.current = true
|
||||
try {
|
||||
await window.electronAPI.meetingMode.exportDocument({
|
||||
documentId: docId,
|
||||
format,
|
||||
})
|
||||
} finally {
|
||||
isExportingRef.current = false
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
|
@ -161,32 +238,66 @@ export function MeetingDetailTabs({
|
|||
// ── 문서 삭제 ──
|
||||
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
|
||||
if (isDeletingDocRef.current) return
|
||||
isDeletingDocRef.current = true
|
||||
try {
|
||||
await window.electronAPI.meetingMode.deleteDocument({ documentId: docId })
|
||||
setDocuments((prev) => {
|
||||
const remaining = prev.filter((d) => d.id !== docId)
|
||||
if (remaining.length > 0) {
|
||||
setActiveTabKey(`doc-${remaining[0].id}`)
|
||||
} else {
|
||||
setActiveTabKey('notepad')
|
||||
}
|
||||
return remaining
|
||||
})
|
||||
return prev.filter((d) => d.id !== docId)
|
||||
})
|
||||
} finally {
|
||||
isDeletingDocRef.current = false
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// ── 화자 구분 ──
|
||||
const handleDiarize = useCallback(async () => {
|
||||
if (diarizing) return
|
||||
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)
|
||||
try {
|
||||
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)
|
||||
}
|
||||
} finally {
|
||||
setDiarizing(false)
|
||||
}
|
||||
setDiarizing(false)
|
||||
}, [detail.id])
|
||||
}, [detail.id, diarizing])
|
||||
|
||||
// ── 문서 생성 ──
|
||||
// ── Granola "Jot & Enhance" AI Note Enhancement ──
|
||||
const handleEnhanceScratchpad = useCallback(async () => {
|
||||
if (!scratchpadNotes.trim() || enhancingNotes) return
|
||||
setEnhancingNotes(true)
|
||||
try {
|
||||
const prompt = `다음은 회의 중 작성한 메모와 전체 회의 전사 내용입니다. 사용자 메모의 맥락을 살려 체계적인 핵심 요약 및 결정 사항, 실행 과제로 깔끔하게 확장 정리해주세요:\n\n[사용자 메모]:\n${scratchpadNotes}\n\n[회의 전사]:\n${detail.editedTranscript || detail.rawTranscript}`
|
||||
const resp = await window.electronAPI.llm.generate({ prompt })
|
||||
if (resp.success && resp.data.text) {
|
||||
const newDocResult = await window.electronAPI.meetingMode.generateDocument({
|
||||
sessionId: detail.id,
|
||||
templateId: 'custom',
|
||||
customTitle: '📝 내 메모 기반 회의록 (AI 증강)',
|
||||
customPrompt: prompt,
|
||||
})
|
||||
if (newDocResult.success) {
|
||||
setDocuments((prev) => [...prev, newDocResult.data])
|
||||
setActiveTabKey(`doc-${newDocResult.data.id}`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setEnhancingNotes(false)
|
||||
}
|
||||
}, [scratchpadNotes, detail.editedTranscript, detail.rawTranscript, detail.id, enhancingNotes])
|
||||
|
||||
// ── 새 문서 생성 다이얼로그 ──
|
||||
const handleGenerate = useCallback(
|
||||
async (templateId: string, customPrompt?: string, customTitle?: string) => {
|
||||
setGenerating(true)
|
||||
|
|
@ -198,155 +309,347 @@ export function MeetingDetailTabs({
|
|||
})
|
||||
setGenerating(false)
|
||||
if (resp.success) {
|
||||
const newDoc = resp.data
|
||||
setDocuments((prev) => {
|
||||
// 새 문서 탭으로 이동: 전사(0) + 기존문서수 + 1
|
||||
setTabIndex(prev.length + 1)
|
||||
return [...prev, newDoc]
|
||||
})
|
||||
setDocuments((prev) => [...prev, resp.data])
|
||||
setActiveTabKey(`doc-${resp.data.id}`)
|
||||
setDialogOpen(false)
|
||||
}
|
||||
},
|
||||
[detail.id],
|
||||
)
|
||||
|
||||
// 탭 수: 전사(1) + 문서들 + [+] 버튼(1)
|
||||
const totalTabCount = 1 + documents.length + 1
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* 헤더 */}
|
||||
{/* ── 1. Header Toolbar ──────────────────────────────────────── */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
|
||||
justifyContent: 'space-between',
|
||||
px: 2.5,
|
||||
py: 1.25,
|
||||
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconButton size="small" onClick={onBack}>
|
||||
<ArrowLeft size={18} style={{ color: d3roPalette.text.secondary }} />
|
||||
</IconButton>
|
||||
{/* Left: Back & Title */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0, flex: 1 }}>
|
||||
<IconButton size="small" onClick={onBack} sx={{ color: d3roPalette.text.secondary }}>
|
||||
<ArrowLeft size={18} />
|
||||
</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) => {
|
||||
if (isImeComposingEvent(e)) return
|
||||
if (e.key === 'Enter') handleSaveTitle()
|
||||
}}
|
||||
sx={{
|
||||
flex: 1,
|
||||
'& .MuiInputBase-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size },
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<IconButton size="small" onClick={handleSaveTitle}>
|
||||
<Check size={16} style={{ color: d3roPalette.accent.main }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<PhosphorText variant="label" sx={{ flex: 1 }}>
|
||||
{detail.title ?? t('meeting.untitled')}
|
||||
</PhosphorText>
|
||||
<Tooltip title={t('meeting.editTitle')}>
|
||||
<IconButton
|
||||
{editingTitle ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, maxWidth: 400 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
onClick={() => { setEditingTitle(true); setTitleDraft(detail.title ?? '') }}
|
||||
>
|
||||
<Pencil size={15} style={{ color: d3roPalette.text.inactive }} />
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isImeComposingEvent(e)) return
|
||||
if (e.key === 'Enter') handleSaveTitle()
|
||||
}}
|
||||
sx={{
|
||||
flex: 1,
|
||||
'& .MuiInputBase-root': { fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 600 },
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<IconButton size="small" onClick={handleSaveTitle} sx={{ color: d3roPalette.accent.light }}>
|
||||
<Check size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
color: d3roPalette.text.primary,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{detail.title || t('meeting.untitled')}
|
||||
</Typography>
|
||||
<Tooltip title={t('common.edit')}>
|
||||
<IconButton size="small" onClick={() => setEditingTitle(true)} sx={{ color: d3roPalette.text.inactive, p: 0.5 }}>
|
||||
<Pencil size={13} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TactileBadge mono tone="mono">
|
||||
{new Date(detail.startedAt || Date.now()).toLocaleDateString()}
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
|
||||
{/* Right: Viewport Mode Switcher (Split / Transcript / Doc) */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<SegmentControl
|
||||
options={[
|
||||
{ value: 'split', label: 'Split ◫' },
|
||||
{ value: 'transcript', label: 'Transcript ▤' },
|
||||
{ value: 'doc', label: 'Minutes ▥' },
|
||||
]}
|
||||
value={viewMode}
|
||||
onChange={(v) => setViewMode(v as ViewMode)}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<PhysicalButton
|
||||
tone="accent"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (!dialogOpen && !generating) setDialogOpen(true)
|
||||
}}
|
||||
disabled={dialogOpen || generating}
|
||||
trailingIcon={<Plus size={13} />}
|
||||
>
|
||||
{t('meeting.addDocument')}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── 2. Dual-Canvas Workspace (Split View) ─────────────────── */}
|
||||
<Box sx={{ display: 'flex', flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
{/* Left Canvas: Interactive Transcript Timeline */}
|
||||
{(viewMode === 'split' || viewMode === 'transcript') && (
|
||||
<Box
|
||||
sx={{
|
||||
width: viewMode === 'split' ? '45%' : '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: viewMode === 'split' ? `1px solid ${d3roPalette.glass.hairline}` : 'none',
|
||||
bgcolor: d3roPalette.bg.app,
|
||||
p: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5, pb: 1, borderBottom: `1px solid ${d3roPalette.glass.hairline}` }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Users size={16} style={{ color: d3roPalette.accent.light }} />
|
||||
<PhosphorText variant="heading" sx={{ fontSize: '13px', fontWeight: 700 }}>
|
||||
SPEAKER TRANSCRIPT TIMELINE
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<TactileBadge mono tone="mono">
|
||||
{segments.length} UTTERANCES
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<TranscriptTab
|
||||
sessionId={detail.id}
|
||||
segments={segments}
|
||||
rawTranscript={detail.rawTranscript}
|
||||
editedTranscript={detail.editedTranscript}
|
||||
memos={detail.memos ?? []}
|
||||
onEditSegment={handleEditSegment}
|
||||
onSaveTranscript={handleSaveTranscript}
|
||||
onDiarize={handleDiarize}
|
||||
diarizing={diarizing}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Right Canvas: Structured AI Minutes & Granola Notepad */}
|
||||
{(viewMode === 'split' || viewMode === 'doc') && (
|
||||
<Box
|
||||
sx={{
|
||||
width: viewMode === 'split' ? '55%' : '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Right Sub-Tabs */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2,
|
||||
pt: 1,
|
||||
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={activeTabKey}
|
||||
onChange={(_, v) => setActiveTabKey(v)}
|
||||
sx={{
|
||||
minHeight: 38,
|
||||
'& .MuiTab-root': {
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
minHeight: 38,
|
||||
py: 0.5,
|
||||
px: 1.75,
|
||||
color: d3roPalette.text.secondary,
|
||||
'&.Mui-selected': { color: d3roPalette.accent.light },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{documents.map((doc, idx) => (
|
||||
<Tab key={doc.id} value={`doc-${doc.id}`} label={doc.title || `Document ${idx + 1}`} />
|
||||
))}
|
||||
<Tab value="notepad" label="📝 Granola Notepad" />
|
||||
<Tab value="action-items" label={`✅ Action Items (${actionItems.length})`} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Document Content View */}
|
||||
<Box sx={{ flex: 1, p: 2.5, minHeight: 0, overflow: 'auto' }}>
|
||||
{activeTabKey.startsWith('doc-') ? (
|
||||
// AI Document Editor Tab
|
||||
(() => {
|
||||
const targetDocId = activeTabKey.replace('doc-', '')
|
||||
const currentDoc = documents.find((d) => d.id === targetDocId) || documents[0]
|
||||
return currentDoc ? (
|
||||
<DocumentTab
|
||||
document={currentDoc}
|
||||
onContentChange={(c) => handleDocContentChange(currentDoc.id, c)}
|
||||
onExport={(fmt) => handleExportDocument(currentDoc.id, fmt)}
|
||||
onDelete={() => handleDeleteDocument(currentDoc.id)}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
||||
<PhosphorText variant="dim">문서가 없습니다. 새 문서를 생성해주세요.</PhosphorText>
|
||||
</Box>
|
||||
)
|
||||
})()
|
||||
) : activeTabKey === 'notepad' ? (
|
||||
// Granola-style Notepad Tab
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 700, color: d3roPalette.text.primary }}>
|
||||
Granola-style Hybrid Notepad
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.secondary }}>
|
||||
회의 중 메모를 자유롭게 적어보세요. AI가 전체 발화 내용과 결합하여 완벽한 회의록으로 증강해 드립니다.
|
||||
</Typography>
|
||||
</Box>
|
||||
<PhysicalButton
|
||||
tone="accent"
|
||||
size="small"
|
||||
onClick={handleEnhanceScratchpad}
|
||||
disabled={!scratchpadNotes.trim() || enhancingNotes}
|
||||
>
|
||||
<Sparkles size={14} style={{ marginRight: 4 }} />
|
||||
{enhancingNotes ? '증강 중...' : '⚡ AI로 메모 증강하기'}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
multiline
|
||||
rows={14}
|
||||
value={scratchpadNotes}
|
||||
onChange={(e) => setScratchpadNotes(e.target.value)}
|
||||
placeholder="- 논의된 주요 이슈 요약 - 고객사 피드백 핵심 포인트 - 내가 해야 할 일 체크리스트..."
|
||||
fullWidth
|
||||
sx={{
|
||||
flex: 1,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.7,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: d3roRadius.inner,
|
||||
p: 2,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
// Interactive Action Items Checklist Tab
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 700, color: d3roPalette.text.primary }}>
|
||||
Interactive Action Items Checklist
|
||||
</Typography>
|
||||
<PhysicalButton
|
||||
tone="glass"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const formatted = actionItems.map((a) => `- [${a.done ? 'x' : ' '}] ${a.text}`).join('\n')
|
||||
navigator.clipboard.writeText(formatted)
|
||||
}}
|
||||
>
|
||||
<Copy size={13} style={{ marginRight: 4 }} />
|
||||
Copy for Slack / Notion
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
|
||||
{actionItems.length === 0 ? (
|
||||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
||||
<PhosphorText variant="dim">문서에서 추출된 액션 아이템이 없습니다.</PhosphorText>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{actionItems.map((item) => (
|
||||
<MetalCard
|
||||
key={item.id}
|
||||
inset
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
p: 1.5,
|
||||
bgcolor: item.done ? d3roPalette.bg.inset : d3roPalette.bg.card,
|
||||
opacity: item.done ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={item.done}
|
||||
onChange={(e) => {
|
||||
setActionItems((prev) =>
|
||||
prev.map((a) => (a.id === item.id ? { ...a, done: e.target.checked } : a)),
|
||||
)
|
||||
}}
|
||||
size="small"
|
||||
sx={{ color: d3roPalette.accent.light, p: 0.5 }}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '13px',
|
||||
color: d3roPalette.text.primary,
|
||||
textDecoration: item.done ? 'line-through' : 'none',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</Typography>
|
||||
</MetalCard>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</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.main },
|
||||
},
|
||||
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 },
|
||||
}}
|
||||
>
|
||||
<Tab label={t('meeting.transcriptTab')} />
|
||||
{documents.filter(Boolean).map((doc) => (
|
||||
<Tab key={doc.id} label={doc.title ?? t('meeting.untitled')} />
|
||||
))}
|
||||
<Tab
|
||||
icon={<Plus size={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 채팅 패널 */}
|
||||
{/* ── 3. Bottom Meeting Copilot Chat Panel ──────────────────── */}
|
||||
<MeetingChatPanel sessionId={detail.id} />
|
||||
|
||||
{/* 문서 생성 다이얼로그 */}
|
||||
{/* Add Document Modal */}
|
||||
<AddDocumentDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onGenerate={handleGenerate}
|
||||
templates={templates}
|
||||
generating={generating}
|
||||
onGenerate={handleGenerate}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue