feat: 사전 관리, 일정 추가 페이지에 애니메이션 추가
- framer-motion을 사용한 페이지 진입 애니메이션 - 섹션별 stagger 애니메이션으로 순차적 등장 효과 - 카테고리 전환 시 폼 fade 애니메이션 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
84ed48fa78
commit
2576a244c0
2 changed files with 116 additions and 29 deletions
|
|
@ -9,6 +9,35 @@ import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||||
import useToast from '../../../hooks/useToast';
|
import useToast from '../../../hooks/useToast';
|
||||||
import * as suggestionsApi from '../../../api/admin/suggestions';
|
import * as suggestionsApi from '../../../api/admin/suggestions';
|
||||||
|
|
||||||
|
// 애니메이션 variants
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.08,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.4, ease: "easeOut" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
hidden: { opacity: 0, scale: 0.95 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: { duration: 0.3, ease: "easeOut" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// 품사 태그 옵션
|
// 품사 태그 옵션
|
||||||
const POS_TAGS = [
|
const POS_TAGS = [
|
||||||
{ value: 'NNP', label: '고유명사 (NNP)', description: '사람, 그룹, 프로그램 이름 등', examples: '프로미스나인, 송하영, 뮤직뱅크' },
|
{ value: 'NNP', label: '고유명사 (NNP)', description: '사람, 그룹, 프로그램 이름 등', examples: '프로미스나인, 송하영, 뮤직뱅크' },
|
||||||
|
|
@ -403,9 +432,14 @@ function AdminScheduleDict() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 메인 콘텐츠 */}
|
{/* 메인 콘텐츠 */}
|
||||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
<motion.div
|
||||||
|
className="max-w-5xl mx-auto px-6 py-8"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
>
|
||||||
{/* 브레드크럼 */}
|
{/* 브레드크럼 */}
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
<motion.div variants={itemVariants} className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
||||||
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
||||||
<Home size={16} />
|
<Home size={16} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -415,36 +449,36 @@ function AdminScheduleDict() {
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
<span className="text-gray-700">사전 관리</span>
|
<span className="text-gray-700">사전 관리</span>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 타이틀 */}
|
{/* 타이틀 */}
|
||||||
<div className="mb-8">
|
<motion.div variants={itemVariants} className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">사전 관리</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">사전 관리</h1>
|
||||||
<p className="text-gray-500">형태소 분석기 사용자 사전을 관리합니다</p>
|
<p className="text-gray-500">형태소 분석기 사용자 사전을 관리합니다</p>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
{/* 통계 카드 */}
|
||||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
<motion.div variants={itemVariants} className="grid grid-cols-4 gap-4 mb-6">
|
||||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
<motion.div variants={cardVariants} 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-2xl font-bold text-gray-900">{posStats.total || 0}</div>
|
||||||
<div className="text-sm text-gray-500">전체 단어</div>
|
<div className="text-sm text-gray-500">전체 단어</div>
|
||||||
</div>
|
</motion.div>
|
||||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
<motion.div variants={cardVariants} 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-2xl font-bold text-blue-500">{posStats.NNP || 0}</div>
|
||||||
<div className="text-sm text-gray-500">고유명사</div>
|
<div className="text-sm text-gray-500">고유명사</div>
|
||||||
</div>
|
</motion.div>
|
||||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
<motion.div variants={cardVariants} 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-2xl font-bold text-green-500">{posStats.NNG || 0}</div>
|
||||||
<div className="text-sm text-gray-500">일반명사</div>
|
<div className="text-sm text-gray-500">일반명사</div>
|
||||||
</div>
|
</motion.div>
|
||||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
<motion.div variants={cardVariants} 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-2xl font-bold text-purple-500">{posStats.SL || 0}</div>
|
||||||
<div className="text-sm text-gray-500">외국어</div>
|
<div className="text-sm text-gray-500">외국어</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 단어 추가 영역 */}
|
{/* 단어 추가 영역 */}
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
|
<motion.div variants={itemVariants} 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>
|
<h3 className="font-bold text-gray-900 mb-4">단어 추가</h3>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
@ -502,10 +536,10 @@ function AdminScheduleDict() {
|
||||||
추가
|
추가
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 단어 목록 */}
|
{/* 단어 목록 */}
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
<motion.div variants={itemVariants} 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="p-4 border-b border-gray-100 flex items-center gap-4">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
|
|
@ -616,8 +650,8 @@ function AdminScheduleDict() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, Link } from "react-router-dom";
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Home, ChevronRight } from "lucide-react";
|
import { Home, ChevronRight } from "lucide-react";
|
||||||
import AdminLayout from "../../../../../components/admin/AdminLayout";
|
import AdminLayout from "../../../../../components/admin/AdminLayout";
|
||||||
import useAdminAuth from "../../../../../hooks/useAdminAuth";
|
import useAdminAuth from "../../../../../hooks/useAdminAuth";
|
||||||
|
|
@ -8,6 +9,40 @@ import CategorySelector from "./components/CategorySelector";
|
||||||
import YouTubeForm from "./YouTubeForm";
|
import YouTubeForm from "./YouTubeForm";
|
||||||
import XForm from "./XForm";
|
import XForm from "./XForm";
|
||||||
|
|
||||||
|
// 애니메이션 variants
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.4, ease: "easeOut" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const formVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.3, ease: "easeOut" },
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
y: -10,
|
||||||
|
transition: { duration: 0.2 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// 카테고리 ID 상수
|
// 카테고리 ID 상수
|
||||||
const CATEGORY_IDS = {
|
const CATEGORY_IDS = {
|
||||||
YOUTUBE: 2,
|
YOUTUBE: 2,
|
||||||
|
|
@ -86,9 +121,17 @@ function ScheduleFormPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout user={user}>
|
<AdminLayout user={user}>
|
||||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
<motion.div
|
||||||
|
className="max-w-4xl mx-auto px-6 py-8"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
>
|
||||||
{/* 브레드크럼 */}
|
{/* 브레드크럼 */}
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-400 mb-8"
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/admin/dashboard"
|
to="/admin/dashboard"
|
||||||
className="hover:text-primary transition-colors"
|
className="hover:text-primary transition-colors"
|
||||||
|
|
@ -104,26 +147,36 @@ function ScheduleFormPage() {
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
<span className="text-gray-700">일정 추가</span>
|
<span className="text-gray-700">일정 추가</span>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 타이틀 */}
|
{/* 타이틀 */}
|
||||||
<div className="mb-8">
|
<motion.div variants={itemVariants} className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">일정 추가</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">일정 추가</h1>
|
||||||
<p className="text-gray-500">카테고리를 선택하고 일정을 등록하세요</p>
|
<p className="text-gray-500">카테고리를 선택하고 일정을 등록하세요</p>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 카테고리 선택 */}
|
{/* 카테고리 선택 */}
|
||||||
<div className="mb-6">
|
<motion.div variants={itemVariants} className="mb-6">
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
categories={categories}
|
categories={categories}
|
||||||
selectedId={selectedCategory}
|
selectedId={selectedCategory}
|
||||||
onChange={setSelectedCategory}
|
onChange={setSelectedCategory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 카테고리별 폼 */}
|
{/* 카테고리별 폼 */}
|
||||||
{renderForm()}
|
<AnimatePresence mode="wait">
|
||||||
</div>
|
<motion.div
|
||||||
|
key={selectedCategory}
|
||||||
|
variants={formVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
>
|
||||||
|
{renderForm()}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue