This commit is contained in:
13
src/api/dashboard.ts
Normal file
13
src/api/dashboard.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { http } from './http';
|
||||
import { AdminDashboardResponse, ApiResponse, DashboardRange } from '@/types';
|
||||
|
||||
export const getAdminDashboard = async (
|
||||
range: DashboardRange = '30d',
|
||||
timezone = 'Asia/Seoul',
|
||||
) => {
|
||||
const response = await http.get<ApiResponse<AdminDashboardResponse>>('/api/admin/dashboard', {
|
||||
params: { range, timezone },
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
// 🛠️ 환경 변수 처리 (배포 환경 대응)
|
||||
@@ -26,10 +26,33 @@ http.interceptors.request.use(
|
||||
|
||||
// --- 토큰 갱신 관련 변수 ---
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
type QueuedRequest = {
|
||||
resolve: (token: string | null) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
type AuthStorageData = {
|
||||
state?: {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type RetriableRequestConfig = AxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
type NavigatorWithLocks = Navigator & {
|
||||
locks?: {
|
||||
request: <T>(name: string, callback: () => Promise<T> | T) => Promise<T>;
|
||||
};
|
||||
};
|
||||
|
||||
let failedQueue: QueuedRequest[] = [];
|
||||
|
||||
// 실패한 요청들을 큐에 담아두었다가 토큰 갱신 후 재시도하는 함수
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
const processQueue = (error: unknown, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
@@ -43,7 +66,7 @@ const processQueue = (error: any, token: string | null = null) => {
|
||||
// 실제 토큰 갱신을 수행하는 함수 (Lock 안에서 실행됨)
|
||||
async function handleTokenRefresh() {
|
||||
try {
|
||||
const { accessToken: currentAccessToken, refreshToken: currentRefreshToken, login, logout } = useAuthStore.getState();
|
||||
const { accessToken: currentAccessToken, refreshToken: currentRefreshToken, login } = useAuthStore.getState();
|
||||
|
||||
// [1] 로컬 스토리지 확인 (다른 탭에서 이미 갱신했는지 체크)
|
||||
let actualRefreshToken = currentRefreshToken;
|
||||
@@ -53,12 +76,12 @@ async function handleTokenRefresh() {
|
||||
const storageData = localStorage.getItem('auth-storage');
|
||||
if (storageData) {
|
||||
try {
|
||||
const parsed = JSON.parse(storageData);
|
||||
const parsed = JSON.parse(storageData) as AuthStorageData;
|
||||
const storedRefreshToken = parsed.state?.refreshToken;
|
||||
const storedAccessToken = parsed.state?.accessToken;
|
||||
|
||||
// 저장된 토큰이 현재 메모리의 토큰과 다르다면? => 이미 다른 탭/요청이 갱신을 완료함!
|
||||
if (storedRefreshToken && currentRefreshToken && storedRefreshToken !== currentRefreshToken) {
|
||||
if (storedAccessToken && storedRefreshToken && currentRefreshToken && storedRefreshToken !== currentRefreshToken) {
|
||||
// 현재 탭의 스토어 상태를 스토리지와 동기화
|
||||
login(storedAccessToken, storedRefreshToken);
|
||||
// 대기 중인 요청들 해소
|
||||
@@ -70,7 +93,7 @@ async function handleTokenRefresh() {
|
||||
// 갱신 시도할 토큰 정보를 최신 스토리지 값으로 설정
|
||||
if (storedRefreshToken) actualRefreshToken = storedRefreshToken;
|
||||
if (storedAccessToken) actualAccessToken = storedAccessToken;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// console.error('Storage parse error', e);
|
||||
}
|
||||
}
|
||||
@@ -113,9 +136,10 @@ async function handleTokenRefresh() {
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
const originalRequest = error.config as RetriableRequestConfig | undefined;
|
||||
|
||||
if (!error.response) return Promise.reject(error);
|
||||
if (!originalRequest) return Promise.reject(error);
|
||||
|
||||
const status = error.response.status;
|
||||
|
||||
@@ -143,9 +167,11 @@ http.interceptors.response.use(
|
||||
try {
|
||||
let newToken;
|
||||
|
||||
if (typeof navigator !== 'undefined' && 'locks' in navigator) {
|
||||
const locks = typeof navigator !== 'undefined' ? (navigator as NavigatorWithLocks).locks : undefined;
|
||||
|
||||
if (locks) {
|
||||
// Lock을 획득한 놈만 handleTokenRefresh 실행
|
||||
newToken = await (navigator as any).locks.request('auth-refresh-lock', async () => {
|
||||
newToken = await locks.request('auth-refresh-lock', async () => {
|
||||
return await handleTokenRefresh();
|
||||
});
|
||||
} else {
|
||||
@@ -167,4 +193,4 @@ http.interceptors.response.use(
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowRight,
|
||||
Eye,
|
||||
CalendarClock,
|
||||
FileText,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
PenLine,
|
||||
Settings,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { getAdminDashboard } from '@/api/dashboard';
|
||||
import { getCategories } from '@/api/category';
|
||||
import { getAdminComments } from '@/api/comments';
|
||||
import { getPosts } from '@/api/posts';
|
||||
import { AdminComment, AdminCommentListResponse, Category, PageMeta, Post } from '@/types';
|
||||
import AdminDashboardActionCenter from '@/components/admin/dashboard/AdminDashboardActionCenter';
|
||||
import AdminDashboardCategoryHealth from '@/components/admin/dashboard/AdminDashboardCategoryHealth';
|
||||
import AdminDashboardOverview from '@/components/admin/dashboard/AdminDashboardOverview';
|
||||
import AdminDashboardPostPerformance from '@/components/admin/dashboard/AdminDashboardPostPerformance';
|
||||
import AdminDashboardTrafficChart from '@/components/admin/dashboard/AdminDashboardTrafficChart';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import {
|
||||
AdminComment,
|
||||
AdminCommentListResponse,
|
||||
Category,
|
||||
DashboardPostStat,
|
||||
DashboardRange,
|
||||
PageMeta,
|
||||
Post,
|
||||
} from '@/types';
|
||||
|
||||
const countCategories = (categories: Category[] = []): number => {
|
||||
return categories.reduce((count, category) => {
|
||||
@@ -31,10 +46,10 @@ const emptyPageMeta: PageMeta = {
|
||||
last: true,
|
||||
};
|
||||
|
||||
const formatDate = (value?: string, withTime = false) => {
|
||||
const formatDate = (value?: string | Date, withTime = false) => {
|
||||
if (!value) return '-';
|
||||
|
||||
const date = new Date(value);
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
@@ -56,40 +71,51 @@ const getCommentListMeta = (data?: AdminCommentListResponse): PageMeta => {
|
||||
};
|
||||
};
|
||||
|
||||
const getPostTotal = (data?: { page?: PageMeta; totalElements?: number }) => {
|
||||
return data?.page?.totalElements ?? data?.totalElements ?? 0;
|
||||
};
|
||||
|
||||
const getAuthorName = (comment: AdminComment) => {
|
||||
return comment.author || comment.memberNickname || comment.guestNickname || '익명';
|
||||
};
|
||||
|
||||
function PostRow({ post }: { post: Post }) {
|
||||
const toPostStat = (post: Post): DashboardPostStat => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
categoryName: post.categoryName,
|
||||
viewCount: post.viewCount,
|
||||
rangeViewCount: post.viewCount,
|
||||
createdAt: post.createdAt,
|
||||
});
|
||||
|
||||
function RecentPostRow({ post }: { post: Post }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
className="flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50"
|
||||
className="group flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-black/[0.03] dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-gray-900">{post.title}</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="truncate text-sm font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">{post.title}</p>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
|
||||
{post.categoryName || '미분류'} · {formatDate(post.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 text-xs text-gray-400">
|
||||
<Eye size={14} />
|
||||
<span>{post.viewCount.toLocaleString()}</span>
|
||||
</div>
|
||||
<ArrowRight className="shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5" size={15} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentRow({ comment }: { comment: AdminComment }) {
|
||||
function RecentCommentRow({ 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">
|
||||
<p className="line-clamp-2 text-sm leading-6 text-[var(--color-text-muted)]">{comment.content}</p>
|
||||
<p className="mt-1 truncate text-xs text-[var(--color-text-subtle)]">
|
||||
{getAuthorName(comment)} · {comment.postTitle || '게시글 정보 없음'} · {formatDate(comment.createdAt, true)}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="shrink-0 text-gray-300" size={15} />
|
||||
<ArrowRight className="shrink-0 text-[var(--color-text-subtle)]" size={15} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -97,7 +123,7 @@ function CommentRow({ comment }: { comment: AdminComment }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/posts/${comment.postSlug}`}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 transition hover:bg-black/[0.03] dark:hover:bg-white/10"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
@@ -107,7 +133,86 @@ function CommentRow({ comment }: { comment: AdminComment }) {
|
||||
return <div className="flex items-center gap-3 rounded-lg px-3 py-3">{content}</div>;
|
||||
}
|
||||
|
||||
function RecentActivity({
|
||||
posts,
|
||||
comments,
|
||||
isLoading,
|
||||
}: {
|
||||
posts: Post[];
|
||||
comments: AdminComment[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="grid gap-6 xl:grid-cols-2">
|
||||
<Surface className="p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">최근 글</h2>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">발행 흐름을 빠르게 확인합니다.</p>
|
||||
</div>
|
||||
<Link href="/admin/posts" className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
관리
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={28} />
|
||||
</div>
|
||||
) : posts.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{posts.slice(0, 5).map((post) => (
|
||||
<RecentPostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="아직 작성된 글이 없습니다." />
|
||||
)}
|
||||
</Surface>
|
||||
|
||||
<Surface className="p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">최근 댓글</h2>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">새 반응을 대시보드에서 바로 봅니다.</p>
|
||||
</div>
|
||||
<Link href="/admin/comments" className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
관리
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={28} />
|
||||
</div>
|
||||
) : comments.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{comments.slice(0, 5).map((comment) => (
|
||||
<RecentCommentRow key={comment.id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="아직 작성된 댓글이 없습니다." />
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [range, setRange] = useState<DashboardRange>('30d');
|
||||
|
||||
const {
|
||||
data: dashboard,
|
||||
isLoading: isDashboardLoading,
|
||||
} = useQuery({
|
||||
queryKey: ['admin-dashboard', range],
|
||||
queryFn: () => getAdminDashboard(range),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const { data: latestData, isLoading: isLatestLoading } = useQuery({
|
||||
queryKey: ['posts', 'admin', 'latest'],
|
||||
queryFn: () => getPosts({ size: 5, sort: 'createdAt,desc' }),
|
||||
@@ -132,42 +237,52 @@ export default function AdminPage() {
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const isLoading = isLatestLoading || isPopularLoading || isCategoriesLoading || isCommentsLoading;
|
||||
const latestPosts = latestData?.content ?? [];
|
||||
const popularPosts = popularData?.content ?? [];
|
||||
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;
|
||||
const recentComments = dashboard?.recentComments ?? commentsData?.content ?? [];
|
||||
const recentPosts = dashboard?.recentPosts ?? latestPosts;
|
||||
const totalPosts = dashboard?.overview.totalPosts ?? getPostTotal(latestData);
|
||||
const totalCategories = dashboard?.overview.totalCategories ?? countCategories(categories);
|
||||
const totalComments = dashboard?.overview.totalComments ?? getCommentListMeta(commentsData).totalElements;
|
||||
const isFallback = !dashboard;
|
||||
const isFallbackLoading = isLatestLoading || isPopularLoading || isCategoriesLoading || isCommentsLoading;
|
||||
const topPosts = useMemo(
|
||||
() => dashboard?.topPosts ?? (popularData?.content ?? []).map(toPostStat),
|
||||
[dashboard?.topPosts, popularData?.content],
|
||||
);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<header className="flex flex-col justify-between gap-5 border-b border-[var(--color-line)] 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">
|
||||
<StatusBadge tone="info" className="mb-3">
|
||||
<Settings size={14} />
|
||||
Admin
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold tracking-normal text-gray-950 md:text-4xl">
|
||||
관리자
|
||||
</StatusBadge>
|
||||
<h1 className="text-3xl font-bold tracking-normal text-[var(--color-text)] md:text-4xl">
|
||||
관리자 대시보드
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-gray-500">
|
||||
공개 화면과 분리된 운영 공간입니다. 게시글, 댓글, 카테고리, 프로필 관리는 상단 메뉴에서 바로 이동합니다.
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
공개 화면과 분리된 운영 공간입니다. 통계 API가 준비되기 전에도 기존 데이터로 운영 흐름을 확인합니다.
|
||||
</p>
|
||||
<p className="mt-3 inline-flex items-center gap-2 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
<CalendarClock size={14} />
|
||||
오늘 {formatDate(new Date())}
|
||||
{isDashboardLoading && ' · 통계 API 확인 중'}
|
||||
{isFallback && !isDashboardLoading && ' · fallback 데이터 사용 중'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full border border-[var(--color-line)] bg-white/70 px-5 py-3 text-sm font-semibold text-[var(--color-text-muted)] transition hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10"
|
||||
>
|
||||
<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"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-[var(--color-text)] px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:opacity-90 dark:bg-white dark:text-black"
|
||||
>
|
||||
<PenLine size={17} />
|
||||
새 글 작성
|
||||
@@ -175,126 +290,46 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<FileText size={18} />
|
||||
</div>
|
||||
<p className="mt-4 text-3xl font-bold text-gray-950">{totalPosts.toLocaleString()}</p>
|
||||
</div>
|
||||
<AdminDashboardOverview
|
||||
overview={dashboard?.overview}
|
||||
fallbackTotals={{
|
||||
totalPosts,
|
||||
totalComments,
|
||||
totalCategories,
|
||||
lastPublishedAt: latestPosts[0]?.createdAt,
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<FolderTree size={18} />
|
||||
</div>
|
||||
<p className="mt-4 text-3xl font-bold text-gray-950">{totalCategories.toLocaleString()}</p>
|
||||
</div>
|
||||
<AdminDashboardTrafficChart
|
||||
points={dashboard?.traffic ?? []}
|
||||
range={range}
|
||||
onRangeChange={setRange}
|
||||
isFallback={isFallback}
|
||||
/>
|
||||
|
||||
<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="grid gap-6 xl:grid-cols-[1fr_0.8fr]">
|
||||
<AdminDashboardPostPerformance
|
||||
topPosts={topPosts}
|
||||
risingPosts={dashboard?.risingPosts ?? []}
|
||||
stalePopularPosts={dashboard?.stalePopularPosts ?? []}
|
||||
isFallback={isFallback}
|
||||
/>
|
||||
<AdminDashboardActionCenter
|
||||
actionItems={dashboard?.actionItems}
|
||||
isFallback={isFallback}
|
||||
/>
|
||||
</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>
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<p className="mt-4 text-2xl font-bold text-gray-950">{formatDate(lastUpdatedAt)}</p>
|
||||
</div>
|
||||
</section>
|
||||
<RecentActivity
|
||||
posts={recentPosts}
|
||||
comments={recentComments}
|
||||
isLoading={isFallbackLoading}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<h2 className="text-lg font-bold text-gray-950">최근 글</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">발행 흐름을 빠르게 확인합니다.</p>
|
||||
</div>
|
||||
<Link
|
||||
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>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||
</div>
|
||||
) : latestPosts.length > 0 ? (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{latestPosts.map((post) => (
|
||||
<PostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||
아직 작성된 글이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h2 className="text-lg font-bold text-gray-950">인기 글</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">조회수가 높은 글을 확인합니다.</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||
</div>
|
||||
) : popularPosts.length > 0 ? (
|
||||
<div className="mt-4 divide-y divide-gray-100">
|
||||
{popularPosts.map((post) => (
|
||||
<PostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||
아직 집계된 인기 글이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<AdminDashboardCategoryHealth
|
||||
categoryStats={dashboard?.categoryStats ?? []}
|
||||
isFallback={isFallback}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import { Loader2, Calendar, Archive, FileText, ChevronRight } from 'lucide-react
|
||||
import { useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { clsx } from 'clsx';
|
||||
import { PostListResponse } from '@/types';
|
||||
|
||||
const getTotalElements = (data?: PostListResponse) => {
|
||||
return data?.page?.totalElements ?? data?.totalElements ?? 0;
|
||||
};
|
||||
|
||||
export default function ArchivePage() {
|
||||
// 1. 전체 게시글 조회 (최대 1000개)
|
||||
@@ -44,8 +48,7 @@ export default function ArchivePage() {
|
||||
|
||||
// 🛠️ 총 게시글 수 수정 (백엔드 PagedModel 대응)
|
||||
// page 정보가 data 안에 직접 있거나, page 객체 안에 있을 수 있음
|
||||
const meta = (data as any)?.page || data;
|
||||
const totalPosts = meta?.totalElements || 0;
|
||||
const totalPosts = getTotalElements(data);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -61,7 +64,7 @@ export default function ArchivePage() {
|
||||
<div className="mb-12 text-center md:text-left border-b border-gray-100 pb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center justify-center md:justify-start gap-3 mb-3">
|
||||
<Archive className="text-blue-600" size={32} />
|
||||
<span>Archives</span>
|
||||
<span>아카이브</span>
|
||||
</h1>
|
||||
<p className="text-gray-500">
|
||||
지금까지 작성한 <span className="text-blue-600 font-bold">{totalPosts}</span>개의 글이 기록되어 있습니다.
|
||||
@@ -149,4 +152,4 @@ export default function ArchivePage() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState, useEffect } from 'react';
|
||||
import { use, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPostsByCategory } from '@/api/posts'; // 🛠️ 수정된 API 사용
|
||||
import PostCard from '@/components/post/PostCard';
|
||||
@@ -18,17 +18,14 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [keyword, setKeyword] = useState(''); // 🆕 검색어 상태
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const PAGE_SIZE = 10;
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
|
||||
if (isNoticeCategory || typeof window === 'undefined') return isNoticeCategory ? 'list' : 'grid';
|
||||
|
||||
useEffect(() => {
|
||||
if (isNoticeCategory) {
|
||||
setViewMode('list');
|
||||
return;
|
||||
}
|
||||
const savedMode = localStorage.getItem('postViewMode') as 'grid' | 'list';
|
||||
if (savedMode) setViewMode(savedMode);
|
||||
}, [isNoticeCategory]);
|
||||
const savedMode = localStorage.getItem('postViewMode');
|
||||
return savedMode === 'list' ? 'list' : 'grid';
|
||||
});
|
||||
const PAGE_SIZE = 10;
|
||||
const activeViewMode = isNoticeCategory ? 'list' : viewMode;
|
||||
|
||||
const handleViewModeChange = (mode: 'grid' | 'list') => {
|
||||
setViewMode(mode);
|
||||
@@ -75,8 +72,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
const posts = postsData?.content || [];
|
||||
|
||||
// 🛠️ 백엔드 PagedModel 구조 대응 (page 필드 내부에 메타데이터가 있을 수 있음)
|
||||
// postsData가 any로 캐스팅되어 안전하게 접근
|
||||
const pagingData = (postsData as any)?.page || postsData;
|
||||
const pagingData = postsData?.page || postsData;
|
||||
const totalElements = pagingData?.totalElements ?? 0;
|
||||
const totalPages = pagingData?.totalPages ?? 0;
|
||||
// page.number가 존재하면 계산해서 isLast 판단, 아니면 기존 last 필드 사용
|
||||
@@ -103,7 +99,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
{/* 🔍 카테고리 내 검색바 */}
|
||||
<PostSearch
|
||||
onSearch={handleSearch}
|
||||
placeholder={`'${apiCategoryName}' 내 검색`}
|
||||
placeholder={`${apiCategoryName} 내 검색`}
|
||||
className="w-full md:w-64"
|
||||
/>
|
||||
|
||||
@@ -113,9 +109,10 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
onClick={() => handleViewModeChange('grid')}
|
||||
className={clsx(
|
||||
"p-2 rounded-md transition-all duration-200",
|
||||
viewMode === 'grid' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
|
||||
activeViewMode === 'grid' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
|
||||
)}
|
||||
title="카드형 보기"
|
||||
aria-label="카드형 보기"
|
||||
>
|
||||
<LayoutGrid size={18} />
|
||||
</button>
|
||||
@@ -123,9 +120,10 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
onClick={() => handleViewModeChange('list')}
|
||||
className={clsx(
|
||||
"p-2 rounded-md transition-all duration-200",
|
||||
viewMode === 'list' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
|
||||
activeViewMode === 'list' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
|
||||
)}
|
||||
title="리스트형 보기"
|
||||
aria-label="리스트형 보기"
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
@@ -138,7 +136,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
<div className="mb-6 flex items-center gap-2 text-sm text-gray-600 bg-blue-50 px-4 py-3 rounded-lg border border-blue-100">
|
||||
<SearchIcon size={16} className="text-blue-500" />
|
||||
<span>
|
||||
"{keyword}" 검색 결과: <strong>{totalElements}</strong>건
|
||||
검색어 <strong>{keyword}</strong> 결과: <strong>{totalElements}</strong>건
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -146,12 +144,12 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center py-20 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<p className="text-gray-400 mb-2">
|
||||
{keyword ? `"${keyword}"에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
|
||||
{keyword ? `${keyword}에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{viewMode === 'grid' ? (
|
||||
{activeViewMode === 'grid' ? (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
@@ -176,7 +174,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Page <span className="text-gray-900 font-bold">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
|
||||
페이지 <span className="text-gray-900 font-bold">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
|
||||
</span>
|
||||
|
||||
<button
|
||||
@@ -192,4 +190,4 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--color-page: #f5f5f7;
|
||||
--color-surface: rgba(255, 255, 255, 0.78);
|
||||
--color-surface-strong: #ffffff;
|
||||
--color-line: rgba(0, 0, 0, 0.08);
|
||||
--color-text: #1d1d1f;
|
||||
--color-text-muted: #6e6e73;
|
||||
--color-text-subtle: #86868b;
|
||||
--color-accent: #007aff;
|
||||
--color-accent-soft: rgba(0, 122, 255, 0.1);
|
||||
--shadow-panel: 0 18px 50px rgba(0, 0, 0, 0.08);
|
||||
--shadow-card: 0 10px 28px rgba(0, 0, 0, 0.06);
|
||||
--background: var(--color-page);
|
||||
--foreground: var(--color-text);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -14,15 +25,40 @@
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--color-page: #0b0b0c;
|
||||
--color-surface: rgba(28, 28, 30, 0.72);
|
||||
--color-surface-strong: #1c1c1e;
|
||||
--color-line: rgba(255, 255, 255, 0.1);
|
||||
--color-text: #f5f5f7;
|
||||
--color-text-muted: #a1a1a6;
|
||||
--color-text-subtle: #8e8e93;
|
||||
--color-accent: #0a84ff;
|
||||
--color-accent-soft: rgba(10, 132, 255, 0.16);
|
||||
--shadow-panel: 0 18px 50px rgba(0, 0, 0, 0.36);
|
||||
--shadow-card: 0 10px 28px rgba(0, 0, 0, 0.28);
|
||||
--background: var(--color-page);
|
||||
--foreground: var(--color-text);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"SF Pro Display",
|
||||
"SF Pro Text",
|
||||
"Apple SD Gothic Neo",
|
||||
"Noto Sans KR",
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.w-md-editor {
|
||||
@@ -34,4 +70,4 @@ body {
|
||||
.w-md-editor-toolbar {
|
||||
border-radius: 0.75rem 0.75rem 0 0 !important;
|
||||
background-color: #f9fafb !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function RootLayout({
|
||||
<head>
|
||||
<meta name="google-site-verification" content="cFJSK1ayy2Y4lqRKNv8wZ_vybg5De22zYCdbKSfvAJA" />
|
||||
</head>
|
||||
<body className="bg-[#f8f9fa] text-gray-800">
|
||||
<body className="bg-[var(--color-page)] text-[var(--color-text)]">
|
||||
{/* 🌟 Google Analytics 스크립트 */}
|
||||
<Script
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-2GLCM9ZKMK"
|
||||
@@ -56,4 +56,4 @@ export default function RootLayout({
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
562
src/app/page.tsx
562
src/app/page.tsx
@@ -1,184 +1,478 @@
|
||||
'use client';
|
||||
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { getPosts } from '@/api/posts';
|
||||
import PostCard from '@/components/post/PostCard';
|
||||
import PostListItem from '@/components/post/PostListItem';
|
||||
// PostSearch 컴포넌트 제거 (사이드바로 이동)
|
||||
import { Post } from '@/types';
|
||||
import { Loader2, Megaphone, Flame, Clock, ChevronRight, Search as SearchIcon } from 'lucide-react';
|
||||
import { Suspense, useMemo } from 'react';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Archive,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { getCategories } from '@/api/category';
|
||||
import { getPosts } from '@/api/posts';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { Category, Post, PostListResponse } from '@/types';
|
||||
|
||||
const isNoticePost = (post: Post) => {
|
||||
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
};
|
||||
|
||||
const formatDate = (value?: string) => {
|
||||
if (!value) return '';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const getSummary = (content?: string, maxLength = 118) => {
|
||||
if (!content) return '아직 요약할 본문이 없습니다. 글을 열어 전체 내용을 확인해 보세요.';
|
||||
|
||||
return content
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/[#*`_~>]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
};
|
||||
|
||||
const flattenCategories = (categories: Category[] = []): Category[] => {
|
||||
return categories.flatMap((category) => [
|
||||
category,
|
||||
...flattenCategories(category.children || []),
|
||||
]);
|
||||
};
|
||||
|
||||
const getHomeCategories = (categories: Category[] = []) => {
|
||||
return flattenCategories(categories)
|
||||
.filter((category) => {
|
||||
const name = category.name.toLowerCase();
|
||||
return category.name !== '공지' && name !== 'notice' && name !== '미분류';
|
||||
})
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.slice(0, 3);
|
||||
};
|
||||
|
||||
const getTotalElements = (data?: PostListResponse) => {
|
||||
return data?.page?.totalElements ?? data?.totalElements ?? 0;
|
||||
};
|
||||
|
||||
function SearchResults({
|
||||
keyword,
|
||||
data,
|
||||
isLoading,
|
||||
}: {
|
||||
keyword: string;
|
||||
data?: PostListResponse;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const searchResults = data?.content || [];
|
||||
const searchTotalElements = getTotalElements(data);
|
||||
|
||||
return (
|
||||
<section className="animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="mb-6 flex flex-col gap-2 border-b border-[var(--color-line)] pb-5">
|
||||
<div className="flex items-center gap-2 text-[var(--color-accent)]">
|
||||
<Search size={22} />
|
||||
<h1 className="text-2xl font-bold tracking-normal text-[var(--color-text)]">
|
||||
검색 결과
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
검색어 <span className="font-semibold text-[var(--color-text)]">{keyword}</span>에 대한 글 {searchTotalElements.toLocaleString()}건
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-60 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={30} />
|
||||
</div>
|
||||
) : searchResults.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{searchResults.map((post) => (
|
||||
<CompactPostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="검색 결과가 없습니다." description="다른 키워드로 다시 찾아보세요." />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NoticeStrip({ notices }: { notices: Post[] }) {
|
||||
if (notices.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-2" aria-label="공지">
|
||||
{notices.slice(0, 3).map((notice) => (
|
||||
<Link key={notice.id} href={`/posts/${notice.slug}`} className="group block">
|
||||
<Surface
|
||||
interactive
|
||||
className="flex items-center justify-between gap-4 border-red-500/10 bg-red-500/[0.06] px-4 py-3 shadow-none"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<StatusBadge tone="danger">공지</StatusBadge>
|
||||
<span className="truncate text-sm font-semibold text-[var(--color-text)]">
|
||||
{notice.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
<time>{formatDate(notice.createdAt)}</time>
|
||||
<ChevronRight size={15} className="transition group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</Surface>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedPost({ post }: { post?: Post }) {
|
||||
if (!post) {
|
||||
return (
|
||||
<Surface as="section" strong className="flex min-h-72 items-center justify-center p-6">
|
||||
<EmptyState title="대표 글을 기다리고 있습니다." description="새 글이 발행되면 이곳에 먼저 표시됩니다." />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/posts/${post.slug}`} className="group block h-full">
|
||||
<Surface as="article" strong interactive className="flex h-full min-h-72 flex-col p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<StatusBadge tone="info">{post.categoryName || '미분류'}</StatusBadge>
|
||||
<time className="text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{formatDate(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<p className="mb-3 text-sm font-semibold text-[var(--color-accent)]">대표 글</p>
|
||||
<h2 className="text-2xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-3xl">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p className="mt-4 line-clamp-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{getSummary(post.content, 160)}
|
||||
</p>
|
||||
<span className="mt-6 inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
읽기
|
||||
<ChevronRight size={16} className="transition group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</div>
|
||||
</Surface>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactPostRow({ post }: { post: Post }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
className="group flex items-start justify-between gap-4 rounded-lg px-3 py-4 transition duration-150 hover:bg-black/[0.03] dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<StatusBadge tone={isNoticePost(post) ? 'danger' : 'neutral'} className="shrink-0">
|
||||
{post.categoryName || '미분류'}
|
||||
</StatusBadge>
|
||||
<time className="text-xs text-[var(--color-text-subtle)]">
|
||||
{formatDate(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
<h3 className="line-clamp-1 text-base font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="mt-1 line-clamp-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{getSummary(post.content, 96)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight size={18} className="mt-7 shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryShelf({
|
||||
category,
|
||||
posts,
|
||||
isLoading,
|
||||
}: {
|
||||
category: Category;
|
||||
posts: Post[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Surface as="article" className="p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-bold text-[var(--color-text)]">
|
||||
{category.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">최근 글</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/category/${category.name}`}
|
||||
className="shrink-0 text-xs font-semibold text-[var(--color-accent)]"
|
||||
>
|
||||
전체 보기
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2].map((item) => (
|
||||
<div key={item} className="h-10 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
|
||||
))}
|
||||
</div>
|
||||
) : posts.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{posts.slice(0, 3).map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/posts/${post.slug}`}
|
||||
className="group flex items-center justify-between gap-3 py-3"
|
||||
>
|
||||
<span className="line-clamp-1 text-sm font-semibold text-[var(--color-text-muted)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</span>
|
||||
<ChevronRight size={15} className="shrink-0 text-[var(--color-text-subtle)]" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="아직 글이 없습니다." className="min-h-28 py-6" />
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// URL 쿼리 스트링에서 검색어 가져오기
|
||||
const keyword = searchParams.get('keyword') || '';
|
||||
|
||||
// 1. 공지사항 조회
|
||||
const { data: noticesData, isLoading: isNoticesLoading } = useQuery({
|
||||
queryKey: ['posts', 'notices'],
|
||||
queryFn: () => getPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
// 2. 최신 게시글 조회
|
||||
const { data: latestData, isLoading: isLatestLoading } = useQuery({
|
||||
queryKey: ['posts', 'latest'],
|
||||
queryFn: () => getPosts({ size: 10, sort: 'createdAt,desc' }),
|
||||
queryFn: () => getPosts({ size: 8, sort: 'createdAt,desc' }),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
// 3. 인기 게시글 조회
|
||||
const { data: popularData, isLoading: isPopularLoading } = useQuery({
|
||||
queryKey: ['posts', 'popular'],
|
||||
queryFn: () => getPosts({ size: 10, sort: 'viewCount,desc' }),
|
||||
queryFn: () => getPosts({ size: 5, sort: 'viewCount,desc' }),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: getCategories,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
// 4. 검색 결과 조회
|
||||
const { data: searchData, isLoading: isSearchLoading } = useQuery({
|
||||
queryKey: ['posts', 'search', keyword],
|
||||
queryFn: () => getPosts({ keyword, size: 20 }),
|
||||
enabled: !!keyword,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
if (isNoticesLoading || isLatestLoading || isPopularLoading) {
|
||||
const shelfCategories = useMemo(() => getHomeCategories(categories), [categories]);
|
||||
const categoryPostQueries = useQueries({
|
||||
queries: shelfCategories.map((category) => ({
|
||||
queryKey: ['posts', 'category-shelf', category.name],
|
||||
queryFn: () => getPosts({ category: category.name, size: 3, sort: 'createdAt,desc' }),
|
||||
enabled: !keyword,
|
||||
retry: 0,
|
||||
})),
|
||||
});
|
||||
|
||||
const notices = noticesData?.content || [];
|
||||
const latestPosts = (latestData?.content || []).filter((post) => !isNoticePost(post));
|
||||
const popularPosts = (popularData?.content || []).filter((post) => !isNoticePost(post));
|
||||
const featuredPost = latestPosts[0];
|
||||
const latestList = latestPosts.slice(1, 7);
|
||||
const isHomeLoading = !keyword && (isNoticesLoading || isLatestLoading || isPopularLoading);
|
||||
|
||||
if (keyword) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||
<main className="mx-auto max-w-5xl px-4 py-8 md:px-6">
|
||||
<SearchResults keyword={keyword} data={searchData} isLoading={isSearchLoading} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (isHomeLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filterPosts = (posts: Post[] | undefined, limit: number) => {
|
||||
if (!posts) return [];
|
||||
return posts
|
||||
.filter((post) => post.categoryName !== '공지' && post.categoryName.toLowerCase() !== 'notice')
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
const notices = noticesData?.content || [];
|
||||
const latestPosts = filterPosts(latestData?.content, 3);
|
||||
const popularPosts = filterPosts(popularData?.content, 3);
|
||||
|
||||
const searchResults = searchData?.content || [];
|
||||
const searchMeta = (searchData as any)?.page || searchData;
|
||||
const searchTotalElements = searchMeta?.totalElements ?? 0;
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-4 py-8 space-y-12">
|
||||
|
||||
{/* 🅰️ 검색 모드: 검색어가 있을 때 표시 */}
|
||||
{keyword ? (
|
||||
<section className="animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="flex items-center gap-2 mb-6 border-b border-gray-100 pb-4">
|
||||
<SearchIcon className="text-blue-500" size={24} />
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
"{keyword}" 검색 결과 <span className="text-blue-600 text-lg ml-1">{searchTotalElements}</span>건
|
||||
</h2>
|
||||
<main className="mx-auto max-w-6xl space-y-12 px-4 py-8 md:px-6">
|
||||
<section className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr] lg:items-end">
|
||||
<div className="max-w-2xl">
|
||||
<StatusBadge tone="info" className="mb-5">
|
||||
WYPark Blog
|
||||
</StatusBadge>
|
||||
<h1 className="text-4xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-5xl">
|
||||
박원엽의 개발 기록
|
||||
</h1>
|
||||
<p className="mt-5 text-base leading-7 text-[var(--color-text-muted)] md:text-lg">
|
||||
백엔드, 네트워크, 운영 경험을 차분하게 정리하는 개인 기술 블로그입니다.
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/archive"
|
||||
className="inline-flex h-10 items-center gap-2 rounded-full bg-[var(--color-text)] px-4 text-sm font-semibold text-white transition hover:opacity-90 dark:bg-white dark:text-black"
|
||||
>
|
||||
전체 글 둘러보기
|
||||
<Archive size={16} />
|
||||
</Link>
|
||||
<Link
|
||||
href="#blog-search"
|
||||
className="inline-flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/70 px-4 text-sm font-semibold text-[var(--color-text-muted)] transition hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10"
|
||||
>
|
||||
검색으로 찾기
|
||||
<Search size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Surface className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[var(--color-accent-soft)] text-[var(--color-accent)]">
|
||||
<Sparkles size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[var(--color-text)]">최근 업데이트</p>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{featuredPost ? formatDate(featuredPost.createdAt) : '새 글을 준비 중입니다.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
<NoticeStrip notices={notices} />
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<FeaturedPost post={featuredPost} />
|
||||
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={18} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">최신 글</h2>
|
||||
</div>
|
||||
<Link href="/archive" className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
전체 보기
|
||||
<ChevronRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isSearchLoading ? (
|
||||
<div className="py-20 flex justify-center"><Loader2 className="animate-spin text-blue-500" /></div>
|
||||
) : searchResults.length > 0 ? (
|
||||
<div className="flex flex-col gap-0 border-t border-gray-100">
|
||||
{searchResults.map((post) => (
|
||||
<PostListItem key={post.id} post={post} />
|
||||
{latestList.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{latestList.map((post) => (
|
||||
<CompactPostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 bg-gray-50 rounded-xl text-gray-400">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
/* 🅱️ 대시보드 모드: 검색어가 없을 때 기존 화면 표시 */
|
||||
<div className="space-y-16 animate-in fade-in duration-500">
|
||||
{/* 공지사항 섹션 */}
|
||||
{notices.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4 pb-2 border-b border-gray-100">
|
||||
<Megaphone className="text-red-500" size={20} />
|
||||
<h2 className="text-xl font-bold text-gray-800">공지사항</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{notices.map((post) => (
|
||||
<PostListItem key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<EmptyState title="최신 글 목록을 기다리고 있습니다." />
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* 최신 포스트 섹션 */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-blue-500" size={20} />
|
||||
<h2 className="text-xl font-bold text-gray-800">최신 포스트</h2>
|
||||
</div>
|
||||
<Link href="/archive" className="text-sm text-gray-400 hover:text-blue-600 flex items-center gap-1 transition-colors">
|
||||
전체보기 <ChevronRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{latestPosts.length > 0 ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{latestPosts.map((post) => (
|
||||
<div key={post.id} className="h-full">
|
||||
<PostCard post={post} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-400">
|
||||
아직 작성된 글이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 인기 포스트 섹션 */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Flame className="text-orange-500" size={20} />
|
||||
<h2 className="text-xl font-bold text-gray-800">인기 포스트</h2>
|
||||
</div>
|
||||
|
||||
{popularPosts.length > 0 ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{popularPosts.map((post) => (
|
||||
<div key={post.id} className="h-full">
|
||||
<PostCard post={post} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-400">
|
||||
아직 인기 글이 집계되지 않았습니다.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 하단 아카이브 링크 */}
|
||||
{/* <div className="pt-8 pb-4 text-center">
|
||||
<Link
|
||||
href="/archive"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200 transition-colors font-medium text-sm"
|
||||
>
|
||||
모든 글 보러가기 <ChevronRight size={16} />
|
||||
</Link>
|
||||
</div> */}
|
||||
<section>
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<FolderTree size={19} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-xl font-bold text-[var(--color-text)]">카테고리별 글</h2>
|
||||
</div>
|
||||
)}
|
||||
{shelfCategories.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{shelfCategories.map((category, index) => (
|
||||
<CategoryShelf
|
||||
key={category.id}
|
||||
category={category}
|
||||
posts={categoryPostQueries[index]?.data?.content || []}
|
||||
isLoading={categoryPostQueries[index]?.isLoading || false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="카테고리를 불러오지 못했습니다." description="카테고리 API가 연결되면 이곳에 탐색 선반이 표시됩니다." />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles size={18} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">많이 읽힌 글</h2>
|
||||
</div>
|
||||
{popularPosts.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{popularPosts.slice(0, 4).map((post) => (
|
||||
<CompactPostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="인기 글을 집계 중입니다." description="조회수 숫자는 공개 홈에서 강조하지 않습니다." />
|
||||
)}
|
||||
</Surface>
|
||||
|
||||
<Surface as="section" className="flex flex-col justify-between gap-8 p-6">
|
||||
<div>
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--color-accent-soft)] text-[var(--color-accent)]">
|
||||
<Archive size={20} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold tracking-normal text-[var(--color-text)]">기록은 아카이브에 쌓입니다.</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
월별 글 흐름과 지난 글은 아카이브에서 한 번에 살펴볼 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/archive"
|
||||
className="inline-flex w-fit items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/70 px-4 py-2 text-sm font-semibold text-[var(--color-text)] transition hover:bg-white dark:bg-white/10"
|
||||
>
|
||||
아카이브 보기
|
||||
<ChevronRight size={16} />
|
||||
</Link>
|
||||
</Surface>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex justify-center items-center h-screen"><Loader2 className="animate-spin text-blue-500" size={40} /></div>}>
|
||||
<Suspense
|
||||
fallback={(
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<HomeContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ function isTokenExpired(token: string) {
|
||||
// 현재 시간(초)이 만료 시간(exp)보다 크거나 같으면 만료됨
|
||||
// 안전 마진 60초 추가 (만료 1분 전이면 미리 갱신)
|
||||
return Date.now() / 1000 >= exp - 60;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return true; // 파싱 실패 시 만료된 것으로 간주
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ function AuthInitializer() {
|
||||
} else {
|
||||
throw new Error('Token refresh response invalid');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// console.error('❌ Failed to refresh token on init:', error);
|
||||
// 갱신 실패 시 깔끔하게 로그아웃 처리하여 꼬임 방지
|
||||
logout();
|
||||
|
||||
@@ -7,6 +7,19 @@ import { signup, verifyEmail } from '@/api/auth';
|
||||
import { SignupRequest } from '@/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
const fallbackMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
||||
const responseError = error as {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
return responseError.response?.data?.message || fallbackMessage;
|
||||
};
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<'FORM' | 'VERIFY'>('FORM'); // 단계 관리
|
||||
@@ -14,7 +27,7 @@ export default function SignupPage() {
|
||||
const [registeredEmail, setRegisteredEmail] = useState(''); // 인증할 이메일 저장
|
||||
|
||||
// React Hook Form 설정
|
||||
const { register, handleSubmit, formState: { errors }, watch } = useForm<SignupRequest>();
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<SignupRequest>();
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
|
||||
// 1단계: 회원가입 정보 제출
|
||||
@@ -29,8 +42,8 @@ export default function SignupPage() {
|
||||
} else {
|
||||
alert('회원가입 실패: ' + res.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert('오류 발생: ' + (error.response?.data?.message || error.message));
|
||||
} catch (error) {
|
||||
alert('오류 발생: ' + getErrorMessage(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -50,8 +63,8 @@ export default function SignupPage() {
|
||||
} else {
|
||||
alert('인증 실패: ' + res.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert('오류 발생: ' + (error.response?.data?.message || error.message));
|
||||
} catch (error) {
|
||||
alert('오류 발생: ' + getErrorMessage(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -160,4 +173,4 @@ export default function SignupPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
interface SitemapPost {
|
||||
slug: string;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = 'https://blog.wypark.me';
|
||||
// API 주소 환경변수 사용 (없으면 로컬)
|
||||
@@ -33,14 +39,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
throw new Error('Failed to fetch posts');
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const json = await response.json() as { data?: { content?: SitemapPost[] } };
|
||||
const posts = json.data?.content || [];
|
||||
|
||||
// 3. 게시글 데이터를 사이트맵 형식으로 변환
|
||||
const postRoutes = posts.map((post: any) => ({
|
||||
const postRoutes = posts.map((post) => ({
|
||||
// 🛠️ 핵심 수정: slug를 encodeURIComponent로 감싸서 특수문자(&, 한글 등)를 안전하게 처리합니다.
|
||||
url: `${baseUrl}/posts/${encodeURIComponent(post.slug)}`,
|
||||
lastModified: new Date(post.updatedAt),
|
||||
lastModified: new Date(post.updatedAt || post.createdAt || Date.now()),
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
}));
|
||||
@@ -51,4 +57,4 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
console.error('Sitemap generation error:', error);
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,14 +50,14 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
|
||||
if (!_hasHydrated || !isAdmin) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={36} />
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6 px-1 py-4 md:px-4">
|
||||
<nav className="flex gap-2 overflow-x-auto border-b border-gray-200 pb-3" aria-label="관리자 메뉴">
|
||||
<div className="mx-auto max-w-7xl space-y-6 px-1 py-4 md:px-4">
|
||||
<nav className="flex gap-2 overflow-x-auto border-b border-[var(--color-line)] pb-3" aria-label="관리자 메뉴">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
@@ -69,8 +69,8 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
|
||||
className={clsx(
|
||||
'inline-flex shrink-0 items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition',
|
||||
isActive
|
||||
? 'bg-gray-950 text-white'
|
||||
: 'border border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-950',
|
||||
? 'bg-[var(--color-text)] text-white shadow-sm dark:bg-white dark:text-black'
|
||||
: 'border border-[var(--color-line)] bg-white/70 text-[var(--color-text-muted)] hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import Link from 'next/link';
|
||||
import { AlertCircle, ArrowRight, MessageSquareText, RefreshCcw, Tags } from 'lucide-react';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { DashboardActionItems } from '@/types';
|
||||
|
||||
interface AdminDashboardActionCenterProps {
|
||||
actionItems?: DashboardActionItems;
|
||||
isFallback: boolean;
|
||||
}
|
||||
|
||||
export default function AdminDashboardActionCenter({
|
||||
actionItems,
|
||||
isFallback,
|
||||
}: AdminDashboardActionCenterProps) {
|
||||
const items = [
|
||||
{
|
||||
label: '답변 필요한 댓글',
|
||||
value: actionItems?.unansweredComments,
|
||||
href: '/admin/comments',
|
||||
icon: MessageSquareText,
|
||||
},
|
||||
{
|
||||
label: '미분류 글',
|
||||
value: actionItems?.uncategorizedPosts,
|
||||
href: '/admin/posts',
|
||||
icon: Tags,
|
||||
},
|
||||
{
|
||||
label: '오래 방치된 인기 글',
|
||||
value: actionItems?.stalePopularPosts,
|
||||
href: '/admin/posts',
|
||||
icon: RefreshCcw,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<AlertCircle size={19} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">액션 센터</h2>
|
||||
</div>
|
||||
|
||||
{actionItems ? (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-black/[0.03] dark:hover:bg-white/10"
|
||||
>
|
||||
<span className="flex items-center gap-3 text-sm font-semibold text-[var(--color-text-muted)]">
|
||||
<Icon size={16} />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
|
||||
{(item.value ?? 0).toLocaleString()}
|
||||
<ArrowRight size={15} className="text-[var(--color-text-subtle)]" />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="액션 집계 연결 전"
|
||||
description={isFallback ? '관리자 대시보드 API가 준비되면 처리할 일을 모아 보여줍니다.' : '처리할 항목이 없습니다.'}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight, FolderTree } from 'lucide-react';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { DashboardCategoryStat } from '@/types';
|
||||
|
||||
interface AdminDashboardCategoryHealthProps {
|
||||
categoryStats: DashboardCategoryStat[];
|
||||
isFallback: boolean;
|
||||
}
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return '발행 기록 없음';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '발행 기록 없음';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function AdminDashboardCategoryHealth({
|
||||
categoryStats,
|
||||
isFallback,
|
||||
}: AdminDashboardCategoryHealthProps) {
|
||||
return (
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<FolderTree size={19} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">카테고리 상태</h2>
|
||||
</div>
|
||||
|
||||
{categoryStats.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--color-line)]">
|
||||
<div className="min-w-[680px]">
|
||||
<div className="grid grid-cols-[1.2fr_0.6fr_0.7fr_0.9fr_auto] gap-3 bg-black/[0.03] px-4 py-3 text-xs font-bold text-[var(--color-text-subtle)] dark:bg-white/10">
|
||||
<span>카테고리</span>
|
||||
<span>글</span>
|
||||
<span>최근 조회</span>
|
||||
<span>최근 발행</span>
|
||||
<span />
|
||||
</div>
|
||||
{categoryStats.slice(0, 6).map((category) => (
|
||||
<Link
|
||||
key={category.id}
|
||||
href={`/category/${category.name}`}
|
||||
className="grid grid-cols-[1.2fr_0.6fr_0.7fr_0.9fr_auto] gap-3 border-t border-[var(--color-line)] px-4 py-3 text-sm transition hover:bg-black/[0.03] dark:hover:bg-white/10"
|
||||
>
|
||||
<span className="truncate font-semibold text-[var(--color-text)]">{category.name}</span>
|
||||
<span className="text-[var(--color-text-muted)]">{category.postCount.toLocaleString()}</span>
|
||||
<span className="text-[var(--color-text-muted)]">{category.recentViewCount.toLocaleString()}</span>
|
||||
<span className="truncate text-[var(--color-text-subtle)]">{formatDate(category.lastPublishedAt)}</span>
|
||||
<ArrowRight size={15} className="text-[var(--color-text-subtle)]" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={isFallback ? '카테고리 통계 API 연결 전' : '카테고리 통계가 없습니다.'}
|
||||
description={isFallback ? '글 수, 최근 조회수, 최근 발행일은 dashboard API 연결 후 표시됩니다.' : undefined}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
82
src/components/admin/dashboard/AdminDashboardOverview.tsx
Normal file
82
src/components/admin/dashboard/AdminDashboardOverview.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { CalendarClock, Eye, FileText, FolderTree, MessageSquareText } from 'lucide-react';
|
||||
import MetricCard from '@/components/ui/MetricCard';
|
||||
import { DashboardOverview } from '@/types';
|
||||
|
||||
interface AdminDashboardOverviewProps {
|
||||
overview?: DashboardOverview;
|
||||
fallbackTotals: {
|
||||
totalPosts: number;
|
||||
totalComments: number;
|
||||
totalCategories: number;
|
||||
lastPublishedAt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) return '최근 발행 정보 없음';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '최근 발행 정보 없음';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function AdminDashboardOverview({
|
||||
overview,
|
||||
fallbackTotals,
|
||||
}: AdminDashboardOverviewProps) {
|
||||
const totalPosts = overview?.totalPosts ?? fallbackTotals.totalPosts;
|
||||
const totalComments = overview?.totalComments ?? fallbackTotals.totalComments;
|
||||
const totalCategories = overview?.totalCategories ?? fallbackTotals.totalCategories;
|
||||
const lastPublishedAt = overview?.lastPublishedAt ?? fallbackTotals.lastPublishedAt;
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<MetricCard
|
||||
label="오늘 조회수"
|
||||
value={overview?.todayViews.value ?? null}
|
||||
changeRate={overview?.todayViews.changeRate}
|
||||
helper="백엔드 집계가 준비되면 표시됩니다."
|
||||
disabled={!overview}
|
||||
icon={<Eye size={18} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="최근 7일 조회수"
|
||||
value={overview?.weekViews.value ?? null}
|
||||
changeRate={overview?.weekViews.changeRate}
|
||||
helper="백엔드 집계가 준비되면 표시됩니다."
|
||||
disabled={!overview}
|
||||
icon={<Eye size={18} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="최근 30일 조회수"
|
||||
value={overview?.monthViews.value ?? null}
|
||||
changeRate={overview?.monthViews.changeRate}
|
||||
helper="백엔드 집계가 준비되면 표시됩니다."
|
||||
disabled={!overview}
|
||||
icon={<Eye size={18} />}
|
||||
/>
|
||||
<MetricCard label="총 게시글" value={totalPosts} icon={<FileText size={18} />} />
|
||||
<MetricCard label="총 댓글" value={totalComments} icon={<MessageSquareText size={18} />} />
|
||||
<MetricCard
|
||||
label="카테고리"
|
||||
value={totalCategories}
|
||||
helper={formatDateTime(lastPublishedAt)}
|
||||
icon={<FolderTree size={18} />}
|
||||
/>
|
||||
{overview?.generatedAt && (
|
||||
<div className="sm:col-span-2 xl:col-span-3">
|
||||
<p className="inline-flex items-center gap-2 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
<CalendarClock size={14} />
|
||||
마지막 집계 {formatDateTime(overview.generatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight, Flame, TrendingUp } from 'lucide-react';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { DashboardPostStat } from '@/types';
|
||||
|
||||
interface AdminDashboardPostPerformanceProps {
|
||||
topPosts: DashboardPostStat[];
|
||||
risingPosts: DashboardPostStat[];
|
||||
stalePopularPosts: DashboardPostStat[];
|
||||
isFallback: boolean;
|
||||
}
|
||||
|
||||
function PostStatRow({ post, isFallback }: { post: DashboardPostStat; isFallback: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
className="group flex items-start justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-black/[0.03] dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<StatusBadge>{post.categoryName || '미분류'}</StatusBadge>
|
||||
<span className="text-xs text-[var(--color-text-subtle)]">
|
||||
{isFallback ? '누적 조회' : '기간 조회'} {(isFallback ? post.viewCount : post.rangeViewCount).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="line-clamp-1 text-sm font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight size={15} className="mt-5 shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminDashboardPostPerformance({
|
||||
topPosts,
|
||||
risingPosts,
|
||||
stalePopularPosts,
|
||||
isFallback,
|
||||
}: AdminDashboardPostPerformanceProps) {
|
||||
const secondaryPosts = risingPosts.length > 0 ? risingPosts : stalePopularPosts;
|
||||
|
||||
return (
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<Flame size={19} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">콘텐츠 성과</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
|
||||
<Flame size={16} />
|
||||
인기 글
|
||||
</div>
|
||||
{topPosts.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{topPosts.slice(0, 5).map((post) => (
|
||||
<PostStatRow key={post.id} post={post} isFallback={isFallback} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="인기 글 데이터가 없습니다." className="min-h-40" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
|
||||
<TrendingUp size={16} />
|
||||
{risingPosts.length > 0 ? '상승 중인 글' : '관리 후보 글'}
|
||||
</div>
|
||||
{secondaryPosts.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{secondaryPosts.slice(0, 5).map((post) => (
|
||||
<PostStatRow key={post.id} post={post} isFallback={isFallback} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={isFallback ? '통계 API 연결 전' : '관리 후보가 없습니다.'}
|
||||
description={isFallback ? '상승/방치 기준은 dashboard API 연결 후 표시됩니다.' : undefined}
|
||||
className="min-h-40"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import SegmentedControl from '@/components/ui/SegmentedControl';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { DashboardRange, DashboardTrafficPoint } from '@/types';
|
||||
|
||||
interface AdminDashboardTrafficChartProps {
|
||||
points: DashboardTrafficPoint[];
|
||||
range: DashboardRange;
|
||||
onRangeChange: (range: DashboardRange) => void;
|
||||
isFallback: boolean;
|
||||
}
|
||||
|
||||
const rangeOptions = [
|
||||
{ label: '7일', value: '7d' },
|
||||
{ label: '30일', value: '30d' },
|
||||
{ label: '90일', value: '90d' },
|
||||
] satisfies { label: string; value: DashboardRange }[];
|
||||
|
||||
const formatDay = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function AdminDashboardTrafficChart({
|
||||
points,
|
||||
range,
|
||||
onRangeChange,
|
||||
isFallback,
|
||||
}: AdminDashboardTrafficChartProps) {
|
||||
const maxViews = Math.max(...points.map((point) => point.views), 1);
|
||||
|
||||
return (
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-6 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 size={19} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">트래픽 흐름</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
최근 기간별 조회수 흐름을 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
ariaLabel="트래픽 기간"
|
||||
options={rangeOptions}
|
||||
value={range}
|
||||
onChange={onRangeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{points.length > 0 ? (
|
||||
<div className="flex h-64 items-end gap-1.5 overflow-hidden rounded-lg border border-[var(--color-line)] bg-white/50 px-3 py-4 dark:bg-white/5">
|
||||
{points.map((point) => {
|
||||
const height = Math.max((point.views / maxViews) * 100, 4);
|
||||
|
||||
return (
|
||||
<div key={point.date} className="flex min-w-2 flex-1 flex-col items-center gap-2">
|
||||
<div
|
||||
className="w-full rounded-t bg-[var(--color-accent)]/70 transition hover:bg-[var(--color-accent)]"
|
||||
style={{ height: `${height}%` }}
|
||||
title={`${formatDay(point.date)} 조회 ${point.views.toLocaleString()}`}
|
||||
/>
|
||||
<span className="hidden text-[10px] font-medium text-[var(--color-text-subtle)] sm:block">
|
||||
{formatDay(point.date)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="통계 API 연결 전"
|
||||
description={isFallback ? '백엔드 집계가 준비되면 기간별 그래프가 표시됩니다.' : '표시할 트래픽 데이터가 없습니다.'}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { useAuthStore } from '@/store/authStore';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createComment } from '@/api/comments';
|
||||
import { Loader2, Send } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface CommentFormProps {
|
||||
postSlug: string;
|
||||
@@ -15,7 +14,7 @@ interface CommentFormProps {
|
||||
}
|
||||
|
||||
export default function CommentForm({ postSlug, parentId = null, onSuccess, placeholder = '댓글을 남겨보세요.' }: CommentFormProps) {
|
||||
const { isLoggedIn, role } = useAuthStore();
|
||||
const { isLoggedIn } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
@@ -29,8 +28,16 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||
if (onSuccess) onSuccess();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
alert('댓글 작성 실패: ' + (err.response?.data?.message || err.message));
|
||||
onError: (error: unknown) => {
|
||||
const responseError = error as {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
const fallbackMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
||||
alert('댓글 작성 실패: ' + (responseError.response?.data?.message || fallbackMessage));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,4 +105,4 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getComments } from '@/api/comments';
|
||||
import CommentForm from './CommentForm';
|
||||
import CommentItem from './CommentItem';
|
||||
import { Loader2, MessageCircle } from 'lucide-react';
|
||||
import { Comment } from '@/types';
|
||||
|
||||
interface CommentListProps {
|
||||
postSlug: string;
|
||||
@@ -18,7 +19,7 @@ export default function CommentList({ postSlug }: CommentListProps) {
|
||||
});
|
||||
|
||||
// 총 댓글 수 계산 (재귀)
|
||||
const countComments = (list: any[]): number => {
|
||||
const countComments = (list: Comment[]): number => {
|
||||
if (!list) return 0;
|
||||
return list.reduce((acc, curr) => acc + 1 + countComments(curr.children), 0);
|
||||
};
|
||||
@@ -61,4 +62,4 @@ export default function CommentList({ postSlug }: CommentListProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between rounded-lg px-4 py-2 text-sm transition-all',
|
||||
isActive ? 'bg-blue-50 font-medium text-blue-600' : 'text-gray-600 hover:bg-gray-50',
|
||||
isActive
|
||||
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
style={{ marginLeft: `${depth * 10}px` }}
|
||||
>
|
||||
@@ -82,19 +84,19 @@ function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
|
||||
event.stopPropagation();
|
||||
setIsExpanded((previous) => !previous);
|
||||
}}
|
||||
className="ml-1 rounded-full p-1 transition-colors hover:bg-gray-200/50"
|
||||
className="ml-1 rounded-full p-1 transition-colors hover:bg-black/[0.06] dark:hover:bg-white/10"
|
||||
aria-label={isChildrenVisible ? `${category.name} 접기` : `${category.name} 펼치기`}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={clsx('text-gray-400 transition-transform duration-200', isChildrenVisible && 'rotate-90')}
|
||||
className={clsx('text-[var(--color-text-subtle)] transition-transform duration-200', isChildrenVisible && 'rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isChildrenVisible && hasChildren && (
|
||||
<div className="ml-4 border-l-2 border-gray-100">
|
||||
<div className="ml-4 border-l border-[var(--color-line)]">
|
||||
{sortedChildren.map((child) => (
|
||||
<CategoryItem
|
||||
key={child.id}
|
||||
@@ -147,7 +149,7 @@ function SidebarContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((previous) => !previous)}
|
||||
className="fixed left-4 top-4 z-50 rounded-full bg-white p-2 shadow-md transition-colors hover:bg-gray-100 md:hidden"
|
||||
className="fixed left-4 top-4 z-50 rounded-full border border-[var(--color-line)] bg-[var(--color-surface-strong)] p-2 shadow-md transition-colors hover:bg-white md:hidden"
|
||||
aria-label={isOpen ? '사이드바 닫기' : '사이드바 열기'}
|
||||
>
|
||||
{isOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
@@ -155,15 +157,15 @@ function SidebarContent() {
|
||||
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed left-0 top-0 z-40 flex h-screen flex-col overflow-hidden border-r border-gray-100 bg-white transition-all duration-300 ease-in-out',
|
||||
'fixed left-0 top-0 z-40 flex h-screen flex-col overflow-hidden border-r border-[var(--color-line)] bg-[var(--color-surface-strong)]/95 backdrop-blur-xl transition-all duration-300 ease-in-out',
|
||||
isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0',
|
||||
)}
|
||||
>
|
||||
<div className={clsx('shrink-0 p-6 text-center transition-opacity duration-200', !isOpen && 'md:hidden md:opacity-0')}>
|
||||
<Link href="/" className="block transition-opacity hover:opacity-80">
|
||||
<div className="relative mx-auto mb-4 h-24 w-24 overflow-hidden rounded-full bg-gray-200 shadow-inner ring-4 ring-gray-50">
|
||||
<div className="relative mx-auto mb-4 h-24 w-24 overflow-hidden rounded-full bg-black/[0.04] shadow-inner ring-4 ring-white/70 dark:bg-white/10 dark:ring-white/10">
|
||||
{isProfileLoading ? (
|
||||
<div className="h-full w-full animate-pulse bg-gray-200" />
|
||||
<div className="h-full w-full animate-pulse bg-black/[0.06] dark:bg-white/10" />
|
||||
) : (
|
||||
<Image
|
||||
src={displayProfile.imageUrl || defaultProfile.imageUrl!}
|
||||
@@ -179,13 +181,13 @@ function SidebarContent() {
|
||||
|
||||
{isProfileLoading ? (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="h-6 w-24 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-gray-100" />
|
||||
<div className="h-6 w-24 animate-pulse rounded bg-black/[0.06] dark:bg-white/10" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-bold text-gray-800">{displayProfile.name}</h2>
|
||||
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-gray-500">{displayProfile.bio}</p>
|
||||
<h2 className="text-xl font-bold tracking-normal text-[var(--color-text)]">{displayProfile.name}</h2>
|
||||
<p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-[var(--color-text-muted)]">{displayProfile.bio}</p>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
@@ -193,12 +195,12 @@ function SidebarContent() {
|
||||
|
||||
<nav className="flex min-h-0 flex-1 flex-col px-4 py-2">
|
||||
<div className={clsx('mt-4 flex shrink-0 flex-col items-center gap-4', isOpen && 'hidden')}>
|
||||
<Folder size={24} className="text-gray-400" />
|
||||
<Folder size={24} className="text-[var(--color-text-subtle)]" />
|
||||
</div>
|
||||
|
||||
<div className={clsx('flex h-full flex-col', !isOpen && 'md:hidden')}>
|
||||
<div className="shrink-0 space-y-1">
|
||||
<div className="mb-6 mt-2 px-1">
|
||||
<div id="blog-search" className="mb-6 mt-2 px-1">
|
||||
<PostSearch
|
||||
onSearch={handleSearch}
|
||||
placeholder="검색..."
|
||||
@@ -212,19 +214,19 @@ function SidebarContent() {
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all',
|
||||
pathname === '/archive'
|
||||
? 'bg-blue-50 font-medium text-blue-600'
|
||||
: 'text-gray-600 hover:bg-gray-50',
|
||||
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<Archive size={16} />
|
||||
<span>Archives</span>
|
||||
<span>아카이브</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 border-t border-gray-100" />
|
||||
<div className="mb-4 border-t border-[var(--color-line)]" />
|
||||
|
||||
<div className="mb-3 flex h-8 items-center justify-between px-4">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-gray-400">Categories</p>
|
||||
<p className="text-xs font-bold text-[var(--color-text-subtle)]">카테고리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -232,7 +234,7 @@ function SidebarContent() {
|
||||
{!categories && (
|
||||
<div className="space-y-2 px-4">
|
||||
{[1, 2, 3].map((item) => (
|
||||
<div key={item} className="h-8 animate-pulse rounded bg-gray-100" />
|
||||
<div key={item} className="h-8 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -253,8 +255,8 @@ function SidebarContent() {
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all',
|
||||
pathname === '/category/uncategorized'
|
||||
? 'bg-blue-50 font-medium text-blue-600'
|
||||
: 'text-gray-600 hover:bg-gray-50',
|
||||
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<FileQuestion size={16} />
|
||||
@@ -266,26 +268,26 @@ function SidebarContent() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className={clsx('shrink-0 border-t border-gray-100 bg-white p-6', !isOpen && 'hidden')}>
|
||||
<div className={clsx('shrink-0 border-t border-[var(--color-line)] bg-[var(--color-surface-strong)]/80 p-6', !isOpen && 'hidden')}>
|
||||
<div className="flex justify-center gap-3">
|
||||
<a
|
||||
href={displayProfile.githubUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full bg-gray-50 p-2.5 text-gray-500 shadow-sm transition-all hover:bg-gray-800 hover:text-white"
|
||||
aria-label="Github"
|
||||
className="rounded-full border border-[var(--color-line)] bg-white/70 p-2.5 text-[var(--color-text-muted)] shadow-sm transition-all hover:bg-[var(--color-text)] hover:text-white dark:bg-white/10 dark:hover:bg-white dark:hover:text-black"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<Github size={18} />
|
||||
</a>
|
||||
<a
|
||||
href={`mailto:${displayProfile.email}`}
|
||||
className="rounded-full bg-gray-50 p-2.5 text-gray-500 shadow-sm transition-all hover:bg-blue-500 hover:text-white"
|
||||
aria-label="Email"
|
||||
className="rounded-full border border-[var(--color-line)] bg-white/70 p-2.5 text-[var(--color-text-muted)] shadow-sm transition-all hover:bg-[var(--color-accent)] hover:text-white dark:bg-white/10"
|
||||
aria-label="이메일"
|
||||
>
|
||||
<Mail size={18} />
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-4 text-center text-[10px] font-light text-gray-300">
|
||||
<p className="mt-4 text-center text-[10px] font-light text-[var(--color-text-subtle)]">
|
||||
© {new Date().getFullYear()} {displayProfile.name}. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
@@ -296,7 +298,7 @@ function SidebarContent() {
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-screen w-72 border-r border-gray-100 bg-white" />}>
|
||||
<Suspense fallback={<div className="h-screen w-72 border-r border-[var(--color-line)] bg-[var(--color-surface-strong)]" />}>
|
||||
<SidebarContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -29,15 +29,15 @@ export default function TopHeader() {
|
||||
<>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-gray-700 text-sm font-semibold rounded-full border border-gray-200 shadow-sm hover:bg-gray-50 hover:text-blue-600 transition-colors"
|
||||
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-semibold text-[var(--color-text-muted)] shadow-sm backdrop-blur-xl transition-colors hover:bg-white hover:text-[var(--color-accent)] dark:bg-white/10"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span className="hidden sm:inline">관리자 설정</span>
|
||||
<span className="hidden sm:inline">관리자</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/posts/new"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded-full shadow-md hover:bg-blue-700 hover:shadow-lg transition-all transform hover:-translate-y-0.5"
|
||||
className="flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-bold text-white shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
<PenLine size={16} />
|
||||
<span>새 글</span>
|
||||
@@ -47,7 +47,7 @@ export default function TopHeader() {
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-gray-600 text-sm font-medium rounded-full border border-gray-200 shadow-sm hover:bg-gray-50 hover:text-red-500 transition-colors"
|
||||
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-medium text-[var(--color-text-muted)] shadow-sm backdrop-blur-xl transition-colors hover:bg-white hover:text-red-500 dark:bg-white/10"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span className="hidden sm:inline">로그아웃</span>
|
||||
@@ -57,7 +57,7 @@ export default function TopHeader() {
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-500 text-sm font-medium hover:text-blue-600 transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]"
|
||||
>
|
||||
<User size={18} />
|
||||
<span>로그인</span>
|
||||
@@ -65,7 +65,7 @@ export default function TopHeader() {
|
||||
|
||||
<Link
|
||||
href="/signup"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-blue-600 text-sm font-bold rounded-full border border-blue-100 shadow-sm hover:bg-blue-50 hover:shadow-md transition-all"
|
||||
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-bold text-[var(--color-accent)] shadow-sm backdrop-blur-xl transition-all hover:bg-white hover:shadow-md dark:bg-white/10"
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
<span>회원가입</span>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
@@ -15,128 +16,129 @@ interface MarkdownRendererProps {
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
const components: Components = {
|
||||
// 1. 코드 블록 커스텀
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
const codeString = String(children).replace(/\n$/, '');
|
||||
|
||||
if (match) {
|
||||
return (
|
||||
<CodeBlock language={language} code={codeString} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className="mx-1 break-words rounded-md bg-black/[0.05] px-1.5 py-0.5 font-mono text-[0.9em] font-medium text-red-600 dark:bg-white/10 dark:text-red-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
|
||||
// 2. 인용구
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="my-7 rounded-r-lg border-l-4 border-[var(--color-accent)] bg-[var(--color-accent-soft)] py-3 pl-5 pr-4 text-[var(--color-text-muted)]">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
|
||||
// 3. 링크
|
||||
a({ href, children }) {
|
||||
const isExternal = href?.startsWith('http');
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
className="inline-flex items-center gap-0.5 font-semibold text-[var(--color-accent)] underline-offset-4 transition-colors hover:underline"
|
||||
>
|
||||
{children}
|
||||
{isExternal && <ExternalLink size={12} className="opacity-70" />}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// 4. 테이블
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="my-8 overflow-x-auto rounded-lg border border-[var(--color-line)] bg-white/70 shadow-sm dark:bg-white/10">
|
||||
<table className="w-full text-left text-sm text-[var(--color-text-muted)]">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="border-b border-[var(--color-line)] bg-black/[0.03] text-xs text-[var(--color-text-muted)] dark:bg-white/10">{children}</thead>;
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="px-5 py-3 font-bold text-[var(--color-text)]">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="border-b border-[var(--color-line)] px-5 py-4 whitespace-pre-wrap">{children}</td>;
|
||||
},
|
||||
|
||||
// 5. 이미지
|
||||
img({ src, alt }) {
|
||||
return (
|
||||
<span className="my-8 flex flex-col items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="h-auto max-h-[700px] max-w-full rounded-lg border border-[var(--color-line)] shadow-[var(--shadow-card)] transition-transform duration-150 hover:scale-[1.005]"
|
||||
loading="lazy"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{alt && <span className="mt-3 block w-full text-center text-sm text-[var(--color-text-subtle)]">{alt}</span>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
||||
// 6. 리스트 스타일
|
||||
ul({ children }) {
|
||||
return <ul className="my-4 list-disc space-y-2 pl-6 text-[var(--color-text-muted)] marker:text-[var(--color-text-subtle)]">{children}</ul>;
|
||||
},
|
||||
ol({ children, ...props }) {
|
||||
return (
|
||||
<ol
|
||||
className="my-4 list-decimal space-y-2 pl-6 font-medium text-[var(--color-text-muted)] marker:text-[var(--color-text-subtle)]"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="pl-1">{children}</li>;
|
||||
},
|
||||
|
||||
// 7. 헤딩 스타일
|
||||
h1({ children, ...props }) {
|
||||
return <h1 className="mt-12 mb-6 border-b border-[var(--color-line)] pb-4 text-3xl font-extrabold tracking-normal text-[var(--color-text)]" {...props}>{children}</h1>;
|
||||
},
|
||||
h2({ children, ...props }) {
|
||||
return <h2 className="mt-11 mb-5 text-2xl font-bold tracking-normal text-[var(--color-text)]" {...props}>{children}</h2>;
|
||||
},
|
||||
h3({ children, ...props }) {
|
||||
return <h3 className="mt-9 mb-4 border-l-4 border-[var(--color-accent)] pl-3 text-xl font-bold tracking-normal text-[var(--color-text)]" {...props}>{children}</h3>;
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize, rehypeSlug]}
|
||||
components={{
|
||||
// 1. 코드 블록 커스텀
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
const codeString = String(children).replace(/\n$/, '');
|
||||
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<CodeBlock language={language} code={codeString} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className="bg-gray-100 text-red-500 px-1.5 py-0.5 rounded-md text-[0.9em] font-mono font-medium mx-1 break-words"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
|
||||
// 2. 인용구
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-blue-500 bg-blue-50 pl-4 py-3 my-6 text-gray-700 rounded-r-lg italic shadow-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
|
||||
// 3. 링크
|
||||
a({ href, children }) {
|
||||
const isExternal = href?.startsWith('http');
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium underline-offset-4 hover:underline inline-flex items-center gap-0.5 transition-colors"
|
||||
>
|
||||
{children}
|
||||
{isExternal && <ExternalLink size={12} className="opacity-70" />}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// 4. 테이블
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-8 rounded-lg border border-gray-200 shadow-sm">
|
||||
<table className="w-full text-sm text-left text-gray-700 bg-white">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="text-xs text-gray-700 uppercase bg-gray-50 border-b border-gray-200">{children}</thead>;
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="px-6 py-3 font-bold text-gray-900">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="px-6 py-4 border-b border-gray-100 whitespace-pre-wrap">{children}</td>;
|
||||
},
|
||||
|
||||
// 5. 이미지 (비율 유지 및 중앙 정렬)
|
||||
img({ src, alt }) {
|
||||
return (
|
||||
// 🛠️ [Fix] flex-col 추가: 이미지와 캡션을 세로로 정렬
|
||||
// items-center 추가: 가로축 중앙 정렬
|
||||
<span className="block my-8 flex flex-col items-center justify-center">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="rounded-xl shadow-lg border border-gray-100 max-w-full h-auto max-h-[700px] mx-auto hover:scale-[1.01] transition-transform duration-300"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{alt && <span className="block text-center text-sm text-gray-400 mt-2 w-full">{alt}</span>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
||||
// 6. 리스트 스타일
|
||||
ul({ children }) {
|
||||
return <ul className="list-disc pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-400">{children}</ul>;
|
||||
},
|
||||
ol({ children, ...props }: any) {
|
||||
return (
|
||||
<ol
|
||||
className="list-decimal pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-500 font-medium"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="pl-1">{children}</li>;
|
||||
},
|
||||
|
||||
// 7. 헤딩 스타일 (🛠️ 수정: ...props를 전달해야 id가 붙어서 목차 이동이 작동함)
|
||||
h1({ children, ...props }: any) {
|
||||
return <h1 className="text-3xl font-extrabold mt-12 mb-6 pb-4 border-b border-gray-100 text-gray-900" {...props}>{children}</h1>;
|
||||
},
|
||||
h2({ children, ...props }: any) {
|
||||
return <h2 className="text-2xl font-bold mt-10 mb-5 pb-2 text-gray-800" {...props}>{children}</h2>;
|
||||
},
|
||||
h3({ children, ...props }: any) {
|
||||
return <h3 className="text-xl font-bold mt-8 mb-4 text-gray-800 flex items-center gap-2 before:content-[''] before:w-1.5 before:h-6 before:bg-blue-500 before:rounded-full before:mr-1" {...props}>{children}</h3>;
|
||||
},
|
||||
}}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
@@ -155,7 +157,7 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative my-8 group rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl bg-[#1e1e1e]">
|
||||
<div className="group relative my-8 overflow-hidden rounded-lg border border-gray-700/50 bg-[#1e1e1e] shadow-[var(--shadow-card)]">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[#2d2d2d] border-b border-gray-700 select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
@@ -182,7 +184,7 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
title="코드 복사"
|
||||
>
|
||||
{isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
<span>{isCopied ? 'Copied!' : 'Copy'}</span>
|
||||
<span>{isCopied ? '복사됨' : '복사'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -204,4 +206,4 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +1,71 @@
|
||||
import { Post } from '@/types';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { clsx } from 'clsx'; // 🎨 스타일 조건부 적용을 위해 clsx 사용
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Post } from '@/types';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
|
||||
const isNoticePost = (post: Post) => {
|
||||
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// 🛠️ 헬퍼 함수: 마크다운 문법 제거하고 순수 텍스트만 추출
|
||||
function getSummary(content?: string) {
|
||||
if (!content) return '';
|
||||
|
||||
if (!content) return '본문을 열어 전체 내용을 확인해 보세요.';
|
||||
|
||||
return content
|
||||
.replace(/[#*`_~]/g, '') // 특수문자(#, *, ` 등) 제거
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // 링크에서 텍스트만 남김 [글자](주소) -> 글자
|
||||
.replace(/\n/g, ' ') // 줄바꿈을 공백으로
|
||||
.substring(0, 120); // 120자까지만 자르기
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/[#*`_~>]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
export default function PostCard({ post }: { post: Post }) {
|
||||
// 요약문 생성
|
||||
const summary = getSummary(post.content);
|
||||
|
||||
// 📢 공지 카테고리 여부 확인 (한글 '공지' 또는 영문 'Notice' 대소문자 무관)
|
||||
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
const isNotice = isNoticePost(post);
|
||||
|
||||
return (
|
||||
<Link href={`/posts/${post.slug}`} className="block group h-full">
|
||||
<article className={clsx(
|
||||
"flex flex-col h-full bg-white rounded-2xl p-6 transition-all duration-300 border",
|
||||
// 공지글이면 테두리에 살짝 붉은 기운을 줌
|
||||
isNotice
|
||||
? "border-red-100 shadow-[0_2px_8px_rgba(239,68,68,0.08)] hover:shadow-[0_8px_24px_rgba(239,68,68,0.12)]"
|
||||
: "border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.08)]",
|
||||
"hover:-translate-y-1"
|
||||
)}>
|
||||
|
||||
{/* 상단 카테고리 & 날짜 */}
|
||||
<div className="flex items-center justify-between text-xs mb-4">
|
||||
<span className={clsx(
|
||||
"px-2.5 py-1 rounded-md font-medium transition-colors",
|
||||
isNotice
|
||||
? "bg-red-50 text-red-600 font-bold border border-red-100" // 🔴 공지 스타일
|
||||
: "bg-slate-100 text-slate-600" // 기본 스타일
|
||||
)}>
|
||||
{isNotice && '📢 '}{post.categoryName}
|
||||
</span>
|
||||
<time className="text-gray-400 font-light">
|
||||
{format(new Date(post.createdAt), 'MMM dd, yyyy')}
|
||||
<Link href={`/posts/${post.slug}`} className="group block h-full">
|
||||
<Surface
|
||||
as="article"
|
||||
interactive
|
||||
className="flex h-full flex-col p-5"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="shrink-0">
|
||||
{post.categoryName || '미분류'}
|
||||
</StatusBadge>
|
||||
<time className="text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{formatDate(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<h2 className={clsx(
|
||||
"text-xl font-bold mb-3 transition-colors line-clamp-2",
|
||||
isNotice ? "text-gray-900 group-hover:text-red-600" : "text-gray-800 group-hover:text-blue-600"
|
||||
)}>
|
||||
|
||||
<h2 className="line-clamp-2 text-xl font-bold leading-snug tracking-normal text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</h2>
|
||||
|
||||
{/* 요약글 */}
|
||||
<p className="text-gray-500 text-sm line-clamp-3 mb-6 flex-1 leading-relaxed break-words min-h-[3rem]">
|
||||
{summary}
|
||||
|
||||
<p className="mt-3 line-clamp-3 flex-1 break-words text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{getSummary(post.content)}
|
||||
</p>
|
||||
|
||||
{/* 하단 정보 */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-50">
|
||||
<span className={clsx(
|
||||
"text-xs font-medium flex items-center gap-1",
|
||||
isNotice ? "text-red-500" : "text-blue-500"
|
||||
)}>
|
||||
Read more <span className="group-hover:translate-x-1 transition-transform">→</span>
|
||||
<div className="mt-6 flex items-center justify-between border-t border-[var(--color-line)] pt-4">
|
||||
<span className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
읽기
|
||||
<ChevronRight size={15} className="transition group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</Surface>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import TOC from '@/components/post/TOC';
|
||||
import { Loader2, Calendar, Eye, Folder, User, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Post } from '@/types'; // 타입 임포트
|
||||
|
||||
interface PostDetailClientProps {
|
||||
@@ -66,18 +67,18 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
||||
const isAuthError = errorStatus === 401 || errorStatus === 403;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-20 text-center">
|
||||
<div className="mx-auto max-w-4xl px-4 py-20 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<AlertCircle className="text-gray-300" size={64} />
|
||||
<AlertCircle className="text-[var(--color-text-subtle)]" size={64} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
<h2 className="mb-2 text-2xl font-bold text-[var(--color-text)]">
|
||||
{isAuthError ? '접근 권한이 없습니다.' : '게시글을 불러올 수 없습니다.'}
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-6">
|
||||
<p className="mb-6 text-[var(--color-text-muted)]">
|
||||
{isAuthError ? '로그인이 필요하거나 비공개 게시글일 수 있습니다.' : errorMessage}
|
||||
</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button onClick={() => router.push('/')} className="px-5 py-2.5 bg-gray-100 text-gray-700 font-medium rounded-lg hover:bg-gray-200 transition-colors">메인으로</button>
|
||||
<button onClick={() => router.push('/')} className="rounded-lg border border-[var(--color-line)] bg-white/70 px-5 py-2.5 font-semibold text-[var(--color-text-muted)] transition-colors hover:bg-white">메인으로</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -88,59 +89,70 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
||||
const nextPost = post.nextPost;
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-2xl mx-auto px-4 md:px-8 py-12">
|
||||
<Link href="/" className="inline-flex items-center gap-1 text-gray-500 hover:text-blue-600 mb-8 transition-colors">
|
||||
<div className="mx-auto max-w-7xl px-4 py-12 md:px-8">
|
||||
<Link href="/" className="mb-8 inline-flex items-center gap-1 text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]">
|
||||
<ArrowLeft size={18} />
|
||||
<span className="text-sm font-medium">목록으로</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col xl:flex-row gap-8 xl:gap-16 relative">
|
||||
<div className="relative grid gap-8 xl:grid-cols-[minmax(0,820px)_220px] xl:justify-center xl:gap-16">
|
||||
|
||||
<main className="min-w-0 xl:flex-1">
|
||||
<main className="min-w-0">
|
||||
<article>
|
||||
<header className="mb-10 border-b border-gray-100 pb-8">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-600 font-medium bg-blue-50 px-3 py-1 rounded-full">
|
||||
<header className="mb-10 border-b border-[var(--color-line)] pb-8">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex items-center gap-2 rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
<Folder size={14} />
|
||||
<span>{post.categoryName || 'Uncategorized'}</span>
|
||||
<span>{post.categoryName || '미분류'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6 leading-tight break-keep">{post.title}</h1>
|
||||
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500">
|
||||
<h1 className="mb-6 break-keep text-3xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-5xl">{post.title}</h1>
|
||||
<div className="flex flex-wrap items-center gap-6 text-sm text-[var(--color-text-muted)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{profile?.imageUrl ? <img src={profile.imageUrl} alt="Author" className="w-8 h-8 rounded-full object-cover border border-gray-100 shadow-sm" /> : <div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center"><User size={16} /></div>}
|
||||
<span className="font-bold text-gray-800">{profile?.name || 'Dev Park'}</span>
|
||||
{profile?.imageUrl ? (
|
||||
<Image
|
||||
src={profile.imageUrl}
|
||||
alt="작성자"
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 rounded-full border border-[var(--color-line)] object-cover shadow-sm"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black/[0.05] dark:bg-white/10"><User size={16} /></div>
|
||||
)}
|
||||
<span className="font-bold text-[var(--color-text)]">{profile?.name || 'Dev Park'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5"><Calendar size={16} />{new Date(post.createdAt).toLocaleDateString()}</div>
|
||||
<div className="flex items-center gap-1.5"><Eye size={16} />{post.viewCount} views</div>
|
||||
<div className="flex items-center gap-1.5"><Calendar size={16} />{new Date(post.createdAt).toLocaleDateString('ko-KR')}</div>
|
||||
<div className="flex items-center gap-1.5"><Eye size={16} />조회 {post.viewCount.toLocaleString()}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="prose prose-lg max-w-none prose-headings:font-bold prose-a:text-blue-600 prose-img:rounded-2xl prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100 mb-20">
|
||||
<div className="prose prose-lg mb-20 max-w-none prose-headings:font-bold prose-a:text-[var(--color-accent)] prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100">
|
||||
<MarkdownRenderer content={post.content || ''} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<nav className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-b border-gray-100 py-8 mb-16">
|
||||
<nav className="mb-16 grid grid-cols-1 gap-4 border-y border-[var(--color-line)] py-8 md:grid-cols-2">
|
||||
{prevPost ? (
|
||||
<Link href={`/posts/${prevPost.slug}`} className="group flex flex-col items-start gap-1 p-5 rounded-2xl bg-gray-50 hover:bg-blue-50 transition-colors w-full border border-transparent hover:border-blue-100">
|
||||
<span className="text-xs font-bold text-gray-400 uppercase flex items-center gap-1 group-hover:text-blue-600 transition-colors"><ChevronLeft size={16} /> 이전 글</span>
|
||||
<span className="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-left">{prevPost.title}</span>
|
||||
<Link href={`/posts/${prevPost.slug}`} className="group flex w-full flex-col items-start gap-1 rounded-lg border border-transparent bg-white/60 p-5 transition-colors hover:border-[var(--color-line)] hover:bg-white dark:bg-white/10">
|
||||
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]"><ChevronLeft size={16} /> 이전 글</span>
|
||||
<span className="line-clamp-1 w-full text-left font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{prevPost.title}</span>
|
||||
</Link>
|
||||
) : <div className="hidden md:block p-5 rounded-2xl bg-gray-50/50 w-full opacity-50 cursor-not-allowed"><span className="text-xs font-bold text-gray-300 uppercase flex items-center gap-1"><ChevronLeft size={16} /> 이전 글 없음</span></div>}
|
||||
) : <div className="hidden w-full cursor-not-allowed rounded-lg bg-white/40 p-5 opacity-60 dark:bg-white/5 md:block"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]"><ChevronLeft size={16} /> 이전 글 없음</span></div>}
|
||||
|
||||
{nextPost ? (
|
||||
<Link href={`/posts/${nextPost.slug}`} className="group flex flex-col items-end gap-1 p-5 rounded-2xl bg-gray-50 hover:bg-blue-50 transition-colors w-full border border-transparent hover:border-blue-100">
|
||||
<span className="text-xs font-bold text-gray-400 uppercase flex items-center gap-1 group-hover:text-blue-600 transition-colors">다음 글 <ChevronRight size={16} /></span>
|
||||
<span className="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-right">{nextPost.title}</span>
|
||||
<Link href={`/posts/${nextPost.slug}`} className="group flex w-full flex-col items-end gap-1 rounded-lg border border-transparent bg-white/60 p-5 transition-colors hover:border-[var(--color-line)] hover:bg-white dark:bg-white/10">
|
||||
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]">다음 글 <ChevronRight size={16} /></span>
|
||||
<span className="line-clamp-1 w-full text-right font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{nextPost.title}</span>
|
||||
</Link>
|
||||
) : <div className="hidden md:flex flex-col items-end gap-1 p-5 rounded-2xl bg-gray-50/50 w-full opacity-50 cursor-not-allowed"><span className="text-xs font-bold text-gray-300 uppercase flex items-center gap-1">다음 글 없음 <ChevronRight size={16} /></span></div>}
|
||||
) : <div className="hidden w-full cursor-not-allowed flex-col items-end gap-1 rounded-lg bg-white/40 p-5 opacity-60 dark:bg-white/5 md:flex"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]">다음 글 없음 <ChevronRight size={16} /></span></div>}
|
||||
</nav>
|
||||
|
||||
<CommentList postSlug={post.slug} />
|
||||
</main>
|
||||
|
||||
<aside className="hidden 2xl:block w-[220px] shrink-0">
|
||||
<aside className="hidden w-[220px] shrink-0 xl:block">
|
||||
<div className="sticky top-24">
|
||||
<TOC content={post.content || ''} />
|
||||
</div>
|
||||
|
||||
@@ -1,60 +1,64 @@
|
||||
import Link from 'next/link';
|
||||
import { Post } from '@/types';
|
||||
import { format } from 'date-fns';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { ChevronRight, Eye } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Post } from '@/types';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
|
||||
interface PostListItemProps {
|
||||
post: Post;
|
||||
showViews?: boolean;
|
||||
}
|
||||
|
||||
export default function PostListItem({ post }: PostListItemProps) {
|
||||
// 📢 공지 카테고리 여부 확인
|
||||
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
const isNoticePost = (post: Post) => {
|
||||
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function PostListItem({ post, showViews = false }: PostListItemProps) {
|
||||
const isNotice = isNoticePost(post);
|
||||
|
||||
return (
|
||||
<Link href={`/posts/${post.slug}`} className="block group">
|
||||
<div className={clsx(
|
||||
"flex items-center justify-between py-4 border-b px-4 -mx-4 rounded-lg transition-colors",
|
||||
isNotice
|
||||
? "border-red-50 hover:bg-red-50/30" // 공지일 때 배경색 살짝 붉게
|
||||
: "border-gray-100 hover:bg-gray-50"
|
||||
)}>
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
{/* 카테고리 라벨 */}
|
||||
<span className={clsx(
|
||||
"hidden sm:inline-block px-2.5 py-1 rounded-md text-xs font-medium whitespace-nowrap",
|
||||
isNotice
|
||||
? "bg-red-100 text-red-600 font-bold" // 🔴 공지 스타일 강조
|
||||
: "bg-slate-100 text-slate-600"
|
||||
)}>
|
||||
{isNotice && '📢 '}{post.categoryName}
|
||||
</span>
|
||||
|
||||
{/* 제목 */}
|
||||
<h3 className={clsx(
|
||||
"text-base font-medium truncate transition-colors",
|
||||
isNotice
|
||||
? "text-gray-900 group-hover:text-red-600 font-semibold"
|
||||
: "text-gray-800 group-hover:text-blue-600"
|
||||
)}>
|
||||
<Link href={`/posts/${post.slug}`} className="group block">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition duration-150',
|
||||
isNotice ? 'hover:bg-red-500/[0.06]' : 'hover:bg-black/[0.03] dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="hidden shrink-0 sm:inline-flex">
|
||||
{post.categoryName || '미분류'}
|
||||
</StatusBadge>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</h3>
|
||||
<time className="mt-1 block text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{formatDate(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 sm:gap-6 text-sm text-gray-400 ml-4 whitespace-nowrap">
|
||||
{/* 조회수 */}
|
||||
<div className="hidden sm:flex items-center gap-1.5" title="조회수">
|
||||
<Eye size={14} />
|
||||
<span className="text-xs">{post.viewCount}</span>
|
||||
</div>
|
||||
|
||||
{/* 날짜 */}
|
||||
<time className="font-light tabular-nums text-xs sm:text-sm">
|
||||
{format(new Date(post.createdAt), 'yyyy.MM.dd')}
|
||||
</time>
|
||||
<div className="ml-3 flex shrink-0 items-center gap-4 text-sm text-[var(--color-text-subtle)]">
|
||||
{showViews && (
|
||||
<div className="hidden items-center gap-1.5 sm:flex" title="조회수">
|
||||
<Eye size={14} />
|
||||
<span className="text-xs">조회 {post.viewCount.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight size={18} className="transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
// 🛠️ 중요: rehype-slug와 동일한 ID 생성을 위해 라이브러리 사용
|
||||
// npm install github-slugger 실행 필요
|
||||
@@ -18,23 +18,21 @@ interface HeadingItem {
|
||||
|
||||
export default function TOC({ content }: TOCProps) {
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
const [headings, setHeadings] = useState<HeadingItem[]>([]);
|
||||
|
||||
// 1. 마크다운에서 헤딩(#) 추출 및 Slug 생성
|
||||
useEffect(() => {
|
||||
const headings = useMemo(() => {
|
||||
const slugger = new GithubSlugger(); // Slugger 인스턴스 (중복 ID 처리용)
|
||||
const lines = content.split('\n');
|
||||
|
||||
// 코드 블럭 내의 #은 무시
|
||||
let inCodeBlock = false;
|
||||
|
||||
const matches = lines.reduce<HeadingItem[]>((acc, line) => {
|
||||
const result = lines.reduce<{ headings: HeadingItem[]; inCodeBlock: boolean }>((acc, line) => {
|
||||
// 코드 블럭 진입/이탈 체크 (```)
|
||||
if (line.trim().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
return acc;
|
||||
return {
|
||||
...acc,
|
||||
inCodeBlock: !acc.inCodeBlock,
|
||||
};
|
||||
}
|
||||
if (inCodeBlock) return acc;
|
||||
if (acc.inCodeBlock) return acc;
|
||||
|
||||
// 헤딩 매칭 (# 1~3개)
|
||||
const match = line.match(/^(#{1,3})\s+(.+)$/);
|
||||
@@ -45,12 +43,15 @@ export default function TOC({ content }: TOCProps) {
|
||||
// 🛠️ 핵심: github-slugger로 ID 생성 (한글, 띄어쓰기 등 완벽 호환)
|
||||
const slug = slugger.slug(text);
|
||||
|
||||
acc.push({ text, level, slug });
|
||||
return {
|
||||
...acc,
|
||||
headings: [...acc.headings, { text, level, slug }],
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}, { headings: [], inCodeBlock: false });
|
||||
|
||||
setHeadings(matches);
|
||||
return result.headings;
|
||||
}, [content]);
|
||||
|
||||
// 2. 스크롤 감지 (IntersectionObserver)
|
||||
@@ -103,18 +104,18 @@ export default function TOC({ content }: TOCProps) {
|
||||
|
||||
return (
|
||||
<aside className="w-full">
|
||||
<div className="border-l-2 border-gray-100 pl-4 py-2">
|
||||
<h4 className="font-bold text-gray-900 mb-4 text-sm uppercase tracking-wider text-opacity-80">On this page</h4>
|
||||
<div className="rounded-lg border border-[var(--color-line)] bg-white/65 p-4 shadow-sm backdrop-blur-xl dark:bg-white/10">
|
||||
<h4 className="mb-4 text-sm font-bold text-[var(--color-text)]">목차</h4>
|
||||
<ul className="space-y-2.5">
|
||||
{headings.map((heading, index) => (
|
||||
<li
|
||||
key={`${heading.slug}-${index}`}
|
||||
className={clsx(
|
||||
"text-sm transition-all duration-200",
|
||||
heading.level === 3 ? "pl-4 text-xs" : "", // h3는 들여쓰기
|
||||
"border-l-2 pl-3 text-sm transition-all duration-200",
|
||||
heading.level === 3 ? "ml-3 text-xs" : "", // h3는 들여쓰기
|
||||
activeId === heading.slug
|
||||
? "text-blue-600 font-bold translate-x-1"
|
||||
: "text-gray-500 hover:text-gray-900"
|
||||
? "border-[var(--color-accent)] font-bold text-[var(--color-accent)]"
|
||||
: "border-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
)}
|
||||
>
|
||||
<a
|
||||
@@ -130,4 +131,4 @@ export default function TOC({ content }: TOCProps) {
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
33
src/components/ui/EmptyState.tsx
Normal file
33
src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex min-h-36 flex-col items-center justify-center rounded-lg border border-dashed border-[var(--color-line)] bg-white/45 px-5 py-10 text-center dark:bg-white/5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon && <div className="mb-3 text-[var(--color-text-subtle)]">{icon}</div>}
|
||||
<p className="text-sm font-semibold text-[var(--color-text-muted)]">{title}</p>
|
||||
{description && (
|
||||
<p className="mt-1 max-w-sm text-xs leading-5 text-[var(--color-text-subtle)]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/ui/MetricCard.tsx
Normal file
54
src/components/ui/MetricCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import Surface from './Surface';
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string;
|
||||
value?: number | string | null;
|
||||
icon?: ReactNode;
|
||||
helper?: string;
|
||||
changeRate?: number | null;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatValue = (value?: number | string | null) => {
|
||||
if (value === null || value === undefined) return '통계 API 연결 전';
|
||||
if (typeof value === 'number') return value.toLocaleString();
|
||||
return value;
|
||||
};
|
||||
|
||||
export default function MetricCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
helper,
|
||||
changeRate,
|
||||
disabled = false,
|
||||
className,
|
||||
}: MetricCardProps) {
|
||||
const hasChange = typeof changeRate === 'number';
|
||||
const isPositive = hasChange && changeRate >= 0;
|
||||
|
||||
return (
|
||||
<Surface className={clsx('p-5', disabled && 'opacity-70', className)}>
|
||||
<div className="flex items-center justify-between gap-3 text-[var(--color-text-muted)]">
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
{icon}
|
||||
</div>
|
||||
<p
|
||||
className={clsx(
|
||||
'mt-4 break-keep text-3xl font-bold tracking-normal text-[var(--color-text)]',
|
||||
disabled && 'text-base leading-7 text-[var(--color-text-muted)]',
|
||||
)}
|
||||
>
|
||||
{formatValue(value)}
|
||||
</p>
|
||||
{(helper || hasChange) && (
|
||||
<p className="mt-3 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{hasChange ? `${isPositive ? '+' : ''}${changeRate.toFixed(1)}%` : helper}
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
54
src/components/ui/SegmentedControl.tsx
Normal file
54
src/components/ui/SegmentedControl.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface SegmentedControlOption<T extends string> {
|
||||
label: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
ariaLabel: string;
|
||||
options: SegmentedControlOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SegmentedControl<T extends string>({
|
||||
ariaLabel,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'inline-flex h-9 rounded-full border border-[var(--color-line)] bg-white/60 p-1 shadow-sm backdrop-blur-xl dark:bg-white/10',
|
||||
className,
|
||||
)}
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isSelected = option.value === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={clsx(
|
||||
'min-w-14 rounded-full px-3 text-sm font-semibold transition duration-150',
|
||||
isSelected
|
||||
? 'bg-[var(--color-text)] text-white shadow-sm dark:bg-white dark:text-black'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/components/ui/StatusBadge.tsx
Normal file
33
src/components/ui/StatusBadge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
type BadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
||||
|
||||
interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
tone?: BadgeTone;
|
||||
}
|
||||
|
||||
const toneClass: Record<BadgeTone, string> = {
|
||||
neutral: 'border-black/10 bg-black/[0.04] text-[var(--color-text-muted)] dark:border-white/10 dark:bg-white/10',
|
||||
info: 'border-blue-500/15 bg-blue-500/10 text-blue-700 dark:text-blue-300',
|
||||
success: 'border-emerald-500/15 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
|
||||
warning: 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300',
|
||||
danger: 'border-red-500/15 bg-red-500/10 text-red-700 dark:text-red-300',
|
||||
};
|
||||
|
||||
export default function StatusBadge({
|
||||
tone = 'neutral',
|
||||
className,
|
||||
...props
|
||||
}: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex max-w-full items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-semibold leading-none',
|
||||
toneClass[tone],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
src/components/ui/Surface.tsx
Normal file
30
src/components/ui/Surface.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
type SurfaceElement = 'div' | 'section' | 'article' | 'aside';
|
||||
|
||||
interface SurfaceProps extends HTMLAttributes<HTMLElement> {
|
||||
as?: SurfaceElement;
|
||||
strong?: boolean;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export default function Surface({
|
||||
as: Component = 'div',
|
||||
strong = false,
|
||||
interactive = false,
|
||||
className,
|
||||
...props
|
||||
}: SurfaceProps) {
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
'rounded-lg border border-[var(--color-line)] backdrop-blur-xl',
|
||||
strong ? 'bg-[var(--color-surface-strong)] shadow-[var(--shadow-panel)]' : 'bg-[var(--color-surface)] shadow-[var(--shadow-card)]',
|
||||
interactive && 'transition duration-150 hover:border-black/10 hover:bg-white/90 hover:shadow-[var(--shadow-panel)] dark:hover:border-white/15 dark:hover:bg-white/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ interface JwtPayload {
|
||||
nickname?: string;
|
||||
name?: string;
|
||||
sub?: string;
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
@@ -50,7 +50,7 @@ const parseToken = (token: string): { role: string; user: UserInfo | null } => {
|
||||
email: decoded.sub || '',
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// console.error('Token parsing error:', e);
|
||||
return { role: 'USER', user: null };
|
||||
}
|
||||
@@ -98,4 +98,4 @@ export const useAuthStore = create(
|
||||
// partialize: (state) => ({ accessToken: state.accessToken, isLoggedIn: state.isLoggedIn, user: state.user, role: state.role }),
|
||||
}
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface Post {
|
||||
categoryName: string;
|
||||
viewCount: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string | null;
|
||||
content?: string;
|
||||
tags: string[];
|
||||
// 🆕 백엔드 변경 사항 반영: 이전글/다음글 정보 추가
|
||||
@@ -157,3 +158,68 @@ export interface AdminCommentListResponse extends PageMeta {
|
||||
content: AdminComment[];
|
||||
page?: PageMeta;
|
||||
}
|
||||
|
||||
export type DashboardRange = '7d' | '30d' | '90d';
|
||||
|
||||
export interface DashboardMetric {
|
||||
value: number;
|
||||
previousValue?: number;
|
||||
changeRate?: number;
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
todayViews: DashboardMetric;
|
||||
weekViews: DashboardMetric;
|
||||
monthViews: DashboardMetric;
|
||||
totalPosts: number;
|
||||
totalComments: number;
|
||||
totalCategories: number;
|
||||
lastPublishedAt?: string | null;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface DashboardTrafficPoint {
|
||||
date: string;
|
||||
views: number;
|
||||
}
|
||||
|
||||
export interface DashboardPostStat {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
categoryName: string;
|
||||
viewCount: number;
|
||||
rangeViewCount: number;
|
||||
commentCount?: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface DashboardCategoryStat {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId?: number | null;
|
||||
postCount: number;
|
||||
viewCount: number;
|
||||
recentViewCount: number;
|
||||
lastPublishedAt?: string | null;
|
||||
childrenCount: number;
|
||||
}
|
||||
|
||||
export interface DashboardActionItems {
|
||||
unansweredComments: number;
|
||||
uncategorizedPosts: number;
|
||||
stalePopularPosts: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardResponse {
|
||||
overview: DashboardOverview;
|
||||
traffic: DashboardTrafficPoint[];
|
||||
topPosts: DashboardPostStat[];
|
||||
risingPosts: DashboardPostStat[];
|
||||
stalePopularPosts: DashboardPostStat[];
|
||||
recentPosts: Post[];
|
||||
recentComments: AdminComment[];
|
||||
categoryStats: DashboardCategoryStat[];
|
||||
actionItems: DashboardActionItems;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user