// src/renderer/components/TemplateSection.tsx // Phase 12.3: 딕테이션 템플릿 관리 UI (CommandsPage 내 섹션) import { useState, useEffect, useCallback, useRef } from 'react' import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Tooltip } from '@mui/material' import { Plus, Trash2, Pencil, Play } from 'lucide-react' import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds' import { PageHeader, EmptyStateCard } from './shared' import { d3roPalette, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { DictationTemplate, TemplateField, TemplateSessionInfo } from '@d3ro/core/types' export function TemplateSection(): React.ReactElement { const { t } = useI18n() const [templates, setTemplates] = useState([]) const [loading, setLoading] = useState(true) const [dialogOpen, setDialogOpen] = useState(false) const [editId, setEditId] = useState(null) const [formName, setFormName] = useState('') const [formDesc, setFormDesc] = useState('') const [formOutput, setFormOutput] = useState('') const [formFields, setFormFields] = useState([]) const [session, setSession] = useState(null) /** 마지막으로 완성된 템플릿 결과 — 활성 앱 삽입이 꺼져 있거나 실패해도 복사할 수 있게 보여 준다 */ const [lastOutput, setLastOutput] = useState(null) const [saving, setSaving] = useState(false) const isSavingRef = useRef(false) const isDeletingRef = useRef(false) const loadData = useCallback(async () => { setLoading(true) const result = await window.electronAPI.dictationTemplate.getAll() if (result.success) setTemplates(result.data) const sessionResult = await window.electronAPI.dictationTemplate.getSessionState() if (sessionResult.success) setSession(sessionResult.data) setLoading(false) }, []) useEffect(() => { loadData() }, [loadData]) // 다른 기기에서 바뀐 템플릿이 반영되면 다시 불러온다. useEffect( () => window.electronAPI.app.onDataChanged((data) => { if (data.type === 'cloud-sync' && data.entities?.includes('user_templates')) loadData() }), [loadData], ) useEffect(() => { const unsub = window.electronAPI.dictationTemplate.onSessionStateChanged((data) => { setSession(data) }) const unsubComplete = window.electronAPI.dictationTemplate.onSessionCompleted((data) => { setSession(null) setLastOutput(data.outputText.length > 0 ? data.outputText : null) loadData() }) return () => { unsub(); unsubComplete() } }, [loadData]) const openCreate = () => { if (dialogOpen) return setEditId(null) setFormName('') setFormDesc('') setFormOutput('{{field1}}') setFormFields([{ id: 'field1', name: 'field1', label: 'Field 1', promptText: '', required: true, maxDurationSec: 30 }]) setDialogOpen(true) } const openEdit = (template: DictationTemplate) => { if (dialogOpen) return setEditId(template.id) setFormName(template.name) setFormDesc(template.description) setFormOutput(template.outputFormat) setFormFields([...template.fields]) setDialogOpen(true) } const handleSave = async () => { if (!formName.trim() || formFields.length === 0 || saving || isSavingRef.current) return isSavingRef.current = true setSaving(true) try { if (editId) { await window.electronAPI.dictationTemplate.update({ id: editId, name: formName.trim(), description: formDesc.trim(), fields: formFields, outputFormat: formOutput, }) } else { await window.electronAPI.dictationTemplate.create({ name: formName.trim(), description: formDesc.trim(), fields: formFields, outputFormat: formOutput, }) } setDialogOpen(false) loadData() } finally { setSaving(false) isSavingRef.current = false } } const handleDelete = async (id: string) => { if (isDeletingRef.current) return isDeletingRef.current = true try { await window.electronAPI.dictationTemplate.delete({ id }) loadData() } finally { isDeletingRef.current = false } } const handleStartSession = async (templateId: string) => { setLastOutput(null) await window.electronAPI.dictationTemplate.startSession({ templateId }) } const handleCopyOutput = () => { if (lastOutput) void navigator.clipboard.writeText(lastOutput) } const handleCancelSession = async () => { await window.electronAPI.dictationTemplate.cancelSession() setSession(null) } const addField = () => { const idx = formFields.length + 1 setFormFields([...formFields, { id: `field${idx}`, name: `field${idx}`, label: `Field ${idx}`, promptText: '', required: true, maxDurationSec: 30, }]) } const updateField = (index: number, updates: Partial) => { const updated = [...formFields] updated[index] = { ...updated[index], ...updates } setFormFields(updated) } const removeField = (index: number) => { setFormFields(formFields.filter((_, i) => i !== index)) } return ( {t('template.create')} } /> {/* 활성 세션 표시 */} {session && ( {session.templateName}: {session.currentField?.label ?? '...'} ({session.currentFieldIndex + 1}/{session.totalFields}) {t('template.cancelSession')} {/* 현재 필드의 음성 프롬프트 — 받아쓰기 결과가 이 필드 값으로 들어간다 */} {session.currentField?.promptText && ( {session.currentField.promptText} )} )} {/* 마지막 완성 결과 */} {!session && lastOutput && ( {lastOutput} {t('common.copy')} setLastOutput(null)}> {t('common.close')} )} {/* 템플릿 목록 */} {templates.length === 0 && !loading ? ( ) : ( {templates.map((tmpl) => ( {tmpl.name} {tmpl.isBuiltin && ( PRESET )} {tmpl.fields.length} {t('template.fields').toLowerCase()} — {tmpl.description} handleStartSession(tmpl.id)} disabled={!!session} sx={{ color: d3roPalette.accent.main, '&:hover': { opacity: 0.8 } }} > openEdit(tmpl)} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.main } }} > {!tmpl.isBuiltin && ( handleDelete(tmpl.id)} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }} > )} ))} )} {/* 편집 다이얼로그 */} setDialogOpen(false)} maxWidth="sm" fullWidth> {editId ? t('template.edit') : t('template.create')} setFormName(e.target.value)} size="small" fullWidth /> setFormDesc(e.target.value)} size="small" fullWidth /> {t('template.fields').toUpperCase()} {formFields.map((field, idx) => ( updateField(idx, { name: e.target.value, id: e.target.value })} size="small" sx={{ flex: 1 }} /> updateField(idx, { label: e.target.value })} size="small" sx={{ flex: 1 }} /> updateField(idx, { promptText: e.target.value })} size="small" sx={{ flex: 2 }} /> removeField(idx)} disabled={formFields.length <= 1} sx={{ color: d3roPalette.text.inactive }} > ))} setFormOutput(e.target.value)} size="small" fullWidth multiline rows={3} helperText={t('template.outputHelperText')} /> ) }