refactor: ScheduleDict.jsx 분리 - WordItem 컴포넌트 추출
- WordItem.jsx 컴포넌트 추출 (단어 테이블 행 + 품사 드롭다운) - POS_TAGS 상수 분리하여 export - ScheduleDict.jsx: 714줄 → 572줄 (142줄 감소) - 일정 관련 대형 파일 분리 완료 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cbce382d94
commit
e31cb82649
4 changed files with 176 additions and 147 deletions
|
|
@ -47,7 +47,7 @@ components/
|
|||
| AlbumPhotos.jsx | 1536 | 1536 | 미분리 |
|
||||
| Schedules.jsx | 1465 | 1159 | ✅ 분리 완료 |
|
||||
| ScheduleForm.jsx | 1047 | 765 | ✅ 분리 완료 |
|
||||
| ScheduleDict.jsx | 714 | 714 | 미분리 |
|
||||
| ScheduleDict.jsx | 714 | 572 | ✅ 분리 완료 |
|
||||
| AlbumForm.jsx | 631 | 631 | 미분리 |
|
||||
|
||||
---
|
||||
|
|
@ -176,8 +176,10 @@ pages/pc/admin/schedules/
|
|||
- `LocationSearchDialog.jsx` 추출 (장소 검색 모달)
|
||||
- `MemberSelector.jsx` 추출 (멤버 선택 UI)
|
||||
- `ImageUploader.jsx` 추출 (이미지 업로드 및 드래그앤드롭)
|
||||
3. [ ] AlbumPhotos.jsx 분리 (1536줄, 구조 복잡)
|
||||
4. [ ] ScheduleDict.jsx 분리 (714줄)
|
||||
3. [x] ScheduleDict.jsx 분리 (714줄 → 572줄, 142줄 감소)
|
||||
- `WordItem.jsx` 추출 (단어 테이블 행 컴포넌트)
|
||||
- `POS_TAGS` 상수 함께 분리
|
||||
4. [ ] AlbumPhotos.jsx 분리 (1536줄, 구조 복잡)
|
||||
5. [ ] AlbumForm.jsx 분리 (631줄)
|
||||
|
||||
### Phase 3: 추가 개선
|
||||
|
|
|
|||
168
frontend-temp/src/components/pc/admin/schedule/WordItem.jsx
Normal file
168
frontend-temp/src/components/pc/admin/schedule/WordItem.jsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* 사전 단어 항목 컴포넌트
|
||||
* - 사전 관리 페이지의 단어 테이블 행
|
||||
*/
|
||||
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;
|
||||
|
|
@ -4,3 +4,4 @@ export { default as ScheduleItem, getEditPath } from './ScheduleItem';
|
|||
export { default as LocationSearchDialog } from './LocationSearchDialog';
|
||||
export { default as MemberSelector } from './MemberSelector';
|
||||
export { default as ImageUploader } from './ImageUploader';
|
||||
export { default as WordItem, POS_TAGS } from './WordItem';
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Home, ChevronRight, Book, Plus, Trash2, Search, ChevronDown } from 'lucide-react';
|
||||
import { Home, ChevronRight, Book, Plus, Search, ChevronDown } from 'lucide-react';
|
||||
import { Toast } from '@/components/common';
|
||||
import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
|
||||
import { WordItem, POS_TAGS } from '@/components/pc/admin/schedule';
|
||||
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||
import { useToast } from '@/hooks/common';
|
||||
import * as suggestionsApi from '@/api/admin/suggestions';
|
||||
|
|
@ -38,149 +39,6 @@ const cardVariants = {
|
|||
},
|
||||
};
|
||||
|
||||
// 품사 태그 옵션
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
// 단어 항목 컴포넌트
|
||||
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(index)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleDict() {
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
const { toast, setToast } = useToast();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue