feat: 사전 관리, 일정 추가 페이지에 애니메이션 추가

- framer-motion을 사용한 페이지 진입 애니메이션
- 섹션별 stagger 애니메이션으로 순차적 등장 효과
- 카테고리 전환 시 폼 fade 애니메이션

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-19 16:01:54 +09:00
parent 84ed48fa78
commit 2576a244c0
2 changed files with 116 additions and 29 deletions

View file

@ -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>
); );
} }

View file

@ -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>
{/* 카테고리별 폼 */} {/* 카테고리별 폼 */}
<AnimatePresence mode="wait">
<motion.div
key={selectedCategory}
variants={formVariants}
initial="hidden"
animate="visible"
exit="exit"
>
{renderForm()} {renderForm()}
</div> </motion.div>
</AnimatePresence>
</motion.div>
</AdminLayout> </AdminLayout>
); );
} }