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:
parent
c201de203e
commit
5521c44fa9
7 changed files with 754 additions and 60 deletions
|
|
@ -1,15 +1,8 @@
|
|||
# 사용자 정의 사전
|
||||
# 형식: 단어\t품사\t점수(선택)
|
||||
# 품사: NNP(고유명사), NNG(일반명사), SL(외국어)
|
||||
|
||||
# 그룹명
|
||||
프로미스나인 NNP
|
||||
프미나 NNP
|
||||
프나 NNP
|
||||
fromis_9 SL
|
||||
fromis SL
|
||||
|
||||
# 멤버 이름
|
||||
이새롬 NNP
|
||||
새롬 NNP
|
||||
송하영 NNP
|
||||
|
|
@ -28,70 +21,20 @@ fromis SL
|
|||
나경 NNP
|
||||
백지헌 NNP
|
||||
지헌 NNP
|
||||
|
||||
# 팬덤
|
||||
플로버 NNP
|
||||
flover SL
|
||||
|
||||
# 음악 프로그램
|
||||
뮤직뱅크 NNP
|
||||
인기가요 NNP
|
||||
음악중심 NNP
|
||||
엠카운트다운 NNP
|
||||
쇼챔피언 NNP
|
||||
더쇼 NNP
|
||||
|
||||
# 앨범/곡명
|
||||
하얀그리움 NNP
|
||||
LIKE_YOU_BETTER SL
|
||||
|
||||
# 콘텐츠/채널명
|
||||
스프 NNP
|
||||
성수기 NNP
|
||||
이단장 NNP
|
||||
슈퍼E나경 NNP
|
||||
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
|
||||
|
||||
# 다른 아이돌/연예인
|
||||
류진 NNP
|
||||
RYUJIN SL
|
||||
ITZY SL
|
||||
이즈나 NNP
|
||||
izna SL
|
||||
아일릿 NNP
|
||||
ILLIT SL
|
||||
키스오브라이프 NNP
|
||||
하이키 NNP
|
||||
H1KEY SL
|
||||
아이들 NNP
|
||||
미연 NNP
|
||||
효연 NNP
|
||||
T1 SL
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
/**
|
||||
* 추천 검색어 API 라우트
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { SuggestionService } from '../../services/suggestions/index.js';
|
||||
import { reloadMorpheme, getUserDictPath } from '../../services/suggestions/morpheme.js';
|
||||
|
||||
let suggestionService = null;
|
||||
|
||||
|
|
@ -113,4 +115,83 @@ export default async function suggestionsRoutes(fastify) {
|
|||
await suggestionService.saveSearchQuery(query);
|
||||
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: '사전 저장 중 오류가 발생했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,3 +162,22 @@ function fallbackExtract(text) {
|
|||
export function isReady() {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import AdminSchedule from './pages/pc/admin/AdminSchedule';
|
|||
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
|
||||
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
|
||||
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
|
||||
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
|
||||
|
||||
// 레이아웃
|
||||
import PCLayout from './components/pc/Layout';
|
||||
|
|
@ -75,6 +76,7 @@ function App() {
|
|||
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
||||
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
||||
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
||||
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
|
||||
|
||||
{/* 일반 페이지 (레이아웃 포함) */}
|
||||
<Route path="/*" element={
|
||||
|
|
|
|||
17
frontend/src/api/admin/suggestions.js
Normal file
17
frontend/src/api/admin/suggestions.js
Normal 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 }),
|
||||
});
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { useNavigate, Link } from 'react-router-dom';
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
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';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
|
@ -657,6 +657,13 @@ function AdminSchedule() {
|
|||
<p className="text-gray-500">fromis_9의 일정을 관리합니다</p>
|
||||
</div>
|
||||
<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
|
||||
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"
|
||||
|
|
|
|||
625
frontend/src/pages/pc/admin/AdminScheduleDict.jsx
Normal file
625
frontend/src/pages/pc/admin/AdminScheduleDict.jsx
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue