feat: 어드민 사전 관리 기능 추가

- 형태소 분석기 사용자 사전 관리 페이지 추가
- 단어 추가/삭제/수정 시 즉시 저장 및 형태소 분석기 리로드
- 품사별 통계 및 필터링 기능
- 검색 기능 추가

백엔드:
- GET/PUT /api/schedules/suggestions/dict API 추가
- morpheme.js에 reloadMorpheme(), getUserDictPath() 함수 추가

프론트엔드:
- AdminScheduleDict.jsx 페이지 추가
- AdminSchedule.jsx에 사전 관리 버튼 추가
- 라우트 추가 (/admin/schedule/dict)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-18 13:53:51 +09:00
parent c201de203e
commit 5521c44fa9
7 changed files with 754 additions and 60 deletions

View file

@ -1,15 +1,8 @@
# 사용자 정의 사전
# 형식: 단어\t품사\t점수(선택)
# 품사: NNP(고유명사), NNG(일반명사), SL(외국어)
# 그룹명
프로미스나인 NNP 프로미스나인 NNP
프미나 NNP 프미나 NNP
프나 NNP 프나 NNP
fromis_9 SL fromis_9 SL
fromis SL fromis SL
# 멤버 이름
이새롬 NNP 이새롬 NNP
새롬 NNP 새롬 NNP
송하영 NNP 송하영 NNP
@ -28,70 +21,20 @@ fromis SL
나경 NNP 나경 NNP
백지헌 NNP 백지헌 NNP
지헌 NNP 지헌 NNP
# 팬덤
플로버 NNP 플로버 NNP
flover SL flover SL
# 음악 프로그램
뮤직뱅크 NNP 뮤직뱅크 NNP
인기가요 NNP 인기가요 NNP
음악중심 NNP 음악중심 NNP
엠카운트다운 NNP 엠카운트다운 NNP
쇼챔피언 NNP 쇼챔피언 NNP
더쇼 NNP 더쇼 NNP
# 앨범/곡명
하얀그리움 NNP
LIKE_YOU_BETTER SL
# 콘텐츠/채널명
스프 NNP 스프 NNP
성수기 NNP 성수기 NNP
이단장 NNP 이단장 NNP
슈퍼E나경 NNP 슈퍼E나경 NNP
FM_1.24 SL FM_1.24 SL
벌거벗은한국사 NNP
꿈친구 NNP
미미미누 NNP
비밀전학생 NNP
하일병 NNP
동네스타K쇼 NNP
밥사효 NNP
워크맨 NNP
영업중 NNP
개그콘서트 NNP
# 방송/행사
워터밤 NNP
서든어택 NNP
NPOP SL
하이록스 NNP
# 관련 용어
팬미팅 NNG 팬미팅 NNG
직캠 NNG 직캠 NNG
시구 NNG
시타 NNG
컴백 NNG 컴백 NNG
호캉스 NNG
팬캠 NNG
응원법 NNG 응원법 NNG
브이라이브 NNG
브이앱 NNG
# 다른 아이돌/연예인
류진 NNP
RYUJIN SL
ITZY SL
이즈나 NNP
izna SL
아일릿 NNP
ILLIT SL
키스오브라이프 NNP
하이키 NNP
H1KEY SL
아이들 NNP
미연 NNP
효연 NNP
T1 SL

View file

@ -1,7 +1,9 @@
/** /**
* 추천 검색어 API 라우트 * 추천 검색어 API 라우트
*/ */
import { readFileSync, writeFileSync } from 'fs';
import { SuggestionService } from '../../services/suggestions/index.js'; import { SuggestionService } from '../../services/suggestions/index.js';
import { reloadMorpheme, getUserDictPath } from '../../services/suggestions/morpheme.js';
let suggestionService = null; let suggestionService = null;
@ -113,4 +115,83 @@ export default async function suggestionsRoutes(fastify) {
await suggestionService.saveSearchQuery(query); await suggestionService.saveSearchQuery(query);
return { success: true }; return { success: true };
}); });
/**
* GET /api/schedules/suggestions/dict
* 사용자 사전 조회 (관리자 전용)
*/
fastify.get('/dict', {
schema: {
tags: ['suggestions'],
summary: '사용자 사전 조회',
security: [{ bearerAuth: [] }],
response: {
200: {
type: 'object',
properties: {
content: { type: 'string', description: '사전 내용' },
},
},
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
const dictPath = getUserDictPath();
const content = readFileSync(dictPath, 'utf-8');
return { content };
} catch (error) {
if (error.code === 'ENOENT') {
return { content: '' };
}
throw error;
}
});
/**
* PUT /api/schedules/suggestions/dict
* 사용자 사전 저장 리로드 (관리자 전용)
*/
fastify.put('/dict', {
schema: {
tags: ['suggestions'],
summary: '사용자 사전 저장',
security: [{ bearerAuth: [] }],
body: {
type: 'object',
required: ['content'],
properties: {
content: { type: 'string', description: '사전 내용' },
},
},
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
},
},
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { content } = request.body;
try {
const dictPath = getUserDictPath();
writeFileSync(dictPath, content, 'utf-8');
// 형태소 분석기 리로드
await reloadMorpheme();
return { success: true, message: '사전이 저장되었습니다.' };
} catch (error) {
console.error('[Suggestions] 사전 저장 오류:', error.message);
return reply.code(500).send({
success: false,
message: '사전 저장 중 오류가 발생했습니다.',
});
}
});
} }

View file

@ -162,3 +162,22 @@ function fallbackExtract(text) {
export function isReady() { export function isReady() {
return isInitialized && kiwi !== null; return isInitialized && kiwi !== null;
} }
/**
* 형태소 분석기 리로드 (사전 변경 호출)
*/
export async function reloadMorpheme() {
console.log('[Morpheme] 리로드 시작...');
isInitialized = false;
kiwi = null;
initPromise = null;
await initMorpheme();
console.log('[Morpheme] 리로드 완료');
}
/**
* 사용자 사전 파일 경로 반환
*/
export function getUserDictPath() {
return join(__dirname, '../../../models/kiwi', USER_DICT);
}

View file

@ -40,6 +40,7 @@ import AdminSchedule from './pages/pc/admin/AdminSchedule';
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm'; import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory'; import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots'; import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
// //
import PCLayout from './components/pc/Layout'; import PCLayout from './components/pc/Layout';
@ -75,6 +76,7 @@ function App() {
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} /> <Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} /> <Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} /> <Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
{/* 일반 페이지 (레이아웃 포함) */} {/* 일반 페이지 (레이아웃 포함) */}
<Route path="/*" element={ <Route path="/*" element={

View file

@ -0,0 +1,17 @@
/**
* 어드민 추천 검색어 API
*/
import { fetchAdminApi } from "../index";
// 사전 내용 조회
export async function getDict() {
return fetchAdminApi("/api/schedules/suggestions/dict");
}
// 사전 저장
export async function saveDict(content) {
return fetchAdminApi("/api/schedules/suggestions/dict", {
method: "PUT",
body: JSON.stringify({ content }),
});
}

View file

@ -3,7 +3,7 @@ import { useNavigate, Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Home, ChevronRight, Calendar, Plus, Edit2, Trash2, Home, ChevronRight, Calendar, Plus, Edit2, Trash2,
ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2 ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2, Book
} from 'lucide-react'; } from 'lucide-react';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
@ -657,6 +657,13 @@ function AdminSchedule() {
<p className="text-gray-500">fromis_9의 일정을 관리합니다</p> <p className="text-gray-500">fromis_9의 일정을 관리합니다</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button
onClick={() => navigate('/admin/schedule/dict')}
className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors font-medium"
>
<Book size={20} />
사전 관리
</button>
<button <button
onClick={() => navigate('/admin/schedule/bots')} onClick={() => navigate('/admin/schedule/bots')}
className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors font-medium" className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors font-medium"

View file

@ -0,0 +1,625 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Home, ChevronRight, Book, Plus, Trash2, Search, ChevronDown } from 'lucide-react';
import Toast from '../../../components/Toast';
import AdminLayout from '../../../components/admin/AdminLayout';
import ConfirmDialog from '../../../components/admin/ConfirmDialog';
import useAdminAuth from '../../../hooks/useAdminAuth';
import useToast from '../../../hooks/useToast';
import * as suggestionsApi from '../../../api/admin/suggestions';
//
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);
}
};
const posLabel = POS_TAGS.find(t => t.value === pos)?.label || pos;
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 AdminScheduleDict() {
const { user, isAuthenticated } = useAdminAuth();
const { toast, setToast } = useToast();
const [entries, setEntries] = useState([]); // [{word, pos, isComment, id}]
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [filterPos, setFilterPos] = useState('all');
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
//
const [newWord, setNewWord] = useState('');
const [newPos, setNewPos] = useState('NNP');
const [showNewPosDropdown, setShowNewPosDropdown] = useState(false);
// refs
const newPosDropdownRef = useRef(null);
const filterDropdownRef = useRef(null);
//
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [wordToDelete, setWordToDelete] = useState(null); // { index, word, id }
//
useEffect(() => {
const handleClickOutside = (event) => {
if (newPosDropdownRef.current && !newPosDropdownRef.current.contains(event.target)) {
setShowNewPosDropdown(false);
}
if (filterDropdownRef.current && !filterDropdownRef.current.contains(event.target)) {
setShowFilterDropdown(false);
}
};
if (showNewPosDropdown || showFilterDropdown) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showNewPosDropdown, showFilterDropdown]);
//
const filteredEntries = useMemo(() => {
return entries.filter((entry, index) => {
if (entry.isComment) return true; // (but )
const matchesSearch = !searchQuery ||
entry.word.toLowerCase().includes(searchQuery.toLowerCase());
const matchesPos = filterPos === 'all' || entry.pos === filterPos;
return matchesSearch && matchesPos;
});
}, [entries, searchQuery, filterPos]);
// ( )
const wordEntries = useMemo(() => {
return filteredEntries.filter(e => !e.isComment);
}, [filteredEntries]);
//
const posStats = useMemo(() => {
const stats = { total: 0 };
entries.forEach(e => {
if (!e.isComment) {
stats.total++;
stats[e.pos] = (stats[e.pos] || 0) + 1;
}
});
return stats;
}, [entries]);
useEffect(() => {
if (isAuthenticated) {
fetchDict();
}
}, [isAuthenticated]);
// ID
const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
//
const parseDict = (content) => {
const lines = content.split('\n');
return lines.map(line => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
return { isComment: true, raw: line, id: generateId() };
}
const parts = trimmed.split('\t');
return {
word: parts[0] || '',
pos: parts[1] || 'NNP',
isComment: false,
id: generateId(),
};
}).filter(e => e.isComment || e.word); //
};
//
const serializeDict = (entries) => {
return entries.map(e => {
if (e.isComment) return e.raw;
return `${e.word}\t${e.pos}`;
}).join('\n');
};
//
const fetchDict = async () => {
setLoading(true);
try {
const data = await suggestionsApi.getDict();
const parsed = parseDict(data.content || '');
setEntries(parsed);
} catch (error) {
console.error('사전 조회 오류:', error);
setToast({ type: 'error', message: '사전을 불러올 수 없습니다.' });
} finally {
setLoading(false);
}
};
// (entries )
const saveDict = async (newEntries) => {
try {
const content = serializeDict(newEntries);
await suggestionsApi.saveDict(content);
return true;
} catch (error) {
console.error('사전 저장 오류:', error);
setToast({ type: 'error', message: error.message || '저장 중 오류가 발생했습니다.' });
return false;
}
};
//
const openAddDialog = () => {
if (!newWord.trim()) return;
//
const isDuplicate = entries.some(e => !e.isComment && e.word.toLowerCase() === newWord.trim().toLowerCase());
if (isDuplicate) {
setToast({ type: 'error', message: '이미 존재하는 단어입니다.' });
return;
}
setAddDialogOpen(true);
};
//
const handleAddWord = async () => {
setSaving(true);
const wordToAdd = newWord.trim();
const newEntry = { word: wordToAdd, pos: newPos, isComment: false, id: generateId() };
const newEntries = [...entries, newEntry];
const success = await saveDict(newEntries);
if (success) {
setEntries(newEntries);
setNewWord('');
setToast({ type: 'success', message: `"${wordToAdd}" 단어가 추가되었습니다.` });
}
setAddDialogOpen(false);
setSaving(false);
};
// (id )
const handleUpdateWord = async (id, word, pos) => {
const entryIndex = entries.findIndex(e => e.id === id);
if (entryIndex === -1) return;
const newEntries = [...entries];
newEntries[entryIndex] = { ...newEntries[entryIndex], word, pos };
const success = await saveDict(newEntries);
if (success) {
setEntries(newEntries);
}
};
//
const openDeleteDialog = (id, word) => {
setWordToDelete({ id, word });
setDeleteDialogOpen(true);
};
//
const handleDeleteWord = async () => {
if (!wordToDelete) return;
setSaving(true);
const deletedWord = wordToDelete.word;
const newEntries = entries.filter(e => e.id !== wordToDelete.id);
const success = await saveDict(newEntries);
if (success) {
setEntries(newEntries);
setToast({ type: 'success', message: `"${deletedWord}" 단어가 삭제되었습니다.` });
}
setDeleteDialogOpen(false);
setWordToDelete(null);
setSaving(false);
};
//
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
openAddDialog();
}
};
return (
<AdminLayout user={user}>
<Toast toast={toast} onClose={() => setToast(null)} />
{/* 단어 추가 확인 다이얼로그 */}
<ConfirmDialog
isOpen={addDialogOpen}
onClose={() => !saving && setAddDialogOpen(false)}
onConfirm={handleAddWord}
title="단어 추가"
message={
<>
<p className="text-gray-600 mb-2">다음 단어를 추가하시겠습니까?</p>
<div className="p-3 bg-gray-50 rounded-lg">
<p className="font-medium text-gray-900">{newWord}</p>
<p className="text-sm text-gray-500 mt-1">
{POS_TAGS.find(t => t.value === newPos)?.label}
</p>
</div>
</>
}
confirmText="추가"
loadingText="추가 중..."
loading={saving}
variant="primary"
icon={Plus}
/>
{/* 단어 삭제 확인 다이얼로그 */}
<ConfirmDialog
isOpen={deleteDialogOpen}
onClose={() => {
if (!saving) {
setDeleteDialogOpen(false);
setWordToDelete(null);
}
}}
onConfirm={handleDeleteWord}
title="단어 삭제"
message={
<>
<p className="text-gray-600 mb-2">다음 단어를 삭제하시겠습니까?</p>
<p className="font-medium text-gray-900 p-3 bg-gray-50 rounded-lg">
{wordToDelete?.word}
</p>
</>
}
confirmText="삭제"
loadingText="삭제 중..."
loading={saving}
variant="danger"
/>
{/* 메인 콘텐츠 */}
<div className="max-w-5xl mx-auto px-6 py-8">
{/* 브레드크럼 */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
<Home size={16} />
</Link>
<ChevronRight size={14} />
<Link to="/admin/schedule" className="hover:text-primary transition-colors">
일정 관리
</Link>
<ChevronRight size={14} />
<span className="text-gray-700">사전 관리</span>
</div>
{/* 타이틀 */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">사전 관리</h1>
<p className="text-gray-500">형태소 분석기 사용자 사전을 관리합니다</p>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 border border-gray-100">
<div className="text-2xl font-bold text-gray-900">{posStats.total || 0}</div>
<div className="text-sm text-gray-500">전체 단어</div>
</div>
<div className="bg-white rounded-xl p-4 border border-gray-100">
<div className="text-2xl font-bold text-blue-500">{posStats.NNP || 0}</div>
<div className="text-sm text-gray-500">고유명사</div>
</div>
<div className="bg-white rounded-xl p-4 border border-gray-100">
<div className="text-2xl font-bold text-green-500">{posStats.NNG || 0}</div>
<div className="text-sm text-gray-500">일반명사</div>
</div>
<div className="bg-white rounded-xl p-4 border border-gray-100">
<div className="text-2xl font-bold text-purple-500">{posStats.SL || 0}</div>
<div className="text-sm text-gray-500">외국어</div>
</div>
</div>
{/* 단어 추가 영역 */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
<h3 className="font-bold text-gray-900 mb-4">단어 추가</h3>
<div className="flex items-center gap-3">
<div className="flex-1">
<input
type="text"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="추가할 단어 입력..."
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
/>
</div>
<div className="relative w-48" ref={newPosDropdownRef}>
<button
onClick={() => setShowNewPosDropdown(!showNewPosDropdown)}
className="flex items-center gap-2 px-4 py-3 bg-gray-100 hover:bg-gray-200 rounded-xl text-sm transition-colors w-full justify-between"
>
<span>{POS_TAGS.find(t => t.value === newPos)?.label.split(' ')[0]}</span>
<ChevronDown size={14} className={`transition-transform ${showNewPosDropdown ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{showNewPosDropdown && (
<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={() => {
setNewPos(tag.value);
setShowNewPosDropdown(false);
}}
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
newPos === 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>
<button
onClick={openAddDialog}
disabled={!newWord.trim()}
className="flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={20} />
추가
</button>
</div>
</div>
{/* 단어 목록 */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
{/* 검색 및 필터 */}
<div className="p-4 border-b border-gray-100 flex items-center gap-4">
<div className="flex-1 relative">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="단어 검색..."
className="w-full pl-11 pr-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
/>
</div>
<div className="relative" ref={filterDropdownRef}>
<button
onClick={() => setShowFilterDropdown(!showFilterDropdown)}
className="flex items-center gap-2 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 rounded-xl text-sm transition-colors"
>
<span>{filterPos === 'all' ? '전체 품사' : POS_TAGS.find(t => t.value === filterPos)?.label.split(' ')[0]}</span>
<ChevronDown size={14} className={`transition-transform ${showFilterDropdown ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{showFilterDropdown && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className="absolute top-full right-0 mt-1 w-48 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
>
<button
onClick={() => {
setFilterPos('all');
setShowFilterDropdown(false);
}}
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors text-sm ${
filterPos === 'all' ? 'bg-primary/5 text-primary' : ''
}`}
>
전체 품사
</button>
{POS_TAGS.map((tag) => (
<button
key={tag.value}
onClick={() => {
setFilterPos(tag.value);
setShowFilterDropdown(false);
}}
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors text-sm ${
filterPos === tag.value ? 'bg-primary/5 text-primary' : ''
}`}
>
{tag.label.split(' ')[0]}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* 테이블 */}
{loading ? (
<div className="flex justify-center items-center py-20">
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary border-t-transparent"></div>
</div>
) : wordEntries.length === 0 ? (
<div className="text-center py-20 text-gray-400">
<Book size={48} className="mx-auto mb-4 opacity-30" />
<p>{searchQuery || filterPos !== 'all' ? '검색 결과가 없습니다' : '등록된 단어가 없습니다'}</p>
<p className="text-sm mt-1">위의 입력창에서 단어를 추가하세요</p>
</div>
) : (
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
<table className="w-full">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-16">#</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">단어</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-48">품사</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
<AnimatePresence>
{wordEntries.map((entry, index) => (
<WordItem
key={entry.id}
id={entry.id}
word={entry.word}
pos={entry.pos}
index={index}
onUpdate={handleUpdateWord}
onDelete={() => openDeleteDialog(entry.id, entry.word)}
/>
))}
</AnimatePresence>
</tbody>
</table>
</div>
)}
{/* 푸터 */}
{wordEntries.length > 0 && (
<div className="px-4 py-3 bg-gray-50 border-t border-gray-100 text-sm text-gray-500">
{searchQuery || filterPos !== 'all' ? (
<span>{wordEntries.length} 검색됨 (전체 {posStats.total})</span>
) : (
<span> {posStats.total} 단어</span>
)}
</div>
)}
</div>
</div>
</AdminLayout>
);
}
export default AdminScheduleDict;