diff --git a/README.md b/README.md index 2c24aa8..dcf8645 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,36 @@ # TechBlog - ## 프로젝트 개요 NextJS를 기반으로 한 개인 기술 블로그와 포트폴리오 웹 애플리케이션입니다. 테크 관련 게시물을 작성하고 개인 프로젝트를 전시하는 공간으로 활용하는 프로젝트 -### 블로그 +### 블로그 + ![image](https://github.com/user-attachments/assets/240cc1a5-5ad6-4921-bbef-ee1cd76fa379) -### 블로그 작성 +### 블로그 작성 + ![image](https://github.com/user-attachments/assets/4bb0c223-cfa2-4414-9a47-59883820d08b) -### 시리즈 -![image](https://github.com/user-attachments/assets/25d14078-8734-44a4-8943-aa7a2f70951f) +### 시리즈 +![image](https://github.com/user-attachments/assets/25d14078-8734-44a4-8943-aa7a2f70951f) ## 기술 스택 ### Frontend + - **Framework**: [Next.js](https://nextjs.org/) (App Router) - **Language**: [TypeScript](https://www.typescriptlang.org/) - **Styling**: [TailwindCSS](https://tailwindcss.com/) - **State Management**: [Zustand](https://github.com/pmndrs/zustand) -- **UI Components**: +- **UI Components**: - [React Icons](https://react-icons.github.io/react-icons/) - [React Lottie Player](https://github.com/LottieFiles/react-lottie-player) - **Markdown Editor**: [@uiw/react-md-editor](https://uiwjs.github.io/react-md-editor/) ### Backend + - **Runtime**: [Node.js](https://nodejs.org/) - **Database**: [MongoDB](https://www.mongodb.com/) (with [Mongoose](https://mongoosejs.com/)) - **Authentication**: [NextAuth.js](https://next-auth.js.org/) @@ -35,8 +38,9 @@ NextJS를 기반으로 한 개인 기술 블로그와 포트폴리오 웹 애플 - **HTTP Client**: [Axios](https://axios-http.com/) ### DevOps & Testing + - **Testing Framework**: [Jest](https://jestjs.io/) with [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) -- **Linting & Formatting**: +- **Linting & Formatting**: - [ESLint](https://eslint.org/) - [Prettier](https://prettier.io/) @@ -63,3 +67,19 @@ TechBlog/ ├── .github/ # GitHub 워크플로우 └── __test__/ # 테스트 파일 ``` + +## 필요한 환경변수 + +.env 파일에 필요한 환경변수는 다음과 같다. + +```text + 1 GITHUB_ID=your_github_client_id + 2 GITHUB_SECRET=your_github_client_secret + 3 ADMIN_EMAIL=your_admin_email@example.com + 4 NEXTAUTH_SECRET=your_nextauth_secret + 5 NEXTAUTH_URL=http://localhost:3000 + 6 DB_URI=your_mongodb_connection_string + 7 NEXT_PUBLIC_DEPLOYMENT_URL=https://your-deployment-url.com + 8 NEXT_PUBLIC_URL=http://localhost:3000 + 9 BLOB_READ_WRITE_TOKEN=your_vercel_blob_token +``` diff --git a/app/admin/comments/page.tsx b/app/admin/comments/page.tsx new file mode 100644 index 0000000..4bf9181 --- /dev/null +++ b/app/admin/comments/page.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import IssueCard from '@/app/entities/admin/comments/IssueCard'; + +interface GitHubUser { + login: string; + avatar_url: string; +} + +interface GitHubComment { + id: number; + user: GitHubUser; + created_at: string; + updated_at: string; + body: string; + html_url: string; +} + +interface GitHubIssue { + id: number; + number: number; + title: string; + html_url: string; + state: string; + comments: number; + created_at: string; + updated_at: string; + user: GitHubUser; + body?: string; +} + +interface IssueWithComments { + issue: GitHubIssue; + comments: GitHubComment[]; +} + +const AdminCommentsPage = () => { + const { status } = useSession(); + const router = useRouter(); + const [issuesWithComments, setIssuesWithComments] = useState< + IssueWithComments[] + >([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (status === 'unauthenticated') { + router.push('/admin'); + } + }, [status, router]); + + useEffect(() => { + if (status === 'authenticated') { + fetchComments(); + } + }, [status]); + + const fetchComments = async () => { + try { + setLoading(true); + const response = await fetch('/api/admin/comments'); + const data = await response.json(); + + if (data.success) { + setIssuesWithComments(data.data); + } else { + setError(data.error || '댓글을 불러올 수 없습니다.'); + } + } catch (err) { + setError('댓글을 불러오는 중 오류가 발생했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }; + + if (status === 'loading' || loading) { + return ( +
+

댓글 관리

+
+
+

댓글을 불러오는 중...

+
+
+ ); + } + + if (error) { + return ( +
+

댓글 관리

+
+ {error} +
+
+ ); + } + + return ( +
+
+

댓글 관리

+ + 대시보드로 돌아가기 + +
+ + {issuesWithComments.length === 0 ? ( +
+

아직 댓글이 없습니다.

+
+ ) : ( +
+
+

+ 총 {issuesWithComments.length}개의 글에 댓글이 + 달려있습니다. +

+
+ + {issuesWithComments.map(({ issue, comments }) => ( + + ))} +
+ )} +
+ ); +}; + +export default AdminCommentsPage; diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 487ce41..2c2054f 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -13,6 +13,9 @@ import BubbleBackground from '@/app/entities/common/Background/BubbleBackground' import { useEffect } from 'react'; import useToast from '@/app/hooks/useToast'; import { FaBuffer } from 'react-icons/fa6'; +import RecentActivity from '@/app/entities/admin/dashboard/RecentActivity'; +import QuickStats from '@/app/entities/admin/dashboard/QuickStats'; +import DecryptedText from '../entities/bits/DecryptedText'; const AdminDashboard = () => { const { data: session } = useSession(); @@ -95,8 +98,22 @@ const AdminDashboard = () => {
-

관리자 대시보드

-

{session.user?.name}님, 환영합니다

+

+ +

+

+ +

-
-

최근 활동

-
-
-

빠른 통계

-
+ +
); diff --git a/app/api/admin/comments/route.ts b/app/api/admin/comments/route.ts new file mode 100644 index 0000000..4179c7b --- /dev/null +++ b/app/api/admin/comments/route.ts @@ -0,0 +1,171 @@ +// GET /api/admin/comments - 관리자용 GitHub Issues 댓글 조회 +import { getServerSession } from 'next-auth'; + +export const dynamic = 'force-dynamic'; + +interface GitHubIssue { + id: number; + number: number; + title: string; + html_url: string; + state: string; + comments: number; + created_at: string; + updated_at: string; + user: { + login: string; + avatar_url: string; + }; + body?: string; + pull_request?: { + url: string; + }; +} + +interface GitHubComment { + id: number; + user: { + login: string; + avatar_url: string; + }; + created_at: string; + updated_at: string; + body: string; + html_url: string; +} + +export async function GET() { + try { + // 인증 확인 + const session = await getServerSession(); + if (!session) { + return Response.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ); + } + + const GITHUB_REPO = 'ShipFriend0516/TechBlog'; + const GITHUB_TOKEN = process.env.GITHUB_TOKEN; + + if (!GITHUB_TOKEN) { + console.error( + 'GitHub token 이 설정되지 않았습니다. .env 파일을 확인하세요.' + ); + return Response.json( + { + success: false, + error: 'GitHub token 이 설정되지 않았습니다. 관리자에게 문의하세요.', + }, + { status: 500 } + ); + } + + // GitHub Issues API로 모든 이슈 가져오기 (utterances가 생성한 이슈들) + const issuesResponse = await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/issues?state=all&per_page=100`, + { + headers: { + Authorization: `Bearer ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + } + ); + + if (!issuesResponse.ok) { + const errorText = await issuesResponse.text(); + console.error('GitHub API error:', issuesResponse.status, errorText); + throw new Error( + `Failed to fetch issues from GitHub: ${issuesResponse.status} ${errorText}` + ); + } + + const allIssues: GitHubIssue[] = await issuesResponse.json(); + + if (!Array.isArray(allIssues)) { + console.error('Invalid response from GitHub API:', allIssues); + throw new Error('Invalid response from GitHub API'); + } + + // Pull Request를 제외하고 실제 Issues만 필터링 + const issues = allIssues.filter((issue) => !issue.pull_request); + + // 각 이슈에 대한 댓글 가져오기 + const issuesWithComments = await Promise.all( + issues.map(async (issue) => { + if (!issue || issue.comments === 0) { + return { + issue, + comments: [], + }; + } + + try { + const commentsResponse = await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/issues/${issue.number}/comments`, + { + headers: { + Authorization: `Bearer ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + } + ); + + if (!commentsResponse.ok) { + console.error( + `Failed to fetch comments for issue ${issue.number}:`, + commentsResponse.status + ); + return { + issue, + comments: [], + }; + } + + const comments: GitHubComment[] = await commentsResponse.json(); + + return { + issue, + comments: Array.isArray(comments) ? comments : [], + }; + } catch (error) { + console.error( + `Error fetching comments for issue ${issue.number}:`, + error + ); + return { + issue, + comments: [], + }; + } + }) + ); + + // 댓글이 있는 이슈만 필터링 + const issuesWithCommentsOnly = issuesWithComments.filter( + (item) => item.comments.length > 0 + ); + + return Response.json( + { + success: true, + data: issuesWithCommentsOnly, + total: issuesWithCommentsOnly.length, + }, + { + status: 200, + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0', + }, + } + ); + } catch (error) { + console.error('Error fetching comments:', error); + return Response.json( + { success: false, error: '댓글 불러오기 실패', detail: error }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/posts/recent/route.ts b/app/api/admin/posts/recent/route.ts new file mode 100644 index 0000000..8eb4d7d --- /dev/null +++ b/app/api/admin/posts/recent/route.ts @@ -0,0 +1,44 @@ +// GET /api/admin/posts/recent - 관리자용 최근 게시글 조회 +import Post from '@/app/models/Post'; +import dbConnect from '@/app/lib/dbConnect'; +import { getServerSession } from 'next-auth'; + +export const dynamic = 'force-dynamic'; + +export async function GET(req: Request) { + try { + const session = await getServerSession(); + if (!session) { + return Response.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ); + } + + await dbConnect(); + + const recentPosts = await Post.find({}) + .select('title slug date createdAt') + .sort({ date: -1 }) + .limit(3); + + return Response.json( + { + success: true, + posts: recentPosts, + }, + { + status: 200, + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0', + }, + } + ); + } catch (error) { + console.error('Error fetching recent posts:', error); + return Response.json( + { success: false, error: '최근 게시글 불러오기 실패', detail: error }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts new file mode 100644 index 0000000..c64035e --- /dev/null +++ b/app/api/admin/stats/route.ts @@ -0,0 +1,53 @@ +// GET /api/admin/stats - 관리자용 블로그 통계 +import Post from '@/app/models/Post'; +import Series from '@/app/models/Series'; +import dbConnect from '@/app/lib/dbConnect'; +import { getServerSession } from 'next-auth'; + +export const dynamic = 'force-dynamic'; + +export async function GET(req: Request) { + try { + const session = await getServerSession(); + if (!session) { + return Response.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ); + } + + await dbConnect(); + + const [totalPosts, totalSeries, publicPosts, privatePosts] = + await Promise.all([ + Post.countDocuments({}), + Series.countDocuments({}), + Post.countDocuments({ isPrivate: false }), + Post.countDocuments({ isPrivate: true }), + ]); + + return Response.json( + { + success: true, + stats: { + totalPosts, + totalSeries, + publicPosts, + privatePosts, + }, + }, + { + status: 200, + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0', + }, + } + ); + } catch (error) { + console.error('Error fetching stats:', error); + return Response.json( + { success: false, error: '통계 불러오기 실패', detail: error }, + { status: 500 } + ); + } +} diff --git a/app/entities/admin/comments/CommentItem.tsx b/app/entities/admin/comments/CommentItem.tsx new file mode 100644 index 0000000..114a71f --- /dev/null +++ b/app/entities/admin/comments/CommentItem.tsx @@ -0,0 +1,56 @@ +import { formatDate } from '@/app/lib/utils/format'; + +interface GitHubUser { + login: string; + avatar_url: string; +} + +interface GitHubComment { + id: number; + user: GitHubUser; + created_at: string; + updated_at: string; + body: string; + html_url: string; +} + +interface CommentItemProps { + comment: GitHubComment; +} + +const CommentItem = ({ comment }: CommentItemProps) => { + return ( +
+
+ {comment.user.login} +
+
+ + {comment.user.login} + + + {formatDate(new Date(comment.created_at).getTime())} + +
+
+ {comment.body} +
+ + GitHub에서 보기 → + +
+
+
+ ); +}; + +export default CommentItem; diff --git a/app/entities/admin/comments/IssueCard.tsx b/app/entities/admin/comments/IssueCard.tsx new file mode 100644 index 0000000..1ad535b --- /dev/null +++ b/app/entities/admin/comments/IssueCard.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import CommentItem from './CommentItem'; + +interface GitHubUser { + login: string; + avatar_url: string; +} + +interface GitHubComment { + id: number; + user: GitHubUser; + created_at: string; + updated_at: string; + body: string; + html_url: string; +} + +interface GitHubIssue { + id: number; + number: number; + title: string; + html_url: string; + state: string; + comments: number; + created_at: string; + updated_at: string; + user: GitHubUser; + body?: string; +} + +interface IssueCardProps { + issue: GitHubIssue; + comments: GitHubComment[]; +} + +// 이슈 제목에서 slug 추출 +const extractSlugFromTitle = (title: string): string => { + try { + let titleString = title; + if (title.startsWith('posts/')) { + titleString = title.slice(6).trim(); + } + // 마지막 슬래시 제거 + if (titleString.endsWith('/')) { + titleString = titleString.slice(0, -1); + } + return decodeURIComponent(titleString); + } catch { + return title; + } +}; + +// 이슈 제목을 읽기 쉬운 형태로 변환 +const extractPostTitle = (title: string): string => { + try { + let titleString = title; + if (title.startsWith('posts/')) { + titleString = title.slice(6).trim(); + } + + const decodedSlug = decodeURIComponent(titleString); + + const readableTitle = decodedSlug + .split(/[-_]/) + .map((word) => { + if (!word) return word; + return word.charAt(0).toUpperCase() + word.slice(1); + }) + .join(' '); + + return readableTitle || decodedSlug || title; + } catch { + return title; + } +}; + +const IssueCard = ({ issue, comments }: IssueCardProps) => { + const [isExpanded, setIsExpanded] = useState(false); + const postTitle = extractPostTitle(issue.title); + const slug = extractSlugFromTitle(issue.title); + + return ( +
+
setIsExpanded(!isExpanded)} + > +
+
+

+ {postTitle} +

+
+ + + + + {comments.length}개의 댓글 + + e.stopPropagation()} + > + 글 보러가기 → + + e.stopPropagation()} + > + GitHub에서 보기 → + +
+
+ +
+
+ + {isExpanded && ( +
+
+ {comments.map((comment) => ( + + ))} +
+
+ )} +
+ ); +}; + +export default IssueCard; diff --git a/app/entities/admin/dashboard/QuickStats.tsx b/app/entities/admin/dashboard/QuickStats.tsx new file mode 100644 index 0000000..1b45c36 --- /dev/null +++ b/app/entities/admin/dashboard/QuickStats.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +interface Stats { + totalPosts: number; + totalSeries: number; + publicPosts: number; + privatePosts: number; +} + +const QuickStats = () => { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchStats = async () => { + try { + setLoading(true); + const response = await fetch('/api/admin/stats'); + const data = await response.json(); + + if (data.success) { + setStats(data.stats); + } else { + setError(data.error || '통계를 불러올 수 없습니다.'); + } + } catch (err) { + setError('통계를 불러오는 중 오류가 발생했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }; + + fetchStats(); + }, []); + + if (loading) { + return ( +
+

빠른 통계

+
로딩 중...
+
+ ); + } + + if (error || !stats) { + return ( +
+

빠른 통계

+
{error || '통계를 불러올 수 없습니다.'}
+
+ ); + } + + return ( +
+

빠른 통계

+
+
+

전체 게시글

+

{stats.totalPosts}

+
+
+

전체 시리즈

+

+ {stats.totalSeries} +

+
+
+

공개 게시글

+

+ {stats.publicPosts} +

+
+
+

비공개 게시글

+

+ {stats.privatePosts} +

+
+
+
+ ); +}; + +export default QuickStats; diff --git a/app/entities/admin/dashboard/RecentActivity.tsx b/app/entities/admin/dashboard/RecentActivity.tsx new file mode 100644 index 0000000..b692bec --- /dev/null +++ b/app/entities/admin/dashboard/RecentActivity.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { formatDate } from '@/app/lib/utils/format'; + +interface RecentPost { + _id: string; + title: string; + slug: string; + date: number; + createdAt: string; +} + +const RecentActivity = () => { + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRecentPosts = async () => { + try { + setLoading(true); + const response = await fetch('/api/admin/posts/recent'); + const data = await response.json(); + + if (data.success) { + setPosts(data.posts); + } else { + setError(data.error || '최근 게시글을 불러올 수 없습니다.'); + } + } catch (err) { + setError('최근 게시글을 불러오는 중 오류가 발생했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }; + + fetchRecentPosts(); + }, []); + + if (loading) { + return ( +
+

최근 활동

+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+

최근 활동

+
{error}
+
+ ); + } + + return ( +
+

최근 활동

+ {posts.length === 0 ? ( +

최근 게시글이 없습니다.

+ ) : ( +
    + {posts.map((post) => ( +
  • + + {post.title} + + + {formatDate(post.date)} + +
  • + ))} +
+ )} +
+ ); +}; + +export default RecentActivity; diff --git a/app/entities/bits/DecryptedText.tsx b/app/entities/bits/DecryptedText.tsx new file mode 100644 index 0000000..6f92d85 --- /dev/null +++ b/app/entities/bits/DecryptedText.tsx @@ -0,0 +1,241 @@ +import { useEffect, useState, useRef } from 'react'; +import { motion, HTMLMotionProps } from 'motion/react'; + +interface DecryptedTextProps extends HTMLMotionProps<'span'> { + text: string; + speed?: number; + maxIterations?: number; + sequential?: boolean; + revealDirection?: 'start' | 'end' | 'center'; + useOriginalCharsOnly?: boolean; + characters?: string; + className?: string; + encryptedClassName?: string; + parentClassName?: string; + animateOn?: 'view' | 'hover' | 'both'; +} + +const DecryptedText = ({ + text, + speed = 50, + maxIterations = 10, + sequential = false, + revealDirection = 'start', + useOriginalCharsOnly = false, + characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+', + className = '', + parentClassName = '', + encryptedClassName = '', + animateOn = 'hover', + ...props +}: DecryptedTextProps) => { + const [displayText, setDisplayText] = useState(text); + const [isHovering, setIsHovering] = useState(false); + const [isScrambling, setIsScrambling] = useState(false); + const [revealedIndices, setRevealedIndices] = useState>( + new Set() + ); + const [hasAnimated, setHasAnimated] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + let interval: NodeJS.Timeout; + let currentIteration = 0; + + const getNextIndex = (revealedSet: Set): number => { + const textLength = text.length; + switch (revealDirection) { + case 'start': + return revealedSet.size; + case 'end': + return textLength - 1 - revealedSet.size; + case 'center': { + const middle = Math.floor(textLength / 2); + const offset = Math.floor(revealedSet.size / 2); + const nextIndex = + revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1; + + if ( + nextIndex >= 0 && + nextIndex < textLength && + !revealedSet.has(nextIndex) + ) { + return nextIndex; + } + for (let i = 0; i < textLength; i++) { + if (!revealedSet.has(i)) return i; + } + return 0; + } + default: + return revealedSet.size; + } + }; + + const availableChars = useOriginalCharsOnly + ? Array.from(new Set(text.split(''))).filter((char) => char !== ' ') + : characters.split(''); + + const shuffleText = ( + originalText: string, + currentRevealed: Set + ): string => { + if (useOriginalCharsOnly) { + const positions = originalText.split('').map((char, i) => ({ + char, + isSpace: char === ' ', + index: i, + isRevealed: currentRevealed.has(i), + })); + + const nonSpaceChars = positions + .filter((p) => !p.isSpace && !p.isRevealed) + .map((p) => p.char); + + for (let i = nonSpaceChars.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [nonSpaceChars[i], nonSpaceChars[j]] = [ + nonSpaceChars[j], + nonSpaceChars[i], + ]; + } + + let charIndex = 0; + return positions + .map((p) => { + if (p.isSpace) return ' '; + if (p.isRevealed) return originalText[p.index]; + return nonSpaceChars[charIndex++]; + }) + .join(''); + } else { + return originalText + .split('') + .map((char, i) => { + if (char === ' ') return ' '; + if (currentRevealed.has(i)) return originalText[i]; + return availableChars[ + Math.floor(Math.random() * availableChars.length) + ]; + }) + .join(''); + } + }; + + if (isHovering) { + setIsScrambling(true); + interval = setInterval(() => { + setRevealedIndices((prevRevealed) => { + if (sequential) { + if (prevRevealed.size < text.length) { + const nextIndex = getNextIndex(prevRevealed); + const newRevealed = new Set(prevRevealed); + newRevealed.add(nextIndex); + setDisplayText(shuffleText(text, newRevealed)); + return newRevealed; + } else { + clearInterval(interval); + setIsScrambling(false); + return prevRevealed; + } + } else { + setDisplayText(shuffleText(text, prevRevealed)); + currentIteration++; + if (currentIteration >= maxIterations) { + clearInterval(interval); + setIsScrambling(false); + setDisplayText(text); + } + return prevRevealed; + } + }); + }, speed); + } else { + setDisplayText(text); + setRevealedIndices(new Set()); + setIsScrambling(false); + } + + return () => { + if (interval) clearInterval(interval); + }; + }, [ + isHovering, + text, + speed, + maxIterations, + sequential, + revealDirection, + characters, + useOriginalCharsOnly, + ]); + + useEffect(() => { + if (animateOn !== 'view' && animateOn !== 'both') return; + + const observerCallback = (entries: IntersectionObserverEntry[]) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !hasAnimated) { + setIsHovering(true); + setHasAnimated(true); + } + }); + }; + + const observerOptions = { + root: null, + rootMargin: '0px', + threshold: 0.1, + }; + + const observer = new IntersectionObserver( + observerCallback, + observerOptions + ); + const currentRef = containerRef.current; + if (currentRef) { + observer.observe(currentRef); + } + + return () => { + if (currentRef) observer.unobserve(currentRef); + }; + }, [animateOn, hasAnimated]); + + const hoverProps = + animateOn === 'hover' || animateOn === 'both' + ? { + onMouseEnter: () => setIsHovering(true), + onMouseLeave: () => setIsHovering(false), + } + : {}; + + return ( + + {displayText} + + + + ); +}; + +export default DecryptedText; diff --git a/app/entities/common/Toast/ToastProvider.tsx b/app/entities/common/Toast/ToastProvider.tsx index f1f9b2e..e7a2e6e 100644 --- a/app/entities/common/Toast/ToastProvider.tsx +++ b/app/entities/common/Toast/ToastProvider.tsx @@ -11,7 +11,7 @@ interface Toast { const ToastProvider = () => { const { toasts, removeToast } = useToastStore(); - const reversedToasts = toasts.toReversed(); + const reversedToasts = [...toasts].reverse(); return (
{ loading || !series?.posts ? [] : orderOption === 'latest' - ? series.posts?.toReversed() + ? [...series.posts].reverse() : series.posts; return ( diff --git a/package.json b/package.json index ccf99bf..1a7d300 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "feed": "^4.2.2", "lodash": "^4.17.21", "mongoose": "^8.13.2", + "motion": "^12.23.24", "next": "14.2.13", "next-auth": "^4.24.11", "prettier": "^3.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4265cfb..9801a53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: mongoose: specifier: ^8.13.2 version: 8.13.2 + motion: + specifier: ^12.23.24 + version: 12.23.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next: specifier: 14.2.13 version: 14.2.13(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1755,6 +1758,20 @@ packages: resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} engines: {node: '>= 6'} + framer-motion@12.23.24: + resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -2682,6 +2699,26 @@ packages: resolution: {integrity: sha512-riCBqZmNkYBWjXpM3qWLDQw7QmTKsVZDPhLXFJqC87+OjocEVpvS3dA2BPPUiLAu+m0/QmEj5pSXKhH+/DgerQ==} engines: {node: '>=16.20.1'} + motion-dom@12.23.23: + resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + + motion@12.23.24: + resolution: {integrity: sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mpath@0.9.0: resolution: {integrity: sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==} engines: {node: '>=4.0.0'} @@ -5699,6 +5736,15 @@ snapshots: es-set-tostringtag: 2.1.0 mime-types: 2.1.35 + framer-motion@12.23.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + motion-dom: 12.23.23 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -7123,6 +7169,20 @@ snapshots: - socks - supports-color + motion-dom@12.23.23: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + + motion@12.23.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + framer-motion: 12.23.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + mpath@0.9.0: {} mquery@5.0.0: