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

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