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
240
apps/desktop/src/renderer/components/AppLayout.tsx
Normal file
240
apps/desktop/src/renderer/components/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
// src/renderer/components/AppLayout.tsx
|
||||
// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Typography, Tooltip } from '@mui/material'
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||
import HistoryIcon from '@mui/icons-material/History'
|
||||
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
||||
import ExtensionIcon from '@mui/icons-material/Extension'
|
||||
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'
|
||||
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
|
||||
import GroupsIcon from '@mui/icons-material/Groups'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import { Led } from './ds'
|
||||
import { DashboardPage } from '../pages/DashboardPage'
|
||||
import { HistoryPage } from '../pages/HistoryPage'
|
||||
import { DictionaryPage } from '../pages/DictionaryPage'
|
||||
import { CommandsPage } from '../pages/CommandsPage'
|
||||
import { VoiceConversationPage } from '../pages/VoiceConversationPage'
|
||||
import { KnowledgeBasePage } from '../pages/KnowledgeBasePage'
|
||||
import { MeetingModePage } from '../pages/MeetingModePage'
|
||||
import { SettingsModal } from './SettingsModal'
|
||||
import { LicenseModal } from './LicenseModal'
|
||||
import { OnboardingModal } from './OnboardingModal'
|
||||
import { StatusBar } from './StatusBar'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
import type { TranslationKey } from '../i18n'
|
||||
import type { LicenseTier } from '@shared/types'
|
||||
|
||||
type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' | 'conversation' | 'knowledge' | 'meeting'
|
||||
|
||||
interface NavItem {
|
||||
route: Route
|
||||
labelKey: TranslationKey
|
||||
abbr: string
|
||||
icon: React.ReactElement
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ route: 'dashboard', labelKey: 'nav.dashboard', abbr: 'DASH', icon: <DashboardIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'history', labelKey: 'nav.history', abbr: 'HIST', icon: <HistoryIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'dictionary', labelKey: 'nav.dictionary', abbr: 'DICT', icon: <MenuBookIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'commands', labelKey: 'nav.commands', abbr: 'CMD', icon: <ExtensionIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'conversation', labelKey: 'nav.conversation', abbr: 'TALK', icon: <RecordVoiceOverIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'knowledge', labelKey: 'nav.knowledge', abbr: 'RAG', icon: <AutoStoriesIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'meeting', labelKey: 'nav.meeting', abbr: 'MTG', icon: <GroupsIcon sx={{ fontSize: 20 }} /> },
|
||||
]
|
||||
|
||||
function tierToLedColor(tier: LicenseTier): 'amber' | 'green' {
|
||||
switch (tier) {
|
||||
case 'free': return 'amber'
|
||||
case 'pro': return 'green'
|
||||
case 'pro_plus': return 'green'
|
||||
}
|
||||
}
|
||||
|
||||
export function AppLayout(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [onboardingOpen, setOnboardingOpen] = useState(false)
|
||||
const [licenseModalOpen, setLicenseModalOpen] = useState(false)
|
||||
const [currentTier, setCurrentTier] = useState<LicenseTier>('free')
|
||||
|
||||
// 첫 실행 감지
|
||||
useEffect(() => {
|
||||
window.electronAPI.config.getAll().then((r) => {
|
||||
if (r.success) {
|
||||
const cfg = r.data as Record<string, unknown>
|
||||
if (!cfg['onboardingCompleted']) {
|
||||
setOnboardingOpen(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// License: load tier + subscribe to changes + listen for open-modal events
|
||||
useEffect(() => {
|
||||
window.electronAPI.license.getInfo().then((r) => {
|
||||
if (r.success) setCurrentTier(r.data.tier)
|
||||
})
|
||||
|
||||
const unsubTier = window.electronAPI.license.onTierChanged((info) => {
|
||||
setCurrentTier(info.tier)
|
||||
})
|
||||
|
||||
const unsubUpgrade = window.electronAPI.license.onUpgradePrompt(() => {
|
||||
setLicenseModalOpen(true)
|
||||
})
|
||||
|
||||
const handleOpenLicenseModal = () => setLicenseModalOpen(true)
|
||||
window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
||||
|
||||
return () => {
|
||||
unsubTier()
|
||||
unsubUpgrade()
|
||||
window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
|
||||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* ── 사이드바: 인스트루먼트 섀시 스타일 ─── */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 72,
|
||||
flexShrink: 0,
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
borderRight: `1px solid ${d3roPalette.border.subtle}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
py: 2,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{/* 로고 LED — reflects license tier */}
|
||||
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<Led color={tierToLedColor(currentTier)} pulse={currentTier !== 'free'} size={10} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: d3roTypo.micro.size,
|
||||
fontFamily: d3roFontMono,
|
||||
letterSpacing: d3roTypo.micro.spacing,
|
||||
color: d3roPalette.text.dimLabel,
|
||||
fontWeight: d3roTypo.micro.weight,
|
||||
}}
|
||||
>
|
||||
D3RO
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 네비게이션 버튼 */}
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = currentRoute === item.route
|
||||
return (
|
||||
<Tooltip key={item.route} title={t(item.labelKey)} placement="right" arrow>
|
||||
<Box
|
||||
onClick={() => setCurrentRoute(item.route)}
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: d3roRadius.button,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 0.5,
|
||||
cursor: 'pointer',
|
||||
bgcolor: isActive ? d3roPalette.bg.chassis : 'transparent',
|
||||
boxShadow: isActive
|
||||
? d3roShadow.buttonPressed
|
||||
: d3roShadow.buttonRaised,
|
||||
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.inactive,
|
||||
transition: 'all 0.05s linear',
|
||||
transform: isActive ? 'translateY(1px)' : 'none',
|
||||
'&:active': {
|
||||
transform: 'translateY(2px)',
|
||||
boxShadow: d3roShadow.buttonPressed,
|
||||
},
|
||||
'&:hover': {
|
||||
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: d3roTypo.nano.size,
|
||||
fontFamily: d3roFontMono,
|
||||
fontWeight: d3roTypo.nano.weight,
|
||||
letterSpacing: d3roTypo.nano.spacing,
|
||||
}}
|
||||
>
|
||||
{item.abbr}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 스페이서 */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
{/* Settings */}
|
||||
<Tooltip title={t('nav.settings')} placement="right" arrow>
|
||||
<Box
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: d3roRadius.button,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: d3roPalette.text.inactive,
|
||||
boxShadow: d3roShadow.buttonRaised,
|
||||
transition: 'all 0.05s linear',
|
||||
'&:active': {
|
||||
transform: 'translateY(2px)',
|
||||
boxShadow: d3roShadow.buttonPressed,
|
||||
},
|
||||
'&:hover': { color: d3roPalette.text.hover },
|
||||
}}
|
||||
>
|
||||
<SettingsIcon sx={{ fontSize: 20 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* ── 콘텐츠 영역 ────────────────────────── */}
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
bgcolor: d3roPalette.bg.app,
|
||||
// 미묘한 방사형 비네팅 (시안 A 배경)
|
||||
background: `radial-gradient(circle at 50% 30%, ${d3roPalette.bg.chassis} 0%, ${d3roPalette.bg.app} 70%)`,
|
||||
}}
|
||||
>
|
||||
{currentRoute === 'dashboard' && <DashboardPage />}
|
||||
{currentRoute === 'history' && <HistoryPage />}
|
||||
{currentRoute === 'dictionary' && <DictionaryPage />}
|
||||
{currentRoute === 'commands' && <CommandsPage />}
|
||||
{currentRoute === 'conversation' && <VoiceConversationPage />}
|
||||
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
|
||||
{currentRoute === 'meeting' && <MeetingModePage />}
|
||||
</Box>
|
||||
</Box>
|
||||
<StatusBar />
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<LicenseModal open={licenseModalOpen} onClose={() => setLicenseModalOpen(false)} />
|
||||
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
272
apps/desktop/src/renderer/components/FileDropZone.tsx
Normal file
272
apps/desktop/src/renderer/components/FileDropZone.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// src/renderer/components/FileDropZone.tsx
|
||||
// Phase 12.1: 파일 전사 드래그앤드롭 UI
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Box, LinearProgress, IconButton, Tooltip } from '@mui/material'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile'
|
||||
import { MetalCard, PhosphorText, Led } from './ds'
|
||||
import { d3roPalette, d3roTypo } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
import type {
|
||||
FileTranscriptionProgress,
|
||||
FileTranscriptionResult,
|
||||
FileTranscriptionState,
|
||||
} from '@shared/types'
|
||||
|
||||
const SUPPORTED_EXTENSIONS = [
|
||||
'.mp3', '.wav', '.m4a', '.ogg', '.flac', '.wma', '.aac',
|
||||
'.mp4', '.mkv', '.webm', '.avi', '.mov',
|
||||
]
|
||||
|
||||
export function FileDropZone(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [state, setState] = useState<FileTranscriptionState>('idle')
|
||||
const [progress, setProgress] = useState<FileTranscriptionProgress | null>(null)
|
||||
const [result, setResult] = useState<FileTranscriptionResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubProgress = window.electronAPI.fileTranscription.onProgress((data) => {
|
||||
setProgress(data)
|
||||
setState('transcribing')
|
||||
})
|
||||
const unsubComplete = window.electronAPI.fileTranscription.onComplete((data) => {
|
||||
setResult(data)
|
||||
setState('completed')
|
||||
setProgress(null)
|
||||
})
|
||||
const unsubError = window.electronAPI.fileTranscription.onError((data) => {
|
||||
setError(data.message)
|
||||
setState('error')
|
||||
setProgress(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubProgress()
|
||||
unsubComplete()
|
||||
unsubError()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (!file) return
|
||||
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
|
||||
if (!SUPPORTED_EXTENSIONS.includes(ext)) {
|
||||
setError(t('fileTranscription.error.invalidFormat'))
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
||||
setState('converting')
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
const filePath = (file as unknown as { path: string }).path
|
||||
const resp = await window.electronAPI.fileTranscription.start({ filePath })
|
||||
if (!resp.success) {
|
||||
setError(resp.error.message)
|
||||
setState('error')
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
setState('converting')
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
const resp = await window.electronAPI.fileTranscription.start({ filePath: '' })
|
||||
if (!resp.success) {
|
||||
if (resp.error.message.includes('cancelled')) {
|
||||
setState('idle')
|
||||
} else {
|
||||
setError(resp.error.message)
|
||||
setState('error')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
await window.electronAPI.fileTranscription.cancel()
|
||||
setState('idle')
|
||||
setProgress(null)
|
||||
}, [])
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (result?.fullText) {
|
||||
navigator.clipboard.writeText(result.fullText)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}, [result])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setState('idle')
|
||||
setResult(null)
|
||||
setError(null)
|
||||
setProgress(null)
|
||||
}, [])
|
||||
|
||||
// ── idle: 드래그 존 ──
|
||||
if (state === 'idle') {
|
||||
return (
|
||||
<MetalCard>
|
||||
<Box
|
||||
ref={dropRef}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleBrowse}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: `2px dashed ${dragging ? d3roPalette.accent.amber : d3roPalette.border.subtle}`,
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.2s',
|
||||
'&:hover': { borderColor: d3roPalette.accent.amber },
|
||||
}}
|
||||
>
|
||||
<UploadFileIcon sx={{ fontSize: 40, color: d3roPalette.text.inactive, mb: 1 }} />
|
||||
<PhosphorText variant="body" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('fileTranscription.dropZone')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
|
||||
{t('fileTranscription.dropZoneHint')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ── converting / transcribing: 진행률 ──
|
||||
if (state === 'converting' || state === 'transcribing') {
|
||||
return (
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led color="amber" pulse />
|
||||
<PhosphorText variant="body">
|
||||
{state === 'converting'
|
||||
? t('fileTranscription.converting')
|
||||
: t('fileTranscription.processing')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<PhysicalButton size="small" onClick={handleCancel}>
|
||||
{t('fileTranscription.cancel')}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
|
||||
{progress && (
|
||||
<>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress.percent}
|
||||
sx={{
|
||||
mb: 1,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.amber },
|
||||
}}
|
||||
/>
|
||||
<PhosphorText variant="dim">
|
||||
{t('fileTranscription.progress', {
|
||||
current: String(progress.currentChunk),
|
||||
total: String(progress.totalChunks),
|
||||
})}
|
||||
</PhosphorText>
|
||||
{progress.currentText && (
|
||||
<PhosphorText variant="compact" sx={{ mt: 1, opacity: 0.7, fontStyle: 'italic' }}>
|
||||
{progress.currentText.slice(0, 100)}...
|
||||
</PhosphorText>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ── completed: 결과 ──
|
||||
if (state === 'completed' && result) {
|
||||
return (
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led color="green" />
|
||||
<PhosphorText variant="body">
|
||||
{t('fileTranscription.complete')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim">
|
||||
({result.fileName})
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Tooltip title={copied ? 'Copied!' : t('fileTranscription.copyAll')}>
|
||||
<IconButton size="small" onClick={handleCopy}>
|
||||
<ContentCopyIcon sx={{ fontSize: 16, color: copied ? d3roPalette.accent.amber : d3roPalette.text.inactive }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton size="small" onClick={handleReset}>
|
||||
<CloseIcon sx={{ fontSize: 16, color: d3roPalette.text.inactive }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: '6px',
|
||||
fontSize: d3roTypo.compact.size,
|
||||
lineHeight: d3roTypo.compact.line,
|
||||
color: d3roPalette.text.primary,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{result.fullText}
|
||||
</Box>
|
||||
|
||||
<PhosphorText variant="dim" sx={{ mt: 1 }}>
|
||||
{Math.round(result.totalDurationSec)}s audio / {Math.round(result.processingTimeMs / 1000)}s processing
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ── error ──
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Led color="red" />
|
||||
<PhosphorText variant="body" sx={{ color: d3roPalette.tag.red }}>
|
||||
{error ?? t('fileTranscription.error.unknown')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<PhysicalButton size="small" onClick={handleReset}>
|
||||
{t('fileTranscription.retry')}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)
|
||||
}
|
||||
|
||||
return <></>
|
||||
}
|
||||
332
apps/desktop/src/renderer/components/HotkeyRecordModal.tsx
Normal file
332
apps/desktop/src/renderer/components/HotkeyRecordModal.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
// src/renderer/components/HotkeyRecordModal.tsx
|
||||
// Speakly HotkeyRecordModal 패턴: Modal → 키 입력 대기 → Chip 표시 → 저장/취소
|
||||
// 조합키: modifier(Ctrl/Alt/Shift) 누른 상태에서 일반키 입력 → 조합 완성
|
||||
// 단일키: modifier만 눌렀다 놓으면 해당 modifier가 단독 핫키로 등록
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { d3roPalette } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
import type { HotkeyBinding } from '@shared/types'
|
||||
|
||||
// ── 키 이름 매핑 (Windows) ──────────────────────────────
|
||||
const KEY_DISPLAY_MAP: Record<number, string> = {
|
||||
// Modifier keys
|
||||
16: 'Shift', 17: 'Ctrl', 18: 'Alt', 91: 'Win', 92: 'Win',
|
||||
160: 'Left Shift', 161: 'Right Shift',
|
||||
162: 'Left Ctrl', 163: 'Right Ctrl',
|
||||
164: 'Left Alt', 165: 'Right Alt',
|
||||
// Common keys
|
||||
8: 'Backspace', 9: 'Tab', 13: 'Enter', 19: 'Pause', 20: 'CapsLock',
|
||||
27: 'Esc', 32: 'Space',
|
||||
33: 'PgUp', 34: 'PgDn', 35: 'End', 36: 'Home',
|
||||
37: '←', 38: '↑', 39: '→', 40: '↓',
|
||||
45: 'Insert', 46: 'Delete',
|
||||
// F-keys
|
||||
112: 'F1', 113: 'F2', 114: 'F3', 115: 'F4',
|
||||
116: 'F5', 117: 'F6', 118: 'F7', 119: 'F8',
|
||||
120: 'F9', 121: 'F10', 122: 'F11', 123: 'F12',
|
||||
// Numpad
|
||||
96: 'Num0', 97: 'Num1', 98: 'Num2', 99: 'Num3', 100: 'Num4',
|
||||
101: 'Num5', 102: 'Num6', 103: 'Num7', 104: 'Num8', 105: 'Num9',
|
||||
106: 'Num*', 107: 'Num+', 109: 'Num-', 110: 'Num.', 111: 'Num/',
|
||||
// Special
|
||||
186: ';', 187: '=', 188: ',', 189: '-', 190: '.', 191: '/', 192: '`',
|
||||
219: '[', 220: '\\', 221: ']', 222: "'",
|
||||
}
|
||||
|
||||
// 시스템 예약 조합
|
||||
const RESERVED_COMBOS = [
|
||||
'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S', 'Ctrl+W',
|
||||
'Alt+F4', 'Alt+Tab', 'Ctrl+Alt+Delete',
|
||||
]
|
||||
|
||||
const MODIFIER_KEYCODES = new Set([16, 17, 18, 91, 92, 160, 161, 162, 163, 164, 165])
|
||||
|
||||
function getKeyName(keyCode: number, key: string): string {
|
||||
if (KEY_DISPLAY_MAP[keyCode]) return KEY_DISPLAY_MAP[keyCode]
|
||||
if (key.length === 1) return key.toUpperCase()
|
||||
return key
|
||||
}
|
||||
|
||||
function isModifier(keyCode: number): boolean {
|
||||
return MODIFIER_KEYCODES.has(keyCode)
|
||||
}
|
||||
|
||||
interface HotkeyRecordModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSave: (binding: HotkeyBinding) => void
|
||||
currentBinding?: HotkeyBinding | null
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function HotkeyRecordModal({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
currentBinding,
|
||||
title,
|
||||
}: HotkeyRecordModalProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
// 현재 눌려있는 키들을 실시간 추적
|
||||
const [pressedKeys, setPressedKeys] = useState<Array<{ keyCode: number; name: string }>>([])
|
||||
// 확정된 조합 (녹화 완료 후)
|
||||
const [captured, setCaptured] = useState<Array<{ keyCode: number; name: string }> | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const pressedRef = useRef<Map<number, string>>(new Map())
|
||||
// modifier-only 확정을 위한 타이머 (Alt만 눌렀을 때 바로 확정하지 않고 잠시 대기)
|
||||
const modifierTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// 가장 최근 pressedKeys 스냅샷 (타이머 콜백에서 stale closure 방지)
|
||||
const lastPressedRef = useRef<Array<{ keyCode: number; name: string }>>([])
|
||||
|
||||
// Modal 열릴 때 리셋
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPressedKeys([])
|
||||
setCaptured(null)
|
||||
setError(null)
|
||||
pressedRef.current.clear()
|
||||
lastPressedRef.current = []
|
||||
if (modifierTimerRef.current) {
|
||||
clearTimeout(modifierTimerRef.current)
|
||||
modifierTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (captured) return // 이미 확정됨
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// 새 키가 눌렸으므로 modifier-only 타이머 취소
|
||||
if (modifierTimerRef.current) {
|
||||
clearTimeout(modifierTimerRef.current)
|
||||
modifierTimerRef.current = null
|
||||
}
|
||||
|
||||
const keyCode = e.keyCode || e.which
|
||||
if (pressedRef.current.has(keyCode)) return
|
||||
|
||||
const name = getKeyName(keyCode, e.key)
|
||||
pressedRef.current.set(keyCode, name)
|
||||
|
||||
const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n }))
|
||||
setPressedKeys(keys)
|
||||
lastPressedRef.current = keys
|
||||
|
||||
// modifier가 아닌 키가 눌리면 → 조합 즉시 확정 (Alt+1, Ctrl+F5 등)
|
||||
if (!isModifier(keyCode)) {
|
||||
setCaptured(keys)
|
||||
}
|
||||
}, [captured])
|
||||
|
||||
const handleKeyUp = useCallback((e: KeyboardEvent) => {
|
||||
if (captured) return // 이미 확정됨
|
||||
|
||||
const keyCode = e.keyCode || e.which
|
||||
pressedRef.current.delete(keyCode)
|
||||
|
||||
// 모든 키를 놓았고 modifier만 눌렀었다면 → 500ms 대기 후 확정
|
||||
// 이 대기 시간 동안 추가 키를 누르면 타이머가 취소되어 조합키로 확장 가능
|
||||
if (pressedRef.current.size === 0 && lastPressedRef.current.length > 0 && lastPressedRef.current.every(k => isModifier(k.keyCode))) {
|
||||
if (modifierTimerRef.current) clearTimeout(modifierTimerRef.current)
|
||||
modifierTimerRef.current = setTimeout(() => {
|
||||
// 타이머 만료: 여전히 confirmed 안됐고 추가 키 입력 없으면 단일 modifier로 확정
|
||||
setCaptured(lastPressedRef.current)
|
||||
modifierTimerRef.current = null
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n }))
|
||||
setPressedKeys(keys)
|
||||
}, [captured])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
window.addEventListener('keyup', handleKeyUp, true)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
window.removeEventListener('keyup', handleKeyUp, true)
|
||||
}
|
||||
}, [open, handleKeyDown, handleKeyUp])
|
||||
|
||||
const displayKeys = captured ?? pressedKeys
|
||||
const isReady = captured !== null && captured.length > 0
|
||||
|
||||
const handleSave = () => {
|
||||
if (!captured || captured.length === 0) {
|
||||
setError(t('hotkey.noKey'))
|
||||
return
|
||||
}
|
||||
|
||||
// 예약 단축키 체크
|
||||
const label = captured.map(k => k.name).join('+')
|
||||
if (RESERVED_COMBOS.includes(label)) {
|
||||
setError(t('hotkey.reserved', { keys: label }))
|
||||
handleReset()
|
||||
return
|
||||
}
|
||||
|
||||
// HotkeyBinding 생성
|
||||
// 주 키(main key) = modifier가 아닌 키가 있으면 그것, 없으면 첫 번째 modifier
|
||||
const mainKey = captured.find(k => !isModifier(k.keyCode))
|
||||
const primaryKeyCode = mainKey?.keyCode ?? captured[0].keyCode
|
||||
|
||||
// modifier 플래그: 주 키 자체가 해당 modifier인 경우 false 유지
|
||||
// 예: Right Alt 단독 → keyCode=165, alt=false (주 키가 Alt 자체이므로)
|
||||
// 예: Ctrl+A → keyCode=A, ctrl=true (Ctrl은 modifier로 사용)
|
||||
const isCtrlKey = (kc: number) => kc === 17 || kc === 162 || kc === 163
|
||||
const isAltKey = (kc: number) => kc === 18 || kc === 164 || kc === 165
|
||||
const isShiftKey = (kc: number) => kc === 16 || kc === 160 || kc === 161
|
||||
const isMetaKey = (kc: number) => kc === 91 || kc === 92
|
||||
|
||||
// 주 키를 제외한 나머지 키들만 modifier로 취급
|
||||
const modifierKeys = captured.filter(k => k.keyCode !== primaryKeyCode)
|
||||
|
||||
const binding: HotkeyBinding = {
|
||||
keyCode: primaryKeyCode,
|
||||
ctrl: modifierKeys.some(k => isCtrlKey(k.keyCode)),
|
||||
alt: modifierKeys.some(k => isAltKey(k.keyCode)),
|
||||
shift: modifierKeys.some(k => isShiftKey(k.keyCode)),
|
||||
meta: modifierKeys.some(k => isMetaKey(k.keyCode)),
|
||||
displayLabel: label,
|
||||
}
|
||||
|
||||
onSave(binding)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setPressedKeys([])
|
||||
setCaptured(null)
|
||||
pressedRef.current.clear()
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
handleReset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleCancel}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
// aria-hidden 에러 방지: disableEnforceFocus + disableAutoFocus
|
||||
disableEnforceFocus
|
||||
disableAutoFocus
|
||||
disableRestoreFocus
|
||||
>
|
||||
<DialogTitle sx={{ fontWeight: 700, fontSize: '16px' }}>{title ?? t('hotkey.title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
{/* 녹화 영역 */}
|
||||
<Box
|
||||
sx={{
|
||||
border: `2px solid ${
|
||||
error
|
||||
? d3roPalette.tag.red
|
||||
: isReady
|
||||
? d3roPalette.tag.green
|
||||
: displayKeys.length > 0
|
||||
? d3roPalette.accent.amber
|
||||
: d3roPalette.border.strong
|
||||
}`,
|
||||
borderRadius: '12px',
|
||||
p: 3,
|
||||
minHeight: 80,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
transition: 'border-color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{displayKeys.length > 0 ? (
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" justifyContent="center">
|
||||
{displayKeys.map((key, i) => (
|
||||
<Chip
|
||||
key={`${key.keyCode}-${i}`}
|
||||
label={key.name}
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '14px',
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
color: d3roPalette.text.primary,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: '8px',
|
||||
height: 40,
|
||||
px: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography
|
||||
sx={{
|
||||
color: d3roPalette.accent.amber,
|
||||
fontSize: '13px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('hotkey.prompt')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 상태 표시 */}
|
||||
{isReady && !error && (
|
||||
<Typography sx={{ color: d3roPalette.tag.green, fontSize: '12px', mt: 1, fontWeight: 600 }}>
|
||||
{t('hotkey.ready', { keys: captured?.map(k => k.name).join(' + ') ?? '' })}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Typography sx={{ color: d3roPalette.tag.red, fontSize: '12px', mt: 1 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{currentBinding && (
|
||||
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '12px', mt: 2 }}>
|
||||
{t('hotkey.current', { keys: currentBinding.displayLabel })}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography sx={{ color: d3roPalette.text.muted, fontSize: '11px', mt: 1 }}>
|
||||
{t('hotkey.hint')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={handleCancel} sx={{ color: d3roPalette.text.inactive }}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{isReady && (
|
||||
<Button onClick={handleReset} sx={{ color: d3roPalette.text.inactive }}>
|
||||
{t('hotkey.reset')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={!isReady}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
366
apps/desktop/src/renderer/components/LicenseModal.tsx
Normal file
366
apps/desktop/src/renderer/components/LicenseModal.tsx
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
// src/renderer/components/LicenseModal.tsx
|
||||
// Full-screen license management modal with instrument aesthetic
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
Box,
|
||||
TextField,
|
||||
IconButton,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from '@mui/material'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||
import CancelIcon from '@mui/icons-material/Cancel'
|
||||
import { MetalCard, PhosphorText, Led, ScreenPanel, PhysicalButton } from './ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
import type { LicenseInfo, LicenseTier, TierComparison, UsageQuota } from '@shared/types'
|
||||
|
||||
interface LicenseModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off'
|
||||
|
||||
function tierToLedColor(tier: LicenseTier): LedColor {
|
||||
switch (tier) {
|
||||
case 'free': return 'amber'
|
||||
case 'pro': return 'green'
|
||||
case 'pro_plus': return 'green'
|
||||
}
|
||||
}
|
||||
|
||||
function tierToLabel(tier: LicenseTier, t: (k: string) => string): string {
|
||||
switch (tier) {
|
||||
case 'free': return t('license.free')
|
||||
case 'pro': return t('license.pro')
|
||||
case 'pro_plus': return t('license.proPlus')
|
||||
}
|
||||
}
|
||||
|
||||
function maskKey(key: string): string {
|
||||
if (key.length <= 8) return key
|
||||
return key.slice(0, 4) + '-****-****-' + key.slice(-4)
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number | null): string {
|
||||
if (!timestamp) return '-'
|
||||
return new Date(timestamp).toLocaleDateString()
|
||||
}
|
||||
|
||||
export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null)
|
||||
const [tierComparison, setTierComparison] = useState<TierComparison[]>([])
|
||||
const [usageQuotas, setUsageQuotas] = useState<UsageQuota[]>([])
|
||||
const [keyInput, setKeyInput] = useState('')
|
||||
const [activating, setActivating] = useState(false)
|
||||
const [activateMessage, setActivateMessage] = useState<string | null>(null)
|
||||
const [activateSuccess, setActivateSuccess] = useState(false)
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
window.electronAPI.license.getInfo().then((r) => {
|
||||
if (r.success) setLicenseInfo(r.data)
|
||||
})
|
||||
window.electronAPI.license.getTierComparison().then((r) => {
|
||||
if (r.success) setTierComparison(r.data)
|
||||
})
|
||||
window.electronAPI.license.getAllUsage().then((r) => {
|
||||
if (r.success) setUsageQuotas(r.data)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadData()
|
||||
setKeyInput('')
|
||||
setActivateMessage(null)
|
||||
setActivateSuccess(false)
|
||||
}
|
||||
}, [open, loadData])
|
||||
|
||||
// Subscribe to tier changes
|
||||
useEffect(() => {
|
||||
const unsub = window.electronAPI.license.onTierChanged((info) => {
|
||||
setLicenseInfo(info)
|
||||
loadData()
|
||||
})
|
||||
return unsub
|
||||
}, [loadData])
|
||||
|
||||
const handleActivate = useCallback(async () => {
|
||||
if (!keyInput.trim()) return
|
||||
setActivating(true)
|
||||
setActivateMessage(null)
|
||||
try {
|
||||
const result = await window.electronAPI.license.activate({ licenseKey: keyInput.trim() })
|
||||
if (result.success) {
|
||||
setActivateSuccess(result.data.success)
|
||||
setActivateMessage(
|
||||
result.data.success
|
||||
? t('license.activated')
|
||||
: t('license.activateError', { message: result.data.message }),
|
||||
)
|
||||
if (result.data.success) {
|
||||
loadData()
|
||||
setKeyInput('')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setActivating(false)
|
||||
}
|
||||
}, [keyInput, t, loadData])
|
||||
|
||||
const handleDeactivate = useCallback(async () => {
|
||||
await window.electronAPI.license.deactivate()
|
||||
setActivateMessage(t('license.deactivated'))
|
||||
setActivateSuccess(false)
|
||||
loadData()
|
||||
}, [t, loadData])
|
||||
|
||||
const isFree = licenseInfo?.tier === 'free'
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: d3roPalette.bg.app,
|
||||
backgroundImage: 'none',
|
||||
borderRadius: d3roRadius.card,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
boxShadow: d3roShadow.card,
|
||||
maxHeight: '85vh',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
py: 1.5,
|
||||
px: 3,
|
||||
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
|
||||
}}
|
||||
>
|
||||
<PhosphorText variant="meta">{t('license.title')}</PhosphorText>
|
||||
<IconButton size="small" onClick={onClose} sx={{ color: d3roPalette.text.inactive }}>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* ---- Current Tier ---- */}
|
||||
<MetalCard>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Led color={licenseInfo ? tierToLedColor(licenseInfo.tier) : 'off'} size={12} pulse={!isFree} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<PhosphorText variant="meta">{t('license.currentTier')}</PhosphorText>
|
||||
<PhosphorText variant="value">
|
||||
{licenseInfo ? tierToLabel(licenseInfo.tier, t) : '...'}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* ---- Activate / Info ---- */}
|
||||
<MetalCard>
|
||||
{isFree ? (
|
||||
// Free tier: show activation form
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<PhosphorText variant="meta">{t('license.activate')}</PhosphorText>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
value={keyInput}
|
||||
onChange={(e) => setKeyInput(e.target.value)}
|
||||
placeholder={t('license.keyPlaceholder')}
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={activating}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleActivate()
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
bgcolor: d3roPalette.bg.input,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<PhysicalButton
|
||||
onClick={handleActivate}
|
||||
disabled={activating || !keyInput.trim()}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
{activating ? (
|
||||
<CircularProgress size={16} sx={{ color: d3roPalette.accent.amber }} />
|
||||
) : (
|
||||
t('license.activate')
|
||||
)}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
{activateMessage && (
|
||||
<PhosphorText
|
||||
variant="small"
|
||||
sx={{ color: activateSuccess ? d3roPalette.tag.green : d3roPalette.tag.red }}
|
||||
>
|
||||
{activateMessage}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
// Pro/Pro+: show license info
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<PhosphorText variant="meta">{t('license.keyLabel')}</PhosphorText>
|
||||
<ScreenPanel>
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<PhosphorText variant="compact" sx={{ fontFamily: d3roFontMono }}>
|
||||
{licenseInfo?.licenseKey ? maskKey(licenseInfo.licenseKey) : '-'}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</ScreenPanel>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<Box>
|
||||
<PhosphorText variant="label">{t('license.activatedAt')}</PhosphorText>
|
||||
<PhosphorText variant="compact">
|
||||
{formatDate(licenseInfo?.activatedAt ?? null)}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box>
|
||||
<PhosphorText variant="label">{t('license.machineId')}</PhosphorText>
|
||||
<PhosphorText variant="compact" sx={{ fontFamily: d3roFontMono }}>
|
||||
{licenseInfo?.machineId?.slice(0, 12) ?? '-'}...
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<PhysicalButton
|
||||
onClick={handleDeactivate}
|
||||
sx={{ alignSelf: 'flex-start', mt: 1, color: d3roPalette.tag.red }}
|
||||
>
|
||||
{t('license.deactivate')}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
)}
|
||||
</MetalCard>
|
||||
|
||||
{/* ---- Daily Usage ---- */}
|
||||
{usageQuotas.length > 0 && (
|
||||
<MetalCard>
|
||||
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.dailyUsage')}</PhosphorText>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{usageQuotas.map((q) => (
|
||||
<Box key={q.feature} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<PhosphorText variant="small" sx={{ flex: 1 }}>
|
||||
{t(`license.feature.${q.feature}` as Parameters<typeof t>[0])}
|
||||
</PhosphorText>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
height: 4,
|
||||
borderRadius: d3roRadius.pill,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
borderRadius: d3roRadius.pill,
|
||||
bgcolor:
|
||||
q.limit < 0
|
||||
? d3roPalette.tag.green
|
||||
: q.used >= q.limit
|
||||
? d3roPalette.tag.red
|
||||
: d3roPalette.accent.amber,
|
||||
width: q.limit < 0 ? '100%' : `${Math.min(100, (q.used / q.limit) * 100)}%`,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<PhosphorText variant="dim" sx={{ minWidth: 60, textAlign: 'right' }}>
|
||||
{q.limit < 0
|
||||
? t('license.quotaUnlimited')
|
||||
: t('license.quotaUsed', { used: String(q.used), limit: String(q.limit) })}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* ---- Tier Comparison ---- */}
|
||||
{tierComparison.length > 0 && (
|
||||
<MetalCard>
|
||||
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.tierComparison')}</PhosphorText>
|
||||
<TableContainer>
|
||||
<Table size="small" sx={{ '& td, & th': { borderColor: d3roPalette.border.subtle, py: 0.75 } }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.text.label, letterSpacing: d3roTypo.label.spacing, textTransform: 'uppercase' }}>
|
||||
|
||||
</TableCell>
|
||||
<TableCell align="center" sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.text.label, letterSpacing: d3roTypo.label.spacing }}>
|
||||
{t('license.free')}
|
||||
</TableCell>
|
||||
<TableCell align="center" sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.tag.green, letterSpacing: d3roTypo.label.spacing }}>
|
||||
{t('license.pro')}
|
||||
</TableCell>
|
||||
<TableCell align="center" sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.tag.purple, letterSpacing: d3roTypo.label.spacing }}>
|
||||
{t('license.proPlus')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tierComparison.map((row) => (
|
||||
<TableRow key={row.feature}>
|
||||
<TableCell sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary }}>
|
||||
{row.featureLabel}
|
||||
</TableCell>
|
||||
<TableCell align="center">{renderTierCell(row.free)}</TableCell>
|
||||
<TableCell align="center">{renderTierCell(row.pro)}</TableCell>
|
||||
<TableCell align="center">{renderTierCell(row.proPlus)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</MetalCard>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function renderTierCell(value: boolean | string): React.ReactElement {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? (
|
||||
<CheckCircleIcon sx={{ fontSize: 16, color: d3roPalette.tag.green }} />
|
||||
) : (
|
||||
<CancelIcon sx={{ fontSize: 16, color: d3roPalette.text.disabled }} />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.compact.size }}>
|
||||
{value}
|
||||
</PhosphorText>
|
||||
)
|
||||
}
|
||||
297
apps/desktop/src/renderer/components/LicenseTab.tsx
Normal file
297
apps/desktop/src/renderer/components/LicenseTab.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
// src/renderer/components/LicenseTab.tsx
|
||||
// Phase 11: Settings License 탭 — 라이선스 키 입력, 사용량, 티어 비교
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
} from '@mui/material'
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||
import CancelIcon from '@mui/icons-material/Cancel'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
import type {
|
||||
LicenseInfo,
|
||||
LicenseTier,
|
||||
UsageQuota,
|
||||
TierComparison,
|
||||
ActivateLicenseResult,
|
||||
} from '@shared/types'
|
||||
|
||||
export function LicenseTab(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null)
|
||||
const [usage, setUsage] = useState<UsageQuota[]>([])
|
||||
const [comparison, setComparison] = useState<TierComparison[]>([])
|
||||
const [keyInput, setKeyInput] = useState('')
|
||||
const [activating, setActivating] = useState(false)
|
||||
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
window.electronAPI.license.getInfo().then((r) => {
|
||||
if (r.success) setLicenseInfo(r.data)
|
||||
})
|
||||
window.electronAPI.license.getAllUsage().then((r) => {
|
||||
if (r.success) setUsage(r.data)
|
||||
})
|
||||
window.electronAPI.license.getTierComparison().then((r) => {
|
||||
if (r.success) setComparison(r.data)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
const unsub = window.electronAPI.license.onTierChanged(() => loadData())
|
||||
return unsub
|
||||
}, [loadData])
|
||||
|
||||
const handleActivate = useCallback(async () => {
|
||||
if (!keyInput.trim()) return
|
||||
setActivating(true)
|
||||
setMessage(null)
|
||||
const result = await window.electronAPI.license.activate({ licenseKey: keyInput.trim() })
|
||||
setActivating(false)
|
||||
if (result.success) {
|
||||
const data = result.data as ActivateLicenseResult
|
||||
if (data.success) {
|
||||
setMessage({ text: t('license.activated'), success: true })
|
||||
setKeyInput('')
|
||||
loadData()
|
||||
} else {
|
||||
setMessage({ text: t('license.activateError', { message: data.message }), success: false })
|
||||
}
|
||||
}
|
||||
}, [keyInput, t, loadData])
|
||||
|
||||
const handleDeactivate = useCallback(async () => {
|
||||
await window.electronAPI.license.deactivate()
|
||||
setMessage({ text: t('license.deactivated'), success: true })
|
||||
loadData()
|
||||
}, [t, loadData])
|
||||
|
||||
const tierLabel = (tier: LicenseTier): string => {
|
||||
if (tier === 'pro_plus') return t('license.proPlus')
|
||||
if (tier === 'pro') return t('license.pro')
|
||||
return t('license.free')
|
||||
}
|
||||
|
||||
const tierColor = (tier: LicenseTier): string => {
|
||||
if (tier === 'pro_plus') return d3roPalette.tag.green
|
||||
if (tier === 'pro') return d3roPalette.accent.amber
|
||||
return d3roPalette.text.secondary
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{/* 현재 플랜 */}
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('license.currentTier')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.title.size,
|
||||
fontWeight: d3roTypo.title.weight,
|
||||
color: licenseInfo ? tierColor(licenseInfo.tier) : d3roPalette.text.primary,
|
||||
}}
|
||||
>
|
||||
{licenseInfo ? tierLabel(licenseInfo.tier) : '...'}
|
||||
</Typography>
|
||||
{licenseInfo?.activatedAt && (
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.meta.size, color: d3roPalette.text.secondary }}>
|
||||
{t('license.activatedAt')}: {new Date(licenseInfo.activatedAt).toLocaleDateString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
{/* 라이선스 키 입력 */}
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('license.keyLabel')}
|
||||
</Typography>
|
||||
|
||||
{licenseInfo?.tier === 'free' ? (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder={t('license.keyPlaceholder')}
|
||||
value={keyInput}
|
||||
onChange={(e) => setKeyInput(e.target.value)}
|
||||
disabled={activating}
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleActivate}
|
||||
disabled={activating || !keyInput.trim()}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
bgcolor: d3roPalette.accent.amber,
|
||||
color: d3roPalette.bg.app,
|
||||
whiteSpace: 'nowrap',
|
||||
'&:hover': { bgcolor: d3roPalette.accent.amber, filter: 'brightness(1.1)' },
|
||||
}}
|
||||
>
|
||||
{activating ? t('license.activating') : t('license.activate')}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary }}>
|
||||
{licenseInfo?.licenseKey ? `${licenseInfo.licenseKey.substring(0, 16)}...` : ''}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleDeactivate}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
color: d3roPalette.tag.red,
|
||||
borderColor: d3roPalette.tag.red,
|
||||
}}
|
||||
>
|
||||
{t('license.deactivate')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
color: message.success ? d3roPalette.tag.green : d3roPalette.tag.red,
|
||||
}}
|
||||
>
|
||||
{message.text}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
{/* 일일 사용량 */}
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('license.dailyUsage')}
|
||||
</Typography>
|
||||
|
||||
{usage.map((q) => (
|
||||
<Box key={q.feature}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: d3roPalette.text.primary }}>
|
||||
{t(`license.feature.${q.feature}` as Parameters<typeof t>[0])}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: q.limit === -1 ? d3roPalette.tag.green : d3roPalette.accent.amber }}>
|
||||
{q.limit === -1
|
||||
? t('license.quotaUnlimited')
|
||||
: t('license.quotaUsed', { used: String(q.used), limit: String(q.limit) })}
|
||||
</Typography>
|
||||
</Box>
|
||||
{q.limit > 0 && (
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(100, (q.used / q.limit) * 100)}
|
||||
sx={{
|
||||
height: 4,
|
||||
borderRadius: d3roRadius.xs,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: q.used >= q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber,
|
||||
borderRadius: d3roRadius.xs,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
{/* 티어 비교표 */}
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('license.tierComparison')}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
component="table"
|
||||
sx={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
'& th, & td': {
|
||||
py: 0.5,
|
||||
px: 1,
|
||||
textAlign: 'center',
|
||||
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
|
||||
},
|
||||
'& th': {
|
||||
color: d3roPalette.text.label,
|
||||
fontWeight: d3roTypo.label.weight,
|
||||
letterSpacing: d3roTypo.label.spacing,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
'& td:first-of-type': {
|
||||
textAlign: 'left',
|
||||
color: d3roPalette.text.primary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{''}</th>
|
||||
<th>{t('license.free')}</th>
|
||||
<th>{t('license.pro')}</th>
|
||||
<th>{t('license.proPlus')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comparison.map((row) => (
|
||||
<tr key={row.feature}>
|
||||
<td>{t(row.featureLabel as Parameters<typeof t>[0])}</td>
|
||||
<td><TierCell value={row.free} /></td>
|
||||
<td><TierCell value={row.pro} /></td>
|
||||
<td><TierCell value={row.proPlus} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Box>
|
||||
|
||||
{/* 기기 ID */}
|
||||
{licenseInfo && (
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.micro.size, color: d3roPalette.text.disabled }}>
|
||||
{t('license.machineId')}: {licenseInfo.machineId.substring(0, 16)}...
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function TierCell({ value }: { value: boolean | string }): React.ReactElement {
|
||||
if (value === true) {
|
||||
return <CheckCircleIcon sx={{ fontSize: 14, color: d3roPalette.tag.green }} />
|
||||
}
|
||||
if (value === false) {
|
||||
return <CancelIcon sx={{ fontSize: 14, color: d3roPalette.text.disabled }} />
|
||||
}
|
||||
return (
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, color: d3roPalette.accent.amber }}>
|
||||
{value}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
163
apps/desktop/src/renderer/components/OllamaGuideModal.tsx
Normal file
163
apps/desktop/src/renderer/components/OllamaGuideModal.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// src/renderer/components/OllamaGuideModal.tsx
|
||||
// Ollama 설치/설정 안내 모달
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Divider,
|
||||
IconButton,
|
||||
} from '@mui/material'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow } from '../theme'
|
||||
import { Led } from './ds'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
interface OllamaGuideModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function CodeBlock({ children }: { children: string }): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: '8px',
|
||||
p: 1.5,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '12px',
|
||||
color: d3roPalette.accent.amber,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
boxShadow: d3roShadow.inset,
|
||||
}}
|
||||
>
|
||||
<span>{children}</span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigator.clipboard.writeText(children)}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||
>
|
||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
|
||||
const handleOpenLink = (url: string) => {
|
||||
window.electronAPI.system.openExternal({ url })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
backgroundImage: 'none',
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontFamily: d3roFontMono,
|
||||
fontWeight: 700,
|
||||
fontSize: '14px',
|
||||
letterSpacing: '1px',
|
||||
color: d3roPalette.accent.amber,
|
||||
py: 1.5,
|
||||
}}
|
||||
>
|
||||
{t('ollama.title')}
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.inactive }}>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
<DialogContent sx={{ bgcolor: d3roPalette.bg.app }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, py: 1 }}>
|
||||
{/* Step 1 */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Led color="amber" size={8} />
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', fontWeight: 700 }}>
|
||||
{t('ollama.step1.title')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 1.5 }}>
|
||||
{t('ollama.step1.desc')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
endIcon={<OpenInNewIcon sx={{ fontSize: 14 }} />}
|
||||
onClick={() => handleOpenLink('https://ollama.com/download')}
|
||||
sx={{ fontFamily: d3roFontMono, fontSize: '11px' }}
|
||||
>
|
||||
ollama.com/download
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
{/* Step 2 */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Led color="amber" size={8} />
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', fontWeight: 700 }}>
|
||||
{t('ollama.step2.title')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 1.5 }}>
|
||||
{t('ollama.step2.desc')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<CodeBlock>ollama pull qwen3:4b</CodeBlock>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
||||
{t('ollama.step2.alt')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
{/* Step 3 */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Led color="green" size={8} />
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', fontWeight: 700 }}>
|
||||
{t('ollama.step3.title')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('ollama.step3.desc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2, bgcolor: d3roPalette.bg.app }}>
|
||||
<Button onClick={onClose} variant="contained">
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
270
apps/desktop/src/renderer/components/OnboardingModal.tsx
Normal file
270
apps/desktop/src/renderer/components/OnboardingModal.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// src/renderer/components/OnboardingModal.tsx
|
||||
// 첫 실행 시 마이크 + 핫키 설정 안내
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Stack,
|
||||
Chip,
|
||||
} from '@mui/material'
|
||||
import MicIcon from '@mui/icons-material/Mic'
|
||||
import KeyboardIcon from '@mui/icons-material/Keyboard'
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow } from '../theme'
|
||||
import { Led } from './ds'
|
||||
import { HotkeyRecordModal } from './HotkeyRecordModal'
|
||||
import { useI18n } from '../i18n'
|
||||
import type { HotkeyBinding, AudioDevice } from '@shared/types'
|
||||
|
||||
interface OnboardingModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [step, setStep] = useState(0) // 0: 환영, 1: 마이크, 2: 핫키, 3: Ollama, 4: 완료
|
||||
const [devices, setDevices] = useState<AudioDevice[]>([])
|
||||
const [selectedDevice, setSelectedDevice] = useState('default')
|
||||
const [hotkeyBinding, setHotkeyBinding] = useState<HotkeyBinding | null>(null)
|
||||
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setStep(0)
|
||||
window.electronAPI.audio.getDevices().then((r) => {
|
||||
if (r.success) setDevices(r.data)
|
||||
})
|
||||
window.electronAPI.hotkey.getDictationShortcut().then((r) => {
|
||||
if (r.success && r.data) setHotkeyBinding(r.data)
|
||||
})
|
||||
}, [open])
|
||||
|
||||
const handleFinish = () => {
|
||||
// 온보딩 완료 플래그 저장
|
||||
window.electronAPI.config.set({ key: 'onboardingCompleted' as keyof import('@shared/types').AppConfig, value: true as never })
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleHotkeySave = (binding: HotkeyBinding) => {
|
||||
setHotkeyBinding(binding)
|
||||
window.electronAPI.hotkey.setDictationShortcut({ binding })
|
||||
window.electronAPI.hotkey.setEnabled({ enabled: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
backgroundImage: 'none',
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
boxShadow: d3roShadow.chassis,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 4 }}>
|
||||
{/* Step 0: 환영 */}
|
||||
{step === 0 && (
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<Led color="amber" pulse size={16} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '24px',
|
||||
fontWeight: 300,
|
||||
color: d3roPalette.accent.amber,
|
||||
mt: 3,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
D3RO-VOICE
|
||||
</Typography>
|
||||
<Typography sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
|
||||
{t('onboarding.welcome.desc')}
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => setStep(1)} fullWidth>
|
||||
{t('onboarding.welcome.start')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 1: 마이크 */}
|
||||
{step === 1 && (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
||||
<MicIcon sx={{ color: d3roPalette.accent.amber }} />
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
|
||||
{t('onboarding.mic.title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
|
||||
{t('onboarding.mic.desc')}
|
||||
</Typography>
|
||||
<Stack spacing={1} mb={3}>
|
||||
{devices.map((d, idx) => (
|
||||
<Box
|
||||
key={`${d.deviceId}-${idx}`}
|
||||
onClick={() => {
|
||||
setSelectedDevice(d.deviceId)
|
||||
window.electronAPI.audio.setSelectedDevice({ deviceId: d.deviceId })
|
||||
}}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
bgcolor: selectedDevice === d.deviceId ? d3roPalette.accent.amberDim : d3roPalette.bg.inset,
|
||||
border: selectedDevice === d.deviceId
|
||||
? `1px solid ${d3roPalette.accent.amber}`
|
||||
: `1px solid ${d3roPalette.border.subtle}`,
|
||||
'&:hover': { bgcolor: d3roPalette.bg.cardHover },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontSize: '13px' }}>
|
||||
{d.label}{d.isDefault ? ` ${t('settings.deviceDefault')}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Button onClick={() => setStep(0)} sx={{ color: d3roPalette.text.inactive }}>{t('onboarding.back')}</Button>
|
||||
<Button variant="contained" onClick={() => setStep(2)}>{t('onboarding.next')}</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 2: 핫키 */}
|
||||
{step === 2 && (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
||||
<KeyboardIcon sx={{ color: d3roPalette.accent.amber }} />
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
|
||||
{t('onboarding.hotkey.title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
|
||||
{t('onboarding.hotkey.desc')}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: '10px',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
boxShadow: d3roShadow.inset,
|
||||
textAlign: 'center',
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{hotkeyBinding ? (
|
||||
<Stack direction="row" spacing={1} justifyContent="center" alignItems="center">
|
||||
<Led color="green" size={8} />
|
||||
{hotkeyBinding.displayLabel.split(' + ').map((key) => (
|
||||
<Chip
|
||||
key={key}
|
||||
label={key}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontWeight: 700,
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
color: d3roPalette.text.primary,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '13px' }}>
|
||||
{t('onboarding.hotkey.notSet')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
onClick={() => setHotkeyModalOpen(true)}
|
||||
sx={{ mb: 3, fontFamily: d3roFontMono }}
|
||||
>
|
||||
{hotkeyBinding ? t('onboarding.hotkey.change') : t('onboarding.hotkey.set')}
|
||||
</Button>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Button onClick={() => setStep(1)} sx={{ color: d3roPalette.text.inactive }}>{t('onboarding.back')}</Button>
|
||||
<Button variant="contained" onClick={() => setStep(3)}>{t('onboarding.next')}</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 3: Ollama 설치 */}
|
||||
{step === 3 && (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
||||
<Led color="amber" size={12} />
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
|
||||
{t('onboarding.ollama.title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
|
||||
{t('onboarding.ollama.desc')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
endIcon={<OpenInNewIcon sx={{ fontSize: 14 }} />}
|
||||
onClick={() => window.electronAPI.system.openExternal({ url: 'https://ollama.com/download' })}
|
||||
fullWidth
|
||||
sx={{ mb: 1.5, fontFamily: d3roFontMono }}
|
||||
>
|
||||
{t('onboarding.ollama.download')}
|
||||
</Button>
|
||||
<Box sx={{ p: 1.5, borderRadius: '8px', bgcolor: d3roPalette.bg.inset, boxShadow: d3roShadow.inset, mb: 3 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.accent.amber }}>
|
||||
$ ollama pull qwen3:4b
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '10px', color: d3roPalette.text.inactive, mt: 0.5 }}>
|
||||
{t('onboarding.ollama.modelHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Button onClick={() => setStep(2)} sx={{ color: d3roPalette.text.inactive }}>{t('onboarding.back')}</Button>
|
||||
<Button variant="contained" onClick={() => setStep(4)}>{t('onboarding.next')}</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 4: 완료 */}
|
||||
{step === 4 && (
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<CheckCircleIcon sx={{ fontSize: 48, color: d3roPalette.tag.green, mb: 2 }} />
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '18px', fontWeight: 700, mb: 1 }}>
|
||||
{t('onboarding.done.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
|
||||
{hotkeyBinding
|
||||
? t('onboarding.done.descWithKey', { key: hotkeyBinding.displayLabel })
|
||||
: t('onboarding.done.descNoKey')}
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={handleFinish} fullWidth>
|
||||
{t('onboarding.done.start')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<HotkeyRecordModal
|
||||
open={hotkeyModalOpen}
|
||||
onClose={() => setHotkeyModalOpen(false)}
|
||||
onSave={handleHotkeySave}
|
||||
currentBinding={hotkeyBinding}
|
||||
title={t('hotkey.dictationTitle')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
78
apps/desktop/src/renderer/components/ProBadge.tsx
Normal file
78
apps/desktop/src/renderer/components/ProBadge.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// src/renderer/components/ProBadge.tsx
|
||||
// Feature gate badge: renders children normally if unlocked,
|
||||
// shows lock overlay with PRO badge if locked.
|
||||
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import LockIcon from '@mui/icons-material/Lock'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../theme'
|
||||
import { useProFeature } from '../hooks/useProFeature'
|
||||
import { useI18n } from '../i18n'
|
||||
import type { Feature } from '@shared/types'
|
||||
|
||||
interface ProBadgeProps {
|
||||
feature: Feature
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function ProBadge({ feature, children }: ProBadgeProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const { unlocked, loading, showUpgrade } = useProFeature(feature)
|
||||
|
||||
// While loading or if unlocked, render children normally
|
||||
if (loading || unlocked) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{/* Children rendered with reduced opacity */}
|
||||
<Box sx={{ opacity: 0.35, pointerEvents: 'none', filter: 'grayscale(0.6)' }}>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{/* Lock overlay */}
|
||||
<Box
|
||||
onClick={showUpgrade}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
borderRadius: d3roRadius.inner,
|
||||
transition: 'background-color 0.15s ease',
|
||||
'&:hover': {
|
||||
bgcolor: d3roPalette.accent.amberDim,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
borderRadius: d3roRadius.small,
|
||||
bgcolor: d3roPalette.bg.elevated,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
}}
|
||||
>
|
||||
<LockIcon sx={{ fontSize: 14, color: d3roPalette.accent.amber }} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
fontWeight: d3roTypo.small.weight,
|
||||
letterSpacing: d3roTypo.small.spacing,
|
||||
color: d3roPalette.accent.amber,
|
||||
}}
|
||||
>
|
||||
{t('license.pro.required')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
869
apps/desktop/src/renderer/components/SettingsModal.tsx
Normal file
869
apps/desktop/src/renderer/components/SettingsModal.tsx
Normal file
|
|
@ -0,0 +1,869 @@
|
|||
// src/renderer/components/SettingsModal.tsx
|
||||
// 설계서 03: Settings React Modal — General(음성 모드+핫키)/Audio/STT/LLM 탭
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
Tabs,
|
||||
Tab,
|
||||
Box,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Typography,
|
||||
IconButton,
|
||||
Divider,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
Button,
|
||||
Chip,
|
||||
Stack,
|
||||
Paper,
|
||||
} from '@mui/material'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import KeyboardIcon from '@mui/icons-material/Keyboard'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import MicIcon from '@mui/icons-material/Mic'
|
||||
import LockIcon from '@mui/icons-material/Lock'
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||
import CancelIcon from '@mui/icons-material/Cancel'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow } from '../theme'
|
||||
import { HotkeyRecordModal } from './HotkeyRecordModal'
|
||||
import { LicenseTab } from './LicenseTab'
|
||||
import { useI18n, LOCALE_META } from '../i18n'
|
||||
import type { Locale } from '../i18n'
|
||||
import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice } from '@shared/types'
|
||||
import { Feature } from '@shared/types'
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children: React.ReactNode
|
||||
value: number
|
||||
index: number
|
||||
}
|
||||
|
||||
function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement | null {
|
||||
if (value !== index) return null
|
||||
return <Box sx={{ pt: 2 }}>{children}</Box>
|
||||
}
|
||||
|
||||
// ── 핫키 표시 컴포넌트 ──────────────────────────────────
|
||||
function HotkeyDisplay({
|
||||
binding,
|
||||
onEdit,
|
||||
label,
|
||||
notSetLabel,
|
||||
}: {
|
||||
binding: HotkeyBinding | null
|
||||
onEdit: () => void
|
||||
label: string
|
||||
notSetLabel: string
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, minWidth: 40 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{binding ? (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
{binding.displayLabel.split(' + ').map((key) => (
|
||||
<Chip
|
||||
key={key}
|
||||
label={key}
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '11px',
|
||||
bgcolor: d3roPalette.bg.elevated,
|
||||
color: d3roPalette.text.primary,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: '6px',
|
||||
height: 28,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.disabled }}>
|
||||
{notSetLabel}
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton size="small" onClick={onEdit} sx={{ color: d3roPalette.text.inactive, ml: 'auto' }}>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 음성 모드 카드 ──────────────────────────────────────
|
||||
function VoiceModeCard({
|
||||
title,
|
||||
description,
|
||||
enabled,
|
||||
onToggle,
|
||||
disabled,
|
||||
enabledLabel,
|
||||
disabledLabel,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
onToggle: (enabled: boolean) => void
|
||||
disabled?: boolean
|
||||
enabledLabel: string
|
||||
disabledLabel: string
|
||||
children?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: '10px',
|
||||
border: 'none',
|
||||
boxShadow: d3roShadow.inset,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 700, fontFamily: d3roFontMono, fontSize: '12px', letterSpacing: '0.5px' }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.green },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
|
||||
backgroundColor: d3roPalette.tag.green,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Chip
|
||||
label={enabled ? enabledLabel : disabledLabel}
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
height: 20,
|
||||
bgcolor: enabled ? d3roPalette.tag.greenBg : 'transparent',
|
||||
color: enabled ? d3roPalette.tag.green : d3roPalette.text.disabled,
|
||||
border: enabled ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
sx={{ ml: 0, mr: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{children && <Box sx={{ mt: 1.5 }}>{children}</Box>}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsModal({ open, onClose }: SettingsModalProps): React.ReactElement {
|
||||
const { t, locale, setLocale } = useI18n()
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
const [config, setConfig] = useState<Partial<AppConfig>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 음성 모드 상태
|
||||
const [dictationEnabled, setDictationEnabled] = useState(true)
|
||||
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
|
||||
const [handsFreeEnabled, setHandsFreeEnabled] = useState(false)
|
||||
const [handsFreeBinding, setHandsFreeBinding] = useState<HotkeyBinding | null>(null)
|
||||
const [captionBinding, setCaptionBinding] = useState<HotkeyBinding | null>(null)
|
||||
const [hotkeyGlobalEnabled, setHotkeyGlobalEnabled] = useState(true)
|
||||
|
||||
// 핫키 녹화 모달
|
||||
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
|
||||
const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree' | 'caption'>('dictation')
|
||||
|
||||
// LLM 모델 목록
|
||||
const [llmModels, setLlmModels] = useState<Array<{ id: string; name: string; parameterSize: string }>>([])
|
||||
|
||||
// 오디오 디바이스
|
||||
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([])
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('default')
|
||||
const [micTesting, setMicTesting] = useState(false)
|
||||
const [micLevel, setMicLevel] = useState(0)
|
||||
|
||||
// 설정 로드
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
|
||||
Promise.all([
|
||||
window.electronAPI.config.getAll(),
|
||||
window.electronAPI.hotkey.getDictationShortcut(),
|
||||
window.electronAPI.hotkey.getHandsFreeShortcut(),
|
||||
window.electronAPI.hotkey.getCaptionShortcut(),
|
||||
window.electronAPI.hotkey.isEnabled(),
|
||||
])
|
||||
.then(([configResult, dictResult, hfResult, capResult, enabledResult]) => {
|
||||
if (configResult.success) setConfig(configResult.data)
|
||||
if (dictResult.success && dictResult.data) setDictationBinding(dictResult.data)
|
||||
if (hfResult.success && hfResult.data) setHandsFreeBinding(hfResult.data)
|
||||
if (capResult.success && capResult.data) setCaptionBinding(capResult.data)
|
||||
if (enabledResult.success) {
|
||||
setHotkeyGlobalEnabled(enabledResult.data)
|
||||
setDictationEnabled(enabledResult.data)
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
Promise.all([
|
||||
window.electronAPI.audio.getDevices(),
|
||||
window.electronAPI.audio.getSelectedDevice(),
|
||||
]).then(([devicesResult, selectedResult]) => {
|
||||
if (devicesResult.success) setAudioDevices(devicesResult.data)
|
||||
if (selectedResult.success && selectedResult.data) setSelectedDeviceId(selectedResult.data)
|
||||
})
|
||||
|
||||
// LLM 모델 목록 로드
|
||||
window.electronAPI.llm.getModels().then((resp) => {
|
||||
if (resp.success) setLlmModels(resp.data)
|
||||
})
|
||||
}, [open])
|
||||
|
||||
const updateConfig = useCallback((key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }))
|
||||
window.electronAPI.config.set({ key, value })
|
||||
}, [])
|
||||
|
||||
const handleDictationToggle = useCallback(
|
||||
(enabled: boolean) => {
|
||||
setDictationEnabled(enabled)
|
||||
window.electronAPI.hotkey.setEnabled({ enabled })
|
||||
if (!enabled && handsFreeEnabled) {
|
||||
setHandsFreeEnabled(false)
|
||||
}
|
||||
},
|
||||
[handsFreeEnabled]
|
||||
)
|
||||
|
||||
const handleHandsFreeToggle = useCallback((enabled: boolean) => {
|
||||
if (enabled && !handsFreeBinding) {
|
||||
setHotkeyModalTarget('handsFree')
|
||||
setHotkeyModalOpen(true)
|
||||
return
|
||||
}
|
||||
setHandsFreeEnabled(enabled)
|
||||
}, [handsFreeBinding])
|
||||
|
||||
const handleHotkeySave = useCallback(
|
||||
(binding: HotkeyBinding) => {
|
||||
if (hotkeyModalTarget === 'dictation') {
|
||||
setDictationBinding(binding)
|
||||
window.electronAPI.hotkey.setDictationShortcut({ binding })
|
||||
setDictationEnabled(true)
|
||||
window.electronAPI.hotkey.setEnabled({ enabled: true })
|
||||
} else if (hotkeyModalTarget === 'handsFree') {
|
||||
setHandsFreeBinding(binding)
|
||||
window.electronAPI.hotkey.setHandsFreeShortcut({ binding })
|
||||
setHandsFreeEnabled(true)
|
||||
} else {
|
||||
setCaptionBinding(binding)
|
||||
window.electronAPI.hotkey.setCaptionShortcut({ binding })
|
||||
}
|
||||
},
|
||||
[hotkeyModalTarget]
|
||||
)
|
||||
|
||||
const openHotkeyModal = useCallback((target: 'dictation' | 'handsFree' | 'caption') => {
|
||||
setHotkeyModalTarget(target)
|
||||
setHotkeyModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleLanguageChange = useCallback((newLocale: string) => {
|
||||
setLocale(newLocale as Locale)
|
||||
setConfig((prev) => ({ ...prev, language: newLocale }))
|
||||
}, [setLocale])
|
||||
|
||||
if (loading) return <Dialog open={open} onClose={onClose}><DialogContent /></Dialog>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
disableEnforceFocus
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
backgroundImage: 'none',
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
boxShadow: d3roShadow.chassis,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontFamily: d3roFontMono,
|
||||
fontWeight: 700,
|
||||
fontSize: '14px',
|
||||
letterSpacing: '1px',
|
||||
textTransform: 'uppercase',
|
||||
color: d3roPalette.accent.amber,
|
||||
py: 1.5,
|
||||
}}
|
||||
>
|
||||
{t('settings.title')}
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.inactive }}>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
<DialogContent sx={{ bgcolor: d3roPalette.bg.app, p: 0 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, v: number) => setActiveTab(v)}
|
||||
variant="scrollable"
|
||||
scrollButtons={false}
|
||||
sx={{
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
|
||||
minHeight: 40,
|
||||
'& .MuiTab-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
color: d3roPalette.text.inactive,
|
||||
minHeight: 40,
|
||||
py: 1,
|
||||
'&.Mui-selected': { color: d3roPalette.accent.amber },
|
||||
},
|
||||
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.amber, height: 2 },
|
||||
}}
|
||||
>
|
||||
<Tab label={t('settings.tabs.general')} icon={<KeyboardIcon sx={{ fontSize: 14 }} />} iconPosition="start" />
|
||||
<Tab label={t('settings.tabs.audio')} icon={<MicIcon sx={{ fontSize: 14 }} />} iconPosition="start" />
|
||||
<Tab label={t('settings.tabs.stt')} />
|
||||
<Tab label={t('settings.tabs.llm')} />
|
||||
<Tab label={t('license.nav')} icon={<LockIcon sx={{ fontSize: 14 }} />} iconPosition="start" />
|
||||
<Tab label={t('settings.tabs.about')} />
|
||||
</Tabs>
|
||||
<Box sx={{ p: 3 }}>
|
||||
|
||||
{/* ── 일반 탭 ─────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.shortcuts')}
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<VoiceModeCard
|
||||
title={t('settings.dictation')}
|
||||
description={t('settings.dictation.desc')}
|
||||
enabled={dictationEnabled}
|
||||
onToggle={handleDictationToggle}
|
||||
enabledLabel={t('settings.enabled')}
|
||||
disabledLabel={t('settings.disabled')}
|
||||
>
|
||||
<HotkeyDisplay
|
||||
binding={dictationBinding}
|
||||
onEdit={() => openHotkeyModal('dictation')}
|
||||
label={t('settings.key')}
|
||||
notSetLabel={t('settings.notSet')}
|
||||
/>
|
||||
</VoiceModeCard>
|
||||
|
||||
<VoiceModeCard
|
||||
title={t('settings.agent')}
|
||||
description={
|
||||
dictationBinding
|
||||
? t('settings.agent.descWithKey', { key: dictationBinding.displayLabel })
|
||||
: t('settings.agent.descNoKey')
|
||||
}
|
||||
enabled={dictationEnabled}
|
||||
onToggle={handleDictationToggle}
|
||||
disabled={!dictationEnabled}
|
||||
enabledLabel={t('settings.enabled')}
|
||||
disabledLabel={t('settings.disabled')}
|
||||
/>
|
||||
|
||||
<VoiceModeCard
|
||||
title={t('settings.oneTouch')}
|
||||
description={t('settings.oneTouch.desc')}
|
||||
enabled={handsFreeEnabled}
|
||||
onToggle={handleHandsFreeToggle}
|
||||
disabled={!dictationEnabled}
|
||||
enabledLabel={t('settings.enabled')}
|
||||
disabledLabel={t('settings.disabled')}
|
||||
>
|
||||
<HotkeyDisplay
|
||||
binding={handsFreeBinding}
|
||||
onEdit={() => openHotkeyModal('handsFree')}
|
||||
label={t('settings.key')}
|
||||
notSetLabel={t('settings.notSet')}
|
||||
/>
|
||||
</VoiceModeCard>
|
||||
|
||||
<VoiceModeCard
|
||||
title={t('settings.caption')}
|
||||
description={t('settings.caption.desc')}
|
||||
enabled={true}
|
||||
enabledLabel={t('settings.enabled')}
|
||||
disabledLabel={t('settings.disabled')}
|
||||
>
|
||||
<HotkeyDisplay
|
||||
binding={captionBinding}
|
||||
onEdit={() => openHotkeyModal('caption')}
|
||||
label={t('settings.key')}
|
||||
notSetLabel={t('settings.notSet')}
|
||||
/>
|
||||
</VoiceModeCard>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.interface')}
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.theme')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.theme')}
|
||||
value={config.theme ?? 'auto'}
|
||||
onChange={(e) => updateConfig('theme', e.target.value as ThemeMode)}
|
||||
>
|
||||
<MenuItem value="auto">{t('settings.theme.system')}</MenuItem>
|
||||
<MenuItem value="light">{t('settings.theme.light')}</MenuItem>
|
||||
<MenuItem value="dark">{t('settings.theme.dark')}</MenuItem>
|
||||
<MenuItem value="nord">{t('settings.theme.nord')}</MenuItem>
|
||||
<MenuItem value="solarized">{t('settings.theme.solarized')}</MenuItem>
|
||||
<MenuItem value="catppuccin">{t('settings.theme.catppuccin')}</MenuItem>
|
||||
<MenuItem value="dracula">{t('settings.theme.dracula')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.language')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.language')}
|
||||
value={locale}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
>
|
||||
{LOCALE_META.map((meta) => (
|
||||
<MenuItem key={meta.code} value={meta.code}>
|
||||
{meta.nativeName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.appBehavior')}
|
||||
</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Switch checked={config.closeToTray ?? true} onChange={(e) => updateConfig('closeToTray', e.target.checked)} />}
|
||||
label={t('settings.closeToTray')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={config.autoLaunch ?? false} onChange={(e) => updateConfig('autoLaunch', e.target.checked)} />}
|
||||
label={t('settings.autoLaunch')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={config.autoInsert ?? true} onChange={(e) => updateConfig('autoInsert', e.target.checked)} />}
|
||||
label={t('settings.autoInsert')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={config.soundEnabled ?? true} onChange={(e) => updateConfig('soundEnabled', e.target.checked)} />}
|
||||
label={t('settings.soundEffects')}
|
||||
/>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* ── 오디오 탭 ────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.microphone')}
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.inputDevice')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.inputDevice')}
|
||||
value={selectedDeviceId}
|
||||
onChange={(e) => {
|
||||
const deviceId = e.target.value
|
||||
setSelectedDeviceId(deviceId)
|
||||
window.electronAPI.audio.setSelectedDevice({ deviceId })
|
||||
}}
|
||||
startAdornment={<MicIcon sx={{ color: d3roPalette.text.inactive, mr: 1, fontSize: 18 }} />}
|
||||
>
|
||||
{audioDevices.map((device, idx) => (
|
||||
<MenuItem key={`${device.deviceId}-${idx}`} value={device.deviceId}>
|
||||
{device.label}{device.isDefault ? ` ${t('settings.deviceDefault')}` : ''}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
variant={micTesting ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
if (micTesting) {
|
||||
setMicTesting(false)
|
||||
setMicLevel(0)
|
||||
} else {
|
||||
setMicTesting(true)
|
||||
setMicLevel(0)
|
||||
const unsub = window.electronAPI.audio.onTestLevel((e) => {
|
||||
setMicLevel(e.level)
|
||||
if (e.level === 0) {
|
||||
setMicTesting(false)
|
||||
unsub()
|
||||
}
|
||||
})
|
||||
await window.electronAPI.audio.testDevice({ deviceId: 'default' })
|
||||
}
|
||||
}}
|
||||
sx={{ fontFamily: d3roFontMono, fontSize: '11px', minWidth: 80 }}
|
||||
>
|
||||
{micTesting ? t('common.stop').toUpperCase() : t('common.test').toUpperCase()}
|
||||
</Button>
|
||||
<Box sx={{ flex: 1, height: 8, bgcolor: d3roPalette.bg.inset, borderRadius: '4px', overflow: 'hidden', boxShadow: d3roShadow.inset }}>
|
||||
<Box sx={{ width: `${Math.min(100, micLevel * 100)}%`, height: '100%', bgcolor: micLevel > 0.7 ? d3roPalette.tag.red : d3roPalette.accent.amber, borderRadius: '4px', transition: 'width 100ms ease-out' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.captionAudio')}
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.captionSource')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.captionSource')}
|
||||
value={(config as Record<string, unknown>)['captionAudioSource'] as string ?? 'mic'}
|
||||
onChange={(e) => {
|
||||
updateConfig('captionAudioSource' as keyof AppConfig, e.target.value as never)
|
||||
// CaptionService 설정도 갱신
|
||||
window.electronAPI.caption.setConfig({ audioSource: e.target.value as 'mic' | 'system' | 'both' })
|
||||
}}
|
||||
>
|
||||
<MenuItem value="mic">{t('settings.captionSource.mic')}</MenuItem>
|
||||
<MenuItem value="system">{t('settings.captionSource.system')}</MenuItem>
|
||||
<MenuItem value="both">{t('settings.captionSource.both')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.textInsert')}
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.insertMethod')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.insertMethod')}
|
||||
value={config.insertMethod ?? 'clipboard'}
|
||||
onChange={(e) => updateConfig('insertMethod', e.target.value as 'clipboard' | 'keyboard')}
|
||||
>
|
||||
<MenuItem value="clipboard">{t('settings.insertClipboard')}</MenuItem>
|
||||
<MenuItem value="keyboard">{t('settings.insertKeyboard')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* ── STT 탭 ───────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.whisperModel')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.whisperModel')}
|
||||
value={config.sttModelId ?? 'base'}
|
||||
onChange={(e) => updateConfig('sttModelId', e.target.value)}
|
||||
>
|
||||
<MenuItem value="tiny">{t('settings.model.tiny')}</MenuItem>
|
||||
<MenuItem value="base">{t('settings.model.base')}</MenuItem>
|
||||
<MenuItem value="small">{t('settings.model.small')}</MenuItem>
|
||||
<MenuItem value="medium">{t('settings.model.medium')}</MenuItem>
|
||||
<MenuItem value="large-v3">{t('settings.model.large')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.sttLanguage')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.sttLanguage')}
|
||||
value={config.sttLanguage ?? 'auto'}
|
||||
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
|
||||
>
|
||||
<MenuItem value="auto">{t('settings.sttLang.auto')}</MenuItem>
|
||||
<MenuItem value="ko">{t('settings.sttLang.ko')}</MenuItem>
|
||||
<MenuItem value="en">{t('settings.sttLang.en')}</MenuItem>
|
||||
<MenuItem value="ja">{t('settings.sttLang.ja')}</MenuItem>
|
||||
<MenuItem value="zh">{t('settings.sttLang.zh')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.diarization')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={t('settings.hfToken')}
|
||||
type="password"
|
||||
value={(config as Record<string, unknown>)['hfToken'] as string ?? ''}
|
||||
onChange={(e) => updateConfig('hfToken' as keyof AppConfig, e.target.value as never)}
|
||||
fullWidth
|
||||
size="small"
|
||||
helperText={t('settings.hfTokenHint')}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={(config as Record<string, unknown>)['diarizationEnabled'] as boolean ?? false}
|
||||
onChange={(e) => updateConfig('diarizationEnabled' as keyof AppConfig, e.target.checked as never)}
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.purple },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
|
||||
backgroundColor: d3roPalette.tag.purple,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontSize: '12px' }}>{t('settings.diarization')}</Typography>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
||||
{t('settings.diarizationHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* ── LLM 탭 ──────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={3}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.ollamaServer')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={t('settings.ollamaUrl')}
|
||||
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
|
||||
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
||||
{t('settings.ollamaHint')}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.llmModel')}
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.llmModel')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.llmModel')}
|
||||
value={(config as Record<string, unknown>)['llmModelId'] as string ?? ''}
|
||||
onChange={(e) => {
|
||||
updateConfig('llmModelId' as keyof AppConfig, e.target.value as never)
|
||||
}}
|
||||
>
|
||||
{llmModels.map((model) => (
|
||||
<MenuItem key={model.id} value={model.id}>
|
||||
{model.name} ({model.parameterSize})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.postProcess')}
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.defaultAction')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.defaultAction')}
|
||||
value={config.defaultLLMAction ?? 'refine'}
|
||||
onChange={(e) => updateConfig('defaultLLMAction', e.target.value)}
|
||||
>
|
||||
<MenuItem value="none">{t('settings.action.none')}</MenuItem>
|
||||
<MenuItem value="refine">{t('settings.action.refine')}</MenuItem>
|
||||
<MenuItem value="translate">{t('settings.action.translate')}</MenuItem>
|
||||
<MenuItem value="summarize">{t('settings.action.summarize')}</MenuItem>
|
||||
<MenuItem value="grammar">{t('settings.action.grammar')}</MenuItem>
|
||||
<MenuItem value="custom">{t('settings.action.custom')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
||||
{t('settings.actionHint')}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
{/* Phase 10: 음성 명령어 토글 */}
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.voiceCommands')}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={(config as Record<string, unknown>)['voiceCommandsEnabled'] as boolean ?? false}
|
||||
onChange={async (_, checked) => {
|
||||
updateConfig('voiceCommandsEnabled' as keyof AppConfig, checked as never)
|
||||
await window.electronAPI.voiceCommand.setEnabled({ enabled: checked })
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2">{t('settings.voiceCommands')}</Typography>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>{t('settings.voiceCommands.desc')}</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Phase 10: 화면 컨텍스트 토글 */}
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px', mt: 1 }}>
|
||||
{t('settings.screenContext')}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.screenContextEnabled ?? false}
|
||||
onChange={async (_, checked) => {
|
||||
updateConfig('screenContextEnabled', checked)
|
||||
await window.electronAPI.context.setEnabled({ enabled: checked })
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2">{t('settings.screenContext')}</Typography>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>{t('settings.screenContext.desc')}</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* ── 라이선스 탭 ────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={4}>
|
||||
<LicenseTab />
|
||||
</TabPanel>
|
||||
|
||||
{/* ── 정보 탭 ──────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={5}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
D3RO-VOICE
|
||||
</Typography>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t('settings.about.version')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">v1.0.0</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t('settings.about.techStack')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t('settings.about.techStackValue')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t('settings.about.voiceEngine')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t('settings.about.voiceEngineValue')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '11px', mb: 2 }}>
|
||||
{t('settings.about.description')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
window.electronAPI.config.set({
|
||||
key: 'onboardingCompleted' as keyof import('@shared/types').AppConfig,
|
||||
value: false as never,
|
||||
})
|
||||
onClose()
|
||||
setTimeout(() => window.location.reload(), 300)
|
||||
}}
|
||||
sx={{ fontFamily: d3roFontMono, fontSize: '11px' }}
|
||||
>
|
||||
{t('settings.about.restartOnboarding')}
|
||||
</Button>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<HotkeyRecordModal
|
||||
open={hotkeyModalOpen}
|
||||
onClose={() => setHotkeyModalOpen(false)}
|
||||
onSave={handleHotkeySave}
|
||||
currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : hotkeyModalTarget === 'handsFree' ? handsFreeBinding : captionBinding}
|
||||
title={hotkeyModalTarget === 'dictation' ? t('hotkey.dictationTitle') : hotkeyModalTarget === 'handsFree' ? t('hotkey.oneTouchTitle') : t('hotkey.captionTitle')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
185
apps/desktop/src/renderer/components/StatusBar.tsx
Normal file
185
apps/desktop/src/renderer/components/StatusBar.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// src/renderer/components/StatusBar.tsx
|
||||
// 인스트루먼트 섀시 하단 — 각인 스타일 상태 표시 + Ollama 오프라인 넛징
|
||||
// 타이포 토큰 적용 (d3roTypo SSOT)
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Typography, Fade, IconButton } from '@mui/material'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import { Led } from './ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '../theme'
|
||||
import { OllamaGuideModal } from './OllamaGuideModal'
|
||||
import { useI18n } from '../i18n'
|
||||
import type { LLMStatus } from '@shared/types'
|
||||
|
||||
export function StatusBar(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
|
||||
const [showNudge, setShowNudge] = useState(false)
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
const [captionActive, setCaptionActive] = useState(false)
|
||||
|
||||
// 자막 상태 구독
|
||||
useEffect(() => {
|
||||
window.electronAPI.caption.getState().then((r) => {
|
||||
if (r.success) setCaptionActive(r.data === 'active')
|
||||
})
|
||||
const unsub = window.electronAPI.caption.onStateChanged((data) => {
|
||||
setCaptionActive(data.state === 'active')
|
||||
})
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// 초기 상태 로드 + 이벤트 구독 (폴링 불필요 — onStatusChanged로 실시간 갱신)
|
||||
window.electronAPI.llm.getStatus().then((r) => {
|
||||
if (r.success) setLlmStatus(r.data)
|
||||
})
|
||||
const unsub = window.electronAPI.llm.onStatusChanged((e) => setLlmStatus(e.status))
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
const connected = llmStatus?.connectionState === 'connected'
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected) {
|
||||
const showTimer = setTimeout(() => setShowNudge(true), 3000)
|
||||
const hideTimer = setTimeout(() => setShowNudge(false), 13000)
|
||||
return () => { clearTimeout(showTimer); clearTimeout(hideTimer) }
|
||||
}
|
||||
setShowNudge(false)
|
||||
return undefined
|
||||
}, [connected])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderTop: `1px solid ${d3roPalette.border.subtle}`,
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
minHeight: 28,
|
||||
}}
|
||||
>
|
||||
{/* 넛징 버블 */}
|
||||
<Fade in={showNudge && !connected}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 32,
|
||||
left: 8,
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: d3roRadius.button,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
boxShadow: d3roShadow.tooltip,
|
||||
maxWidth: 280,
|
||||
zIndex: 100,
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
bottom: -6,
|
||||
left: 16,
|
||||
width: 12,
|
||||
height: 12,
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderTop: 'none',
|
||||
borderLeft: 'none',
|
||||
transform: 'rotate(45deg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Typography sx={{ fontSize: d3roTypo.small.size, color: d3roPalette.text.primary, mb: 0.5, fontWeight: d3roTypo.heading.weight }}>
|
||||
{t('status.nudge.title')}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => setShowNudge(false)} sx={{ color: d3roPalette.text.inactive, p: 0, ml: 1 }}>
|
||||
<CloseIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: d3roTypo.meta.size, color: d3roPalette.text.inactive, mb: 1 }}>
|
||||
{t('status.nudge.desc')}
|
||||
</Typography>
|
||||
<Typography
|
||||
onClick={() => { setGuideOpen(true); setShowNudge(false) }}
|
||||
sx={{
|
||||
fontSize: d3roTypo.meta.size,
|
||||
color: d3roPalette.accent.amber,
|
||||
fontWeight: d3roTypo.label.weight,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{t('status.nudge.guide')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Led color={connected ? 'green' : 'red'} size={6} />
|
||||
<Typography
|
||||
onClick={() => !connected && setGuideOpen(true)}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
color: connected ? d3roPalette.text.inactive : d3roPalette.tag.red,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
fontWeight: d3roTypo.engrave.weight,
|
||||
cursor: connected ? 'default' : 'pointer',
|
||||
'&:hover': connected ? {} : { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{connected ? t('status.ollama') : t('status.offline')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{llmStatus?.activeModel && (
|
||||
<Typography sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
color: d3roPalette.text.dimLabel,
|
||||
letterSpacing: d3roTypo.label.spacing,
|
||||
}}>
|
||||
{llmStatus.activeModel.toUpperCase()}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 자막 상태 표시 */}
|
||||
{captionActive && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Led color="amber" pulse size={6} />
|
||||
<Typography sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
color: d3roPalette.accent.amber,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
fontWeight: d3roTypo.engrave.weight,
|
||||
}}>
|
||||
CAPTION
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<Typography sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
color: d3roPalette.text.muted,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
fontWeight: d3roTypo.engrave.weight,
|
||||
}}>
|
||||
PRECISION DATA LINK
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<OllamaGuideModal open={guideOpen} onClose={() => setGuideOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
294
apps/desktop/src/renderer/components/TemplateSection.tsx
Normal file
294
apps/desktop/src/renderer/components/TemplateSection.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
// src/renderer/components/TemplateSection.tsx
|
||||
// Phase 12.3: 딕테이션 템플릿 관리 UI (CommandsPage 내 섹션)
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Tooltip } from '@mui/material'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow'
|
||||
import { MetalCard, PhosphorText, Led, PhysicalButton } from './ds'
|
||||
import { PageHeader, EmptyStateCard } from './shared'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
import type { DictationTemplate, TemplateField, TemplateSessionInfo } from '@shared/types'
|
||||
|
||||
export function TemplateSection(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [templates, setTemplates] = useState<DictationTemplate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editId, setEditId] = useState<string | null>(null)
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formDesc, setFormDesc] = useState('')
|
||||
const [formOutput, setFormOutput] = useState('')
|
||||
const [formFields, setFormFields] = useState<TemplateField[]>([])
|
||||
const [session, setSession] = useState<TemplateSessionInfo | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const result = await window.electronAPI.dictationTemplate.getAll()
|
||||
if (result.success) setTemplates(result.data)
|
||||
const sessionResult = await window.electronAPI.dictationTemplate.getSessionState()
|
||||
if (sessionResult.success) setSession(sessionResult.data)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = window.electronAPI.dictationTemplate.onSessionStateChanged((data) => {
|
||||
setSession(data)
|
||||
})
|
||||
const unsubComplete = window.electronAPI.dictationTemplate.onSessionCompleted(() => {
|
||||
setSession(null)
|
||||
loadData()
|
||||
})
|
||||
return () => { unsub(); unsubComplete() }
|
||||
}, [loadData])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditId(null)
|
||||
setFormName('')
|
||||
setFormDesc('')
|
||||
setFormOutput('{{field1}}')
|
||||
setFormFields([{ id: 'field1', name: 'field1', label: 'Field 1', promptText: '', required: true, maxDurationSec: 30 }])
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (template: DictationTemplate) => {
|
||||
setEditId(template.id)
|
||||
setFormName(template.name)
|
||||
setFormDesc(template.description)
|
||||
setFormOutput(template.outputFormat)
|
||||
setFormFields([...template.fields])
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (editId) {
|
||||
await window.electronAPI.dictationTemplate.update({
|
||||
id: editId,
|
||||
name: formName.trim(),
|
||||
description: formDesc.trim(),
|
||||
fields: formFields,
|
||||
outputFormat: formOutput,
|
||||
})
|
||||
} else {
|
||||
await window.electronAPI.dictationTemplate.create({
|
||||
name: formName.trim(),
|
||||
description: formDesc.trim(),
|
||||
fields: formFields,
|
||||
outputFormat: formOutput,
|
||||
})
|
||||
}
|
||||
setDialogOpen(false)
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await window.electronAPI.dictationTemplate.delete({ id })
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleStartSession = async (templateId: string) => {
|
||||
await window.electronAPI.dictationTemplate.startSession({ templateId })
|
||||
}
|
||||
|
||||
const handleCancelSession = async () => {
|
||||
await window.electronAPI.dictationTemplate.cancelSession()
|
||||
setSession(null)
|
||||
}
|
||||
|
||||
const addField = () => {
|
||||
const idx = formFields.length + 1
|
||||
setFormFields([...formFields, {
|
||||
id: `field${idx}`,
|
||||
name: `field${idx}`,
|
||||
label: `Field ${idx}`,
|
||||
promptText: '',
|
||||
required: true,
|
||||
maxDurationSec: 30,
|
||||
}])
|
||||
}
|
||||
|
||||
const updateField = (index: number, updates: Partial<TemplateField>) => {
|
||||
const updated = [...formFields]
|
||||
updated[index] = { ...updated[index], ...updates }
|
||||
setFormFields(updated)
|
||||
}
|
||||
|
||||
const removeField = (index: number) => {
|
||||
setFormFields(formFields.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 5 }}>
|
||||
<PageHeader
|
||||
title={t('template.title').toUpperCase()}
|
||||
action={
|
||||
<PhysicalButton size="small" onClick={openCreate}>
|
||||
<AddIcon sx={{ fontSize: 14, mr: 0.5 }} /> {t('template.create')}
|
||||
</PhysicalButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 활성 세션 표시 */}
|
||||
{session && (
|
||||
<MetalCard sx={{ mb: 2, border: `1px solid ${d3roPalette.accent.amber}` }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led color="amber" pulse />
|
||||
<PhosphorText variant="body">
|
||||
{session.templateName}: {session.currentField?.label ?? '...'}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim">
|
||||
({session.currentFieldIndex + 1}/{session.totalFields})
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<PhysicalButton size="small" onClick={handleCancelSession}>
|
||||
{t('template.cancelSession')}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 템플릿 목록 */}
|
||||
{templates.length === 0 && !loading ? (
|
||||
<EmptyStateCard message={t('template.empty')} />
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{templates.map((tmpl) => (
|
||||
<MetalCard key={tmpl.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<PhosphorText variant="body" sx={{ fontWeight: d3roTypo.body.weight }}>
|
||||
{tmpl.name}
|
||||
{tmpl.isBuiltin && (
|
||||
<Box component="span" sx={{ ml: 1, fontSize: d3roTypo.micro.size, color: d3roPalette.text.dimLabel }}>
|
||||
PRESET
|
||||
</Box>
|
||||
)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim" sx={{ mt: 0.25 }}>
|
||||
{tmpl.fields.length} {t('template.fields').toLowerCase()} — {tmpl.description}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Tooltip title={t('template.startSession')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleStartSession(tmpl.id)}
|
||||
disabled={!!session}
|
||||
sx={{ color: d3roPalette.accent.amber, '&:hover': { opacity: 0.8 } }}
|
||||
>
|
||||
<PlayArrowIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => openEdit(tmpl)}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
{!tmpl.isBuiltin && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDelete(tmpl.id)}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 편집 다이얼로그 */}
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{editId ? t('template.edit') : t('template.create')}</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '8px !important' }}>
|
||||
<TextField
|
||||
label={t('template.name')}
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t('template.description')}
|
||||
value={formDesc}
|
||||
onChange={(e) => setFormDesc(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<PhosphorText variant="label" sx={{ mt: 1, color: d3roPalette.text.dimLabel }}>
|
||||
{t('template.fields').toUpperCase()}
|
||||
</PhosphorText>
|
||||
|
||||
{formFields.map((field, idx) => (
|
||||
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<TextField
|
||||
label={t('template.fieldName')}
|
||||
value={field.name}
|
||||
onChange={(e) => updateField(idx, { name: e.target.value, id: e.target.value })}
|
||||
size="small"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('template.fieldLabel')}
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(idx, { label: e.target.value })}
|
||||
size="small"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('template.fieldPrompt')}
|
||||
value={field.promptText}
|
||||
onChange={(e) => updateField(idx, { promptText: e.target.value })}
|
||||
size="small"
|
||||
sx={{ flex: 2 }}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => removeField(idx)}
|
||||
disabled={formFields.length <= 1}
|
||||
sx={{ color: d3roPalette.text.inactive }}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Button onClick={addField} startIcon={<AddIcon />} size="small" sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('template.addField')}
|
||||
</Button>
|
||||
|
||||
<TextField
|
||||
label={t('template.outputFormat')}
|
||||
value={formOutput}
|
||||
onChange={(e) => setFormOutput(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
helperText={t('template.outputHelperText')}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={!formName.trim() || formFields.length === 0}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
227
apps/desktop/src/renderer/components/UpgradePromptModal.tsx
Normal file
227
apps/desktop/src/renderer/components/UpgradePromptModal.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
// src/renderer/components/UpgradePromptModal.tsx
|
||||
// Phase 11: 업그레이드 유도 모달 — 쿼터 소진 또는 잠긴 기능 접근 시 표시
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
LinearProgress,
|
||||
} from '@mui/material'
|
||||
import LockIcon from '@mui/icons-material/Lock'
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
import type { UpgradePromptEvent, UsageQuota } from '@shared/types'
|
||||
|
||||
export function UpgradePromptModal(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [event, setEvent] = useState<UpgradePromptEvent | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = window.electronAPI.license.onUpgradePrompt((e) => {
|
||||
setEvent(e)
|
||||
setOpen(true)
|
||||
})
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
// 라이센스 모달 열기 이벤트와 연동
|
||||
const handleLearnMore = useCallback(() => {
|
||||
setOpen(false)
|
||||
window.dispatchEvent(new CustomEvent('d3ro:open-license-modal'))
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
if (!event) return <></>
|
||||
|
||||
const isQuota = event.reason === 'quota_exceeded'
|
||||
const featureLabel = t(`license.feature.${event.feature}`)
|
||||
const tierLabel = event.requiredTier === 'pro_plus' ? t('license.proPlus') : t('license.pro')
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: d3roRadius.card,
|
||||
boxShadow: d3roShadow.dialog,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.heading.size,
|
||||
fontWeight: d3roTypo.heading.weight,
|
||||
letterSpacing: d3roTypo.heading.spacing,
|
||||
color: d3roPalette.accent.amber,
|
||||
}}
|
||||
>
|
||||
<LockIcon sx={{ fontSize: 20 }} />
|
||||
{isQuota
|
||||
? t('license.quotaExceeded.title', { feature: featureLabel })
|
||||
: t('license.tierRequired.title', { feature: featureLabel, tier: tierLabel })}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.body.size,
|
||||
color: d3roPalette.text.secondary,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{isQuota
|
||||
? t('license.quotaExceeded.desc')
|
||||
: t('license.tierRequired.desc', { tier: tierLabel })}
|
||||
</Typography>
|
||||
|
||||
{/* 쿼터 바 */}
|
||||
{isQuota && event.quota && (
|
||||
<QuotaBar quota={event.quota} />
|
||||
)}
|
||||
|
||||
{/* 혜택 목록 */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.label.size,
|
||||
fontWeight: d3roTypo.label.weight,
|
||||
letterSpacing: d3roTypo.label.spacing,
|
||||
color: d3roPalette.text.label,
|
||||
textTransform: 'uppercase',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{t('license.upgradeBenefits')}
|
||||
</Typography>
|
||||
{[
|
||||
t('license.benefit.unlimitedDictation'),
|
||||
t('license.benefit.unlimitedLLM'),
|
||||
t('license.benefit.liveCaption'),
|
||||
t('license.benefit.unlimitedHistory'),
|
||||
].map((benefit) => (
|
||||
<Box
|
||||
key={benefit}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}
|
||||
>
|
||||
<CheckCircleOutlineIcon
|
||||
sx={{ fontSize: 14, color: d3roPalette.tag.green }}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
color: d3roPalette.text.primary,
|
||||
}}
|
||||
>
|
||||
{benefit}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
color: d3roPalette.text.secondary,
|
||||
textTransform: 'none',
|
||||
}}
|
||||
>
|
||||
{isQuota ? t('license.tryTomorrow') : t('common.close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleLearnMore}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
fontWeight: 600,
|
||||
bgcolor: d3roPalette.accent.amber,
|
||||
color: d3roPalette.bg.app,
|
||||
textTransform: 'none',
|
||||
borderRadius: d3roRadius.button,
|
||||
'&:hover': {
|
||||
bgcolor: d3roPalette.accent.amber,
|
||||
filter: 'brightness(1.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t('license.learnMore')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 쿼터 바 서브 컴포넌트 ──────────────────────────────────
|
||||
|
||||
function QuotaBar({ quota }: { quota: UsageQuota }): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const progress = quota.limit > 0 ? (quota.used / quota.limit) * 100 : 100
|
||||
const featureLabel = t(`license.feature.${quota.feature}`)
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
color: d3roPalette.text.label,
|
||||
}}
|
||||
>
|
||||
{featureLabel}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
color: d3roPalette.accent.amber,
|
||||
}}
|
||||
>
|
||||
{t('license.quotaUsed', {
|
||||
used: String(quota.used),
|
||||
limit: String(quota.limit),
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(100, progress)}
|
||||
sx={{
|
||||
height: 4,
|
||||
borderRadius: d3roRadius.xs,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: progress >= 100 ? d3roPalette.tag.red : d3roPalette.accent.amber,
|
||||
borderRadius: d3roRadius.xs,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
30
apps/desktop/src/renderer/components/ds/ButtonGroup.tsx
Normal file
30
apps/desktop/src/renderer/components/ds/ButtonGroup.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// src/renderer/components/ds/ButtonGroup.tsx
|
||||
// 시안 A: 인셋 버튼 클러스터 — 레퍼런스의 .button-group 패턴
|
||||
// 물리 버튼들을 인셋 패널 안에 배치하여 그룹화
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
|
||||
|
||||
interface ButtonGroupProps {
|
||||
children: React.ReactNode
|
||||
/** 가로 배치 (기본 세로) */
|
||||
horizontal?: boolean
|
||||
}
|
||||
|
||||
export function ButtonGroup({ children, horizontal = false }: ButtonGroupProps): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
p: '6px',
|
||||
borderRadius: d3roRadius.inner,
|
||||
boxShadow: d3roShadow.inset,
|
||||
display: 'flex',
|
||||
flexDirection: horizontal ? 'row' : 'column',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
298
apps/desktop/src/renderer/components/ds/CrtDisplay.tsx
Normal file
298
apps/desktop/src/renderer/components/ds/CrtDisplay.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
// src/renderer/components/ds/CrtDisplay.tsx
|
||||
// 시안 A: CRT 디스플레이 — WebGL 셰이더 (스캔라인, 비네팅, 노이즈, 글리치, 파형)
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow } from '../../theme'
|
||||
|
||||
// ── WebGL 유틸 ─────────────────────────────────────────
|
||||
|
||||
function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
|
||||
const shader = gl.createShader(type)
|
||||
if (!shader) return null
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
gl.deleteShader(shader)
|
||||
return null
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
function createProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string): WebGLProgram | null {
|
||||
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource)
|
||||
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource)
|
||||
if (!vs || !fs) return null
|
||||
const prog = gl.createProgram()
|
||||
if (!prog) return null
|
||||
gl.attachShader(prog, vs)
|
||||
gl.attachShader(prog, fs)
|
||||
gl.linkProgram(prog)
|
||||
return prog
|
||||
}
|
||||
|
||||
const VERTEX_SHADER = `
|
||||
attribute vec2 position;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
vUv = position * 0.5 + 0.5;
|
||||
}
|
||||
`
|
||||
|
||||
const FRAGMENT_SHADER = `
|
||||
precision highp float;
|
||||
varying vec2 vUv;
|
||||
uniform float u_time;
|
||||
uniform float u_glitch;
|
||||
uniform float u_amp;
|
||||
uniform float u_freq;
|
||||
|
||||
float random(vec2 st) { return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123); }
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
|
||||
// Glitch displacement
|
||||
if (u_glitch > 0.0) {
|
||||
float gOffset = (random(vec2(uv.y * 5.0, u_time)) - 0.5) * u_glitch * 0.1;
|
||||
uv.x += gOffset;
|
||||
}
|
||||
|
||||
// Base phosphor background
|
||||
vec3 color = vec3(0.02, 0.015, 0.01);
|
||||
|
||||
// Background Grid
|
||||
float gridX = step(0.98, fract(uv.x * 15.0));
|
||||
float gridY = step(0.98, fract(uv.y * 10.0));
|
||||
color += vec3(0.1, 0.04, 0.02) * max(gridX, gridY) * (1.0 - u_glitch);
|
||||
|
||||
// Oscilloscope Waveform
|
||||
float t = u_time * 0.5 + u_glitch * random(uv) * 0.1;
|
||||
float waveY = sin((uv.x * u_freq) + t) * u_amp;
|
||||
float waveDist = abs((uv.y - 0.5) - waveY);
|
||||
float lineThick = 0.005 + (u_glitch * 0.02);
|
||||
float waveGlow = smoothstep(lineThick * 4.0, 0.0, waveDist);
|
||||
float waveCore = smoothstep(lineThick, 0.0, waveDist);
|
||||
|
||||
// Amber wave (#f25b29)
|
||||
vec3 waveColor = vec3(0.95, 0.35, 0.16);
|
||||
color += waveColor * waveCore;
|
||||
color += waveColor * 0.4 * waveGlow;
|
||||
|
||||
// Scanlines
|
||||
float scanline = sin(uv.y * 800.0 - u_time * 10.0) * 0.04;
|
||||
color -= scanline;
|
||||
|
||||
// Noise
|
||||
float noise = random(uv + u_time) * 0.08;
|
||||
color += noise;
|
||||
|
||||
// Vignette
|
||||
float dist = distance(vUv, vec2(0.5));
|
||||
float vig = smoothstep(0.8, 0.4, dist);
|
||||
color *= vig;
|
||||
|
||||
// Edge darkening
|
||||
color *= smoothstep(0.0, 0.02, vUv.x) * smoothstep(1.0, 0.98, vUv.x);
|
||||
color *= smoothstep(0.0, 0.05, vUv.y) * smoothstep(1.0, 0.95, vUv.y);
|
||||
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const QUAD = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1])
|
||||
|
||||
// ── Props ──────────────────────────────────────────────
|
||||
|
||||
interface CrtDisplayProps {
|
||||
/** 파형 진폭 (0.0 ~ 1.0) */
|
||||
amplitude?: number
|
||||
/** 파형 주파수 */
|
||||
frequency?: number
|
||||
/** 글리치 트리거 (변경 시 글리치 발생) */
|
||||
glitchTrigger?: number
|
||||
/** 실시간 오디오 레벨 (0.0~1.0) — 파형 진폭에 반영 */
|
||||
audioLevel?: number
|
||||
/** 오버레이 콘텐츠 (인광 텍스트 등) */
|
||||
children?: React.ReactNode
|
||||
/** 높이 (기본 280px) */
|
||||
height?: number | string
|
||||
}
|
||||
|
||||
export function CrtDisplay({
|
||||
amplitude = 0.1,
|
||||
frequency = 8.0,
|
||||
glitchTrigger = 0,
|
||||
audioLevel = 0,
|
||||
children,
|
||||
height = 280,
|
||||
}: CrtDisplayProps): React.ReactElement {
|
||||
const theme = useTheme()
|
||||
const isLight = theme.palette.mode === 'light'
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const glRef = useRef<{
|
||||
gl: WebGLRenderingContext
|
||||
uTime: WebGLUniformLocation | null
|
||||
uGlitch: WebGLUniformLocation | null
|
||||
uAmp: WebGLUniformLocation | null
|
||||
uFreq: WebGLUniformLocation | null
|
||||
} | null>(null)
|
||||
const animRef = useRef<number>(0)
|
||||
const startTimeRef = useRef(Date.now())
|
||||
const glitchRef = useRef(0)
|
||||
const ampRef = useRef(amplitude)
|
||||
const freqRef = useRef(frequency)
|
||||
const audioLevelRef = useRef(audioLevel)
|
||||
const currentAmpRef = useRef(amplitude)
|
||||
const currentFreqRef = useRef(frequency)
|
||||
|
||||
// amplitude/frequency/audioLevel 변경 추적
|
||||
useEffect(() => {
|
||||
ampRef.current = amplitude
|
||||
freqRef.current = frequency
|
||||
}, [amplitude, frequency])
|
||||
|
||||
useEffect(() => {
|
||||
audioLevelRef.current = audioLevel
|
||||
}, [audioLevel])
|
||||
|
||||
// 글리치 트리거
|
||||
useEffect(() => {
|
||||
if (glitchTrigger > 0) {
|
||||
glitchRef.current = 1.0
|
||||
}
|
||||
}, [glitchTrigger])
|
||||
|
||||
const render = useCallback(() => {
|
||||
const ctx = glRef.current
|
||||
if (!ctx) return
|
||||
|
||||
const { gl, uTime, uGlitch, uAmp, uFreq } = ctx
|
||||
|
||||
// audioLevel → amplitude 반영: 기본 amplitude + 오디오 레벨로 증폭
|
||||
const targetAmp = ampRef.current + audioLevelRef.current * 0.35
|
||||
|
||||
// Smoothing
|
||||
currentAmpRef.current += (targetAmp - currentAmpRef.current) * 0.15
|
||||
currentFreqRef.current += (freqRef.current - currentFreqRef.current) * 0.1
|
||||
glitchRef.current *= 0.85
|
||||
|
||||
gl.uniform1f(uTime, (Date.now() - startTimeRef.current) / 1000)
|
||||
gl.uniform1f(uGlitch, glitchRef.current)
|
||||
gl.uniform1f(uAmp, currentAmpRef.current)
|
||||
gl.uniform1f(uFreq, currentFreqRef.current)
|
||||
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||
animRef.current = requestAnimationFrame(render)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const gl = canvas.getContext('webgl', { alpha: true })
|
||||
if (!gl) return
|
||||
|
||||
canvas.width = canvas.clientWidth * 2
|
||||
canvas.height = canvas.clientHeight * 2
|
||||
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const prog = createProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER)
|
||||
if (!prog) return
|
||||
|
||||
gl.useProgram(prog)
|
||||
|
||||
const buffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, QUAD, gl.STATIC_DRAW)
|
||||
|
||||
const posLoc = gl.getAttribLocation(prog, 'position')
|
||||
gl.enableVertexAttribArray(posLoc)
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
glRef.current = {
|
||||
gl,
|
||||
uTime: gl.getUniformLocation(prog, 'u_time'),
|
||||
uGlitch: gl.getUniformLocation(prog, 'u_glitch'),
|
||||
uAmp: gl.getUniformLocation(prog, 'u_amp'),
|
||||
uFreq: gl.getUniformLocation(prog, 'u_freq'),
|
||||
}
|
||||
|
||||
startTimeRef.current = Date.now()
|
||||
animRef.current = requestAnimationFrame(render)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current)
|
||||
}
|
||||
}, [render])
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height,
|
||||
bgcolor: d3roPalette.bg.crtBezel,
|
||||
borderRadius: '8px',
|
||||
boxShadow: d3roShadow.insetDeep,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Glass surface */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: '2px',
|
||||
borderRadius: '6px',
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
overflow: 'hidden',
|
||||
boxShadow: d3roShadow.screenGlow,
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// 라이트 모드에서 WebGL 셰이더(다크 전용)를 반전하여 밝은 배경에 어울리게 조정
|
||||
...(isLight && { filter: 'invert(0.88) hue-rotate(180deg)', opacity: 0.9 }),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Glass reflection */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, right: 0, bottom: '50%',
|
||||
background: isLight
|
||||
? 'linear-gradient(180deg, rgba(255,255,255,0.30) 0%, rgba(255,255,255,0) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Content overlay (phosphor text) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: '16px',
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
color: d3roPalette.accent.amber,
|
||||
textShadow: '0 0 6px rgba(242, 91, 41, 0.4)',
|
||||
pointerEvents: 'none',
|
||||
fontFamily: d3roFontMono,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
82
apps/desktop/src/renderer/components/ds/InstrumentPanel.tsx
Normal file
82
apps/desktop/src/renderer/components/ds/InstrumentPanel.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// src/renderer/components/ds/InstrumentPanel.tsx
|
||||
// 시안 A: 메탈 섀시 컨테이너 — 노이즈 텍스처, 각인 텍스트, 물리적 존재감
|
||||
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
|
||||
|
||||
interface InstrumentPanelProps {
|
||||
children: React.ReactNode
|
||||
/** 상단 좌측 각인 */
|
||||
engravingLeft?: string
|
||||
/** 상단 우측 각인 */
|
||||
engravingRight?: string
|
||||
/** 하단 중앙 각인 */
|
||||
engravingBottom?: string
|
||||
}
|
||||
|
||||
export function InstrumentPanel({
|
||||
children,
|
||||
engravingLeft = 'D3RO-VOICE SYS.',
|
||||
engravingRight = 'MOD-01 / TERMINAL',
|
||||
engravingBottom = 'LOCAL AI VOICE ASSISTANT',
|
||||
}: InstrumentPanelProps): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
borderRadius: d3roRadius.outer,
|
||||
p: 3,
|
||||
boxShadow: d3roShadow.chassis,
|
||||
// 메탈 노이즈는 CSS로 시뮬레이션
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: d3roRadius.outer,
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
|
||||
opacity: 0.04,
|
||||
mixBlendMode: 'overlay',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 각인 텍스트 */}
|
||||
<Engraving sx={{ top: 12, left: 24 }}>{engravingLeft}</Engraving>
|
||||
<Engraving sx={{ top: 12, right: 24 }}>{engravingRight}</Engraving>
|
||||
<Engraving sx={{ bottom: 12, left: '50%', transform: 'translateX(-50%)' }}>{engravingBottom}</Engraving>
|
||||
|
||||
{/* 콘텐츠 (z-index 5로 노이즈 위) */}
|
||||
<Box sx={{ position: 'relative', zIndex: 5 }}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function Engraving({ children, sx }: { children: string; sx: Record<string, unknown> }): React.ReactElement {
|
||||
const theme = useTheme()
|
||||
const isLight = theme.palette.mode === 'light'
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
color: d3roPalette.text.engraving,
|
||||
textShadow: isLight
|
||||
? '0 -1px 0 rgba(0,0,0,0.1)'
|
||||
: '0 1px 0 rgba(255,255,255,0.08)',
|
||||
fontWeight: 700,
|
||||
fontFamily: d3roFontMono,
|
||||
zIndex: 2,
|
||||
userSelect: 'none',
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
51
apps/desktop/src/renderer/components/ds/Led.tsx
Normal file
51
apps/desktop/src/renderer/components/ds/Led.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// src/renderer/components/ds/Led.tsx
|
||||
// 시안 A: LED 인디케이터 — 물리적 LED, 활성 시 glow + pulse
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette } from '../../theme'
|
||||
|
||||
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off'
|
||||
|
||||
const LED_COLORS: Record<LedColor, { bg: string; glow: string }> = {
|
||||
amber: { bg: d3roPalette.accent.amber, glow: d3roPalette.accent.amberGlow },
|
||||
green: { bg: d3roPalette.tag.green, glow: d3roPalette.tag.greenGlow },
|
||||
red: { bg: d3roPalette.tag.red, glow: d3roPalette.tag.redGlow },
|
||||
orange: { bg: d3roPalette.tag.orange, glow: d3roPalette.tag.orangeGlow },
|
||||
off: { bg: d3roPalette.led.off, glow: 'transparent' },
|
||||
}
|
||||
|
||||
interface LedProps {
|
||||
color?: LedColor
|
||||
pulse?: boolean
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function Led({ color = 'off', pulse = false, size = 8 }: LedProps): React.ReactElement {
|
||||
const c = LED_COLORS[color]
|
||||
const isActive = color !== 'off'
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
bgcolor: c.bg,
|
||||
flexShrink: 0,
|
||||
boxShadow: isActive
|
||||
? `inset 0 1px 2px rgba(255,255,255,0.5), 0 0 10px ${c.glow}`
|
||||
: 'inset 0 1px 3px rgba(0,0,0,0.9), 0 1px 0 rgba(255,255,255,0.05)',
|
||||
transition: 'all 0.1s',
|
||||
...(pulse && isActive
|
||||
? {
|
||||
animation: 'led-pulse 1.5s ease-in-out infinite',
|
||||
'@keyframes led-pulse': {
|
||||
'0%, 100%': { opacity: 1 },
|
||||
'50%': { opacity: 0.5 },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
30
apps/desktop/src/renderer/components/ds/MetalCard.tsx
Normal file
30
apps/desktop/src/renderer/components/ds/MetalCard.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// src/renderer/components/ds/MetalCard.tsx
|
||||
// 시안 A+B 융합: 메탈 카드 컨테이너 — 섀시 느낌의 인셋 패널
|
||||
// 토큰 적용: d3roShadow, d3roRadius
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
|
||||
|
||||
interface MetalCardProps {
|
||||
children: React.ReactNode
|
||||
inset?: boolean
|
||||
}
|
||||
|
||||
export function MetalCard({ children, inset = false }: MetalCardProps): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: inset ? d3roPalette.bg.inset : d3roPalette.bg.card,
|
||||
borderRadius: inset ? d3roRadius.inner : d3roRadius.card,
|
||||
borderTop: inset ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
|
||||
boxShadow: inset ? d3roShadow.inset : d3roShadow.card,
|
||||
p: inset ? '6px' : 3,
|
||||
overflow: 'hidden',
|
||||
transition: 'background-color 0.2s ease',
|
||||
'&:hover': inset ? {} : { bgcolor: d3roPalette.bg.cardHover },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
134
apps/desktop/src/renderer/components/ds/MetalDial.tsx
Normal file
134
apps/desktop/src/renderer/components/ds/MetalDial.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// src/renderer/components/ds/MetalDial.tsx
|
||||
// 시안 A: 메탈 다이얼 — 정밀기기 회전 노브, 동심원 그루브, 금속 광택, LED 인디케이터
|
||||
// 보강: 레퍼런스의 conic-gradient 정적 라이팅 + 방향성 그림자 추가
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow } from '../../theme'
|
||||
import { PhosphorText } from './PhosphorText'
|
||||
|
||||
interface MetalDialProps {
|
||||
/** 0.0 ~ 1.0 값 (다이얼 위치) */
|
||||
value?: number
|
||||
/** 라벨 텍스트 */
|
||||
label?: string
|
||||
/** 크기 (px) */
|
||||
size?: number
|
||||
/** LED 인디케이터 색상 */
|
||||
ledColor?: string
|
||||
}
|
||||
|
||||
export function MetalDial({
|
||||
value = 0,
|
||||
label,
|
||||
size = 120,
|
||||
ledColor = d3roPalette.accent.amber,
|
||||
}: MetalDialProps): React.ReactElement {
|
||||
const rotation = value * 270 - 135 // -135° ~ +135° 범위
|
||||
const knobSize = size - 12 // 웰 패딩
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
|
||||
{/* 다이얼 웰 (inset well) */}
|
||||
<Box
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
bgcolor: d3roPalette.bg.crtBezel,
|
||||
boxShadow: `
|
||||
inset 0 3px 8px rgba(0,0,0,0.8),
|
||||
inset 0 -1px 2px rgba(255,255,255,0.08),
|
||||
0 1px 1px rgba(255,255,255,0.05)
|
||||
`,
|
||||
position: 'relative',
|
||||
p: '6px',
|
||||
}}
|
||||
>
|
||||
{/* 노브 회전체 (동심원 그루브) */}
|
||||
<Box
|
||||
sx={{
|
||||
width: knobSize,
|
||||
height: knobSize,
|
||||
borderRadius: '50%',
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
left: 6,
|
||||
background: `
|
||||
repeating-radial-gradient(
|
||||
circle at 50% 50%,
|
||||
${d3roPalette.text.disabled} 0px,
|
||||
${d3roPalette.text.disabled} 1px,
|
||||
${d3roPalette.text.label} 1.5px,
|
||||
${d3roPalette.text.label} 2.5px
|
||||
)
|
||||
`,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transition: 'transform 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* 포인터 인디케이터 점 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: d3roPalette.bg.crtBezel,
|
||||
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'&::after': {
|
||||
content: '""',
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: '50%',
|
||||
bgcolor: ledColor,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 정적 금속 광택 오버레이 (노브와 별개, 회전 안 함) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: '6px',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none',
|
||||
// 레퍼런스의 핵심: directional light + conic reflections
|
||||
background: `
|
||||
linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0) 40%, rgba(0,0,0,0.6) 100%),
|
||||
conic-gradient(from 180deg at 50% 50%,
|
||||
rgba(255,255,255,0) 0deg,
|
||||
rgba(255,255,255,0.4) 45deg,
|
||||
rgba(255,255,255,0) 90deg,
|
||||
rgba(255,255,255,0.2) 180deg,
|
||||
rgba(255,255,255,0) 270deg,
|
||||
rgba(255,255,255,0.4) 315deg,
|
||||
rgba(255,255,255,0) 360deg
|
||||
)
|
||||
`,
|
||||
mixBlendMode: 'overlay',
|
||||
// 방향성 그림자: 좌상 하이라이트 + 우하 쉐이드
|
||||
boxShadow: `
|
||||
-4px -4px 8px rgba(255,255,255,0.3),
|
||||
12px 16px 20px rgba(0,0,0,0.7),
|
||||
inset 0 2px 3px rgba(255,255,255,0.8)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 라벨 */}
|
||||
{label && (
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
|
||||
{label}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
68
apps/desktop/src/renderer/components/ds/PhosphorText.tsx
Normal file
68
apps/desktop/src/renderer/components/ds/PhosphorText.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// src/renderer/components/ds/PhosphorText.tsx
|
||||
// 시안 A: 인광 텍스트 — 앰버 glow, 모노 폰트, CRT 느낌
|
||||
// 확장: title/stat/body/compact/meta/engrave/micro/nano 변형 추가
|
||||
|
||||
import { Typography, type TypographyProps } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme'
|
||||
|
||||
type PhosphorVariant =
|
||||
| 'hero' | 'title' | 'value' | 'heading'
|
||||
| 'body' | 'compact' | 'small'
|
||||
| 'meta' | 'label' | 'dim'
|
||||
| 'engrave' | 'micro' | 'nano'
|
||||
|
||||
interface VariantDef {
|
||||
fontSize: string
|
||||
color: string
|
||||
glow: string
|
||||
fontWeight: number
|
||||
letterSpacing: string
|
||||
lineHeight: number
|
||||
textTransform?: 'uppercase' | 'none'
|
||||
}
|
||||
|
||||
const amberGlowStrong = `0 0 8px ${d3roPalette.accent.amberGlow}`
|
||||
const amberGlowMedium = `0 0 6px rgba(242, 91, 41, 0.4)`
|
||||
const amberGlowSoft = `0 0 4px rgba(242, 91, 41, 0.3)`
|
||||
|
||||
const VARIANTS: Record<PhosphorVariant, VariantDef> = {
|
||||
hero: { fontSize: d3roTypo.hero.size, color: d3roPalette.accent.amber, glow: amberGlowStrong, fontWeight: d3roTypo.hero.weight, letterSpacing: d3roTypo.hero.spacing, lineHeight: d3roTypo.hero.line },
|
||||
title: { fontSize: d3roTypo.title.size, color: d3roPalette.accent.amber, glow: amberGlowMedium, fontWeight: d3roTypo.title.weight, letterSpacing: d3roTypo.title.spacing, lineHeight: d3roTypo.title.line },
|
||||
value: { fontSize: d3roTypo.value.size, color: d3roPalette.accent.amber, glow: amberGlowSoft, fontWeight: d3roTypo.value.weight, letterSpacing: d3roTypo.value.spacing, lineHeight: d3roTypo.value.line },
|
||||
heading: { fontSize: d3roTypo.heading.size, color: d3roPalette.text.primary, glow: 'none', fontWeight: d3roTypo.heading.weight, letterSpacing: d3roTypo.heading.spacing, lineHeight: d3roTypo.heading.line },
|
||||
body: { fontSize: d3roTypo.body.size, color: d3roPalette.text.secondary, glow: 'none', fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.body.spacing, lineHeight: d3roTypo.body.line },
|
||||
compact: { fontSize: d3roTypo.compact.size, color: d3roPalette.text.primary, glow: 'none', fontWeight: d3roTypo.compact.weight, letterSpacing: d3roTypo.compact.spacing, lineHeight: d3roTypo.compact.line },
|
||||
small: { fontSize: d3roTypo.small.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.small.weight, letterSpacing: d3roTypo.small.spacing, lineHeight: d3roTypo.small.line },
|
||||
meta: { fontSize: d3roTypo.meta.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.meta.weight, letterSpacing: d3roTypo.meta.spacing, lineHeight: d3roTypo.meta.line, textTransform: 'uppercase' },
|
||||
label: { fontSize: d3roTypo.label.size, color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: d3roTypo.label.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line, textTransform: 'uppercase' },
|
||||
dim: { fontSize: d3roTypo.label.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line },
|
||||
engrave: { fontSize: d3roTypo.engrave.size, color: d3roPalette.text.engraving, glow: 'none', fontWeight: d3roTypo.engrave.weight, letterSpacing: d3roTypo.engrave.spacing, lineHeight: d3roTypo.engrave.line, textTransform: 'uppercase' },
|
||||
micro: { fontSize: d3roTypo.micro.size, color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: d3roTypo.micro.weight, letterSpacing: d3roTypo.micro.spacing, lineHeight: d3roTypo.micro.line, textTransform: 'uppercase' },
|
||||
nano: { fontSize: d3roTypo.nano.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.nano.weight, letterSpacing: d3roTypo.nano.spacing, lineHeight: d3roTypo.nano.line, textTransform: 'uppercase' },
|
||||
}
|
||||
|
||||
interface PhosphorTextProps extends Omit<TypographyProps, 'variant'> {
|
||||
variant?: PhosphorVariant
|
||||
}
|
||||
|
||||
export function PhosphorText({ variant = 'value', sx, ...props }: PhosphorTextProps): React.ReactElement {
|
||||
const v = VARIANTS[variant]
|
||||
|
||||
return (
|
||||
<Typography
|
||||
{...props}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: v.fontSize,
|
||||
fontWeight: v.fontWeight,
|
||||
color: v.color,
|
||||
textShadow: v.glow !== 'none' ? v.glow : 'none',
|
||||
letterSpacing: v.letterSpacing,
|
||||
lineHeight: v.lineHeight,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
textTransform: v.textTransform ?? 'none',
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
43
apps/desktop/src/renderer/components/ds/PhysicalButton.tsx
Normal file
43
apps/desktop/src/renderer/components/ds/PhysicalButton.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// src/renderer/components/ds/PhysicalButton.tsx
|
||||
// 시안 A: 물리 버튼 — 돌출 그림자, 눌림 피드백, 선택 상태
|
||||
// 토큰 적용: d3roShadow, d3roRadius, d3roTypo
|
||||
|
||||
import { Button, type ButtonProps } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
|
||||
|
||||
interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButtonProps): React.ReactElement {
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
sx={{
|
||||
height: 44,
|
||||
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.chassis,
|
||||
border: 'none',
|
||||
borderRadius: d3roRadius.small,
|
||||
color: selected ? d3roPalette.accent.amber : d3roPalette.text.inactive,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
fontWeight: d3roTypo.small.weight,
|
||||
cursor: 'pointer',
|
||||
boxShadow: selected ? d3roShadow.buttonPressed : d3roShadow.buttonRaised,
|
||||
transform: selected ? 'translateY(1px)' : 'none',
|
||||
transition: 'all 0.05s linear',
|
||||
'&:active': {
|
||||
transform: 'translateY(2px)',
|
||||
boxShadow: d3roShadow.buttonActive,
|
||||
},
|
||||
'&:hover': {
|
||||
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.cardHover,
|
||||
},
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: d3roTypo.label.spacing,
|
||||
minWidth: 0,
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
69
apps/desktop/src/renderer/components/ds/ScreenPanel.tsx
Normal file
69
apps/desktop/src/renderer/components/ds/ScreenPanel.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// src/renderer/components/ds/ScreenPanel.tsx
|
||||
// 시안 A: CRT 없는 순수 스크린 패널 — 인셋 베젤 + 글래스 반사 + 인광 텍스트용
|
||||
// 레퍼런스의 .display-module > .screen-glass 패턴
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import { d3roPalette, d3roShadow } from '../../theme'
|
||||
|
||||
interface ScreenPanelProps {
|
||||
children: React.ReactNode
|
||||
/** 전체 높이 (px 또는 CSS 값) */
|
||||
height?: number | string
|
||||
}
|
||||
|
||||
export function ScreenPanel({ children, height }: ScreenPanelProps): React.ReactElement {
|
||||
const theme = useTheme()
|
||||
const isLight = theme.palette.mode === 'light'
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
bgcolor: d3roPalette.bg.crtBezel,
|
||||
borderRadius: '12px',
|
||||
boxShadow: d3roShadow.insetDeep,
|
||||
overflow: 'hidden',
|
||||
height,
|
||||
}}
|
||||
>
|
||||
{/* 글래스 배경 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: '2px',
|
||||
borderRadius: '10px',
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
boxShadow: d3roShadow.screenGlow,
|
||||
// 상단 반사 (레퍼런스의 .screen-glass::after)
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: '50%',
|
||||
background: isLight
|
||||
? 'linear-gradient(180deg, rgba(255,255,255,0.40) 0%, rgba(255,255,255,0) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 콘텐츠 오버레이 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
p: 2,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
12
apps/desktop/src/renderer/components/ds/index.ts
Normal file
12
apps/desktop/src/renderer/components/ds/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// src/renderer/components/ds/index.ts
|
||||
// 디자인 시스템 컴포넌트 SSOT barrel export
|
||||
|
||||
export { CrtDisplay } from './CrtDisplay'
|
||||
export { InstrumentPanel } from './InstrumentPanel'
|
||||
export { Led } from './Led'
|
||||
export { PhysicalButton } from './PhysicalButton'
|
||||
export { MetalCard } from './MetalCard'
|
||||
export { PhosphorText } from './PhosphorText'
|
||||
export { MetalDial } from './MetalDial'
|
||||
export { ScreenPanel } from './ScreenPanel'
|
||||
export { ButtonGroup } from './ButtonGroup'
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// src/renderer/components/shared/EmptyStateCard.tsx
|
||||
// 공유: 데이터 없음 상태 카드
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '../ds'
|
||||
|
||||
interface EmptyStateCardProps {
|
||||
message: string
|
||||
}
|
||||
|
||||
export function EmptyStateCard({ message }: EmptyStateCardProps): React.ReactElement {
|
||||
return (
|
||||
<MetalCard>
|
||||
<Box sx={{ py: 6, textAlign: 'center' }}>
|
||||
<PhosphorText variant="dim">{message}</PhosphorText>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)
|
||||
}
|
||||
330
apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx
Normal file
330
apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
// 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 '../ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../../theme'
|
||||
import { useI18n } from '../../i18n'
|
||||
import { formatDuration } from '../../utils/formatters'
|
||||
import type { HistoryEntry, MemoTag, MeetingSummaryResult } from '@shared/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 (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>
|
||||
)
|
||||
}
|
||||
30
apps/desktop/src/renderer/components/shared/PageHeader.tsx
Normal file
30
apps/desktop/src/renderer/components/shared/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// src/renderer/components/shared/PageHeader.tsx
|
||||
// 공유: 각인 스타일 페이지 헤더 (타이틀 + 카운트 + 옵션 액션)
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { PhosphorText } from '../ds'
|
||||
import { d3roPalette } from '../../theme'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
count?: string
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
export function PageHeader({ title, count, action }: PageHeaderProps): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
|
||||
{title}
|
||||
</PhosphorText>
|
||||
{count && (
|
||||
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{count}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
{action}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
40
apps/desktop/src/renderer/components/shared/SearchInput.tsx
Normal file
40
apps/desktop/src/renderer/components/shared/SearchInput.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// src/renderer/components/shared/SearchInput.tsx
|
||||
// 공유: 모노 폰트 검색 입력 필드
|
||||
|
||||
import { TextField, InputAdornment } from '@mui/material'
|
||||
import SearchIcon from '@mui/icons-material/Search'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme'
|
||||
|
||||
interface SearchInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder: string
|
||||
}
|
||||
|
||||
export function SearchInput({ value, onChange, placeholder }: SearchInputProps): React.ReactElement {
|
||||
return (
|
||||
<TextField
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
fullWidth
|
||||
sx={{
|
||||
mb: 3,
|
||||
'& .MuiInputBase-input': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
letterSpacing: d3roTypo.small.spacing,
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ color: d3roPalette.text.inactive, fontSize: 18 }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
7
apps/desktop/src/renderer/components/shared/index.ts
Normal file
7
apps/desktop/src/renderer/components/shared/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// src/renderer/components/shared/index.ts
|
||||
// 공유 컴포넌트 barrel export
|
||||
|
||||
export { EmptyStateCard } from './EmptyStateCard'
|
||||
export { SearchInput } from './SearchInput'
|
||||
export { PageHeader } from './PageHeader'
|
||||
export { HistoryEntryCard } from './HistoryEntryCard'
|
||||
Loading…
Add table
Add a link
Reference in a new issue