fromis_9/frontend-temp/src/components/pc/admin/schedule/WordItem.jsx
caadiq e31cb82649 refactor: ScheduleDict.jsx 분리 - WordItem 컴포넌트 추출
- WordItem.jsx 컴포넌트 추출 (단어 테이블 행 + 품사 드롭다운)
- POS_TAGS 상수 분리하여 export
- ScheduleDict.jsx: 714줄 → 572줄 (142줄 감소)
- 일정 관련 대형 파일 분리 완료

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:31:58 +09:00

168 lines
5.5 KiB
JavaScript

/**
* 사전 단어 항목 컴포넌트
* - 사전 관리 페이지의 단어 테이블 행
*/
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;