d3ro-voice/src/renderer/pages/HistoryPage.tsx
Yun Chan ea48168b1e 실시간 UI 갱신: 세션 완료/명령어 변경 시 렌더러 자동 리로드
- bootstrap: notifyRenderer('app:dataChanged') 이벤트 발행
  - session-completed: Dashboard 통계 + History 갱신
  - command:selected: CMD 페이지 활성 명령어 갱신
- preload: app.onDataChanged 이벤트 리스너 추가
- DashboardPage/CommandsPage/HistoryPage: onDataChanged 구독하여 자동 loadData()
2026-04-05 13:31:57 +09:00

161 lines
6.7 KiB
TypeScript

// src/renderer/pages/HistoryPage.tsx
// 인스트루먼트 미학: MetalCard + PhosphorText + Led + 날짜 그룹핑
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'
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 [data, setData] = useState<HistoryPageData | null>(null)
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
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' })
if (result.success) setData(result.data)
setLoading(false)
}, [search])
useEffect(() => {
loadData()
const unsub = window.electronAPI.app.onDataChanged(() => { loadData() })
return unsub
}, [loadData])
// 날짜별 그룹핑
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].entries.push(entry)
}
return Object.values(groups)
}, [data])
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>,
},
}}
/>
{loading ? (
<PhosphorText variant="dim">LOADING...</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>
) : (
groupedEntries.map((group) => (
<Box key={group.label} sx={{ mb: 3 }}>
{/* 날짜 구분자 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.5 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
{group.label}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
<PhosphorText variant="dim" sx={{ fontSize: '10px' }}>
{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>
))}
</Box>
</Box>
))
)}
</Box>
)
}