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:
Yun Chan 2026-04-05 02:32:53 +09:00
parent 4a5cf6c819
commit 291e2a29d0
17 changed files with 3111 additions and 35 deletions

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