Phase 10~11 전체 구현: 킬러 피처 5종 + 수익화 시스템
Phase 10 킬러 피처: - MemoService: 태그 CRUD + 마크다운 내보내기 (memo_tags DB) - VoiceCommandService: 키워드→명령어 매칭, 프리셋 4종 - ScreenContextService: PowerShell 활성 윈도우 + Ctrl+C 선택 텍스트 - ChainService: LLM 명령어 순차 실행 파이프라인 - CaptionService: 6초 청크 연속 전사 + 시스템 오디오 루프백 VoiceModeService 파이프라인 통합: - 녹음 시작 → 컨텍스트 캡처 → STT → 키워드 매칭 → LLM(체인/컨텍스트 주입) → 삽입 시스템 오디오 캡처: - setDisplayMediaRequestHandler + audio: 'loopback' (IPC 브릿지) - electron-audio-loopback 패키지 contextIsolation 호환 불가 → 직접 구현 Phase 11 수익화: - LicenseService: Free/Pro/Pro+ 3티어, LemonSqueezy API - Feature Gate: requireFeature/checkFeature/consumeFeature - 일일 쿼터: Free dictation 20/일, LLM 10/일 (SQLite daily_usage) - LicenseModal, ProBadge, UpgradePromptModal UI 디자인 보강: - d3roTypo(13종), d3roShadow(10종), d3roRadius(7종) 토큰 시스템 - ScreenPanel, ButtonGroup DS 컴포넌트 신규 - PhosphorText 4→13종 변형, MetalDial conic-gradient 광택 - 공유 컴포넌트: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard 기타: - 자막 핫키 SSOT 전체 연동 (Config→Hotkey→VoiceMode→Caption→Settings) - StatusBar 자막 LED + 효과음, 자막 로딩 UI - LLM 상태 이벤트 전파 수정 (폴링 제거 → onStatusChanged) - 커맨드 팝업 "선택 해제" 항목 추가
This commit is contained in:
parent
36d77ca224
commit
a31f96bbb8
97 changed files with 11853 additions and 1143 deletions
|
|
@ -14,23 +14,45 @@ import { HistoryPage } from '../pages/HistoryPage'
|
|||
import { DictionaryPage } from '../pages/DictionaryPage'
|
||||
import { CommandsPage } from '../pages/CommandsPage'
|
||||
import { SettingsModal } from './SettingsModal'
|
||||
import { LicenseModal } from './LicenseModal'
|
||||
import { OnboardingModal } from './OnboardingModal'
|
||||
import { StatusBar } from './StatusBar'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
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'
|
||||
|
||||
const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement }> = [
|
||||
{ route: 'dashboard', label: 'DASH', icon: <DashboardIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'history', label: 'HIST', icon: <HistoryIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'dictionary', label: 'DICT', icon: <MenuBookIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'commands', label: 'CMD', icon: <ExtensionIcon sx={{ fontSize: 20 }} /> },
|
||||
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 }} /> },
|
||||
]
|
||||
|
||||
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(() => {
|
||||
|
|
@ -44,6 +66,30 @@ export function AppLayout(): React.ReactElement {
|
|||
})
|
||||
}, [])
|
||||
|
||||
// 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' }}>
|
||||
|
|
@ -61,16 +107,16 @@ export function AppLayout(): React.ReactElement {
|
|||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{/* 로고 LED */}
|
||||
{/* 로고 LED — reflects license tier */}
|
||||
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<Led color="amber" pulse size={10} />
|
||||
<Led color={tierToLedColor(currentTier)} pulse={currentTier !== 'free'} size={10} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '8px',
|
||||
fontSize: d3roTypo.micro.size,
|
||||
fontFamily: d3roFontMono,
|
||||
letterSpacing: '1.5px',
|
||||
letterSpacing: d3roTypo.micro.spacing,
|
||||
color: d3roPalette.text.dimLabel,
|
||||
fontWeight: 700,
|
||||
fontWeight: d3roTypo.micro.weight,
|
||||
}}
|
||||
>
|
||||
D3RO
|
||||
|
|
@ -81,13 +127,13 @@ export function AppLayout(): React.ReactElement {
|
|||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = currentRoute === item.route
|
||||
return (
|
||||
<Tooltip key={item.route} title={item.label} placement="right" arrow>
|
||||
<Tooltip key={item.route} title={t(item.labelKey)} placement="right" arrow>
|
||||
<Box
|
||||
onClick={() => setCurrentRoute(item.route)}
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '8px',
|
||||
borderRadius: d3roRadius.button,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
|
|
@ -96,14 +142,14 @@ export function AppLayout(): React.ReactElement {
|
|||
cursor: 'pointer',
|
||||
bgcolor: isActive ? d3roPalette.bg.chassis : 'transparent',
|
||||
boxShadow: isActive
|
||||
? 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #000'
|
||||
: '0 2px 4px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.06)',
|
||||
? 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: 'inset 0 2px 4px rgba(0,0,0,0.6)',
|
||||
boxShadow: d3roShadow.buttonPressed,
|
||||
},
|
||||
'&:hover': {
|
||||
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.hover,
|
||||
|
|
@ -113,13 +159,13 @@ export function AppLayout(): React.ReactElement {
|
|||
{item.icon}
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '7px',
|
||||
fontSize: d3roTypo.nano.size,
|
||||
fontFamily: d3roFontMono,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.5px',
|
||||
fontWeight: d3roTypo.nano.weight,
|
||||
letterSpacing: d3roTypo.nano.spacing,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{item.abbr}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
|
@ -130,23 +176,23 @@ export function AppLayout(): React.ReactElement {
|
|||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
{/* Settings */}
|
||||
<Tooltip title="SETTINGS" placement="right" arrow>
|
||||
<Tooltip title={t('nav.settings')} placement="right" arrow>
|
||||
<Box
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '8px',
|
||||
borderRadius: d3roRadius.button,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: d3roPalette.text.inactive,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.06)',
|
||||
boxShadow: d3roShadow.buttonRaised,
|
||||
transition: 'all 0.05s linear',
|
||||
'&:active': {
|
||||
transform: 'translateY(2px)',
|
||||
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.6)',
|
||||
boxShadow: d3roShadow.buttonPressed,
|
||||
},
|
||||
'&:hover': { color: d3roPalette.text.hover },
|
||||
}}
|
||||
|
|
@ -175,6 +221,7 @@ export function AppLayout(): React.ReactElement {
|
|||
</Box>
|
||||
<StatusBar />
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<LicenseModal open={licenseModalOpen} onClose={() => setLicenseModalOpen(false)} />
|
||||
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
|
||||
</Box>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue