169 lines
5.5 KiB
React
169 lines
5.5 KiB
React
|
|
/**
|
||
|
|
* 사전 단어 항목 컴포넌트
|
||
|
|
* - 사전 관리 페이지의 단어 테이블 행
|
||
|
|
*/
|
||
|
|
import { useState, useEffect, useRef } from 'react';
|
||
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
||
|
|
import { Trash2, ChevronDown } from 'lucide-react';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 품사 태그 옵션
|
||
|
|
*/
|
||
|
|
export const POS_TAGS = [
|
||
|
|
{
|
||
|
|
value: 'NNP',
|
||
|
|
label: '고유명사 (NNP)',
|
||
|
|
description: '사람, 그룹, 프로그램 이름 등',
|
||
|
|
examples: '프로미스나인, 송하영, 뮤직뱅크',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
value: 'NNG',
|
||
|
|
label: '일반명사 (NNG)',
|
||
|
|
description: '일반적인 명사',
|
||
|
|
examples: '직캠, 팬미팅, 콘서트',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
value: 'SL',
|
||
|
|
label: '외국어 (SL)',
|
||
|
|
description: '영어 등 외국어 단어',
|
||
|
|
examples: 'fromis_9, YouTube, fromm',
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 단어 항목 컴포넌트
|
||
|
|
* @param {Object} props
|
||
|
|
* @param {string} props.id - 단어 고유 ID
|
||
|
|
* @param {string} props.word - 단어
|
||
|
|
* @param {string} props.pos - 품사 태그
|
||
|
|
* @param {number} props.index - 목록 인덱스
|
||
|
|
* @param {Function} props.onUpdate - 수정 핸들러 (id, word, pos)
|
||
|
|
* @param {Function} props.onDelete - 삭제 핸들러 ()
|
||
|
|
*/
|
||
|
|
function WordItem({ id, word, pos, index, onUpdate, onDelete }) {
|
||
|
|
const [isEditing, setIsEditing] = useState(false);
|
||
|
|
const [editWord, setEditWord] = useState(word);
|
||
|
|
const [editPos, setEditPos] = useState(pos);
|
||
|
|
const [showPosDropdown, setShowPosDropdown] = useState(false);
|
||
|
|
const dropdownRef = useRef(null);
|
||
|
|
|
||
|
|
// 외부 클릭 시 드롭다운 닫기
|
||
|
|
useEffect(() => {
|
||
|
|
const handleClickOutside = (event) => {
|
||
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||
|
|
setShowPosDropdown(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (showPosDropdown) {
|
||
|
|
document.addEventListener('mousedown', handleClickOutside);
|
||
|
|
}
|
||
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||
|
|
}, [showPosDropdown]);
|
||
|
|
|
||
|
|
const handleSave = () => {
|
||
|
|
if (editWord.trim() && (editWord.trim() !== word || editPos !== pos)) {
|
||
|
|
onUpdate(id, editWord.trim(), editPos);
|
||
|
|
}
|
||
|
|
setIsEditing(false);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleKeyDown = (e) => {
|
||
|
|
if (e.key === 'Enter') {
|
||
|
|
handleSave();
|
||
|
|
} else if (e.key === 'Escape') {
|
||
|
|
setEditWord(word);
|
||
|
|
setEditPos(pos);
|
||
|
|
setIsEditing(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<motion.tr
|
||
|
|
initial={{ opacity: 0, y: -10 }}
|
||
|
|
animate={{ opacity: 1, y: 0 }}
|
||
|
|
exit={{ opacity: 0, x: -20 }}
|
||
|
|
className="group hover:bg-gray-50 transition-colors"
|
||
|
|
>
|
||
|
|
<td className="px-4 py-3 text-sm text-gray-400 w-16">{index + 1}</td>
|
||
|
|
<td className="px-4 py-3">
|
||
|
|
{isEditing ? (
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={editWord}
|
||
|
|
onChange={(e) => setEditWord(e.target.value)}
|
||
|
|
onKeyDown={handleKeyDown}
|
||
|
|
onBlur={handleSave}
|
||
|
|
autoFocus
|
||
|
|
className="w-full px-3 py-1.5 border border-primary rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<span
|
||
|
|
onClick={() => setIsEditing(true)}
|
||
|
|
className="cursor-pointer hover:text-primary transition-colors font-medium"
|
||
|
|
>
|
||
|
|
{word}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-3 w-48">
|
||
|
|
<div className="relative" ref={dropdownRef}>
|
||
|
|
<button
|
||
|
|
onClick={() => setShowPosDropdown(!showPosDropdown)}
|
||
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors w-full justify-between"
|
||
|
|
>
|
||
|
|
<span>
|
||
|
|
{POS_TAGS.find((t) => t.value === (isEditing ? editPos : pos))?.label.split(' ')[0] || pos}
|
||
|
|
</span>
|
||
|
|
<ChevronDown
|
||
|
|
size={14}
|
||
|
|
className={`transition-transform ${showPosDropdown ? 'rotate-180' : ''}`}
|
||
|
|
/>
|
||
|
|
</button>
|
||
|
|
<AnimatePresence>
|
||
|
|
{showPosDropdown && (
|
||
|
|
<motion.div
|
||
|
|
initial={{ opacity: 0, y: -5 }}
|
||
|
|
animate={{ opacity: 1, y: 0 }}
|
||
|
|
exit={{ opacity: 0, y: -5 }}
|
||
|
|
className="absolute top-full left-0 mt-1 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||
|
|
>
|
||
|
|
{POS_TAGS.map((tag) => (
|
||
|
|
<button
|
||
|
|
key={tag.value}
|
||
|
|
onClick={() => {
|
||
|
|
if (isEditing) {
|
||
|
|
setEditPos(tag.value);
|
||
|
|
} else {
|
||
|
|
onUpdate(id, word, tag.value);
|
||
|
|
}
|
||
|
|
setShowPosDropdown(false);
|
||
|
|
}}
|
||
|
|
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
|
||
|
|
(isEditing ? editPos : pos) === tag.value ? 'bg-primary/5 text-primary' : ''
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
<div className="font-medium text-sm">{tag.label}</div>
|
||
|
|
<div className="text-xs text-gray-400">{tag.description}</div>
|
||
|
|
<div className="text-xs text-gray-300 mt-0.5">예: {tag.examples}</div>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</motion.div>
|
||
|
|
)}
|
||
|
|
</AnimatePresence>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-3 w-20">
|
||
|
|
<button
|
||
|
|
onClick={onDelete}
|
||
|
|
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||
|
|
>
|
||
|
|
<Trash2 size={16} />
|
||
|
|
</button>
|
||
|
|
</td>
|
||
|
|
</motion.tr>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default WordItem;
|