Phase 5 구현: SQLite DB + History/Dictionary 서비스 + UI
- DB: better-sqlite3 + drizzle-orm (history/dictionary/stats, WAL 모드) - HistoryService: CRUD + 검색 + 통계 + 30일 보존 정책, 세션 완료 시 자동 저장 - DictionaryService: CRUD + 검색 + 사용 횟수 추적 + STT 프롬프트 힌트 - HistoryPage: 목록 + 검색 + 삭제 + 복사 + 페이지네이션 - DictionaryPage: 목록 + 검색 + 추가 다이얼로그 + 삭제 - DashboardPage: 실데이터 통계 (총/오늘 세션, 시간, 단어수, 연속일수) - IPC: history/dictionary/stats 핸들러 + preload API - Bootstrap: DB 초기화 (critical) + 이력 자동 저장 연동
This commit is contained in:
parent
4a5cf6c819
commit
291e2a29d0
17 changed files with 3111 additions and 35 deletions
|
|
@ -1,10 +1,12 @@
|
|||
// src/renderer/pages/DashboardPage.tsx
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Card, CardContent, Typography, Grid } from '@mui/material'
|
||||
import MicIcon from '@mui/icons-material/Mic'
|
||||
import TimerIcon from '@mui/icons-material/Timer'
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields'
|
||||
import TodayIcon from '@mui/icons-material/Today'
|
||||
import type { StatsSummary } from '@shared/types'
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
|
|
@ -28,7 +30,33 @@ function StatCard({ title, value, icon }: StatCardProps): React.ReactElement {
|
|||
)
|
||||
}
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
const totalSec = Math.round(ms / 1000)
|
||||
const hours = Math.floor(totalSec / 3600)
|
||||
const minutes = Math.floor((totalSec % 3600) / 60)
|
||||
const seconds = totalSec % 60
|
||||
if (hours > 0) return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function DashboardPage(): React.ReactElement {
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.stats.getSummary().then((result) => {
|
||||
if (result.success) setStats(result.data)
|
||||
})
|
||||
|
||||
// 30초마다 갱신
|
||||
const interval = setInterval(() => {
|
||||
window.electronAPI.stats.getSummary().then((result) => {
|
||||
if (result.success) setStats(result.data)
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
|
||||
|
|
@ -37,30 +65,66 @@ export function DashboardPage(): React.ReactElement {
|
|||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard title="Total Sessions" value="0" icon={<MicIcon />} />
|
||||
<StatCard
|
||||
title="Total Sessions"
|
||||
value={String(stats?.totalSessionCount ?? 0)}
|
||||
icon={<MicIcon />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard title="Total Time" value="0:00" icon={<TimerIcon />} />
|
||||
<StatCard
|
||||
title="Total Time"
|
||||
value={formatTime(stats?.totalRecordingTimeMs ?? 0)}
|
||||
icon={<TimerIcon />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard title="Total Words" value="0" icon={<TextFieldsIcon />} />
|
||||
<StatCard
|
||||
title="Total Words"
|
||||
value={String(stats?.totalWordCount ?? 0)}
|
||||
icon={<TextFieldsIcon />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard title="Streak" value="0 days" icon={<TodayIcon />} />
|
||||
<StatCard
|
||||
title="Streak"
|
||||
value={`${stats?.streakDays ?? 0} days`}
|
||||
icon={<TodayIcon />}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
{/* Today's stats */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Recent Sessions
|
||||
Today
|
||||
</Typography>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||
No sessions yet. Press the hotkey to start recording.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="text.secondary">Sessions</Typography>
|
||||
<Typography variant="h5">{stats?.todaySessionCount ?? 0}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="text.secondary">Time</Typography>
|
||||
<Typography variant="h5">{formatTime(stats?.todayRecordingTimeMs ?? 0)}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="text.secondary">Words</Typography>
|
||||
<Typography variant="h5">{stats?.todayWordCount ?? 0}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
|
|
|||
173
src/renderer/pages/DictionaryPage.tsx
Normal file
173
src/renderer/pages/DictionaryPage.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// src/renderer/pages/DictionaryPage.tsx
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Card,
|
||||
CardContent,
|
||||
InputAdornment
|
||||
} from '@mui/material'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import SearchIcon from '@mui/icons-material/Search'
|
||||
import type { DictionaryEntry, DictionaryPage as DictPageData } from '@shared/types'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function DictionaryPage(): React.ReactElement {
|
||||
const [data, setData] = useState<DictPageData | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [newWord, setNewWord] = useState('')
|
||||
const [newPronunciation, setNewPronunciation] = useState('')
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const result = search.trim()
|
||||
? await window.electronAPI.dictionary.search({ query: search, page: 0, pageSize: PAGE_SIZE })
|
||||
: await window.electronAPI.dictionary.getAll({ page: 0, pageSize: PAGE_SIZE })
|
||||
|
||||
if (result.success) {
|
||||
setData(result.data)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [search])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newWord.trim()) return
|
||||
await window.electronAPI.dictionary.add({
|
||||
word: newWord.trim(),
|
||||
pronunciation: newPronunciation.trim() || undefined
|
||||
})
|
||||
setNewWord('')
|
||||
setNewPronunciation('')
|
||||
setAddOpen(false)
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await window.electronAPI.dictionary.delete({ id })
|
||||
loadData()
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
Dictionary
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setAddOpen(true)}
|
||||
size="small"
|
||||
>
|
||||
Add Word
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
placeholder="Search words..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Typography color="text.secondary">Loading...</Typography>
|
||||
) : !data || data.entries.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||
{search ? 'No words found.' : 'No words yet. Add custom words for better STT accuracy.'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<List>
|
||||
{data.entries.map((entry: DictionaryEntry) => (
|
||||
<ListItem
|
||||
key={entry.id}
|
||||
divider
|
||||
secondaryAction={
|
||||
<IconButton size="small" onClick={() => handleDelete(entry.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={entry.word}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||
{entry.pronunciation && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
[{entry.pronunciation}]
|
||||
</Typography>
|
||||
)}
|
||||
<Chip label={entry.category} size="small" variant="outlined" />
|
||||
<Chip label={`used ${entry.usageCount}x`} size="small" variant="outlined" />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* Add Word Dialog */}
|
||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Add Word</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
label="Word"
|
||||
value={newWord}
|
||||
onChange={(e) => setNewWord(e.target.value)}
|
||||
fullWidth
|
||||
autoFocus
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Pronunciation (optional)"
|
||||
value={newPronunciation}
|
||||
onChange={(e) => setNewPronunciation(e.target.value)}
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleAdd} variant="contained" disabled={!newWord.trim()}>
|
||||
Add
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
161
src/renderer/pages/HistoryPage.tsx
Normal file
161
src/renderer/pages/HistoryPage.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// src/renderer/pages/HistoryPage.tsx
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
Chip,
|
||||
Pagination,
|
||||
Card,
|
||||
CardContent,
|
||||
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 type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
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 })
|
||||
|
||||
if (result.success) {
|
||||
setData(result.data)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await window.electronAPI.history.delete({ id })
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
return new Date(ts).toLocaleString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatDuration = (sec: number) => {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.round(sec % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
History
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
placeholder="Search transcriptions..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Typography color="text.secondary">Loading...</Typography>
|
||||
) : !data || data.entries.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||
{search ? 'No results found.' : 'No history yet.'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<List>
|
||||
{data.entries.map((entry: HistoryEntry) => (
|
||||
<ListItem
|
||||
key={entry.id}
|
||||
divider
|
||||
secondaryAction={
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopy(entry.polishedText || entry.originalText)}
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => handleDelete(entry.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={entry.polishedText || entry.originalText}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, alignItems: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(entry.createdAt)}
|
||||
</Typography>
|
||||
<Chip label={formatDuration(entry.duration)} size="small" variant="outlined" />
|
||||
{entry.detectedLanguage && (
|
||||
<Chip label={entry.detectedLanguage} size="small" variant="outlined" />
|
||||
)}
|
||||
<Chip label={entry.mode} size="small" variant="outlined" />
|
||||
</Box>
|
||||
}
|
||||
primaryTypographyProps={{ sx: { pr: 8 } }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{data.totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
||||
<Pagination
|
||||
count={data.totalPages}
|
||||
page={page + 1}
|
||||
onChange={(_, p) => setPage(p - 1)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue