./kaisetsu-app/src/components/UserDictionaryModal.tsx
'use client';
import { useState, useEffect } from 'react';
import {
X,
BookOpen,
Plus,
Trash2,
Search,
Edit3,
Check,
Volume2,
Loader2
} from 'lucide-react';
export interface DictionaryEntry {
id: string;
word: string;
reading: string;
}
interface UserDictionaryModalProps {
isOpen: boolean;
onClose: () => void;
entries: DictionaryEntry[];
onEntriesChange: (entries: DictionaryEntry[]) => void;
}
export default function UserDictionaryModal({
isOpen,
onClose,
entries,
onEntriesChange
}: UserDictionaryModalProps) {
const [searchQuery, setSearchQuery] = useState('');
const [newWord, setNewWord] = useState('');
const [newReading, setNewReading] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editWord, setEditWord] = useState('');
const [editReading, setEditReading] = useState('');
const [playingId, setPlayingId] = useState<string | null>(null);
const [isLoadingAudio, setIsLoadingAudio] = useState(false);
const filteredEntries = entries.filter(
(entry) =>
entry.word.toLowerCase().includes(searchQuery.toLowerCase()) ||
entry.reading.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleAdd = () => {
if (newWord && newReading) {
const newEntry: DictionaryEntry = {
id: Date.now().toString(),
word: newWord,
reading: newReading
};
onEntriesChange([...entries, newEntry]);
setNewWord('');
setNewReading('');
}
};
const handleDelete = (id: string) => {
onEntriesChange(entries.filter((entry) => entry.id !== id));
};
const handleEdit = (entry: DictionaryEntry) => {
setEditingId(entry.id);
setEditWord(entry.word);
setEditReading(entry.reading);
};
const handleSaveEdit = () => {
if (editingId) {
onEntriesChange(
entries.map((entry) =>
entry.id === editingId
? { ...entry, word: editWord, reading: editReading }
: entry
)
);
setEditingId(null);
setEditWord('');
setEditReading('');
}
};
const handleCancelEdit = () => {
setEditingId(null);
setEditWord('');
setEditReading('');
};
const handlePlayPreview = async (entry: DictionaryEntry) => {
if (playingId === entry.id) return;
setPlayingId(entry.id);
setIsLoadingAudio(true);
try {
const response = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: entry.reading,
voiceType: '7nO7lVCISGqz9Dhm3AAx',
}),
});
if (response.ok) {
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.onended = () => {
setPlayingId(null);
URL.revokeObjectURL(audioUrl);
};
audio.onerror = () => {
setPlayingId(null);
URL.revokeObjectURL(audioUrl);
};
await audio.play();
} else {
setPlayingId(null);
}
} catch (error) {
console.error('Preview error:', error);
setPlayingId(null);
} finally {
setIsLoadingAudio(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-900 flex items-center justify-center">
<BookOpen className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900">
ユーザー辞書
</h2>
<p className="text-xs text-gray-500">
読み方をカスタマイズ(単語→読み方に置換)
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Search */}
<div className="px-6 py-4 border-b border-gray-200">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="単語を検索..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 focus:border-gray-400 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-200"
/>
</div>
</div>
{/* Add new entry */}
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-600 mb-1.5">
単語(置換前)
</label>
<input
type="text"
placeholder="例: NVIDIA"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300"
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-gray-600 mb-1.5">
読み方(置換後)
</label>
<input
type="text"
placeholder="例: エヌビディア"
value={newReading}
onChange={(e) => setNewReading(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300"
/>
</div>
<button
onClick={handleAdd}
disabled={!newWord || !newReading}
className="flex items-center gap-2 px-4 py-2 bg-gray-900 hover:bg-gray-800 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
追加
</button>
</div>
<p className="text-xs text-gray-500 mt-2">
Tip: TTSに送る前にテキスト内の「単語」が「読み方」に置換されます
</p>
</div>
{/* Entry list */}
<div className="overflow-y-auto max-h-[300px]">
{filteredEntries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<BookOpen className="w-12 h-12 mb-3 opacity-50" />
<p className="text-sm">辞書に登録された単語がありません</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{filteredEntries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between px-6 py-3 hover:bg-gray-50 transition-colors"
>
{editingId === entry.id ? (
<div className="flex-1 flex items-center gap-3">
<input
type="text"
value={editWord}
onChange={(e) => setEditWord(e.target.value)}
className="flex-1 px-3 py-1.5 bg-white border border-gray-400 rounded-lg text-gray-900 focus:outline-none"
/>
<input
type="text"
value={editReading}
onChange={(e) => setEditReading(e.target.value)}
className="flex-1 px-3 py-1.5 bg-white border border-gray-400 rounded-lg text-gray-900 focus:outline-none"
/>
<button
onClick={handleSaveEdit}
className="p-1.5 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={handleCancelEdit}
className="p-1.5 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<>
<div className="flex-1">
<div className="flex items-center gap-3">
<span className="font-medium text-gray-900">
{entry.word}
</span>
<span className="text-gray-400">→</span>
<span className="text-gray-700 font-medium">
{entry.reading}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handlePlayPreview(entry)}
disabled={playingId === entry.id}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
title="試聴"
>
{playingId === entry.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Volume2 className="w-4 h-4" />
)}
</button>
<button
onClick={() => handleEdit(entry)}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title="編集"
>
<Edit3 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(entry.id)}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="削除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 bg-gray-50">
<p className="text-sm text-gray-500">
{entries.length} 件の単語が登録されています
</p>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg transition-colors"
>
閉じる
</button>
</div>
</div>
</div>
);
}