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:
Yun Chan 2026-04-05 21:36:09 +09:00
parent 36d77ca224
commit a31f96bbb8
97 changed files with 11853 additions and 1143 deletions

View file

@ -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>
)

View file

@ -16,6 +16,7 @@ import {
Typography,
} from '@mui/material'
import { d3roPalette } from '../theme'
import { useI18n } from '../i18n'
import type { HotkeyBinding } from '@shared/types'
// ── 키 이름 매핑 (Windows) ──────────────────────────────
@ -75,8 +76,9 @@ export function HotkeyRecordModal({
onClose,
onSave,
currentBinding,
title = '단축키 설정',
title,
}: HotkeyRecordModalProps): React.ReactElement {
const { t } = useI18n()
// 현재 눌려있는 키들을 실시간 추적
const [pressedKeys, setPressedKeys] = useState<Array<{ keyCode: number; name: string }>>([])
// 확정된 조합 (녹화 완료 후)
@ -144,14 +146,14 @@ export function HotkeyRecordModal({
const handleSave = () => {
if (!captured || captured.length === 0) {
setError('키를 입력해주세요')
setError(t('hotkey.noKey'))
return
}
// 예약 단축키 체크
const label = captured.map(k => k.name).join('+')
if (RESERVED_COMBOS.includes(label)) {
setError(`${label}은 시스템 예약 단축키입니다`)
setError(t('hotkey.reserved', { keys: label }))
handleReset()
return
}
@ -195,7 +197,7 @@ export function HotkeyRecordModal({
disableAutoFocus
disableRestoreFocus
>
<DialogTitle sx={{ fontWeight: 700, fontSize: '16px' }}>{title}</DialogTitle>
<DialogTitle sx={{ fontWeight: 700, fontSize: '16px' }}>{title ?? t('hotkey.title')}</DialogTitle>
<DialogContent>
{/* 녹화 영역 */}
<Box
@ -246,7 +248,7 @@ export function HotkeyRecordModal({
textAlign: 'center',
}}
>
...
{t('hotkey.prompt')}
</Typography>
)}
</Box>
@ -254,7 +256,7 @@ export function HotkeyRecordModal({
{/* 상태 표시 */}
{isReady && !error && (
<Typography sx={{ color: d3roPalette.tag.green, fontSize: '12px', mt: 1, fontWeight: 600 }}>
{captured?.map(k => k.name).join(' + ')}
{t('hotkey.ready', { keys: captured?.map(k => k.name).join(' + ') ?? '' })}
</Typography>
)}
@ -266,21 +268,21 @@ export function HotkeyRecordModal({
{currentBinding && (
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '12px', mt: 2 }}>
: {currentBinding.displayLabel}
{t('hotkey.current', { keys: currentBinding.displayLabel })}
</Typography>
)}
<Typography sx={{ color: d3roPalette.text.muted, fontSize: '11px', mt: 1 }}>
(: Ctrl+Shift+Q) (: F5)
{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
@ -288,7 +290,7 @@ export function HotkeyRecordModal({
onClick={handleSave}
disabled={!isReady}
>
{t('common.save')}
</Button>
</DialogActions>
</Dialog>

View 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' }}>
&nbsp;
</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>
)
}

View 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(`license.feature.${row.feature}`)}</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>
)
}

View file

@ -15,8 +15,9 @@ import {
import CloseIcon from '@mui/icons-material/Close'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { d3roPalette, d3roFontMono } from '../theme'
import { d3roPalette, d3roFontMono, d3roShadow } from '../theme'
import { Led } from './ds'
import { useI18n } from '../i18n'
interface OllamaGuideModalProps {
open: boolean
@ -36,7 +37,7 @@ function CodeBlock({ children }: { children: string }): React.ReactElement {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: 'inset 0 1px 4px rgba(0,0,0,0.3)',
boxShadow: d3roShadow.inset,
}}
>
<span>{children}</span>
@ -52,6 +53,8 @@ function CodeBlock({ children }: { children: string }): React.ReactElement {
}
export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): React.ReactElement {
const { t } = useI18n()
const handleOpenLink = (url: string) => {
window.electronAPI.system.openExternal({ url })
}
@ -83,7 +86,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
py: 1.5,
}}
>
OLLAMA SETUP GUIDE
{t('ollama.title')}
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.inactive }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
@ -96,11 +99,11 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Led color="amber" size={8} />
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', fontWeight: 700 }}>
STEP 1 Ollama
{t('ollama.step1.title')}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 1.5 }}>
Ollama는 LLM을 .
{t('ollama.step1.desc')}
</Typography>
<Button
variant="outlined"
@ -120,16 +123,16 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Led color="amber" size={8} />
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', fontWeight: 700 }}>
STEP 2
{t('ollama.step2.title')}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 1.5 }}>
pull하세요. :
{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 }}>
모델: ollama pull qwen3:8b ( , )
{t('ollama.step2.alt')}
</Typography>
</Box>
</Box>
@ -141,19 +144,18 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Led color="green" size={8} />
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', fontWeight: 700 }}>
STEP 3
{t('ollama.step3.title')}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
Ollama가 D3RO-VOICE가 .
LED가 !
{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>

View file

@ -15,9 +15,10 @@ 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 } from '../theme'
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 {
@ -26,6 +27,7 @@ interface OnboardingModalProps {
}
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')
@ -66,7 +68,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
bgcolor: d3roPalette.bg.chassis,
backgroundImage: 'none',
border: `1px solid ${d3roPalette.border.subtle}`,
boxShadow: '0 40px 80px rgba(0,0,0,0.8)',
boxShadow: d3roShadow.chassis,
},
}}
>
@ -88,10 +90,10 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
D3RO-VOICE
</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
, . AI .
{t('onboarding.welcome.desc')}
</Typography>
<Button variant="contained" onClick={() => setStep(1)} fullWidth>
{t('onboarding.welcome.start')}
</Button>
</Box>
)}
@ -102,11 +104,11 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<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) => (
@ -128,14 +130,14 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
}}
>
<Typography variant="body2" sx={{ fontSize: '13px' }}>
{d.label}{d.isDefault ? ' (기본)' : ''}
{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 }}></Button>
<Button variant="contained" onClick={() => setStep(2)}></Button>
<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>
)}
@ -146,18 +148,18 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<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: 'inset 0 2px 6px rgba(0,0,0,0.4)',
boxShadow: d3roShadow.inset,
textAlign: 'center',
mb: 3,
}}
@ -181,7 +183,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
</Stack>
) : (
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '13px' }}>
{t('onboarding.hotkey.notSet')}
</Typography>
)}
</Box>
@ -191,11 +193,11 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
onClick={() => setHotkeyModalOpen(true)}
sx={{ mb: 3, fontFamily: d3roFontMono }}
>
{hotkeyBinding ? '단축키 변경' : '단축키 설정'}
{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 }}></Button>
<Button variant="contained" onClick={() => setStep(3)}></Button>
<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>
)}
@ -206,12 +208,11 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<Stack direction="row" alignItems="center" gap={1} mb={3}>
<Led color="amber" size={12} />
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
Ollama ()
{t('onboarding.ollama.title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
, LLM Ollama가 .
Ollama .
{t('onboarding.ollama.desc')}
</Typography>
<Button
variant="outlined"
@ -220,19 +221,19 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
fullWidth
sx={{ mb: 1.5, fontFamily: d3roFontMono }}
>
Ollama
{t('onboarding.ollama.download')}
</Button>
<Box sx={{ p: 1.5, borderRadius: '8px', bgcolor: d3roPalette.bg.inset, boxShadow: 'inset 0 1px 4px rgba(0,0,0,0.3)', mb: 3 }}>
<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 }}></Button>
<Button variant="contained" onClick={() => setStep(4)}></Button>
<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>
)}
@ -242,15 +243,15 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<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
? `${hotkeyBinding.displayLabel} 키를 누르고 말하면 음성이 텍스트로 변환됩니다.`
: '설정에서 단축키를 지정하면 음성 받아쓰기를 시작할 수 있습니다.'}
? t('onboarding.done.descWithKey', { key: hotkeyBinding.displayLabel })
: t('onboarding.done.descNoKey')}
</Typography>
<Button variant="contained" onClick={handleFinish} fullWidth>
{t('onboarding.done.start')}
</Button>
</Box>
)}
@ -262,7 +263,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
onClose={() => setHotkeyModalOpen(false)}
onSave={handleHotkeySave}
currentBinding={hotkeyBinding}
title="받아쓰기 단축키 설정"
title={t('hotkey.dictationTitle')}
/>
</>
)

View 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>
)
}

View file

@ -28,9 +28,16 @@ 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 { d3roPalette, d3roFontMono } from '../theme'
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
@ -53,10 +60,12 @@ 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 }}>
@ -84,7 +93,7 @@ function HotkeyDisplay({
</Stack>
) : (
<Typography variant="body2" sx={{ color: d3roPalette.text.disabled }}>
{notSetLabel}
</Typography>
)}
<IconButton size="small" onClick={onEdit} sx={{ color: d3roPalette.text.inactive, ml: 'auto' }}>
@ -101,6 +110,8 @@ function VoiceModeCard({
enabled,
onToggle,
disabled,
enabledLabel,
disabledLabel,
children,
}: {
title: string
@ -108,6 +119,8 @@ function VoiceModeCard({
enabled: boolean
onToggle: (enabled: boolean) => void
disabled?: boolean
enabledLabel: string
disabledLabel: string
children?: React.ReactNode
}): React.ReactElement {
return (
@ -118,7 +131,7 @@ function VoiceModeCard({
bgcolor: d3roPalette.bg.inset,
borderRadius: '10px',
border: 'none',
boxShadow: `inset 0 2px 6px rgba(0,0,0,0.4), 0 1px 1px rgba(255,255,255,0.04)`,
boxShadow: d3roShadow.inset,
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
@ -147,7 +160,7 @@ function VoiceModeCard({
}
label={
<Chip
label={enabled ? '활성화됨' : '비활성화'}
label={enabled ? enabledLabel : disabledLabel}
size="small"
sx={{
fontSize: '10px',
@ -173,6 +186,7 @@ function VoiceModeCard({
}
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)
@ -182,11 +196,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
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'>('dictation')
const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree' | 'caption'>('dictation')
// 오디오 디바이스
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([])
@ -199,17 +214,18 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
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, enabledResult]) => {
.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)
@ -217,7 +233,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
})
.finally(() => setLoading(false))
// 느린 로드: 오디오 디바이스 (백그라운드, UI 블로킹 안 함)
Promise.all([
window.electronAPI.audio.getDevices(),
window.electronAPI.audio.getSelectedDevice(),
@ -232,12 +247,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
window.electronAPI.config.set({ key, value })
}, [])
// 음성 모드 토글
const handleDictationToggle = useCallback(
(enabled: boolean) => {
setDictationEnabled(enabled)
window.electronAPI.hotkey.setEnabled({ enabled })
// 받아쓰기 비활성화 시 핸즈프리도 비활성화
if (!enabled && handsFreeEnabled) {
setHandsFreeEnabled(false)
}
@ -247,7 +260,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
const handleHandsFreeToggle = useCallback((enabled: boolean) => {
if (enabled && !handsFreeBinding) {
// 핫키 설정 없으면 녹화 모달 열기
setHotkeyModalTarget('handsFree')
setHotkeyModalOpen(true)
return
@ -255,7 +267,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
setHandsFreeEnabled(enabled)
}, [handsFreeBinding])
// 핫키 저장
const handleHotkeySave = useCallback(
(binding: HotkeyBinding) => {
if (hotkeyModalTarget === 'dictation') {
@ -263,20 +274,28 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
window.electronAPI.hotkey.setDictationShortcut({ binding })
setDictationEnabled(true)
window.electronAPI.hotkey.setEnabled({ enabled: true })
} else {
} 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') => {
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 (
@ -292,7 +311,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
bgcolor: d3roPalette.bg.chassis,
backgroundImage: 'none',
border: `1px solid ${d3roPalette.border.subtle}`,
boxShadow: `0 40px 80px -20px rgba(0,0,0,0.8), inset 0 1px 1px rgba(255,255,255,0.08)`,
boxShadow: d3roShadow.chassis,
},
}}
>
@ -310,7 +329,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
py: 1.5,
}}
>
Settings
{t('settings.title')}
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.inactive }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
@ -340,143 +359,145 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.amber, height: 2 },
}}
>
<Tab label="일반" icon={<KeyboardIcon sx={{ fontSize: 14 }} />} iconPosition="start" />
<Tab label="오디오" icon={<MicIcon sx={{ fontSize: 14 }} />} iconPosition="start" />
<Tab label="STT" />
<Tab label="LLM" />
<Tab label="정보" />
<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="받아쓰기"
description="누른 상태에서 말하기. 키를 놓으면 전사가 시작됩니다."
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="키"
label={t('settings.key')}
notSetLabel={t('settings.notSet')}
/>
</VoiceModeCard>
{/* Agent 모드 (더블프레스) */}
<VoiceModeCard
title="Agent 모드"
title={t('settings.agent')}
description={
dictationBinding
? `${dictationBinding.displayLabel}를 두 번 클릭하면 Agent 모드에 진입합니다.`
: '받아쓰기 핫키를 먼저 설정하세요.'
? 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="원터치 모드"
description="눌러서 시작, 다시 눌러서 중지. 별도 단축키가 필요합니다."
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="키"
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 }} />
{/* UI 설정 */}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.interface')}
</Typography>
<FormControl size="small">
<InputLabel></InputLabel>
<InputLabel>{t('settings.theme')}</InputLabel>
<Select
label="테마"
label={t('settings.theme')}
value={config.theme ?? 'auto'}
onChange={(e) => updateConfig('theme', e.target.value as ThemeMode)}
>
<MenuItem value="auto"></MenuItem>
<MenuItem value="light"></MenuItem>
<MenuItem value="dark"></MenuItem>
<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">Nord</MenuItem>
<MenuItem value="solarized">Solarized</MenuItem>
<MenuItem value="catppuccin">Catppuccin</MenuItem>
<MenuItem value="dracula">Dracula</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel></InputLabel>
<InputLabel>{t('settings.language')}</InputLabel>
<Select
label="언어"
value={config.language ?? 'ko'}
onChange={(e) => updateConfig('language', e.target.value)}
label={t('settings.language')}
value={locale}
onChange={(e) => handleLanguageChange(e.target.value)}
>
<MenuItem value="ko"></MenuItem>
<MenuItem value="en">English</MenuItem>
{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="트레이로 최소화"
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="시스템 시작 시 자동 실행"
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="전사 후 자동 텍스트 삽입"
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="효과음"
control={<Switch checked={config.soundEnabled ?? true} onChange={(e) => updateConfig('soundEnabled', e.target.checked)} />}
label={t('settings.soundEffects')}
/>
</Box>
</TabPanel>
@ -485,13 +506,13 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
<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> </InputLabel>
<InputLabel>{t('settings.inputDevice')}</InputLabel>
<Select
label="입력 장치"
label={t('settings.inputDevice')}
value={selectedDeviceId}
onChange={(e) => {
const deviceId = e.target.value
@ -502,13 +523,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
>
{audioDevices.map((device, idx) => (
<MenuItem key={`${device.deviceId}-${idx}`} value={device.deviceId}>
{device.label}{device.isDefault ? ' (기본)' : ''}
{device.label}{device.isDefault ? ` ${t('settings.deviceDefault')}` : ''}
</MenuItem>
))}
</Select>
</FormControl>
{/* 마이크 테스트 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant={micTesting ? 'contained' : 'outlined'}
@ -519,7 +539,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
setMicLevel(0)
} else {
setMicTesting(true)
// 3초 후 자동 종료
const unsub = window.electronAPI.voice.onAudioLevel((e) => {
setMicLevel(e.level)
})
@ -530,53 +549,53 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
}, 5000)
}
}}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
minWidth: 80,
}}
sx={{ fontFamily: d3roFontMono, fontSize: '11px', minWidth: 80 }}
>
{micTesting ? 'STOP' : 'TEST'}
{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: 'inset 0 1px 3px rgba(0,0,0,0.4)',
}}
>
<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 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> </InputLabel>
<InputLabel>{t('settings.captionSource')}</InputLabel>
<Select
label="삽입 방식"
value={config.insertMethod ?? 'clipboard'}
onChange={(e) =>
updateConfig('insertMethod', e.target.value as 'clipboard' | 'keyboard')
}
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="clipboard"> (Ctrl+V)</MenuItem>
<MenuItem value="keyboard"> </MenuItem>
<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>
@ -586,28 +605,28 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
<TabPanel value={activeTab} index={2}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl size="small">
<InputLabel>Whisper </InputLabel>
<InputLabel>{t('settings.whisperModel')}</InputLabel>
<Select
label="Whisper 모델"
label={t('settings.whisperModel')}
value={config.sttModelId ?? 'base'}
onChange={(e) => updateConfig('sttModelId', e.target.value)}
>
<MenuItem value="tiny">tiny (39 MB, )</MenuItem>
<MenuItem value="base">base (74 MB, )</MenuItem>
<MenuItem value="small">small (244 MB, )</MenuItem>
<MenuItem value="medium">medium (769 MB, )</MenuItem>
<MenuItem value="large-v3">large-v3 (1.5 GB, )</MenuItem>
<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> </InputLabel>
<InputLabel>{t('settings.sttLanguage')}</InputLabel>
<Select
label="인식 언어"
label={t('settings.sttLanguage')}
value={config.sttLanguage ?? 'auto'}
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
>
<MenuItem value="auto"> </MenuItem>
<MenuItem value="auto">{t('settings.sttLang.auto')}</MenuItem>
<MenuItem value="ko"></MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ja"></MenuItem>
@ -621,99 +640,143 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
<TabPanel value={activeTab} index={3}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
Ollama
{t('settings.ollamaServer')}
</Typography>
<TextField
label="Ollama 서버 URL"
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' }}>
Ollama가 .
Ollama에서 pull하세요 (: ollama pull qwen3:4b).
{t('settings.ollamaHint')}
</Typography>
<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> </InputLabel>
<InputLabel>{t('settings.defaultAction')}</InputLabel>
<Select
label="기본 후처리 명령어"
label={t('settings.defaultAction')}
value={config.defaultLLMAction ?? 'refine'}
onChange={(e) => updateConfig('defaultLLMAction', e.target.value)}
>
<MenuItem value="none"> ( )</MenuItem>
<MenuItem value="refine"> (+)</MenuItem>
<MenuItem value="translate"></MenuItem>
<MenuItem value="summarize"></MenuItem>
<MenuItem value="grammar"> </MenuItem>
<MenuItem value="custom"> </MenuItem>
<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' }}>
LLM .
Ollama가 .
{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 }}></Typography>
<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 }}> </Typography>
<Typography variant="body2" color="text.secondary">
Electron + React 19 + MUI 7 + TypeScript
</Typography>
<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 }}> </Typography>
<Typography variant="body2" color="text.secondary">
STT: faster-whisper () / LLM: Ollama ()
</Typography>
<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 }}>
Speakly AI .
.
{t('settings.about.description')}
</Typography>
<Button
variant="outlined"
size="small"
onClick={() => {
// 온보딩 플래그 리셋 → 다음 Settings 닫기 시 온보딩 다시 표시
window.electronAPI.config.set({
key: 'onboardingCompleted' as keyof import('@shared/types').AppConfig,
value: false as never,
})
onClose()
// 약간의 딜레이 후 AppLayout이 온보딩을 다시 감지
setTimeout(() => window.location.reload(), 300)
}}
sx={{ fontFamily: d3roFontMono, fontSize: '11px' }}
>
{t('settings.about.restartOnboarding')}
</Button>
</Box>
</TabPanel>
@ -721,14 +784,14 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
</DialogContent>
</Dialog>
{/* 핫키 녹화 모달 */}
<HotkeyRecordModal
open={hotkeyModalOpen}
onClose={() => setHotkeyModalOpen(false)}
onSave={handleHotkeySave}
currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : handsFreeBinding}
title={hotkeyModalTarget === 'dictation' ? '받아쓰기 단축키 설정' : '원터치 모드 단축키 설정'}
currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : hotkeyModalTarget === 'handsFree' ? handsFreeBinding : captionBinding}
title={hotkeyModalTarget === 'dictation' ? t('hotkey.dictationTitle') : hotkeyModalTarget === 'handsFree' ? t('hotkey.oneTouchTitle') : t('hotkey.captionTitle')}
/>
</>
)
}

View file

@ -1,37 +1,45 @@
// 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 } from '../theme'
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))
const interval = setInterval(() => {
window.electronAPI.llm.getStatus().then((r) => {
if (r.success) setLlmStatus(r.data)
})
}, 5000)
return () => { unsub(); clearInterval(interval) }
return unsub
}, [])
const connected = llmStatus?.connectionState === 'connected'
// 오프라인 3초 후 넛징 버블 표시 → 10초 후 자동 숨김
useEffect(() => {
if (!connected) {
const showTimer = setTimeout(() => setShowNudge(true), 3000)
@ -66,13 +74,12 @@ export function StatusBar(): React.ReactElement {
left: 8,
bgcolor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: '10px',
borderRadius: d3roRadius.button,
px: 2,
py: 1.5,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
boxShadow: d3roShadow.tooltip,
maxWidth: 280,
zIndex: 100,
// 말풍선 삼각형
'&::after': {
content: '""',
position: 'absolute',
@ -89,27 +96,27 @@ export function StatusBar(): React.ReactElement {
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.primary, mb: 0.5, fontWeight: 600 }}>
Ollama가
<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: '11px', color: d3roPalette.text.inactive, mb: 1 }}>
LLM (, ) Ollama가 .
<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: '11px',
fontSize: d3roTypo.meta.size,
color: d3roPalette.accent.amber,
fontWeight: 700,
fontWeight: d3roTypo.label.weight,
cursor: 'pointer',
'&:hover': { textDecoration: 'underline' },
}}
>
{t('status.nudge.guide')}
</Typography>
</Box>
</Fade>
@ -120,27 +127,54 @@ export function StatusBar(): React.ReactElement {
onClick={() => !connected && setGuideOpen(true)}
sx={{
fontFamily: d3roFontMono,
fontSize: '9px',
fontSize: d3roTypo.engrave.size,
color: connected ? d3roPalette.text.inactive : d3roPalette.tag.red,
letterSpacing: '1px',
fontWeight: 700,
letterSpacing: d3roTypo.engrave.spacing,
fontWeight: d3roTypo.engrave.weight,
cursor: connected ? 'default' : 'pointer',
'&:hover': connected ? {} : { textDecoration: 'underline' },
}}
>
{connected ? 'OLLAMA' : 'OFFLINE'}
{connected ? t('status.ollama') : t('status.offline')}
</Typography>
</Box>
{llmStatus?.activeModel && (
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '9px', color: d3roPalette.text.dimLabel, letterSpacing: '0.5px' }}>
<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: '9px', color: d3roPalette.text.muted, letterSpacing: '1px', fontWeight: 700 }}>
<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>

View 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>
)
}

View 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>
)
}

View file

@ -3,7 +3,8 @@
import { useRef, useEffect, useCallback } from 'react'
import { Box } from '@mui/material'
import { d3roPalette, d3roFontMono } from '../../theme'
import { useTheme } from '@mui/material/styles'
import { d3roPalette, d3roFontMono, d3roShadow } from '../../theme'
// ── WebGL 유틸 ─────────────────────────────────────────
@ -112,6 +113,8 @@ interface CrtDisplayProps {
frequency?: number
/** 글리치 트리거 (변경 시 글리치 발생) */
glitchTrigger?: number
/** 실시간 오디오 레벨 (0.0~1.0) — 파형 진폭에 반영 */
audioLevel?: number
/** 오버레이 콘텐츠 (인광 텍스트 등) */
children?: React.ReactNode
/** 높이 (기본 280px) */
@ -122,9 +125,12 @@ 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
@ -138,15 +144,20 @@ export function CrtDisplay({
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 변경 추적
// amplitude/frequency/audioLevel 변경 추적
useEffect(() => {
ampRef.current = amplitude
freqRef.current = frequency
}, [amplitude, frequency])
useEffect(() => {
audioLevelRef.current = audioLevel
}, [audioLevel])
// 글리치 트리거
useEffect(() => {
if (glitchTrigger > 0) {
@ -160,8 +171,11 @@ export function CrtDisplay({
const { gl, uTime, uGlitch, uAmp, uFreq } = ctx
// audioLevel → amplitude 반영: 기본 amplitude + 오디오 레벨로 증폭
const targetAmp = ampRef.current + audioLevelRef.current * 0.35
// Smoothing
currentAmpRef.current += (ampRef.current - currentAmpRef.current) * 0.1
currentAmpRef.current += (targetAmp - currentAmpRef.current) * 0.15
currentFreqRef.current += (freqRef.current - currentFreqRef.current) * 0.1
glitchRef.current *= 0.85
@ -221,7 +235,7 @@ export function CrtDisplay({
height,
bgcolor: d3roPalette.bg.crtBezel,
borderRadius: '8px',
boxShadow: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #000, 0 1px 1px rgba(255,255,255,0.1)',
boxShadow: d3roShadow.insetDeep,
overflow: 'hidden',
}}
>
@ -233,12 +247,19 @@ export function CrtDisplay({
borderRadius: '6px',
bgcolor: d3roPalette.bg.crtGlass,
overflow: 'hidden',
boxShadow: 'inset 0 0 20px rgba(0,0,0,0.8)',
boxShadow: d3roShadow.screenGlow,
}}
>
<canvas
ref={canvasRef}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
// 라이트 모드에서 WebGL 셰이더(다크 전용)를 반전하여 밝은 배경에 어울리게 조정
...(isLight && { filter: 'invert(0.88) hue-rotate(180deg)', opacity: 0.9 }),
}}
/>
{/* Glass reflection */}
@ -246,7 +267,9 @@ export function CrtDisplay({
sx={{
position: 'absolute',
top: 0, left: 0, right: 0, bottom: '50%',
background: 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%)',
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,
}}

View file

@ -2,7 +2,8 @@
// 시안 A: 메탈 섀시 컨테이너 — 노이즈 텍스처, 각인 텍스트, 물리적 존재감
import { Box, Typography } from '@mui/material'
import { d3roPalette, d3roFontMono } from '../../theme'
import { useTheme } from '@mui/material/styles'
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
interface InstrumentPanelProps {
children: React.ReactNode
@ -25,16 +26,15 @@ export function InstrumentPanel({
sx={{
position: 'relative',
bgcolor: d3roPalette.bg.chassis,
borderRadius: '24px',
borderRadius: d3roRadius.outer,
p: 3,
boxShadow:
`0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 ${d3roPalette.led.off}, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.15), inset 0 -1px 2px rgba(0,0,0,0.4)`,
boxShadow: d3roShadow.chassis,
// 메탈 노이즈는 CSS로 시뮬레이션
'&::before': {
content: '""',
position: 'absolute',
inset: 0,
borderRadius: '24px',
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',
@ -57,14 +57,18 @@ export function InstrumentPanel({
}
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: '9px',
letterSpacing: '1.5px',
fontSize: d3roTypo.engrave.size,
letterSpacing: d3roTypo.engrave.spacing,
color: d3roPalette.text.engraving,
textShadow: '0 1px 0 rgba(255,255,255,0.08)',
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,

View file

@ -1,8 +1,9 @@
// src/renderer/components/ds/MetalCard.tsx
// 시안 A+B 융합: 메탈 카드 컨테이너 — 섀시 느낌의 인셋 패널
// 토큰 적용: d3roShadow, d3roRadius
import { Box } from '@mui/material'
import { d3roPalette } from '../../theme'
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
interface MetalCardProps {
children: React.ReactNode
@ -14,11 +15,9 @@ export function MetalCard({ children, inset = false }: MetalCardProps): React.Re
<Box
sx={{
bgcolor: inset ? d3roPalette.bg.inset : d3roPalette.bg.card,
borderRadius: inset ? '12px' : '22px',
borderRadius: inset ? d3roRadius.inner : d3roRadius.card,
borderTop: inset ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
boxShadow: inset
? 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.05)'
: '0 8px 30px rgba(0,0,0,0.3)',
boxShadow: inset ? d3roShadow.inset : d3roShadow.card,
p: inset ? '6px' : 3,
transition: 'background-color 0.2s ease',
'&:hover': inset ? {} : { bgcolor: d3roPalette.bg.cardHover },

View file

@ -1,8 +1,10 @@
// src/renderer/components/ds/MetalDial.tsx
// 시안 A: 메탈 다이얼 — 정밀기기 회전 노브, 동심원 그루브, LED 인디케이터
// 시안 A: 메탈 다이얼 — 정밀기기 회전 노브, 동심원 그루브, 금속 광택, LED 인디케이터
// 보강: 레퍼런스의 conic-gradient 정적 라이팅 + 방향성 그림자 추가
import { Box, Typography } from '@mui/material'
import { d3roPalette, d3roFontMono } from '../../theme'
import { Box } from '@mui/material'
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow } from '../../theme'
import { PhosphorText } from './PhosphorText'
interface MetalDialProps {
/** 0.0 ~ 1.0 값 (다이얼 위치) */
@ -22,97 +24,110 @@ export function MetalDial({
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 }}>
{/* 다이얼 외곽 */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
{/* 다이얼 웰 (inset well) */}
<Box
sx={{
width: size,
height: size,
borderRadius: '50%',
bgcolor: d3roPalette.bg.chassis,
bgcolor: d3roPalette.bg.crtBezel,
boxShadow: `
0 4px 12px rgba(0,0,0,0.5),
inset 0 2px 4px rgba(255,255,255,0.08),
inset 0 -2px 4px rgba(0,0,0,0.3)
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)
`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
p: '6px',
}}
>
{/* 동심원 그루브 (CSS로 시뮬레이션) */}
{/* 노브 회전체 (동심원 그루브) */}
<Box
sx={{
width: size - 16,
height: size - 16,
width: knobSize,
height: knobSize,
borderRadius: '50%',
position: 'absolute',
top: 6,
left: 6,
background: `
repeating-radial-gradient(
circle at center,
${d3roPalette.bg.chassis} 0px,
${d3roPalette.bg.card} 1px,
${d3roPalette.bg.chassis} 2px
circle at 50% 50%,
#e6e7e9 0px,
#e6e7e9 1px,
#b0b2b5 1.5px,
#b0b2b5 2.5px
)
`,
boxShadow: `
inset 0 1px 3px rgba(0,0,0,0.6),
0 1px 1px rgba(255,255,255,0.05)
`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transform: `rotate(${rotation}deg)`,
transition: 'transform 0.2s ease-out',
}}
>
{/* 포인터 인디케이터 */}
{/* 포인터 인디케이터 점 */}
<Box
sx={{
position: 'absolute',
top: 8,
top: 10,
left: '50%',
transform: 'translateX(-50%)',
width: 4,
height: 4,
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: ledColor,
boxShadow: `0 0 6px ${ledColor}`,
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>
{/* LED 인디케이터 (우측 상단) */}
{/* 정적 금속 광택 오버레이 (노브와 별개, 회전 안 함) */}
<Box
sx={{
position: 'absolute',
top: 4,
right: size * 0.25,
width: 5,
height: 5,
inset: '6px',
borderRadius: '50%',
bgcolor: ledColor,
boxShadow: `0 0 4px ${ledColor}`,
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 && (
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '9px',
fontWeight: 700,
letterSpacing: '1.5px',
color: d3roPalette.text.inactive,
textTransform: 'uppercase',
}}
>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
{label}
</Typography>
</PhosphorText>
)}
</Box>
)

View file

@ -1,16 +1,44 @@
// 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 } from '../../theme'
import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme'
type PhosphorVariant = 'hero' | 'value' | 'label' | 'dim'
type PhosphorVariant =
| 'hero' | 'title' | 'value' | 'heading'
| 'body' | 'compact' | 'small'
| 'meta' | 'label' | 'dim'
| 'engrave' | 'micro' | 'nano'
const VARIANTS: Record<PhosphorVariant, { fontSize: string; color: string; glow: string; fontWeight: number }> = {
hero: { fontSize: '42px', color: d3roPalette.accent.amber, glow: 'rgba(242, 91, 41, 0.4)', fontWeight: 300 },
value: { fontSize: '20px', color: d3roPalette.accent.amber, glow: 'rgba(242, 91, 41, 0.3)', fontWeight: 400 },
label: { fontSize: '10px', color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: 700 },
dim: { fontSize: '10px', color: d3roPalette.text.inactive, glow: 'none', fontWeight: 400 },
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'> {
@ -28,11 +56,11 @@ export function PhosphorText({ variant = 'value', sx, ...props }: PhosphorTextPr
fontSize: v.fontSize,
fontWeight: v.fontWeight,
color: v.color,
textShadow: v.glow !== 'none' ? `0 0 6px ${v.glow}` : 'none',
letterSpacing: variant === 'label' ? '2px' : variant === 'hero' ? '-2px' : '0.02em',
lineHeight: 1,
textShadow: v.glow !== 'none' ? v.glow : 'none',
letterSpacing: v.letterSpacing,
lineHeight: v.lineHeight,
fontVariantNumeric: 'tabular-nums',
textTransform: variant === 'label' ? 'uppercase' : 'none',
textTransform: v.textTransform ?? 'none',
...sx,
}}
/>

View file

@ -1,8 +1,9 @@
// src/renderer/components/ds/PhysicalButton.tsx
// 시안 A: 물리 버튼 — 돌출 그림자, 눌림 피드백, 선택 상태
// 토큰 적용: d3roShadow, d3roRadius, d3roTypo
import { Button, type ButtonProps } from '@mui/material'
import { d3roPalette, d3roFontMono } from '../../theme'
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
selected?: boolean
@ -16,26 +17,24 @@ export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButto
height: 44,
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.chassis,
border: 'none',
borderRadius: '6px',
borderRadius: d3roRadius.small,
color: selected ? d3roPalette.accent.amber : d3roPalette.text.inactive,
fontFamily: d3roFontMono,
fontSize: '12px',
fontWeight: 600,
fontSize: d3roTypo.small.size,
fontWeight: d3roTypo.small.weight,
cursor: 'pointer',
boxShadow: selected
? 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #000'
: '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.1), inset 0 -1px 2px rgba(0,0,0,0.2)',
boxShadow: selected ? d3roShadow.buttonPressed : d3roShadow.buttonRaised,
transform: selected ? 'translateY(1px)' : 'none',
transition: 'all 0.05s linear',
'&:active': {
transform: 'translateY(2px)',
boxShadow: '0 1px 2px rgba(0,0,0,0.4), inset 0 2px 4px rgba(0,0,0,0.3)',
boxShadow: d3roShadow.buttonActive,
},
'&:hover': {
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.cardHover,
},
textTransform: 'uppercase',
letterSpacing: '0.5px',
letterSpacing: d3roTypo.label.spacing,
minWidth: 0,
...sx,
}}

View 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>
)
}

View file

@ -8,3 +8,5 @@ export { PhysicalButton } from './PhysicalButton'
export { MetalCard } from './MetalCard'
export { PhosphorText } from './PhosphorText'
export { MetalDial } from './MetalDial'
export { ScreenPanel } from './ScreenPanel'
export { ButtonGroup } from './ButtonGroup'

View file

@ -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>
)
}

View file

@ -0,0 +1,185 @@
// 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 { MetalCard, Led } from '../ds'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../../theme'
import { useI18n } from '../../i18n'
import { formatDuration } from '../../utils/formatters'
import type { HistoryEntry, MemoTag } 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 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])
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,
color: d3roPalette.text.primary,
lineHeight: d3roTypo.compact.line,
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>
</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>
</MetalCard>
)
}

View 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>
)
}

View 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>
),
},
}}
/>
)
}

View 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'