1. CommandsPage: handleSave/handleDelete에 실제 IPC 호출 연결 - instruction:create/update/delete 호출 추가 - 프리셋은 삭제 불가 (isBuiltin 체크) 2. Preload: instruction API 추가 (getAll/create/update/delete/reorder) - 기존에 preload 브릿지가 없어서 렌더러에서 호출 불가했음 3. Dictionary → STT initialPrompt 주입 - VoiceModeService._transcribe에서 DictionaryService.getPromptHints() 호출 - 사용자 사전 단어를 Whisper initialPrompt로 전달 4. DictionaryPage: 편집 기능 추가 - 각 항목에 Edit 버튼 추가 - Add/Edit 공용 다이얼로그 (editId로 분기)
141 lines
6.1 KiB
TypeScript
141 lines
6.1 KiB
TypeScript
// src/renderer/pages/DictionaryPage.tsx
|
||
// 인스트루먼트 미학: MetalCard + PhosphorText
|
||
|
||
import { useState, useEffect, useCallback } from 'react'
|
||
import { Box, TextField, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, InputAdornment } from '@mui/material'
|
||
import AddIcon from '@mui/icons-material/Add'
|
||
import DeleteIcon from '@mui/icons-material/Delete'
|
||
import EditIcon from '@mui/icons-material/Edit'
|
||
import SearchIcon from '@mui/icons-material/Search'
|
||
import { MetalCard, PhosphorText, PhysicalButton } from '../components/ds'
|
||
import { d3roPalette, d3roFontMono } from '../theme'
|
||
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 [dialogOpen, setDialogOpen] = useState(false)
|
||
const [editId, setEditId] = useState<string | null>(null)
|
||
const [formWord, setFormWord] = useState('')
|
||
const [formPronunciation, setFormPronunciation] = 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 openAdd = () => {
|
||
setEditId(null)
|
||
setFormWord('')
|
||
setFormPronunciation('')
|
||
setDialogOpen(true)
|
||
}
|
||
|
||
const openEdit = (entry: DictionaryEntry) => {
|
||
setEditId(entry.id)
|
||
setFormWord(entry.word)
|
||
setFormPronunciation(entry.pronunciation ?? '')
|
||
setDialogOpen(true)
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
if (!formWord.trim()) return
|
||
if (editId) {
|
||
await window.electronAPI.dictionary.update({
|
||
id: editId,
|
||
word: formWord.trim(),
|
||
pronunciation: formPronunciation.trim() || undefined,
|
||
})
|
||
} else {
|
||
await window.electronAPI.dictionary.add({
|
||
word: formWord.trim(),
|
||
pronunciation: formPronunciation.trim() || undefined,
|
||
})
|
||
}
|
||
setDialogOpen(false)
|
||
loadData()
|
||
}
|
||
|
||
return (
|
||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
|
||
CUSTOM DICTIONARY — {data?.total ?? 0} WORDS
|
||
</PhosphorText>
|
||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
|
||
ADD
|
||
</Button>
|
||
</Box>
|
||
|
||
<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 WORDS — ADD CUSTOM WORDS FOR BETTER STT'}</PhosphorText>
|
||
</Box>
|
||
</MetalCard>
|
||
) : (
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||
{data.entries.map((entry: DictionaryEntry) => (
|
||
<MetalCard key={entry.id}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<Box>
|
||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5 }}>
|
||
<Box sx={{ fontSize: '14px', fontWeight: 600, color: d3roPalette.text.primary }}>{entry.word}</Box>
|
||
{entry.pronunciation && (
|
||
<Box sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.dimLabel }}>[{entry.pronunciation}]</Box>
|
||
)}
|
||
</Box>
|
||
<Box sx={{ fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.inactive, mt: 0.5, letterSpacing: '0.5px' }}>
|
||
{entry.category.toUpperCase()} · {entry.usageCount}× USED
|
||
</Box>
|
||
</Box>
|
||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||
<IconButton size="small" onClick={() => openEdit(entry)}
|
||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
|
||
<EditIcon sx={{ fontSize: 16 }} />
|
||
</IconButton>
|
||
<IconButton size="small" onClick={() => { window.electronAPI.dictionary.delete({ id: entry.id }); loadData() }}
|
||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
|
||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||
</IconButton>
|
||
</Box>
|
||
</Box>
|
||
</MetalCard>
|
||
))}
|
||
</Box>
|
||
)}
|
||
|
||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth>
|
||
<DialogTitle sx={{ fontWeight: 700 }}>{editId ? '단어 편집' : '단어 추가'}</DialogTitle>
|
||
<DialogContent>
|
||
<TextField label="단어" value={formWord} onChange={(e) => setFormWord(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
|
||
<TextField label="발음 (선택)" value={formPronunciation} onChange={(e) => setFormPronunciation(e.target.value)} fullWidth sx={{ mt: 2 }} />
|
||
</DialogContent>
|
||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>취소</Button>
|
||
<Button onClick={handleSave} variant="contained" disabled={!formWord.trim()}>저장</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
</Box>
|
||
)
|
||
}
|