feat: 배포 파이프라인 — Ollama/sidecar 번들 + NSIS 자동 VC++ + GitLab CI + 온보딩 모달
- sidecar 슬림화: torch/pyannote 제거, ctranslate2 GPU 감지, /diarize 삭제 - Ollama 번들: resources/ollama/에 포터블 바이너리 배치, LocalLLMService 1순위 탐색 - installer.nsh: VC++ 재배포 x64 자동 다운로드(aka.ms 경유) + 사일런트 설치 - electron-builder: extraResources에 ollama 추가, nsis.include로 installer.nsh 연결 - scripts: download-ollama.ps1/sh 신규 - LLM.PULL_MODEL IPC 핸들러 + LocalLLMService.pullModel() 구현 (api/pull 스트리밍) - 온보딩 모달: gemma4:e4b 미설치 감지 시 자동 표시, 진행률 UI, i18n(ko/en) 키 추가 - .gitlab-ci.yml: Windows 러너에서 sidecar/sox/ollama 준비 후 NSIS 패키징, 태그 시 Release 자동 생성
This commit is contained in:
parent
d1edad6727
commit
aa65e710ec
16 changed files with 725 additions and 500 deletions
|
|
@ -1,27 +1,30 @@
|
|||
// src/renderer/components/OnboardingModal.tsx
|
||||
// 첫 실행 시 마이크 + 핫키 설정 안내
|
||||
// 첫 실행 온보딩 모달 — 기본 LLM 모델(gemma4:e4b) 미설치 시 다운로드 유도.
|
||||
//
|
||||
// 두 경로로 열림:
|
||||
// 1) AppLayout의 첫 실행 감지(onboardingCompleted=false)
|
||||
// 2) 런타임 중 모델 미설치 감지 (주기적 polling)
|
||||
// 다운로드 성공 시 config.onboardingCompleted=true로 저장.
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
Box,
|
||||
Typography,
|
||||
DialogActions,
|
||||
Button,
|
||||
Stack,
|
||||
Chip,
|
||||
Typography,
|
||||
Box,
|
||||
LinearProgress,
|
||||
} 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 CloudIcon from '@mui/icons-material/Cloud'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { Led } from '@d3ro/ui/components/ds'
|
||||
import { HotkeyRecordModal } from './HotkeyRecordModal'
|
||||
import { formatHotkeyLabel, formatHotkeySegments } from '../utils/format-hotkey'
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
|
||||
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
||||
import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type { HotkeyBinding, AudioDevice } from '@d3ro/core/types'
|
||||
|
||||
const DEFAULT_MODEL = 'gemma4:e4b'
|
||||
type Phase = 'prompt' | 'downloading' | 'success' | 'failed'
|
||||
|
||||
interface OnboardingModalProps {
|
||||
open: boolean
|
||||
|
|
@ -30,310 +33,240 @@ interface OnboardingModalProps {
|
|||
|
||||
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
// 0: 환영, 1: 마이크, 2: 핫키, 3: Ollama, 4: Cloud Sync(선택), 5: 완료
|
||||
const [step, setStep] = useState(0)
|
||||
const [devices, setDevices] = useState<AudioDevice[]>([])
|
||||
const [selectedDevice, setSelectedDevice] = useState('default')
|
||||
const [hotkeyBinding, setHotkeyBinding] = useState<HotkeyBinding | null>(null)
|
||||
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const [phase, setPhase] = useState<Phase>('prompt')
|
||||
const [percent, setPercent] = useState(0)
|
||||
const [status, setStatus] = useState('')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const unsubRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const isVisible = open || internalOpen
|
||||
|
||||
// 모델 존재 여부 체크 — 없으면 auto-open
|
||||
const checkModels = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const result = await window.electronAPI.llm.getModels()
|
||||
if (!result.success) {
|
||||
setPhase('prompt')
|
||||
setInternalOpen(true)
|
||||
return
|
||||
}
|
||||
const hasDefault = result.data.some((m) => m.id === DEFAULT_MODEL)
|
||||
if (!hasDefault) {
|
||||
setPhase('prompt')
|
||||
setInternalOpen(true)
|
||||
} else {
|
||||
setInternalOpen(false)
|
||||
}
|
||||
} catch {
|
||||
setPhase('prompt')
|
||||
setInternalOpen(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
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 initialCheck = setTimeout(() => void checkModels(), 2000)
|
||||
const interval = setInterval(() => {
|
||||
if (phase === 'prompt') void checkModels()
|
||||
}, 15000)
|
||||
return () => {
|
||||
clearTimeout(initialCheck)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [checkModels, phase])
|
||||
|
||||
const handleFinish = (): void => {
|
||||
// 온보딩 완료 플래그 저장
|
||||
window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
|
||||
// pull 진행률 구독
|
||||
useEffect(() => {
|
||||
const unsub = window.electronAPI.llm.onPullProgress((e) => {
|
||||
if (e.modelId !== DEFAULT_MODEL) return
|
||||
setStatus(e.status)
|
||||
if (e.percent > 0) setPercent(e.percent)
|
||||
})
|
||||
unsubRef.current = unsub
|
||||
return () => {
|
||||
unsub()
|
||||
unsubRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDownload = useCallback(async (): Promise<void> => {
|
||||
setPhase('downloading')
|
||||
setPercent(0)
|
||||
setStatus('')
|
||||
setErrorMsg('')
|
||||
|
||||
const result = await window.electronAPI.llm.pullModel({ modelId: DEFAULT_MODEL })
|
||||
if (result.success) {
|
||||
setPhase('success')
|
||||
setPercent(100)
|
||||
// 온보딩 완료 플래그 저장
|
||||
window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
|
||||
} else {
|
||||
setPhase('failed')
|
||||
setErrorMsg(result.error?.message ?? 'unknown')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (phase === 'downloading') return
|
||||
setInternalOpen(false)
|
||||
onClose()
|
||||
}
|
||||
}, [phase, onClose])
|
||||
|
||||
// Ollama step(3) 다음 → Cloud Sync step(4)로
|
||||
const nextAfterOllama = (): void => {
|
||||
setStep(4)
|
||||
}
|
||||
if (!isVisible) return <></>
|
||||
|
||||
// Cloud Sync step에서 Back 누르면 Ollama(3)로 복귀
|
||||
const backToOllama = (): void => {
|
||||
setStep(3)
|
||||
}
|
||||
const isDownloading = phase === 'downloading'
|
||||
const isSuccess = phase === 'success'
|
||||
const isFailed = phase === 'failed'
|
||||
|
||||
const handleHotkeySave = (binding: HotkeyBinding) => {
|
||||
setHotkeyBinding(binding)
|
||||
window.electronAPI.hotkey.setDictationShortcut({ binding })
|
||||
window.electronAPI.hotkey.setEnabled({ enabled: true })
|
||||
}
|
||||
const colorSuccess = d3roPalette.tag.green
|
||||
const colorDanger = d3roPalette.tag.red
|
||||
const colorAccent = d3roPalette.accent.amber
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
backgroundImage: 'none',
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
boxShadow: d3roShadow.chassis,
|
||||
},
|
||||
<Dialog
|
||||
open={isVisible}
|
||||
onClose={handleClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
disableEscapeKeyDown={isDownloading}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: d3roRadius.card,
|
||||
backgroundColor: d3roPalette.bg.elevated,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
...typoSx('heading'),
|
||||
color: d3roPalette.text.primary,
|
||||
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 4 }}>
|
||||
{/* Step 0: 환영 */}
|
||||
{step === 0 && (
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<Led color="amber" pulse size={16} />
|
||||
{isSuccess ? (
|
||||
<CheckCircleOutlineIcon sx={{ color: colorSuccess }} />
|
||||
) : isFailed ? (
|
||||
<ErrorOutlineIcon sx={{ color: colorDanger }} />
|
||||
) : (
|
||||
<CloudDownloadIcon sx={{ color: colorAccent }} />
|
||||
)}
|
||||
{t('onboarding.title')}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ py: 3 }}>
|
||||
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, mb: 2 }}>
|
||||
{t('onboarding.subtitle')}
|
||||
</Typography>
|
||||
|
||||
{(phase === 'prompt' || isDownloading) && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: d3roRadius.inner,
|
||||
backgroundColor: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.primary, mb: 1 }}>
|
||||
{t('onboarding.llmModelMissing', { model: DEFAULT_MODEL })}
|
||||
</Typography>
|
||||
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
|
||||
{t('onboarding.llmModelSize')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isDownloading && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
|
||||
{t('onboarding.downloading')}
|
||||
</Typography>
|
||||
<Typography sx={{ ...typoSx('small'), color: colorAccent }}>
|
||||
{percent}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={percent}
|
||||
sx={{ height: 8, borderRadius: d3roRadius.xs }}
|
||||
/>
|
||||
{status && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '24px',
|
||||
fontWeight: 300,
|
||||
color: d3roPalette.accent.amber,
|
||||
mt: 3,
|
||||
mb: 1,
|
||||
}}
|
||||
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
|
||||
>
|
||||
D3RO-VOICE
|
||||
{t('onboarding.status', { status })}
|
||||
</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>
|
||||
)}
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{isSuccess && (
|
||||
<Typography sx={{ ...typoSx('body'), color: colorSuccess, mt: 2 }}>
|
||||
{t('onboarding.success')}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 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} />
|
||||
{formatHotkeySegments(hotkeyBinding).map((key, idx) => (
|
||||
<Chip
|
||||
key={`${key}-${idx}`}
|
||||
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>
|
||||
)}
|
||||
{isFailed && (
|
||||
<Typography sx={{ ...typoSx('body'), color: colorDanger, mt: 2 }}>
|
||||
{t('onboarding.failed', { message: errorMsg })}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
{/* 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 gemma4:e4b
|
||||
</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={nextAfterOllama}>{t('onboarding.next')}</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
<DialogActions sx={{ px: 3, py: 2, gap: 1 }}>
|
||||
{phase === 'prompt' && (
|
||||
<>
|
||||
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('onboarding.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
variant="contained"
|
||||
sx={{ backgroundColor: colorAccent }}
|
||||
>
|
||||
{t('onboarding.download')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 4: Cloud Sync (선택) */}
|
||||
{step === 4 && (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
||||
<CloudIcon sx={{ color: d3roPalette.accent.amber }} />
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
|
||||
{t('onboarding.cloud.title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
|
||||
{t('onboarding.cloud.tagline')}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: '10px',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
boxShadow: d3roShadow.inset,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.25}>
|
||||
{[
|
||||
t('onboarding.cloud.benefit1'),
|
||||
t('onboarding.cloud.benefit2'),
|
||||
t('onboarding.cloud.benefit3'),
|
||||
].map((b, idx) => (
|
||||
<Stack key={idx} direction="row" spacing={1} alignItems="center">
|
||||
<Led color="green" size={8} />
|
||||
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.primary }}>
|
||||
{b}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '11px',
|
||||
color: d3roPalette.text.inactive,
|
||||
mb: 3,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('onboarding.cloud.signInLater')}
|
||||
</Typography>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Button onClick={backToOllama} sx={{ color: d3roPalette.text.inactive }}>
|
||||
{t('onboarding.back')}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => setStep(5)}>
|
||||
{t('onboarding.next')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
{isDownloading && (
|
||||
<Button disabled sx={{ color: d3roPalette.text.disabled }}>
|
||||
{t('onboarding.downloading')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Step 5: 완료 */}
|
||||
{step === 5 && (
|
||||
<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: formatHotkeyLabel(hotkeyBinding) })
|
||||
: t('onboarding.done.descNoKey')}
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={handleFinish} fullWidth>
|
||||
{t('onboarding.done.start')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{isSuccess && (
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
variant="contained"
|
||||
sx={{ backgroundColor: colorSuccess }}
|
||||
>
|
||||
{t('onboarding.close')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<HotkeyRecordModal
|
||||
open={hotkeyModalOpen}
|
||||
onClose={() => setHotkeyModalOpen(false)}
|
||||
onSave={handleHotkeySave}
|
||||
currentBinding={hotkeyBinding}
|
||||
title={t('hotkey.dictationTitle')}
|
||||
/>
|
||||
</>
|
||||
{isFailed && (
|
||||
<>
|
||||
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('onboarding.close')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
variant="contained"
|
||||
sx={{ backgroundColor: colorAccent }}
|
||||
>
|
||||
{t('onboarding.retry')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue