WIP: 인스트루먼트 UI 시도 (DS 컴포넌트 + CRT 셰이더)
- DS 컴포넌트: CrtDisplay, InstrumentPanel, Led, PhysicalButton, MetalCard, PhosphorText - Dashboard: CRT WebGL 셰이더 + 물리 버튼 + LED 클러스터 - 사이드바: 72px 미니 네비게이션 - 문제: SSOT 위반(매직넘버 41곳), 시안 A 정체성 부족, 모달/팝업 방치 - 다음: 디자인 근본 재설계 필요
This commit is contained in:
parent
3f4d0c5828
commit
85d626b922
13 changed files with 930 additions and 746 deletions
|
|
@ -1,30 +1,19 @@
|
|||
// src/renderer/pages/HistoryPage.tsx
|
||||
// 08-design-system.md SSOT: 다크 카드, 앰버 악센트, 태그 시스템
|
||||
// 인스트루먼트 미학: MetalCard + PhosphorText + Led
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
IconButton,
|
||||
Chip,
|
||||
Pagination,
|
||||
Card,
|
||||
CardContent,
|
||||
InputAdornment
|
||||
} from '@mui/material'
|
||||
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 { d3roPalette, d3roFontMono } from '../theme'
|
||||
import { MetalCard, PhosphorText, Led } from '../components/ds'
|
||||
import type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts).toLocaleString('ko-KR', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
})
|
||||
return new Date(ts).toLocaleString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatDuration(sec: number): string {
|
||||
|
|
@ -33,158 +22,87 @@ function formatDuration(sec: number): string {
|
|||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const MODE_TAG: Record<string, 'primary' | 'secondary' | 'warning'> = {
|
||||
dictation: 'primary',
|
||||
translate: 'secondary',
|
||||
command: 'warning',
|
||||
}
|
||||
|
||||
export function HistoryPage(): React.ReactElement {
|
||||
const [data, setData] = useState<HistoryPageData | null>(null)
|
||||
const [page, setPage] = useState(0)
|
||||
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, pageSize: PAGE_SIZE })
|
||||
: await window.electronAPI.history.getAll({ page, pageSize: PAGE_SIZE })
|
||||
? await window.electronAPI.history.search({ query: search, page: 0, pageSize: PAGE_SIZE })
|
||||
: await window.electronAPI.history.getAll({ page: 0, pageSize: PAGE_SIZE })
|
||||
if (result.success) setData(result.data)
|
||||
setLoading(false)
|
||||
}, [page, search])
|
||||
}, [search])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await window.electronAPI.history.delete({ id })
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 5 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography sx={{ fontSize: '22px', fontWeight: 700 }}>History</Typography>
|
||||
<Typography sx={{ fontSize: '14px', color: 'text.secondary', mt: 0.5 }}>
|
||||
Transcription history
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: '#77797c' }}>
|
||||
TRANSCRIPTION LOG — {data?.total ?? 0} ENTRIES
|
||||
</PhosphorText>
|
||||
|
||||
{/* Search */}
|
||||
<TextField
|
||||
placeholder="Search transcriptions..."
|
||||
placeholder="SEARCH..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0) }}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
fullWidth
|
||||
sx={{ mb: 3 }}
|
||||
sx={{
|
||||
mb: 3,
|
||||
'& .MuiInputBase-input': { fontFamily: MONO, fontSize: '12px', letterSpacing: '0.5px' },
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ color: d3roPalette.text.label }} />
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: '#77797c', fontSize: 18 }} /></InputAdornment>,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Typography color="text.secondary">Loading...</Typography>
|
||||
<PhosphorText variant="dim">LOADING...</PhosphorText>
|
||||
) : !data || data.entries.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ py: 6, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">
|
||||
{search ? 'No results found.' : 'No history yet. Start recording!'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{data.entries.map((entry: HistoryEntry) => (
|
||||
<Card key={entry.id} sx={{ p: 0 }}>
|
||||
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
{/* Text */}
|
||||
<Box sx={{ flex: 1, mr: 2 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.5,
|
||||
color: 'text.primary',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{entry.polishedText || entry.originalText}
|
||||
</Typography>
|
||||
|
||||
{/* Meta row */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '11px',
|
||||
color: d3roPalette.text.label,
|
||||
}}
|
||||
>
|
||||
{formatDate(entry.createdAt)}
|
||||
</Typography>
|
||||
<Chip label={formatDuration(entry.duration)} size="small" color="primary" />
|
||||
{entry.detectedLanguage && (
|
||||
<Chip label={entry.detectedLanguage.toUpperCase()} size="small" color="secondary" />
|
||||
)}
|
||||
<Chip label={entry.mode.toUpperCase()} size="small" color={MODE_TAG[entry.mode] ?? 'primary'} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopy(entry.polishedText || entry.originalText)}
|
||||
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDelete(entry.id)}
|
||||
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.tag.red } }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<MetalCard>
|
||||
<Box sx={{ py: 6, textAlign: 'center' }}>
|
||||
<PhosphorText variant="dim">{search ? 'NO RESULTS' : 'NO HISTORY — START RECORDING'}</PhosphorText>
|
||||
</Box>
|
||||
|
||||
{data.totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
|
||||
<Pagination
|
||||
count={data.totalPages}
|
||||
page={page + 1}
|
||||
onChange={(_, p) => setPage(p - 1)}
|
||||
sx={{
|
||||
'& .Mui-selected': {
|
||||
bgcolor: `${d3roPalette.accent.amberDim} !important`,
|
||||
color: d3roPalette.accent.amber,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
</MetalCard>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{data.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: '#fff', 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: MONO, fontSize: '10px', color: '#5c2615', letterSpacing: '0.5px' }}>
|
||||
<span>{formatDate(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: '#77797c', '&:hover': { color: '#f25b29' } }}>
|
||||
<ContentCopyIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => { window.electronAPI.history.delete({ id: entry.id }); loadData() }}
|
||||
sx={{ color: '#77797c', '&:hover': { color: '#ef4444' } }}>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue