Phase 10~11 전체 구현: 킬러 피처 5종 + 수익화 시스템
Phase 10 킬러 피처: - MemoService: 태그 CRUD + 마크다운 내보내기 (memo_tags DB) - VoiceCommandService: 키워드→명령어 매칭, 프리셋 4종 - ScreenContextService: PowerShell 활성 윈도우 + Ctrl+C 선택 텍스트 - ChainService: LLM 명령어 순차 실행 파이프라인 - CaptionService: 6초 청크 연속 전사 + 시스템 오디오 루프백 VoiceModeService 파이프라인 통합: - 녹음 시작 → 컨텍스트 캡처 → STT → 키워드 매칭 → LLM(체인/컨텍스트 주입) → 삽입 시스템 오디오 캡처: - setDisplayMediaRequestHandler + audio: 'loopback' (IPC 브릿지) - electron-audio-loopback 패키지 contextIsolation 호환 불가 → 직접 구현 Phase 11 수익화: - LicenseService: Free/Pro/Pro+ 3티어, LemonSqueezy API - Feature Gate: requireFeature/checkFeature/consumeFeature - 일일 쿼터: Free dictation 20/일, LLM 10/일 (SQLite daily_usage) - LicenseModal, ProBadge, UpgradePromptModal UI 디자인 보강: - d3roTypo(13종), d3roShadow(10종), d3roRadius(7종) 토큰 시스템 - ScreenPanel, ButtonGroup DS 컴포넌트 신규 - PhosphorText 4→13종 변형, MetalDial conic-gradient 광택 - 공유 컴포넌트: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard 기타: - 자막 핫키 SSOT 전체 연동 (Config→Hotkey→VoiceMode→Caption→Settings) - StatusBar 자막 LED + 효과음, 자막 로딩 UI - LLM 상태 이벤트 전파 수정 (폴링 제거 → onStatusChanged) - 커맨드 팝업 "선택 해제" 항목 추가
This commit is contained in:
parent
36d77ca224
commit
a31f96bbb8
97 changed files with 11853 additions and 1143 deletions
|
|
@ -1,109 +1,167 @@
|
|||
// src/renderer/pages/HistoryPage.tsx
|
||||
// 인스트루먼트 미학: MetalCard + PhosphorText + Led + 날짜 그룹핑
|
||||
// Phase 10: 태그 필터링 + 태그 관리 통합
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Box, TextField, IconButton, InputAdornment } from '@mui/material'
|
||||
import SearchIcon from '@mui/icons-material/Search'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import { MetalCard, PhosphorText, Led } from '../components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
import type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types'
|
||||
import { Box, Chip, IconButton, Tooltip } from '@mui/material'
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload'
|
||||
import { PhosphorText } from '../components/ds'
|
||||
import { d3roPalette, d3roTypo, d3roFontMono, d3roRadius } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
import { getDateKey } from '../utils/formatters'
|
||||
import { EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard } from '../components/shared'
|
||||
import type { HistoryEntry, HistoryPage as HistoryPageData, TagCount } from '@shared/types'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatDuration(sec: number): string {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.round(sec % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getDateKey(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getDateLabel(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
if (d.getTime() === today.getTime()) return 'TODAY'
|
||||
if (d.getTime() === yesterday.getTime()) return 'YESTERDAY'
|
||||
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }).toUpperCase()
|
||||
}
|
||||
|
||||
export function HistoryPage(): React.ReactElement {
|
||||
const { t, formatRelativeDate } = useI18n()
|
||||
const [data, setData] = useState<HistoryPageData | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [allTags, setAllTags] = useState<TagCount[]>([])
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||
|
||||
const loadTags = useCallback(async () => {
|
||||
const result = await window.electronAPI.memo.getAllTags()
|
||||
if (result.success) setAllTags(result.data)
|
||||
}, [])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const result = search.trim()
|
||||
? await window.electronAPI.history.search({ query: search, page: 0, pageSize: PAGE_SIZE })
|
||||
: await window.electronAPI.history.getAll({ page: 0, pageSize: PAGE_SIZE, sortOrder: 'desc' })
|
||||
let result: { success: boolean; data: HistoryPageData } | { success: false; error: unknown }
|
||||
|
||||
if (activeTag) {
|
||||
result = await window.electronAPI.memo.searchByTag({ tag: activeTag, page: 0, pageSize: PAGE_SIZE })
|
||||
} else if (search.trim()) {
|
||||
result = await window.electronAPI.history.search({ query: search, page: 0, pageSize: PAGE_SIZE })
|
||||
} else {
|
||||
result = await window.electronAPI.history.getAll({ page: 0, pageSize: PAGE_SIZE, sortOrder: 'desc' })
|
||||
}
|
||||
if (result.success) setData(result.data)
|
||||
setLoading(false)
|
||||
}, [search])
|
||||
}, [search, activeTag])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
const unsub = window.electronAPI.app.onDataChanged(() => { loadData() })
|
||||
loadTags()
|
||||
const unsub = window.electronAPI.app.onDataChanged(() => { loadData(); loadTags() })
|
||||
return unsub
|
||||
}, [loadData])
|
||||
}, [loadData, loadTags])
|
||||
|
||||
// 날짜별 그룹핑
|
||||
const groupedEntries = useMemo(() => {
|
||||
if (!data) return []
|
||||
const groups: Record<string, { label: string; entries: HistoryEntry[] }> = {}
|
||||
for (const entry of data.entries) {
|
||||
const key = getDateKey(entry.createdAt)
|
||||
if (!groups[key]) {
|
||||
groups[key] = { label: getDateLabel(entry.createdAt), entries: [] }
|
||||
groups[key] = { label: formatRelativeDate(entry.createdAt), entries: [] }
|
||||
}
|
||||
groups[key].entries.push(entry)
|
||||
}
|
||||
return Object.values(groups)
|
||||
}, [data])
|
||||
}, [data, formatRelativeDate])
|
||||
|
||||
const handleCopy = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}, [])
|
||||
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
window.electronAPI.history.delete({ id })
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const handleTagClick = useCallback((tag: string) => {
|
||||
setActiveTag(prev => prev === tag ? null : tag)
|
||||
setSearch('')
|
||||
}, [])
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
await window.electronAPI.memo.export({
|
||||
format: 'markdown' as const,
|
||||
tag: activeTag ?? undefined,
|
||||
})
|
||||
}, [activeTag])
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
|
||||
TRANSCRIPTION LOG — {data?.total ?? 0} ENTRIES
|
||||
</PhosphorText>
|
||||
|
||||
<TextField
|
||||
placeholder="SEARCH..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
fullWidth
|
||||
sx={{
|
||||
mb: 3,
|
||||
'& .MuiInputBase-input': { fontFamily: d3roFontMono, fontSize: '12px', letterSpacing: '0.5px' },
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: d3roPalette.text.inactive, fontSize: 18 }} /></InputAdornment>,
|
||||
},
|
||||
}}
|
||||
{/* 페이지 헤더 -- 각인 스타일 */}
|
||||
<PageHeader
|
||||
title={t('history.title').toUpperCase()}
|
||||
action={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tooltip title={t('memo.export')} arrow>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleExport}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||
>
|
||||
<FileDownloadIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{t('history.entries', { count: data?.total ?? 0 }).toUpperCase()}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 태그 필터 바 */}
|
||||
{allTags.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
|
||||
{allTags.map(tc => (
|
||||
<Chip
|
||||
key={tc.tag}
|
||||
label={`#${tc.tag} (${tc.count})`}
|
||||
size="small"
|
||||
variant={activeTag === tc.tag ? 'filled' : 'outlined'}
|
||||
onClick={() => handleTagClick(tc.tag)}
|
||||
sx={{
|
||||
height: 22,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.micro.size,
|
||||
borderRadius: d3roRadius.small,
|
||||
borderColor: activeTag === tc.tag ? d3roPalette.accent.amber : d3roPalette.border.subtle,
|
||||
bgcolor: activeTag === tc.tag ? d3roPalette.accent.amber : 'transparent',
|
||||
color: activeTag === tc.tag ? d3roPalette.bg.card : d3roPalette.text.secondary,
|
||||
'&:hover': {
|
||||
bgcolor: activeTag === tc.tag ? d3roPalette.accent.amber : d3roPalette.bg.cardHover,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{activeTag && (
|
||||
<Chip
|
||||
label={t('memo.clearFilter')}
|
||||
size="small"
|
||||
onClick={() => setActiveTag(null)}
|
||||
sx={{
|
||||
height: 22,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.micro.size,
|
||||
borderRadius: d3roRadius.small,
|
||||
color: d3roPalette.text.muted,
|
||||
'&:hover': { color: d3roPalette.tag.red },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!activeTag && (
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('history.search').toUpperCase()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<PhosphorText variant="dim">LOADING...</PhosphorText>
|
||||
<PhosphorText variant="dim">{t('common.loading').toUpperCase()}</PhosphorText>
|
||||
) : !data || data.entries.length === 0 ? (
|
||||
<MetalCard>
|
||||
<Box sx={{ py: 6, textAlign: 'center' }}>
|
||||
<PhosphorText variant="dim">{search ? 'NO RESULTS' : 'NO HISTORY — START RECORDING'}</PhosphorText>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
<EmptyStateCard
|
||||
message={search ? t('history.noResults').toUpperCase() : t('history.noHistory').toUpperCase()}
|
||||
/>
|
||||
) : (
|
||||
groupedEntries.map((group) => (
|
||||
<Box key={group.label} sx={{ mb: 3 }}>
|
||||
|
|
@ -113,44 +171,22 @@ export function HistoryPage(): React.ReactElement {
|
|||
{group.label}
|
||||
</PhosphorText>
|
||||
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
|
||||
<PhosphorText variant="dim" sx={{ fontSize: '10px' }}>
|
||||
{group.entries.length}건
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size }}>
|
||||
{t('history.count', { count: group.entries.length })}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
|
||||
{/* 항목 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{group.entries.map((entry: HistoryEntry) => (
|
||||
<MetalCard key={entry.id}>
|
||||
<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: '13px', color: d3roPalette.text.primary, lineHeight: 1.5,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
|
||||
}}>
|
||||
{entry.polishedText || entry.originalText}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 1, fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.dimLabel, letterSpacing: '0.5px' }}>
|
||||
<span>{formatTime(entry.createdAt)}</span>
|
||||
<span>{formatDuration(entry.duration)}</span>
|
||||
{entry.detectedLanguage && <span>{entry.detectedLanguage.toUpperCase()}</span>}
|
||||
<span>{entry.mode.toUpperCase()}</span>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
||||
<IconButton size="small" onClick={() => navigator.clipboard.writeText(entry.polishedText || entry.originalText)}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
|
||||
<ContentCopyIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => { window.electronAPI.history.delete({ id: entry.id }); loadData() }}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
<HistoryEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
showTags
|
||||
onTagClick={handleTagClick}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue