This commit is contained in:
5
src/app/admin/categories/page.tsx
Normal file
5
src/app/admin/categories/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminCategoryPanel from '@/components/admin/AdminCategoryPanel';
|
||||
|
||||
export default function AdminCategoriesPage() {
|
||||
return <AdminCategoryPanel />;
|
||||
}
|
||||
5
src/app/admin/comments/page.tsx
Normal file
5
src/app/admin/comments/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminCommentsPanel from '@/components/admin/AdminCommentsPanel';
|
||||
|
||||
export default function AdminCommentsPage() {
|
||||
return <AdminCommentsPanel />;
|
||||
}
|
||||
5
src/app/admin/layout.tsx
Normal file
5
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminRouteShell from '@/components/admin/AdminRouteShell';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AdminRouteShell>{children}</AdminRouteShell>;
|
||||
}
|
||||
@@ -1,26 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
ArrowRight,
|
||||
Eye,
|
||||
FileText,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
PenLine,
|
||||
Settings,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { getCategories } from '@/api/category';
|
||||
import { getAdminComments } from '@/api/comments';
|
||||
import { getPosts } from '@/api/posts';
|
||||
import AdminCategoryPanel from '@/components/admin/AdminCategoryPanel';
|
||||
import AdminProfilePanel from '@/components/admin/AdminProfilePanel';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { Category, Post } from '@/types';
|
||||
import { AdminComment, AdminCommentListResponse, Category, PageMeta, Post } from '@/types';
|
||||
|
||||
const countCategories = (categories: Category[] = []): number => {
|
||||
return categories.reduce((count, category) => {
|
||||
@@ -28,7 +24,14 @@ const countCategories = (categories: Category[] = []): number => {
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const formatDate = (value?: string) => {
|
||||
const emptyPageMeta: PageMeta = {
|
||||
totalPages: 0,
|
||||
totalElements: 0,
|
||||
number: 0,
|
||||
last: true,
|
||||
};
|
||||
|
||||
const formatDate = (value?: string, withTime = false) => {
|
||||
if (!value) return '-';
|
||||
|
||||
const date = new Date(value);
|
||||
@@ -38,9 +41,25 @@ const formatDate = (value?: string) => {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}),
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const getCommentListMeta = (data?: AdminCommentListResponse): PageMeta => {
|
||||
if (!data) return emptyPageMeta;
|
||||
|
||||
return {
|
||||
totalElements: data.page?.totalElements ?? data.totalElements ?? 0,
|
||||
totalPages: data.page?.totalPages ?? data.totalPages ?? 0,
|
||||
number: data.page?.number ?? data.number ?? 0,
|
||||
last: data.page?.last ?? data.last,
|
||||
};
|
||||
};
|
||||
|
||||
const getAuthorName = (comment: AdminComment) => {
|
||||
return comment.author || comment.memberNickname || comment.guestNickname || '익명';
|
||||
};
|
||||
|
||||
function PostRow({ post }: { post: Post }) {
|
||||
return (
|
||||
<Link
|
||||
@@ -61,63 +80,69 @@ function PostRow({ post }: { post: Post }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CommentRow({ comment }: { comment: AdminComment }) {
|
||||
const content = (
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="line-clamp-2 text-sm leading-6 text-gray-700">{comment.content}</p>
|
||||
<p className="mt-1 truncate text-xs text-gray-400">
|
||||
{getAuthorName(comment)} · {comment.postTitle || '게시글 정보 없음'} · {formatDate(comment.createdAt, true)}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="shrink-0 text-gray-300" size={15} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (comment.postSlug) {
|
||||
return (
|
||||
<Link
|
||||
href={`/posts/${comment.postSlug}`}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="flex items-center gap-3 rounded-lg px-3 py-3">{content}</div>;
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
const { isLoggedIn, role, _hasHydrated } = useAuthStore();
|
||||
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated) return;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
toast.error('관리자 권한이 필요합니다.');
|
||||
router.replace('/');
|
||||
}
|
||||
}, [_hasHydrated, isAdmin, isLoggedIn, router]);
|
||||
|
||||
const { data: latestData, isLoading: isLatestLoading } = useQuery({
|
||||
queryKey: ['posts', 'admin', 'latest'],
|
||||
queryFn: () => getPosts({ size: 5, sort: 'createdAt,desc' }),
|
||||
enabled: isAdmin,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const { data: popularData, isLoading: isPopularLoading } = useQuery({
|
||||
queryKey: ['posts', 'admin', 'popular'],
|
||||
queryFn: () => getPosts({ size: 5, sort: 'viewCount,desc' }),
|
||||
enabled: isAdmin,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const { data: categories, isLoading: isCategoriesLoading } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: getCategories,
|
||||
enabled: isAdmin,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
if (!_hasHydrated || !isAdmin) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={36} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { data: commentsData, isLoading: isCommentsLoading } = useQuery({
|
||||
queryKey: ['comments', 'admin', 'recent'],
|
||||
queryFn: () => getAdminComments(0, 5),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const isLoading = isLatestLoading || isPopularLoading || isCategoriesLoading;
|
||||
const isLoading = isLatestLoading || isPopularLoading || isCategoriesLoading || isCommentsLoading;
|
||||
const latestPosts = latestData?.content ?? [];
|
||||
const popularPosts = popularData?.content ?? [];
|
||||
const totalPosts = latestData?.totalElements ?? 0;
|
||||
const recentComments = commentsData?.content ?? [];
|
||||
const totalPosts = latestData?.page?.totalElements ?? latestData?.totalElements ?? 0;
|
||||
const totalCategories = countCategories(categories);
|
||||
const totalComments = getCommentListMeta(commentsData).totalElements;
|
||||
const lastUpdatedAt = latestPosts[0]?.createdAt;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-8 px-1 py-4 md:px-4">
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-col justify-between gap-5 border-b border-gray-200 pb-8 md:flex-row md:items-end">
|
||||
<div>
|
||||
<p className="mb-3 inline-flex items-center gap-2 rounded-full border border-blue-100 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
|
||||
@@ -125,23 +150,32 @@ export default function AdminPage() {
|
||||
Admin
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold tracking-normal text-gray-950 md:text-4xl">
|
||||
관리자 설정
|
||||
관리자 대시보드
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-gray-500">
|
||||
공개 화면과 분리된 운영 공간입니다. 게시글 현황을 확인하고 프로필과 카테고리를 정리할 수 있습니다.
|
||||
공개 화면과 분리된 운영 공간입니다. 게시글, 댓글, 카테고리, 프로필 관리는 상단 메뉴에서 바로 이동합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/write"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-gray-800"
|
||||
>
|
||||
<PenLine size={17} />
|
||||
새 글 작성
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/admin/posts"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full border border-gray-200 bg-white px-5 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
<FileText size={17} />
|
||||
게시글 관리
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/posts/new"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-gray-800"
|
||||
>
|
||||
<PenLine size={17} />
|
||||
새 글 작성
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between text-gray-500">
|
||||
<span className="text-sm font-medium">총 게시글</span>
|
||||
@@ -158,6 +192,14 @@ export default function AdminPage() {
|
||||
<p className="mt-4 text-3xl font-bold text-gray-950">{totalCategories.toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between text-gray-500">
|
||||
<span className="text-sm font-medium">총 댓글</span>
|
||||
<MessageSquareText size={18} />
|
||||
</div>
|
||||
<p className="mt-4 text-3xl font-bold text-gray-950">{totalComments.toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between text-gray-500">
|
||||
<span className="text-sm font-medium">최근 업데이트</span>
|
||||
@@ -167,7 +209,7 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.8fr)]">
|
||||
<section className="grid gap-6 xl:grid-cols-3">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -175,10 +217,10 @@ export default function AdminPage() {
|
||||
<p className="mt-1 text-sm text-gray-500">발행 흐름을 빠르게 확인합니다.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/archive"
|
||||
href="/admin/posts"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
전체보기
|
||||
관리
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -220,11 +262,38 @@ export default function AdminPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<AdminProfilePanel />
|
||||
<AdminCategoryPanel />
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-950">최근 댓글 5개</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">새 반응을 대시보드에서 바로 봅니다.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/comments"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
관리
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isCommentsLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||
</div>
|
||||
) : recentComments.length > 0 ? (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{recentComments.map((comment) => (
|
||||
<CommentRow key={comment.id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||
아직 작성된 댓글이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
13
src/app/admin/posts/[slug]/edit/page.tsx
Normal file
13
src/app/admin/posts/[slug]/edit/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import AdminPostEditor from '@/components/admin/AdminPostEditor';
|
||||
|
||||
interface EditAdminPostPageProps {
|
||||
params: Promise<{
|
||||
slug: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EditAdminPostPage({ params }: EditAdminPostPageProps) {
|
||||
const { slug } = await params;
|
||||
|
||||
return <AdminPostEditor editSlug={slug} />;
|
||||
}
|
||||
5
src/app/admin/posts/new/page.tsx
Normal file
5
src/app/admin/posts/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminPostEditor from '@/components/admin/AdminPostEditor';
|
||||
|
||||
export default function NewAdminPostPage() {
|
||||
return <AdminPostEditor />;
|
||||
}
|
||||
5
src/app/admin/posts/page.tsx
Normal file
5
src/app/admin/posts/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminPostsPanel from '@/components/admin/AdminPostsPanel';
|
||||
|
||||
export default function AdminPostsPage() {
|
||||
return <AdminPostsPanel />;
|
||||
}
|
||||
5
src/app/admin/profile/page.tsx
Normal file
5
src/app/admin/profile/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminProfilePanel from '@/components/admin/AdminProfilePanel';
|
||||
|
||||
export default function AdminProfilePage() {
|
||||
return <AdminProfilePanel />;
|
||||
}
|
||||
Reference in New Issue
Block a user