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 | 미분리 |
|
| AlbumPhotos.jsx | 1536 | 1536 | 미분리 |
|
||||||
| Schedules.jsx | 1465 | 1159 | ✅ 분리 완료 |
|
| Schedules.jsx | 1465 | 1159 | ✅ 분리 완료 |
|
||||||
| ScheduleForm.jsx | 1047 | 765 | ✅ 분리 완료 |
|
| ScheduleForm.jsx | 1047 | 765 | ✅ 분리 완료 |
|
||||||
| ScheduleDict.jsx | 714 | 714 | 미분리 |
|
| ScheduleDict.jsx | 714 | 572 | ✅ 분리 완료 |
|
||||||
| AlbumForm.jsx | 631 | 631 | 미분리 |
|
| AlbumForm.jsx | 631 | 631 | 미분리 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -176,8 +176,10 @@ pages/pc/admin/schedules/
|
||||||
- `LocationSearchDialog.jsx` 추출 (장소 검색 모달)
|
- `LocationSearchDialog.jsx` 추출 (장소 검색 모달)
|
||||||
- `MemberSelector.jsx` 추출 (멤버 선택 UI)
|
- `MemberSelector.jsx` 추출 (멤버 선택 UI)
|
||||||
- `ImageUploader.jsx` 추출 (이미지 업로드 및 드래그앤드롭)
|
- `ImageUploader.jsx` 추출 (이미지 업로드 및 드래그앤드롭)
|
||||||
3. [ ] AlbumPhotos.jsx 분리 (1536줄, 구조 복잡)
|
3. [x] ScheduleDict.jsx 분리 (714줄 → 572줄, 142줄 감소)
|
||||||
4. [ ] ScheduleDict.jsx 분리 (714줄)
|
- `WordItem.jsx` 추출 (단어 테이블 행 컴포넌트)
|
||||||
|
- `POS_TAGS` 상수 함께 분리
|
||||||
|
4. [ ] AlbumPhotos.jsx 분리 (1536줄, 구조 복잡)
|
||||||
5. [ ] AlbumForm.jsx 분리 (631줄)
|
5. [ ] AlbumForm.jsx 분리 (631줄)
|
||||||
|
|
||||||
### Phase 3: 추가 개선
|
### 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 LocationSearchDialog } from './LocationSearchDialog';
|
||||||
export { default as MemberSelector } from './MemberSelector';
|
export { default as MemberSelector } from './MemberSelector';
|
||||||
export { default as ImageUploader } from './ImageUploader';
|
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 { Link } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { Toast } from '@/components/common';
|
||||||
import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
|
import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
|
||||||
|
import { WordItem, POS_TAGS } from '@/components/pc/admin/schedule';
|
||||||
import { useAdminAuth } from '@/hooks/pc/admin';
|
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||||
import { useToast } from '@/hooks/common';
|
import { useToast } from '@/hooks/common';
|
||||||
import * as suggestionsApi from '@/api/admin/suggestions';
|
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() {
|
function ScheduleDict() {
|
||||||
const { user, isAuthenticated } = useAdminAuth();
|
const { user, isAuthenticated } = useAdminAuth();
|
||||||
const { toast, setToast } = useToast();
|
const { toast, setToast } = useToast();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue