chore: add deployment config
This commit is contained in:
40
DockerFile
Normal file
40
DockerFile
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 1. 의존성 설치 단계 (Deps)
|
||||||
|
FROM node:18-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
# 패키지 매니저 파일 복사
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
# 의존성 설치 (CI 환경에 맞게)
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 2. 빌드 단계 (Builder)
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 환경 변수 설정 (빌드 시점에 필요할 수 있음)
|
||||||
|
# 실제 런타임 변수는 docker run 할 때 주입하는 것이 좋음
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# 빌드 실행
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 3. 실행 단계 (Runner)
|
||||||
|
FROM node:18-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# Next.js의 Standalone 모드를 위한 파일 복사
|
||||||
|
# (Next.js 빌드 시 output: 'standalone' 설정이 next.config.js에 있어야 함)
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# 포트 노출
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT 3000
|
||||||
|
|
||||||
|
# 서버 실행
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -3,6 +3,7 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
output: 'standalone',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
9399
package-lock.json
generated
Normal file
9399
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,16 +12,23 @@
|
|||||||
"@headlessui/react": "^2.2.9",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@uiw/react-md-editor": "^4.0.11",
|
"@uiw/react-md-editor": "^4.0.11",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-hook-form": "^7.69.0",
|
"react-hook-form": "^7.69.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"yarn": "^1.22.22",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
// src/api/category.ts
|
|
||||||
import { http } from './http';
|
import { http } from './http';
|
||||||
import { ApiResponse, Category } from '@/types';
|
import { ApiResponse, Category, CategoryCreateRequest, CategoryUpdateRequest } from '@/types';
|
||||||
|
|
||||||
// 카테고리 트리 구조 조회 (GET /api/categories)
|
// 카테고리 트리 구조 조회 (GET /api/categories)
|
||||||
export const getCategories = async () => {
|
export const getCategories = async () => {
|
||||||
const response = await http.get<ApiResponse<Category[]>>('/api/categories');
|
const response = await http.get<ApiResponse<Category[]>>('/api/categories');
|
||||||
return response.data.data; // ApiResponse로 감싸져 있으므로 .data.data 반환
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리 생성 (POST /api/admin/categories)
|
||||||
|
export const createCategory = async (data: CategoryCreateRequest) => {
|
||||||
|
const response = await http.post<ApiResponse<Category>>('/api/admin/categories', data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리 수정/이동 (PUT /api/admin/categories/{id})
|
||||||
|
// 백엔드 반환값이 ApiResponse<Nothing> 이므로 data는 null입니다.
|
||||||
|
export const updateCategory = async (id: number, data: CategoryUpdateRequest) => {
|
||||||
|
const response = await http.put<ApiResponse<null>>(`/api/admin/categories/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리 삭제 (DELETE /api/admin/categories/{id})
|
||||||
|
export const deleteCategory = async (id: number) => {
|
||||||
|
const response = await http.delete<ApiResponse<null>>(`/api/admin/categories/${id}`);
|
||||||
|
return response.data;
|
||||||
};
|
};
|
||||||
39
src/api/comments.ts
Normal file
39
src/api/comments.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { http } from './http';
|
||||||
|
import { ApiResponse, Comment, CommentSaveRequest, CommentDeleteRequest } from '@/types';
|
||||||
|
|
||||||
|
// 1. 댓글 목록 조회
|
||||||
|
export const getComments = async (postSlug: string) => {
|
||||||
|
const response = await http.get<ApiResponse<Comment[]>>('/api/comments', {
|
||||||
|
params: { postSlug },
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 댓글 작성
|
||||||
|
export const createComment = async (data: CommentSaveRequest) => {
|
||||||
|
const response = await http.post<ApiResponse<null>>('/api/comments', data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. 댓글 삭제
|
||||||
|
export const deleteComment = async (id: number, password?: string) => {
|
||||||
|
// Axios의 delete 메서드에서 body를 보내려면 data 속성을 사용해야 합니다.
|
||||||
|
const config = password ? { data: { guestPassword: password } as CommentDeleteRequest } : undefined;
|
||||||
|
|
||||||
|
const response = await http.delete<ApiResponse<null>>(`/api/comments/${id}`, config);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 관리자 댓글 목록 조회 (대시보드용)
|
||||||
|
export const getAdminComments = async (page = 0, size = 20) => {
|
||||||
|
const response = await http.get<ApiResponse<any>>('/api/admin/comments', {
|
||||||
|
params: { page, size },
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. 관리자 댓글 강제 삭제
|
||||||
|
export const deleteAdminComment = async (id: number) => {
|
||||||
|
const response = await http.delete<ApiResponse<null>>(`/api/admin/comments/${id}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
120
src/api/http.ts
120
src/api/http.ts
@@ -1,44 +1,118 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
|
// 🛠️ 환경 변수 처리 (배포 환경 대응)
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
export const http = axios.create({
|
export const http = axios.create({
|
||||||
baseURL: 'http://localhost:8080', // 백엔드 주소 확인
|
baseURL: BASE_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
withCredentials: true, // 쿠키 사용 시 필요
|
withCredentials: true, // 쿠키(RefreshToken) 전송을 위해 필수
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🟢 요청 인터셉터 추가 (범인 검거 현장)
|
// 1. 요청 인터셉터: 헤더에 AccessToken 주입
|
||||||
http.interceptors.request.use(
|
http.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
// 1. 로컬 스토리지에서 zustand가 저장한 데이터 꺼내기
|
const { accessToken } = useAuthStore.getState();
|
||||||
const storage = localStorage.getItem('auth-storage');
|
if (accessToken) {
|
||||||
|
config.headers['Authorization'] = `Bearer ${accessToken}`;
|
||||||
if (storage) {
|
|
||||||
// Zustand는 { state: { ... }, version: 0 } 형태로 저장함
|
|
||||||
const parsedStorage = JSON.parse(storage);
|
|
||||||
const token = parsedStorage.state?.accessToken;
|
|
||||||
|
|
||||||
// 2. 토큰이 있다면 헤더에 심어주기
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => Promise.reject(error)
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 응답 인터셉터 (에러 처리용, 선택 사항)
|
// --- 토큰 갱신 관련 변수 ---
|
||||||
|
let isRefreshing = false;
|
||||||
|
let failedQueue: any[] = [];
|
||||||
|
|
||||||
|
// 실패한 요청들을 큐에 담아두었다가 토큰 갱신 후 재시도하는 함수
|
||||||
|
const processQueue = (error: any, token: string | null = null) => {
|
||||||
|
failedQueue.forEach((prom) => {
|
||||||
|
if (error) {
|
||||||
|
prom.reject(error);
|
||||||
|
} else {
|
||||||
|
prom.resolve(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 응답 인터셉터: 401 또는 403 발생 시 토큰 갱신
|
||||||
http.interceptors.response.use(
|
http.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
if (error.response && error.response.status === 401) {
|
const originalRequest = error.config;
|
||||||
// 토큰 만료 시 로그아웃 처리 등을 여기서 할 수 있음
|
const status = error.response?.status; // 응답 상태 코드 확인
|
||||||
console.error('인증 실패: 토큰이 만료되었거나 유효하지 않습니다.');
|
|
||||||
|
// 401(Unauthorized) 또는 403(Forbidden) 에러이고, 아직 재시도하지 않은 요청인 경우
|
||||||
|
if ((status === 401 || status === 403) && !originalRequest._retry) {
|
||||||
|
// 이미 갱신 중이라면 큐에 넣고 대기
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject });
|
||||||
|
})
|
||||||
|
.then((token) => {
|
||||||
|
// 대기하던 요청들도 새 토큰으로 헤더 교체 후 재시도
|
||||||
|
if (originalRequest.headers) {
|
||||||
|
originalRequest.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return http(originalRequest);
|
||||||
|
})
|
||||||
|
.catch((err) => Promise.reject(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true; // 재시도 플래그 설정 (무한 루프 방지)
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { refreshToken, login, logout } = useAuthStore.getState();
|
||||||
|
|
||||||
|
// RefreshToken이 없으면 갱신 시도 없이 바로 로그아웃
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('No refresh token available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛠️ 토큰 갱신 요청
|
||||||
|
// 중요: refresh 요청도 쿠키/CORS 처리를 위해 withCredentials: true 추가
|
||||||
|
const { data } = await axios.post(
|
||||||
|
`${BASE_URL}/api/auth/refresh`,
|
||||||
|
{ refreshToken },
|
||||||
|
{ withCredentials: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새 토큰 저장
|
||||||
|
const newAccessToken = data.data.accessToken;
|
||||||
|
const newRefreshToken = data.data.refreshToken || refreshToken;
|
||||||
|
|
||||||
|
login(newAccessToken, newRefreshToken); // 스토어 업데이트
|
||||||
|
|
||||||
|
// 큐에 대기 중이던 요청들 처리 (새 토큰 전달)
|
||||||
|
processQueue(null, newAccessToken);
|
||||||
|
|
||||||
|
// 실패했던 원래 요청 재시도 (헤더 안전하게 교체)
|
||||||
|
if (originalRequest.headers) {
|
||||||
|
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
|
||||||
|
}
|
||||||
|
return http(originalRequest);
|
||||||
|
|
||||||
|
} catch (refreshError) {
|
||||||
|
// 갱신 실패 시 로그아웃 및 큐 정리
|
||||||
|
processQueue(refreshError, null);
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
|
||||||
|
// 브라우저 환경에서만 로그인 페이지로 이동
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -1,18 +1,13 @@
|
|||||||
import { http } from './http';
|
import { http } from './http';
|
||||||
import { ApiResponse } from '@/types';
|
import { ApiResponse } from '@/types';
|
||||||
|
|
||||||
// 이미지 업로드 (POST /api/admin/images)
|
|
||||||
export const uploadImage = async (file: File) => {
|
export const uploadImage = async (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('image', file);
|
formData.append('image', file);
|
||||||
|
|
||||||
// 👇 헤더에 Content-Type을 'multipart/form-data'로 명시하거나,
|
|
||||||
// 아예 지워서(undefined) 브라우저가 알아서 boundary를 붙이게 해야 합니다.
|
|
||||||
// 가장 안전한 방법은 'Content-Type': 'multipart/form-data'를 명시하는 것입니다.
|
|
||||||
|
|
||||||
const response = await http.post<ApiResponse<string>>('/api/admin/images', formData, {
|
const response = await http.post<ApiResponse<string>>('/api/admin/images', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data', // 👈 여기! 이거 추가하면 해결됩니다.
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -1,36 +1,53 @@
|
|||||||
// src/api/posts.ts
|
|
||||||
import { http } from './http';
|
import { http } from './http';
|
||||||
import { ApiResponse, PostListResponse } from '@/types'; // ApiResponse 타입 추가
|
import { ApiResponse, PostListResponse, Post } from '@/types';
|
||||||
import { Post } from '@/types';
|
|
||||||
|
|
||||||
export const getPosts = async (page = 0, size = 10, categoryId?: number, search?: string) => {
|
// 1. 게시글 목록 조회 (검색, 카테고리, 태그 필터링 지원)
|
||||||
const params: any = { page, size };
|
export const getPosts = async (params?: {
|
||||||
|
page?: number;
|
||||||
if (categoryId) params.categoryId = categoryId;
|
size?: number;
|
||||||
if (search) params.search = search;
|
keyword?: string;
|
||||||
|
category?: string;
|
||||||
// 1. 응답 타입을 ApiResponse<PostListResponse>로 변경
|
tag?: string;
|
||||||
const response = await http.get<ApiResponse<PostListResponse>>('/api/posts', { params });
|
}) => {
|
||||||
|
const response = await http.get<ApiResponse<PostListResponse>>('/api/posts', {
|
||||||
// 2. response.data는 { code, message, data: {...} } 형태입니다.
|
params: {
|
||||||
// 우리가 필요한 건 그 안의 data(실제 게시글 목록)이므로 .data를 한번 더 접근합니다.
|
...params,
|
||||||
return response.data.data;
|
sort: 'createdAt,desc',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPostBySlug = async (slug: string) => {
|
// 2. 카테고리별 게시글 조회
|
||||||
|
export const getPostsByCategory = async (categoryName: string, page = 0, size = 10) => {
|
||||||
|
return getPosts({
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
category: categoryName
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. 게시글 상세 조회
|
||||||
|
export const getPost = async (slug: string) => {
|
||||||
const response = await http.get<ApiResponse<Post>>(`/api/posts/${slug}`);
|
const response = await http.get<ApiResponse<Post>>(`/api/posts/${slug}`);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface CreatePostRequest {
|
// 4. 게시글 작성 (추가됨)
|
||||||
title: string;
|
// PostSaveRequest 타입에 맞춰 데이터를 보냅니다.
|
||||||
content: string;
|
export const createPost = async (data: any) => {
|
||||||
categoryId: number;
|
const response = await http.post<ApiResponse<Post>>('/api/admin/posts', data);
|
||||||
}
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
// 게시글 생성
|
// 5. 게시글 수정 (추가됨)
|
||||||
export const createPost = async (data: CreatePostRequest) => {
|
export const updatePost = async (id: number, data: any) => {
|
||||||
// 👇 여기를 수정했습니다! (/api/posts -> /api/admin/posts)
|
const response = await http.put<ApiResponse<Post>>(`/api/admin/posts/${id}`, data);
|
||||||
const response = await http.post<ApiResponse<any>>('/api/admin/posts', data);
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 6. 게시글 삭제 (추가됨)
|
||||||
|
export const deletePost = async (id: number) => {
|
||||||
|
const response = await http.delete<ApiResponse<null>>(`/api/admin/posts/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
14
src/api/profile.ts
Normal file
14
src/api/profile.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { http } from './http';
|
||||||
|
import { ApiResponse, Profile, ProfileUpdateRequest } from '@/types';
|
||||||
|
|
||||||
|
// 블로그 프로필 정보 조회 (GET /api/profile)
|
||||||
|
export const getProfile = async () => {
|
||||||
|
const response = await http.get<ApiResponse<Profile>>('/api/profile');
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 프로필 수정 (PUT /api/admin/profile) - 관리자 전용
|
||||||
|
export const updateProfile = async (data: ProfileUpdateRequest) => {
|
||||||
|
const response = await http.put<ApiResponse<Profile>>('/api/admin/profile', data);
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
144
src/app/category/[id]/page.tsx
Normal file
144
src/app/category/[id]/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { use, useState, useEffect } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getPostsByCategory } from '@/api/posts';
|
||||||
|
import PostCard from '@/components/post/PostCard';
|
||||||
|
import PostListItem from '@/components/post/PostListItem'; // 추가
|
||||||
|
import { Loader2, ChevronLeft, ChevronRight, LayoutGrid, List } from 'lucide-react';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
export default function CategoryPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = use(params);
|
||||||
|
const categoryName = decodeURIComponent(id);
|
||||||
|
const apiCategoryName = categoryName === 'uncategorized' ? '미분류' : categoryName;
|
||||||
|
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
// 💡 로컬 스토리지에서 뷰 모드 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
const savedMode = localStorage.getItem('postViewMode') as 'grid' | 'list';
|
||||||
|
if (savedMode) setViewMode(savedMode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewModeChange = (mode: 'grid' | 'list') => {
|
||||||
|
setViewMode(mode);
|
||||||
|
localStorage.setItem('postViewMode', mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: postsData, isLoading, error, isPlaceholderData } = useQuery({
|
||||||
|
queryKey: ['posts', 'category', apiCategoryName, page],
|
||||||
|
queryFn: () => getPostsByCategory(apiCategoryName, page, PAGE_SIZE),
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || (postsData === undefined && !error)) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-10 text-red-500">
|
||||||
|
게시글을 불러오는 중 오류가 발생했습니다.<br/>
|
||||||
|
<span className="text-sm text-gray-400">카테고리 이름을 확인해주세요.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = postsData?.content || [];
|
||||||
|
|
||||||
|
const handlePrevPage = () => setPage((old) => Math.max(old - 1, 0));
|
||||||
|
const handleNextPage = () => {
|
||||||
|
if (!postsData?.last) {
|
||||||
|
setPage((old) => old + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
{/* 헤더 영역 수정 */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 flex items-baseline gap-2">
|
||||||
|
{apiCategoryName} <span className="text-gray-400 text-lg font-normal">글 목록</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* 뷰 모드 버튼 */}
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
title="카드형 보기"
|
||||||
|
>
|
||||||
|
<LayoutGrid size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
title="리스트형 보기"
|
||||||
|
>
|
||||||
|
<List size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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">아직 작성된 글이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{viewMode === 'grid' ? (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col border-t border-gray-100">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<PostListItem key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-center items-center gap-6 mt-12 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={handlePrevPage}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed"
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-sm font-medium text-gray-600">
|
||||||
|
Page <span className="text-gray-900 font-bold">{page + 1}</span> {postsData && postsData.totalPages > 0 && `/ ${postsData.totalPages}`}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={postsData?.last || isPlaceholderData}
|
||||||
|
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed"
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,86 +1,92 @@
|
|||||||
// src/app/login/page.tsx
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { login } from '@/api/auth';
|
|
||||||
import { useAuthStore } from '@/store/authStore';
|
|
||||||
import { LoginRequest } from '@/types';
|
|
||||||
import Link from 'next/link'; // 👈 추가
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { login } from '@/api/auth';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { login: setLoginState } = useAuthStore();
|
const { login: setLoginState } = useAuthStore();
|
||||||
const [loading, setLoading] = useState(false); // 로딩 상태 추가
|
|
||||||
|
const [formData, setFormData] = useState({ email: '', password: '' });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const { register, handleSubmit, formState: { errors } } = useForm<LoginRequest>();
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
const onSubmit = async (data: LoginRequest) => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await login(data);
|
const res = await login(formData);
|
||||||
|
|
||||||
if (res.code === 'SUCCESS' && res.data) {
|
if (res.code === 'SUCCESS' && res.data) {
|
||||||
setLoginState(res.data.accessToken);
|
// ✨ 수정됨: AccessToken과 RefreshToken 모두 저장
|
||||||
// alert('환영합니다! 😎');
|
setLoginState(res.data.accessToken, res.data.refreshToken);
|
||||||
|
|
||||||
|
// 로그인 성공 후 메인으로 이동
|
||||||
router.push('/');
|
router.push('/');
|
||||||
} else {
|
} else {
|
||||||
alert('로그인 실패: ' + res.message);
|
setError(res.message || '로그인에 실패했습니다.');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (err: any) {
|
||||||
console.error(error);
|
setError(err.response?.data?.message || '로그인 중 오류가 발생했습니다.');
|
||||||
alert('로그인 중 오류가 발생했습니다: ' + (error.response?.data?.message || '서버 오류'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
<div className="flex flex-col items-center justify-center min-h-screen p-4 bg-gray-50">
|
||||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
<div className="w-full max-w-md bg-white rounded-2xl shadow-xl overflow-hidden p-8">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">로그인 🔐</h1>
|
<h1 className="text-3xl font-bold text-gray-900">로그인</h1>
|
||||||
<p className="text-sm text-gray-500 mt-2">블로그 관리자 및 회원 로그인</p>
|
<p className="text-gray-500 mt-2">블로그에 오신 것을 환영합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||||||
<input
|
<input
|
||||||
{...register('email', { required: '이메일을 입력해주세요.' })}
|
|
||||||
type="email"
|
type="email"
|
||||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
value={formData.email}
|
||||||
placeholder="user@example.com"
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
||||||
|
placeholder="example@email.com"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
|
||||||
<input
|
<input
|
||||||
{...register('password', { required: '비밀번호를 입력해주세요.' })}
|
|
||||||
type="password"
|
type="password"
|
||||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-colors disabled:bg-gray-400"
|
className="w-full py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors disabled:bg-blue-400 flex justify-center items-center gap-2"
|
||||||
>
|
>
|
||||||
{loading ? '로그인 중...' : '로그인하기'}
|
{loading && <Loader2 className="animate-spin" size={20} />}
|
||||||
|
로그인
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* 회원가입 링크 추가 */}
|
|
||||||
<div className="mt-6 text-center text-sm text-gray-500">
|
<div className="mt-6 text-center text-sm text-gray-500">
|
||||||
계정이 없으신가요?{' '}
|
계정이 없으신가요?{' '}
|
||||||
<Link href="/signup" className="text-blue-600 font-semibold hover:underline">
|
<Link href="/signup" className="text-blue-600 font-medium hover:underline">
|
||||||
회원가입하기
|
회원가입
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
128
src/app/page.tsx
128
src/app/page.tsx
@@ -1,28 +1,46 @@
|
|||||||
// src/app/page.tsx
|
'use client';
|
||||||
'use client'; // 클라이언트 컴포넌트 선언 (React Query 사용 위해)
|
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getPosts } from '@/api/posts';
|
import { getPosts } from '@/api/posts';
|
||||||
import PostCard from '@/components/post/PostCard';
|
import PostCard from '@/components/post/PostCard';
|
||||||
|
import PostListItem from '@/components/post/PostListItem'; // 새로 만든 컴포넌트 import
|
||||||
import { Post } from '@/types';
|
import { Post } from '@/types';
|
||||||
|
import { ChevronLeft, ChevronRight, Loader2, LayoutGrid, List } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
// 1. React Query로 데이터 가져오기
|
const [page, setPage] = useState(0);
|
||||||
const { data, isLoading, isError } = useQuery({
|
// 뷰 모드 상태: 'grid' 또는 'list'
|
||||||
queryKey: ['posts'], // 캐싱 키
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
queryFn: () => getPosts(0, 10), // 0페이지, 10개 조회
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
// 💡 사용자 선호 모드를 로컬 스토리지에서 불러오기 (UX 향상)
|
||||||
|
useEffect(() => {
|
||||||
|
const savedMode = localStorage.getItem('postViewMode') as 'grid' | 'list';
|
||||||
|
if (savedMode) setViewMode(savedMode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 모드 변경 시 로컬 스토리지에 저장
|
||||||
|
const handleViewModeChange = (mode: 'grid' | 'list') => {
|
||||||
|
setViewMode(mode);
|
||||||
|
localStorage.setItem('postViewMode', mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, isLoading, isError, isPlaceholderData } = useQuery({
|
||||||
|
queryKey: ['posts', page],
|
||||||
|
queryFn: () => getPosts({ page, size: PAGE_SIZE }),
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 로딩 중일 때
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6 text-center pt-20">
|
<div className="flex justify-center items-center h-screen">
|
||||||
<div className="animate-pulse text-gray-400">게시글을 불러오는 중...</div>
|
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 에러 났을 때
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6 text-center pt-20 text-red-500">
|
<div className="max-w-4xl mx-auto p-6 text-center pt-20 text-red-500">
|
||||||
@@ -31,27 +49,95 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 데이터 렌더링
|
const handlePrevPage = () => {
|
||||||
|
setPage((old) => Math.max(old - 1, 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextPage = () => {
|
||||||
|
if (!data?.last) {
|
||||||
|
setPage((old) => old + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="max-w-4xl mx-auto p-6 min-h-screen">
|
<main className="max-w-4xl mx-auto p-6 min-h-screen">
|
||||||
<header className="mb-10 mt-10">
|
{/* 헤더 영역 (제목 + 뷰 모드 버튼) */}
|
||||||
<h1 className="text-3xl font-bold text-gray-900">개발자 블로그 🧑💻</h1>
|
<header className="mb-8 mt-10 flex items-center justify-between">
|
||||||
<p className="text-gray-500 mt-2">공부한 내용을 기록하는 공간입니다.</p>
|
<h1 className="text-3xl font-bold text-gray-900">전체 게시글</h1>
|
||||||
|
|
||||||
|
{/* 뷰 모드 토글 버튼 */}
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
title="카드형 보기"
|
||||||
|
>
|
||||||
|
<LayoutGrid size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
title="리스트형 보기"
|
||||||
|
>
|
||||||
|
<List size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 게시글 목록 그리드 */}
|
{/* 게시글 목록 (조건부 렌더링) */}
|
||||||
<section className="grid gap-6 md:grid-cols-2">
|
{viewMode === 'grid' ? (
|
||||||
{data?.content.map((post: Post) => (
|
// 그리드 뷰
|
||||||
<PostCard key={post.id} post={post} />
|
<section className="grid gap-6 md:grid-cols-2">
|
||||||
))}
|
{data?.content.map((post: Post) => (
|
||||||
</section>
|
<PostCard key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
// 리스트 뷰
|
||||||
|
<section className="flex flex-col border-t border-gray-100">
|
||||||
|
{data?.content.map((post: Post) => (
|
||||||
|
<PostListItem key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 게시글이 하나도 없을 때 */}
|
|
||||||
{data?.content.length === 0 && (
|
{data?.content.length === 0 && (
|
||||||
<div className="text-center py-20 text-gray-500 bg-gray-50 rounded-lg">
|
<div className="text-center py-20 text-gray-500 bg-gray-50 rounded-lg">
|
||||||
작성된 게시글이 없습니다.
|
작성된 게시글이 없습니다.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{data && data.content.length > 0 && (
|
||||||
|
<div className="flex justify-center items-center gap-6 mt-12 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={handlePrevPage}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed"
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-sm font-medium text-gray-600">
|
||||||
|
Page <span className="text-gray-900 font-bold">{page + 1}</span> {data.totalPages > 0 && `/ ${data.totalPages}`}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={data.last || isPlaceholderData}
|
||||||
|
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed"
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,89 +1,243 @@
|
|||||||
// src/app/posts/[slug]/page.tsx
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { use, useEffect, useState } from 'react';
|
||||||
import { getPostBySlug } from '@/api/posts';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getPost, deletePost, getPostsByCategory } from '@/api/posts'; // 👈 목록 조회 API 추가
|
||||||
|
import { getProfile } from '@/api/profile';
|
||||||
import MarkdownRenderer from '@/components/post/MarkdownRenderer';
|
import MarkdownRenderer from '@/components/post/MarkdownRenderer';
|
||||||
import { format } from 'date-fns';
|
import CommentList from '@/components/comment/CommentList';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { Loader2, Calendar, Eye, Folder, User, Edit2, Trash2, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'; // 👈 아이콘 추가
|
||||||
import { ArrowLeft, Calendar, User } from 'lucide-react';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
export default function PostDetailPage() {
|
export default function PostDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
const { slug } = useParams(); // URL에서 slug 가져오기
|
const { slug } = use(params);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { role, _hasHydrated } = useAuthStore();
|
||||||
|
|
||||||
// 데이터 조회
|
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
||||||
const { data: post, isLoading, isError } = useQuery({
|
|
||||||
|
// 1. 현재 게시글 상세 조회
|
||||||
|
const { data: post, isLoading: isPostLoading, error } = useQuery({
|
||||||
queryKey: ['post', slug],
|
queryKey: ['post', slug],
|
||||||
queryFn: () => getPostBySlug(slug as string),
|
queryFn: () => getPost(slug),
|
||||||
enabled: !!slug, // slug가 있을 때만 실행
|
enabled: !!slug,
|
||||||
|
retry: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) return <div className="text-center py-20 animate-pulse">글을 불러오는 중... ⏳</div>;
|
// 2. 🆕 이전/다음 글을 찾기 위해 "같은 카테고리의 글 목록"을 조회합니다.
|
||||||
|
// (백엔드에서 prev/next를 안 주므로 프론트에서 리스트를 가져와서 계산하는 방식)
|
||||||
if (isError || !post) {
|
const { data: neighborPosts } = useQuery({
|
||||||
|
queryKey: ['posts', 'category', post?.categoryName],
|
||||||
|
// 💡 성능을 위해 적당한 사이즈(예: 100개)만 가져옵니다.
|
||||||
|
// 게시글이 아주 많다면 이 범위를 벗어난 과거 글에서는 네비게이션이 안 보일 수 있는 한계가 있습니다.
|
||||||
|
queryFn: () => getPostsByCategory(post!.categoryName, 0, 100),
|
||||||
|
enabled: !!post?.categoryName, // 게시글 로딩이 끝난 후 실행
|
||||||
|
staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: profile } = useQuery({
|
||||||
|
queryKey: ['profile'],
|
||||||
|
queryFn: getProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: deletePost,
|
||||||
|
onSuccess: () => {
|
||||||
|
alert('게시글이 삭제되었습니다.');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||||
|
router.push('/');
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
alert('삭제 실패: ' + (err.response?.data?.message || err.message));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPostLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-20">
|
<div className="flex justify-center items-center h-screen">
|
||||||
<h2 className="text-2xl font-bold mb-4">글을 찾을 수 없습니다. 😭</h2>
|
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||||
<button onClick={() => router.back()} className="text-blue-500 hover:underline">
|
|
||||||
뒤로 가기
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error || !post) {
|
||||||
|
const errorStatus = (error as any)?.response?.status;
|
||||||
|
const errorMessage = (error as any)?.response?.data?.message || error?.message || '게시글을 찾을 수 없습니다.';
|
||||||
|
const isAuthError = errorStatus === 401 || errorStatus === 403;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-20 text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<AlertCircle className="text-gray-300" size={64} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 mb-2">
|
||||||
|
{isAuthError ? '접근 권한이 없습니다.' : '게시글을 불러올 수 없습니다.'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500 mb-6">
|
||||||
|
{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>
|
||||||
|
{isAuthError && (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/login')}
|
||||||
|
className="px-5 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
로그인 하러가기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 🆕 리스트에서 현재 글의 위치를 찾아 이전/다음 글 결정
|
||||||
|
// (리스트는 기본적으로 최신순(DESC) 정렬이므로 인덱스가 작을수록 최신글입니다)
|
||||||
|
const currentIndex = neighborPosts?.content.findIndex((p) => p.id === post.id) ?? -1;
|
||||||
|
|
||||||
|
// 인덱스가 0보다 크면 앞에 더 최신 글이 있다는 뜻 (Next Post)
|
||||||
|
const newerPost = (currentIndex > 0 && neighborPosts)
|
||||||
|
? neighborPosts.content[currentIndex - 1]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 인덱스가 마지막이 아니면 뒤에 더 과거 글이 있다는 뜻 (Previous Post)
|
||||||
|
const olderPost = (currentIndex !== -1 && neighborPosts && currentIndex < neighborPosts.content.length - 1)
|
||||||
|
? neighborPosts.content[currentIndex + 1]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (confirm('정말로 이 게시글을 삭제하시겠습니까? 복구할 수 없습니다.')) {
|
||||||
|
deleteMutation.mutate(post.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
router.push(`/write?slug=${post.slug}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="max-w-3xl mx-auto pb-20">
|
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||||
{/* 1. 뒤로가기 버튼 */}
|
<Link href="/" className="inline-flex items-center gap-1 text-gray-500 hover:text-blue-600 mb-8 transition-colors">
|
||||||
<button
|
<ArrowLeft size={18} />
|
||||||
onClick={() => router.back()}
|
<span className="text-sm font-medium">목록으로</span>
|
||||||
className="flex items-center text-gray-500 hover:text-gray-900 mb-8 transition-colors"
|
</Link>
|
||||||
>
|
|
||||||
<ArrowLeft size={20} className="mr-2" />
|
|
||||||
목록으로
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 2. 헤더 영역 (카테고리, 제목, 날짜) */}
|
<article>
|
||||||
<header className="mb-10 text-center">
|
<header className="mb-10 border-b border-gray-100 pb-8">
|
||||||
<span className="inline-block bg-blue-100 text-blue-700 text-sm font-semibold px-3 py-1 rounded-full mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
{post.categoryName}
|
<div className="flex items-center gap-2 text-sm text-blue-600 font-medium bg-blue-50 px-3 py-1 rounded-full">
|
||||||
</span>
|
<Folder size={14} />
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6 leading-tight">
|
<span>{post.categoryName || 'Uncategorized'}</span>
|
||||||
{post.title}
|
</div>
|
||||||
</h1>
|
|
||||||
|
{isAdmin && (
|
||||||
<div className="flex items-center justify-center gap-6 text-gray-500 text-sm">
|
<div className="flex gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
<User size={16} />
|
onClick={handleEdit}
|
||||||
<span>Dev Park</span>
|
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-colors"
|
||||||
|
title="게시글 수정"
|
||||||
|
>
|
||||||
|
<Edit2 size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-full transition-colors"
|
||||||
|
title="게시글 삭제"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar size={16} />
|
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6 leading-tight break-keep">
|
||||||
<time>{format(new Date(post.createdAt), 'yyyy년 MM월 dd일')}</time>
|
{post.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500">
|
||||||
|
<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>
|
||||||
|
</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>
|
</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">
|
||||||
|
<MarkdownRenderer content={post.content || ''} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</article>
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<hr className="border-gray-200 my-10" />
|
|
||||||
|
|
||||||
{/* 3. 본문 영역 (마크다운) */}
|
{/* 4. 🆕 이전/다음 글 네비게이션 영역 (개선됨) */}
|
||||||
<div className="bg-white p-6 md:p-10 rounded-2xl shadow-sm border border-gray-100 min-h-[300px]">
|
<nav className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-b border-gray-100 py-8 mb-16">
|
||||||
{/* 본문이 있으면 렌더링, 없으면 안내 문구 */}
|
{/* 이전 글 (과거 글) */}
|
||||||
{post.content ? (
|
{olderPost ? (
|
||||||
<MarkdownRenderer content={post.content} />
|
<Link
|
||||||
|
href={`/posts/${olderPost.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">
|
||||||
|
{olderPost.title}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 italic">작성된 본문 내용이 없습니다.</p>
|
<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>
|
|
||||||
|
|
||||||
{/* 4. 하단 댓글 영역 (추후 구현 예정 자리) */}
|
{/* 다음 글 (최신 글) */}
|
||||||
<div className="mt-16">
|
{newerPost ? (
|
||||||
<h3 className="text-xl font-bold mb-6">댓글</h3>
|
<Link
|
||||||
<div className="bg-gray-50 rounded-xl p-10 text-center text-gray-400">
|
href={`/posts/${newerPost.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"
|
||||||
</div>
|
>
|
||||||
</div>
|
<span className="text-xs font-bold text-gray-400 uppercase flex items-center gap-1 group-hover:text-blue-600 transition-colors">
|
||||||
</article>
|
다음 글 <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">
|
||||||
|
{newerPost.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>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<CommentList postSlug={post.slug} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
// src/app/providers.tsx
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
// 🎨 UX 개선: 알림 라이브러리 추가
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const [queryClient] = useState(() => new QueryClient({
|
const [queryClient] = useState(() => new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
// 창을 다시 눌렀을 때 불필요한 재요청 방지
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
},
|
},
|
||||||
@@ -18,6 +18,23 @@ export default function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children}
|
{children}
|
||||||
|
{/* 🎨 전역 알림 컴포넌트 배치 (상단 중앙) */}
|
||||||
|
<Toaster
|
||||||
|
position="top-center"
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
background: '#333',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#4ade80',
|
||||||
|
secondary: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ export default function SignupPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await verifyEmail({ email: registeredEmail, code: verifyCode });
|
const res = await verifyEmail({ email: registeredEmail, code: verifyCode });
|
||||||
if (res.code === 'SUCCESS') {
|
if (res.code === 'SUCCESS') {
|
||||||
alert('✅ 인증되었습니다! 로그인 페이지로 이동합니다.');
|
alert('인증되었습니다! 로그인 페이지로 이동합니다.');
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
} else {
|
} else {
|
||||||
alert('인증 실패: ' + res.message);
|
alert('인증 실패: ' + res.message);
|
||||||
@@ -64,9 +64,9 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">회원가입 🚀</h1>
|
<h1 className="text-2xl font-bold text-gray-900">회원가입</h1>
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
{step === 'FORM' ? '정보를 입력하고 인증 메일을 받으세요.' : '이메일로 전송된 6자리 코드를 입력하세요.'}
|
{step === 'FORM' ? '' : '이메일로 전송된 6자리 코드를 입력하세요.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ export default function SignupPage() {
|
|||||||
<input
|
<input
|
||||||
{...register('nickname', { required: '닉네임을 입력해주세요.' })}
|
{...register('nickname', { required: '닉네임을 입력해주세요.' })}
|
||||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none"
|
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
placeholder="개발자박씨"
|
placeholder="개발자"
|
||||||
/>
|
/>
|
||||||
{errors.nickname && <p className="text-red-500 text-xs mt-1">{errors.nickname.message}</p>}
|
{errors.nickname && <p className="text-red-500 text-xs mt-1">{errors.nickname.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,247 +1,277 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, Fragment, useRef } from 'react';
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import dynamic from 'next/dynamic';
|
import { createPost, updatePost, getPost } from '@/api/posts';
|
||||||
import { getCategories } from '@/api/category';
|
import { getCategories } from '@/api/category';
|
||||||
import { createPost } from '@/api/posts';
|
import { uploadImage } from '@/api/image';
|
||||||
import { uploadImage } from '@/api/image'; // 👈 추가
|
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { Listbox, Transition } from '@headlessui/react';
|
import { Loader2, Save, Image as ImageIcon, ArrowLeft, Folder, UploadCloud } from 'lucide-react';
|
||||||
import { CheckIcon, ChevronUpDownIcon, PhotoIcon } from '@heroicons/react/20/solid'; // 👈 아이콘 추가
|
// 🎨 UX 개선: 토스트 알림 사용
|
||||||
import { clsx } from 'clsx';
|
import toast from 'react-hot-toast';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
|
const MDEditor = dynamic(
|
||||||
|
() => import('@uiw/react-md-editor').then((mod) => mod.default),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
export default function WritePage() {
|
function WritePageContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { role, _hasHydrated } = useAuthStore();
|
const { role, _hasHydrated } = useAuthStore();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null); // 👈 파일 입력 참조
|
|
||||||
|
const editSlug = searchParams.get('slug');
|
||||||
|
const isEditMode = !!editSlug;
|
||||||
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [categoryId, setCategoryId] = useState<number | null>(null);
|
const [content, setContent] = useState('**Hello world!**');
|
||||||
const [content, setContent] = useState<string>('**여기에 내용을 작성하세요.**');
|
const [categoryId, setCategoryId] = useState<number | ''>('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false); // 👈 이미지 업로드 상태
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (_hasHydrated && (!role || !role.includes('ADMIN'))) {
|
||||||
|
toast.error('관리자 권한이 필요합니다.'); // 🎨 Alert 대체
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
}, [role, _hasHydrated, router]);
|
||||||
|
|
||||||
const { data: categories } = useQuery({
|
const { data: categories } = useQuery({
|
||||||
queryKey: ['categories'],
|
queryKey: ['categories'],
|
||||||
queryFn: getCategories,
|
queryFn: getCategories,
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedCategoryName = (() => {
|
const { data: existingPost, isLoading: isLoadingPost } = useQuery({
|
||||||
if (!categoryId || !categories) return '카테고리 선택';
|
queryKey: ['post', editSlug],
|
||||||
for (const cat of categories) {
|
queryFn: () => getPost(editSlug!),
|
||||||
if (cat.id === categoryId) return cat.name;
|
enabled: isEditMode,
|
||||||
if (cat.children) {
|
});
|
||||||
const child = cat.children.find(c => c.id === categoryId);
|
|
||||||
if (child) return child.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '카테고리 선택';
|
|
||||||
})();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!_hasHydrated) return;
|
if (existingPost) {
|
||||||
if (!role || !role.includes('ADMIN')) {
|
setTitle(existingPost.title);
|
||||||
alert('관리자만 접근 가능합니다.');
|
setContent(existingPost.content || '');
|
||||||
router.replace('/');
|
if (categories && existingPost.categoryName) {
|
||||||
|
const found = findCategoryByName(categories, existingPost.categoryName);
|
||||||
|
if (found) setCategoryId(found.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [role, _hasHydrated, router]);
|
}, [existingPost, categories]);
|
||||||
|
|
||||||
// 👇 이미지 업로드 핸들러
|
const findCategoryByName = (cats: any[], name: string): any => {
|
||||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
for (const cat of cats) {
|
||||||
|
if (cat.name === name) return cat;
|
||||||
|
if (cat.children) {
|
||||||
|
const found = findCategoryByName(cat.children, name);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data: any) => isEditMode ? updatePost(existingPost!.id, data) : createPost(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||||
|
if (isEditMode) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['post', editSlug] });
|
||||||
|
}
|
||||||
|
// 🎨 UX 개선: 성공 메시지 토스트
|
||||||
|
toast.success(isEditMode ? '게시글이 수정되었습니다.' : '게시글이 발행되었습니다!');
|
||||||
|
router.push(isEditMode ? `/posts/${editSlug}` : '/');
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
// 🎨 UX 개선: 에러 메시지 토스트
|
||||||
|
toast.error('저장 실패: ' + (err.response?.data?.message || err.message));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim() || !content.trim()) {
|
||||||
|
toast.error('제목과 내용을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (categoryId === '') {
|
||||||
|
toast.error('카테고리를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
const uploadToast = toast.loading('이미지 업로드 중...'); // 🎨 업로드 로딩 표시
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 서버로 이미지 전송
|
|
||||||
const res = await uploadImage(file);
|
const res = await uploadImage(file);
|
||||||
|
|
||||||
if (res.code === 'SUCCESS' && res.data) {
|
if (res.code === 'SUCCESS' && res.data) {
|
||||||
const imageUrl = res.data; // 서버가 준 이미지 URL
|
const imageUrl = res.data;
|
||||||
|
const markdownImage = ``;
|
||||||
// 2. 본문에 마크다운 이미지 문법 삽입
|
setContent((prev) => prev + '\n' + markdownImage);
|
||||||
// 현재 내용 뒤에 추가하거나, 커서 위치를 찾아서 넣을 수 있습니다.
|
toast.success('이미지가 업로드되었습니다.', { id: uploadToast }); // 로딩 토스트를 성공으로 변경
|
||||||
// 여기서는 간단히 맨 뒤에 한 줄 띄우고 추가합니다.
|
|
||||||
const imageMarkdown = `\n\n`;
|
|
||||||
setContent((prev) => prev + imageMarkdown);
|
|
||||||
} else {
|
|
||||||
alert('이미지 업로드 실패: ' + res.message);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
toast.error('이미지 업로드 실패', { id: uploadToast });
|
||||||
alert('이미지 업로드 중 오류가 발생했습니다.');
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
// 같은 파일을 다시 선택할 수 있도록 input 초기화
|
e.target.value = '';
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const onPaste = async (event: any) => {
|
||||||
e.preventDefault();
|
const items = event.clipboardData?.items;
|
||||||
if (!title.trim()) return alert('제목을 입력해주세요.');
|
if (!items) return;
|
||||||
if (!categoryId) return alert('카테고리를 선택해주세요.');
|
|
||||||
if (!content.trim()) return alert('내용을 입력해주세요.');
|
|
||||||
if (!confirm('글을 발행하시겠습니까?')) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
for (const item of items) {
|
||||||
try {
|
if (item.type.indexOf('image') !== -1) {
|
||||||
await createPost({ title, content, categoryId });
|
event.preventDefault();
|
||||||
alert('글이 성공적으로 발행되었습니다! 🎉');
|
const file = item.getAsFile();
|
||||||
router.push('/');
|
if (!file) return;
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
setIsUploading(true);
|
||||||
alert('에러 발생: ' + (error.response?.data?.message || '서버 오류'));
|
const uploadToast = toast.loading('이미지 업로드 중...');
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
try {
|
||||||
|
const res = await uploadImage(file);
|
||||||
|
if (res.code === 'SUCCESS' && res.data) {
|
||||||
|
const imageUrl = res.data;
|
||||||
|
const markdownImage = ``;
|
||||||
|
setContent((prev) => prev + '\n' + markdownImage);
|
||||||
|
toast.success('이미지 붙여넣기 완료!', { id: uploadToast });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('이미지 업로드 실패', { id: uploadToast });
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!_hasHydrated || !role) {
|
if (isEditMode && isLoadingPost) {
|
||||||
return <div className="min-h-screen flex justify-center items-center">로딩 중...</div>;
|
return (
|
||||||
|
<div className="flex justify-center items-center h-screen">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderCategoryOptions = (cats: any[], depth = 0) => {
|
||||||
|
return cats.map((cat) => (
|
||||||
|
<div key={cat.id}>
|
||||||
|
<label className="flex items-center gap-2 p-2 hover:bg-gray-50 cursor-pointer rounded transition-colors">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="category"
|
||||||
|
value={cat.id}
|
||||||
|
checked={categoryId === cat.id}
|
||||||
|
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||||
|
className="text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span style={{ marginLeft: depth * 10 + 'px' }} className={depth === 0 ? 'font-medium' : 'text-gray-600'}>
|
||||||
|
{depth > 0 && '- '} {cat.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{cat.children && renderCategoryOptions(cat.children, depth + 1)}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto pb-20 z-10 relative">
|
<div className="max-w-5xl mx-auto px-4 py-8" onPaste={onPaste}>
|
||||||
<h1 className="text-3xl font-bold text-gray-800 mb-8">새 글 작성 ✍️</h1>
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<button onClick={() => router.back()} className="p-2 hover:bg-gray-100 rounded-full text-gray-500 transition-colors">
|
||||||
|
<ArrowLeft size={20} />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 relative z-20">
|
</button>
|
||||||
<div className="col-span-1">
|
<h1 className="text-2xl font-bold text-gray-800">{isEditMode ? '게시글 수정' : '새 글 작성'}</h1>
|
||||||
<Listbox value={categoryId} onChange={setCategoryId}>
|
</div>
|
||||||
<div className="relative mt-1">
|
<button
|
||||||
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-3 pl-4 pr-10 text-left border border-gray-300 focus:outline-none focus-visible:border-blue-500 focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-blue-300 sm:text-sm transition-all shadow-sm">
|
onClick={handleSubmit}
|
||||||
<span className={clsx("block truncate", !categoryId && "text-gray-400")}>
|
disabled={mutation.isPending || isUploading}
|
||||||
{selectedCategoryName}
|
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors disabled:bg-gray-400 shadow-md hover:shadow-lg transform active:scale-95 duration-200"
|
||||||
</span>
|
>
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
{mutation.isPending ? <Loader2 className="animate-spin" size={18} /> : <Save size={18} />}
|
||||||
<ChevronUpDownIcon
|
{isEditMode ? '수정하기' : '발행하기'}
|
||||||
className="h-5 w-5 text-gray-400"
|
</button>
|
||||||
aria-hidden="true"
|
</div>
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</Listbox.Button>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<Listbox.Options className="absolute mt-2 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm z-50">
|
|
||||||
{categories?.map((cat) => (
|
|
||||||
<Fragment key={cat.id}>
|
|
||||||
<div className="px-4 py-2 text-xs font-bold text-gray-500 bg-gray-50 uppercase tracking-wider">
|
|
||||||
{cat.name}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Listbox.Option
|
|
||||||
value={cat.id}
|
|
||||||
className={({ active }) =>
|
|
||||||
clsx(
|
|
||||||
'relative cursor-default select-none py-2.5 pl-10 pr-4',
|
|
||||||
active ? 'bg-blue-50 text-blue-600' : 'text-gray-900'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className={clsx('block truncate font-medium', selected && 'text-blue-600')}>
|
|
||||||
{cat.name} (전체)
|
|
||||||
</span>
|
|
||||||
{selected && (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-600">
|
|
||||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Listbox.Option>
|
|
||||||
|
|
||||||
{cat.children?.map((child) => (
|
|
||||||
<Listbox.Option
|
|
||||||
key={child.id}
|
|
||||||
value={child.id}
|
|
||||||
className={({ active }) =>
|
|
||||||
clsx(
|
|
||||||
'relative cursor-default select-none py-2.5 pl-10 pr-4',
|
|
||||||
active ? 'bg-blue-50 text-blue-600' : 'text-gray-700'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className={clsx('block truncate ml-4 border-l-2 border-gray-200 pl-3', selected && 'font-semibold text-blue-600 border-blue-600')}>
|
|
||||||
{child.name}
|
|
||||||
</span>
|
|
||||||
{selected && (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-600">
|
|
||||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Listbox.Option>
|
|
||||||
))}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</Listbox.Options>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Listbox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
<div className="lg:col-span-3 space-y-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="제목을 입력하세요"
|
placeholder="제목을 입력하세요"
|
||||||
className="col-span-3 px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none font-bold text-lg shadow-sm"
|
className="w-full text-3xl font-bold placeholder:text-gray-300 border-none outline-none py-2 bg-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
<div data-color-mode="light">
|
||||||
|
<MDEditor
|
||||||
<div className="relative z-10">
|
value={content}
|
||||||
{/* 👇 이미지 업로드 버튼 영역 추가 */}
|
onChange={(val) => setContent(val || '')}
|
||||||
<div className="flex justify-end mb-2">
|
height={600}
|
||||||
<input
|
preview="edit"
|
||||||
type="file"
|
className="border border-gray-200 rounded-lg shadow-sm !font-sans"
|
||||||
ref={fileInputRef}
|
|
||||||
className="hidden"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleImageUpload}
|
|
||||||
/>
|
/>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={isUploading}
|
<div className="space-y-6">
|
||||||
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-gray-600 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 hover:text-blue-600 transition-colors shadow-sm"
|
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-6">
|
||||||
|
<h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2">
|
||||||
|
<Folder size={18} /> 카테고리
|
||||||
|
</h3>
|
||||||
|
<div className="max-h-60 overflow-y-auto space-y-1 text-sm border-t border-gray-100 pt-2 scrollbar-thin scrollbar-thumb-gray-200">
|
||||||
|
{categories ? renderCategoryOptions(categories) : <p className="text-gray-400 text-sm">로딩 중...</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-[300px]">
|
||||||
|
<h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2">
|
||||||
|
<ImageIcon size={18} /> 이미지 업로드
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3 leading-relaxed">
|
||||||
|
에디터에 이미지를 <br/><strong>복사 & 붙여넣기(Ctrl+V)</strong> 하거나<br/> 아래 버튼을 사용하세요.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className={`flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50 hover:border-blue-400 transition-all duration-200 ${isUploading ? 'opacity-50 cursor-wait' : ''}`}
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
<span className="animate-pulse">업로드 중...</span>
|
<UploadCloud className="w-8 h-8 text-gray-400 mb-2 group-hover:text-blue-500" />
|
||||||
) : (
|
<p className="text-xs text-gray-500 font-medium">
|
||||||
<>
|
{isUploading ? '업로드 중...' : '클릭하여 이미지 선택'}
|
||||||
<PhotoIcon className="w-4 h-4" />
|
</p>
|
||||||
<span>이미지 추가</span>
|
</div>
|
||||||
</>
|
<input
|
||||||
)}
|
type="file"
|
||||||
</button>
|
className="hidden"
|
||||||
</div>
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
<div data-color-mode="light" className="editor-container">
|
disabled={isUploading}
|
||||||
<MDEditor value={content} onChange={(val) => setContent(val || '')} height={500} preview="live" className="rounded-lg border border-gray-200 shadow-sm" />
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex justify-end gap-4 mt-8 relative z-10">
|
|
||||||
<button type="button" onClick={() => router.back()} className="px-6 py-3 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 font-medium transition-colors">취소</button>
|
|
||||||
<button type="submit" disabled={isSubmitting || isUploading} className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-bold shadow-md transition-all transform hover:-translate-y-1 disabled:bg-gray-400">{isSubmitting ? '발행 중...' : '글 발행하기 🚀'}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WritePage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="flex justify-center items-center h-screen"><Loader2 className="animate-spin text-blue-500" /></div>}>
|
||||||
|
<WritePageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
101
src/components/comment/CommentForm.tsx
Normal file
101
src/components/comment/CommentForm.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
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;
|
||||||
|
parentId?: number | null;
|
||||||
|
onSuccess?: () => void; // 작성 완료 후 콜백 (답글창 닫기 등)
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommentForm({ postSlug, parentId = null, onSuccess, placeholder = '댓글을 남겨보세요.' }: CommentFormProps) {
|
||||||
|
const { isLoggedIn, role } = useAuthStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [guestInfo, setGuestInfo] = useState({ nickname: '', password: '' });
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createComment,
|
||||||
|
onSuccess: () => {
|
||||||
|
setContent('');
|
||||||
|
setGuestInfo({ nickname: '', password: '' });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
alert('댓글 작성 실패: ' + (err.response?.data?.message || err.message));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!content.trim()) return;
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
if (!guestInfo.nickname.trim() || !guestInfo.password.trim()) {
|
||||||
|
alert('닉네임과 비밀번호를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate({
|
||||||
|
postSlug,
|
||||||
|
content,
|
||||||
|
parentId,
|
||||||
|
guestNickname: isLoggedIn ? undefined : guestInfo.nickname,
|
||||||
|
guestPassword: isLoggedIn ? undefined : guestInfo.password,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-gray-50 p-4 rounded-xl border border-gray-100">
|
||||||
|
{/* 비회원 입력 필드 */}
|
||||||
|
{!isLoggedIn && (
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="닉네임"
|
||||||
|
value={guestInfo.nickname}
|
||||||
|
onChange={(e) => setGuestInfo({ ...guestInfo, nickname: e.target.value })}
|
||||||
|
className="w-1/2 px-3 py-2 text-sm border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="비밀번호"
|
||||||
|
value={guestInfo.password}
|
||||||
|
onChange={(e) => setGuestInfo({ ...guestInfo, password: e.target.value })}
|
||||||
|
className="w-1/2 px-3 py-2 text-sm border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 댓글 내용 입력 */}
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder={isLoggedIn ? placeholder : '댓글을 남겨보세요.'}
|
||||||
|
className="w-full p-3 pr-12 text-sm border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 resize-none h-24 bg-white"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="absolute bottom-3 right-3 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-300"
|
||||||
|
title="등록"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? <Loader2 className="animate-spin" size={16} /> : <Send size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/components/comment/CommentItem.tsx
Normal file
207
src/components/comment/CommentItem.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Comment } from '@/types';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { deleteComment, deleteAdminComment } from '@/api/comments';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { User, CheckCircle2, Trash2, MessageSquare, UserCheck, ShieldAlert, X } from 'lucide-react'; // X 아이콘 추가
|
||||||
|
import CommentForm from './CommentForm';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import toast from 'react-hot-toast'; // 🎨 Toast 추가
|
||||||
|
|
||||||
|
interface CommentItemProps {
|
||||||
|
comment: Comment;
|
||||||
|
postSlug: string;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommentItem({ comment, postSlug, depth = 0 }: CommentItemProps) {
|
||||||
|
const { isLoggedIn, role, user } = useAuthStore();
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isReplying, setIsReplying] = useState(false);
|
||||||
|
// 🎨 비회원 삭제용 UI 상태 추가
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [guestPassword, setGuestPassword] = useState('');
|
||||||
|
|
||||||
|
const isAdmin = isLoggedIn && role?.includes('ADMIN');
|
||||||
|
const isGuestComment = !comment.memberId;
|
||||||
|
|
||||||
|
const isMyComment = isLoggedIn && !isGuestComment && (
|
||||||
|
(user?.memberId && comment.memberId && user.memberId === comment.memberId) ||
|
||||||
|
(user?.nickname === comment.author)
|
||||||
|
);
|
||||||
|
|
||||||
|
const showDeleteButton = isAdmin || isGuestComment || isMyComment;
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: ({ id, password }: { id: number; password?: string }) => deleteComment(id, password),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||||
|
toast.success('댓글이 삭제되었습니다.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error('삭제 실패: ' + (err.response?.data?.message || '비밀번호가 틀렸습니다.'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminDeleteMutation = useMutation({
|
||||||
|
mutationFn: deleteAdminComment,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||||
|
toast.success('관리자 권한으로 삭제했습니다.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error('관리자 삭제 실패: ' + (err.response?.data?.message || err.message));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDeleteClick = () => {
|
||||||
|
// A. 관리자 -> 즉시 삭제 (컨펌만)
|
||||||
|
if (isAdmin) {
|
||||||
|
if (confirm('관리자 권한으로 삭제하시겠습니까?')) {
|
||||||
|
adminDeleteMutation.mutate(comment.id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// B. 내 댓글 -> 즉시 삭제 (컨펌만)
|
||||||
|
if (isMyComment) {
|
||||||
|
if (confirm('이 댓글을 삭제하시겠습니까?')) {
|
||||||
|
deleteMutation.mutate({ id: comment.id });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// C. 비회원 댓글 -> 인라인 입력창 표시 (UX 개선)
|
||||||
|
if (isGuestComment) {
|
||||||
|
setIsDeleting(!isDeleting);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGuestDeleteSubmit = () => {
|
||||||
|
if (!guestPassword.trim()) {
|
||||||
|
toast.error('비밀번호를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteMutation.mutate({ id: comment.id, password: guestPassword });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex flex-col", depth > 0 && "mt-3")}>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"relative p-4 rounded-xl transition-colors group",
|
||||||
|
comment.isPostAuthor ? "bg-blue-50/50 border border-blue-100" : "bg-white border border-gray-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"p-1.5 rounded-full flex items-center justify-center",
|
||||||
|
comment.isPostAuthor ? "bg-blue-100 text-blue-600" :
|
||||||
|
!isGuestComment ? "bg-green-100 text-green-600" :
|
||||||
|
"bg-gray-100 text-gray-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{comment.isPostAuthor ? <CheckCircle2 size={14} /> :
|
||||||
|
!isGuestComment ? <UserCheck size={14} /> :
|
||||||
|
<User size={14} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-0 sm:gap-2">
|
||||||
|
<span className={clsx("text-sm font-bold flex items-center gap-1", comment.isPostAuthor ? "text-blue-700" : "text-gray-700")}>
|
||||||
|
{comment.author}
|
||||||
|
{comment.isPostAuthor && <span className="px-1.5 py-0.5 bg-blue-100 text-blue-600 text-[10px] rounded-full font-medium">작성자</span>}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{format(new Date(comment.createdAt), 'yyyy.MM.dd HH:mm')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsReplying(!isReplying)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded"
|
||||||
|
title="답글 달기"
|
||||||
|
>
|
||||||
|
<MessageSquare size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDeleteButton && (
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
className={clsx(
|
||||||
|
"p-1.5 rounded transition-colors",
|
||||||
|
isDeleting ? "bg-red-50 text-red-600" :
|
||||||
|
isAdmin ? "text-red-400 hover:text-red-600 hover:bg-red-50" : "text-gray-400 hover:text-red-600 hover:bg-red-50"
|
||||||
|
)}
|
||||||
|
title={isAdmin ? "관리자 삭제" : "삭제"}
|
||||||
|
>
|
||||||
|
{isAdmin ? <ShieldAlert size={14} /> : <Trash2 size={14} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-800 text-sm whitespace-pre-wrap leading-relaxed pl-1">
|
||||||
|
{comment.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 🎨 비회원 비밀번호 입력창 (인라인) */}
|
||||||
|
{isDeleting && isGuestComment && (
|
||||||
|
<div className="mt-3 flex items-center gap-2 p-2 bg-gray-50 rounded-lg animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="비밀번호 입력"
|
||||||
|
value={guestPassword}
|
||||||
|
onChange={(e) => setGuestPassword(e.target.value)}
|
||||||
|
className="text-xs px-2 py-1.5 border border-gray-200 rounded focus:outline-none focus:border-blue-500 bg-white"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleGuestDeleteSubmit}
|
||||||
|
className="text-xs bg-red-500 text-white px-3 py-1.5 rounded hover:bg-red-600 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setIsDeleting(false); setGuestPassword(''); }}
|
||||||
|
className="p-1 text-gray-400 hover:bg-gray-200 rounded-full"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isReplying && (
|
||||||
|
<div className="mt-2 pl-4 border-l-2 border-gray-200 ml-4 animate-in fade-in slide-in-from-top-2">
|
||||||
|
<CommentForm
|
||||||
|
postSlug={postSlug}
|
||||||
|
parentId={comment.id}
|
||||||
|
onSuccess={() => setIsReplying(false)}
|
||||||
|
placeholder={`@${comment.author}님에게 답글 남기기`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{comment.children && comment.children.length > 0 && (
|
||||||
|
<div className="mt-2 pl-4 md:pl-8 border-l-2 border-gray-100 ml-2 md:ml-4 space-y-3">
|
||||||
|
{comment.children.map((child) => (
|
||||||
|
<CommentItem
|
||||||
|
key={child.id}
|
||||||
|
comment={child}
|
||||||
|
postSlug={postSlug}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/comment/CommentList.tsx
Normal file
64
src/components/comment/CommentList.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getComments } from '@/api/comments';
|
||||||
|
import CommentForm from './CommentForm';
|
||||||
|
import CommentItem from './CommentItem';
|
||||||
|
import { Loader2, MessageCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CommentListProps {
|
||||||
|
postSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommentList({ postSlug }: CommentListProps) {
|
||||||
|
// 댓글 목록 조회
|
||||||
|
const { data: comments, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['comments', postSlug],
|
||||||
|
queryFn: () => getComments(postSlug),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 총 댓글 수 계산 (재귀)
|
||||||
|
const countComments = (list: any[]): number => {
|
||||||
|
if (!list) return 0;
|
||||||
|
return list.reduce((acc, curr) => acc + 1 + countComments(curr.children), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCount = comments ? countComments(comments) : 0;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="py-10 flex justify-center"><Loader2 className="animate-spin text-blue-500" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="py-10 text-center text-red-500">댓글을 불러오는데 실패했습니다.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-16 pt-10 border-t border-gray-100">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<MessageCircle className="text-blue-600" size={24} />
|
||||||
|
<h3 className="text-xl font-bold text-gray-800">
|
||||||
|
댓글 <span className="text-blue-600">{totalCount}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최상위 댓글 작성 폼 */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<CommentForm postSlug={postSlug} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 댓글 목록 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{comments && comments.length > 0 ? (
|
||||||
|
comments.map((comment) => (
|
||||||
|
<CommentItem key={comment.id} comment={comment} postSlug={postSlug} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-500 text-sm">
|
||||||
|
아직 댓글이 없습니다. 첫 번째 댓글을 남겨보세요!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,175 +1,444 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
// 🎨 이미지 최적화를 위해 next/image 사용
|
||||||
import { getCategories } from '@/api/category';
|
import Image from 'next/image';
|
||||||
import { Github, Mail, Menu, X, ChevronRight, Folder, FolderOpen } from 'lucide-react';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getCategories, createCategory, updateCategory, deleteCategory } from '@/api/category';
|
||||||
|
import { getProfile, updateProfile } from '@/api/profile';
|
||||||
|
import { uploadImage } from '@/api/image';
|
||||||
|
import {
|
||||||
|
Github, Mail, Menu, X, ChevronRight, Folder, FolderOpen,
|
||||||
|
Edit3, Camera, Save, XCircle, Plus, Trash2, Move, Settings, FileQuestion
|
||||||
|
} from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
|
import { Profile, ProfileUpdateRequest, Category } from '@/types';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import toast from 'react-hot-toast'; // 🎨 Toast 추가
|
||||||
|
|
||||||
|
const findCategoryNameById = (categories: Category[], id: number): string | undefined => {
|
||||||
|
for (const cat of categories) {
|
||||||
|
if (cat.id === id) return cat.name;
|
||||||
|
if (cat.children && cat.children.length > 0) {
|
||||||
|
const found = findCategoryNameById(cat.children, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CategoryItemProps {
|
||||||
|
category: Category;
|
||||||
|
depth: number;
|
||||||
|
pathname: string;
|
||||||
|
isEditMode: boolean;
|
||||||
|
onDrop: (draggedId: number, targetId: number | null) => void;
|
||||||
|
onAdd: (parentId: number) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, onDelete }: CategoryItemProps) {
|
||||||
|
const isActive = decodeURIComponent(pathname) === `/category/${category.name}`;
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.setData('categoryId', category.id.toString());
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isEditMode) {
|
||||||
|
setIsDragOver(true);
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
|
if (!isEditMode) return;
|
||||||
|
|
||||||
|
const draggedId = Number(e.dataTransfer.getData('categoryId'));
|
||||||
|
if (!draggedId || draggedId === category.id) return;
|
||||||
|
|
||||||
|
if (confirm(`'${category.name}' 하위로 이동하시겠습니까?`)) {
|
||||||
|
onDrop(draggedId, category.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-1">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center justify-between px-4 py-2 text-sm rounded-lg transition-all group relative',
|
||||||
|
isActive && !isEditMode ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50',
|
||||||
|
isEditMode && 'border border-dashed border-gray-300 hover:border-blue-400 cursor-move bg-white',
|
||||||
|
isDragOver && 'bg-blue-100 border-blue-500'
|
||||||
|
)}
|
||||||
|
style={{ marginLeft: `${depth * 12}px` }}
|
||||||
|
draggable={isEditMode}
|
||||||
|
onDragStart={isEditMode ? handleDragStart : undefined}
|
||||||
|
onDragOver={isEditMode ? handleDragOver : undefined}
|
||||||
|
onDragLeave={isEditMode ? handleDragLeave : undefined}
|
||||||
|
onDrop={isEditMode ? handleDrop : undefined}
|
||||||
|
>
|
||||||
|
{!isEditMode ? (
|
||||||
|
<Link href={`/category/${category.name}`} className="flex-1 flex items-center gap-2.5">
|
||||||
|
{isActive ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||||
|
<span>{category.name}</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center gap-2.5">
|
||||||
|
<Move size={14} className="text-gray-400" />
|
||||||
|
<span>{category.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditMode && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onAdd(category.id); }}
|
||||||
|
className="p-1 text-green-600 hover:bg-green-100 rounded"
|
||||||
|
title="하위 카테고리 추가"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(category.id); }}
|
||||||
|
className="p-1 text-red-500 hover:bg-red-100 rounded"
|
||||||
|
title="카테고리 삭제"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEditMode && category.children && category.children.length > 0 && (
|
||||||
|
<ChevronRight size={14} className={clsx("text-gray-300 transition-transform", isActive && "rotate-90")} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{category.children && category.children.length > 0 && (
|
||||||
|
<div className="border-l-2 border-gray-100 ml-4">
|
||||||
|
{category.children.map((child) => (
|
||||||
|
<CategoryItem
|
||||||
|
key={child.id}
|
||||||
|
category={child}
|
||||||
|
depth={0}
|
||||||
|
pathname={pathname}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const [isOpen, setIsOpen] = useState(true); // 사이드바 열림/닫힘 상태
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { role, _hasHydrated } = useAuthStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
const [editForm, setEditForm] = useState<ProfileUpdateRequest>({
|
||||||
|
name: '', bio: '', imageUrl: '', githubUrl: '', email: '',
|
||||||
|
});
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [isCategoryEditMode, setIsCategoryEditMode] = useState(false);
|
||||||
|
const [isRootDragOver, setIsRootDragOver] = useState(false);
|
||||||
|
|
||||||
|
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) {
|
||||||
|
setIsCategoryEditMode(false);
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
}
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
// 1. 서버에서 카테고리 데이터 가져오기
|
|
||||||
const { data: categories } = useQuery({
|
const { data: categories } = useQuery({
|
||||||
queryKey: ['categories'],
|
queryKey: ['categories'],
|
||||||
queryFn: getCategories,
|
queryFn: getCategories,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: profile, isLoading: isProfileLoading } = useQuery({
|
||||||
|
queryKey: ['profile'],
|
||||||
|
queryFn: getProfile,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCategoryMutation = useMutation({
|
||||||
|
mutationFn: createCategory,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||||
|
toast.success('카테고리가 생성되었습니다.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error('생성 실패: ' + (err.response?.data?.message || err.message)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCategoryMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: any }) => updateCategory(id, data),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['categories'] }),
|
||||||
|
onError: (err: any) => toast.error('이동 실패: ' + (err.response?.data?.message || err.message)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteCategoryMutation = useMutation({
|
||||||
|
mutationFn: deleteCategory,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||||
|
toast.success('카테고리가 삭제되었습니다.');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error('삭제 실패: ' + (err.response?.data?.message || err.message)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateProfileMutation = useMutation({
|
||||||
|
mutationFn: updateProfile,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
toast.success('프로필이 수정되었습니다!');
|
||||||
|
},
|
||||||
|
onError: (error: any) => toast.error('수정 실패: ' + (error.response?.data?.message || error.message)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddCategory = (parentId: number | null) => {
|
||||||
|
// 🎨 Prompt 대신 간단한 로직 유지 (복잡도 증가 방지)
|
||||||
|
// 실제로는 모달로 바꾸는게 좋지만, 여기선 일단 Toast만 적용
|
||||||
|
const name = prompt('새 카테고리 이름을 입력하세요:');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
createCategoryMutation.mutate({ name, parentId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCategory = (id: number) => {
|
||||||
|
if (confirm('정말로 이 카테고리를 삭제하시겠습니까?\n하위 카테고리와 게시글이 모두 삭제될 수 있습니다.')) {
|
||||||
|
deleteCategoryMutation.mutate(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveCategory = useCallback((draggedId: number, targetParentId: number | null) => {
|
||||||
|
if (draggedId === targetParentId) return;
|
||||||
|
if (!categories) return;
|
||||||
|
|
||||||
|
const currentName = findCategoryNameById(categories, draggedId);
|
||||||
|
if (!currentName) {
|
||||||
|
toast.error('카테고리 정보를 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCategoryMutation.mutate({
|
||||||
|
id: draggedId,
|
||||||
|
data: { name: currentName, parentId: targetParentId }
|
||||||
|
});
|
||||||
|
}, [categories, updateCategoryMutation]);
|
||||||
|
|
||||||
|
const handleRootDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsRootDragOver(false);
|
||||||
|
const draggedId = Number(e.dataTransfer.getData('categoryId'));
|
||||||
|
if (!draggedId) return;
|
||||||
|
|
||||||
|
if (confirm('이 카테고리를 최상위로 이동하시겠습니까?')) {
|
||||||
|
handleMoveCategory(draggedId, null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultProfile: Profile = {
|
||||||
|
name: 'Dev Park',
|
||||||
|
bio: '풀스택을 꿈꾸는 개발자\n"코드로 세상을 바꾸고 싶은 박개발의 기술 블로그입니다."',
|
||||||
|
imageUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Felix',
|
||||||
|
githubUrl: 'https://github.com',
|
||||||
|
email: 'user@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
|
||||||
|
|
||||||
|
const handleEditClick = () => {
|
||||||
|
setEditForm({
|
||||||
|
name: displayProfile.name,
|
||||||
|
bio: displayProfile.bio,
|
||||||
|
imageUrl: displayProfile.imageUrl,
|
||||||
|
githubUrl: displayProfile.githubUrl || '',
|
||||||
|
email: displayProfile.email || '',
|
||||||
|
});
|
||||||
|
setIsEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
const res = await uploadImage(file);
|
||||||
|
if (res.code === 'SUCCESS' && res.data) {
|
||||||
|
setEditForm((prev) => ({ ...prev, imageUrl: res.data }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('이미지 업로드 오류');
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateProfileMutation.mutate(editForm);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 📱 모바일용 메뉴 토글 버튼 (화면 왼쪽 위에 고정) */}
|
<button onClick={() => setIsOpen(!isOpen)} className="fixed top-4 left-4 z-50 p-2 bg-white rounded-full shadow-md md:hidden hover:bg-gray-100 transition-colors">
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="fixed top-4 left-4 z-50 p-2 bg-white rounded-full shadow-md md:hidden hover:bg-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
{isOpen ? <X size={20} /> : <Menu size={20} />}
|
{isOpen ? <X size={20} /> : <Menu size={20} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 🖥️ 사이드바 본체 */}
|
<aside className={clsx('fixed top-0 left-0 z-40 h-screen bg-white border-r border-gray-100 transition-all duration-300 ease-in-out overflow-y-auto scrollbar-hide', isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0', 'flex flex-col')}>
|
||||||
<aside
|
<div className={clsx('p-6 text-center transition-opacity duration-200 relative group', !isOpen && 'md:opacity-0 md:hidden')}>
|
||||||
className={clsx(
|
{isAdmin && (
|
||||||
// 기본 스타일 & 애니메이션
|
<button onClick={handleEditClick} className="absolute top-4 right-4 p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-all opacity-0 group-hover:opacity-100 z-10" title="프로필 수정">
|
||||||
'fixed top-0 left-0 z-40 h-screen bg-white border-r border-gray-100 transition-all duration-300 ease-in-out overflow-y-auto scrollbar-hide',
|
<Edit3 size={16} />
|
||||||
// 열렸을 때 vs 닫혔을 때 너비 조절
|
</button>
|
||||||
isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0',
|
)}
|
||||||
'flex flex-col'
|
<Link href="/" className="block hover:opacity-80 transition-opacity">
|
||||||
)}
|
<div className="w-24 h-24 mx-auto bg-gray-200 rounded-full mb-4 overflow-hidden shadow-inner ring-4 ring-gray-50 relative">
|
||||||
>
|
{/* 🛠️ 수정됨: 로딩 중일 때는 이미지 대신 스켈레톤 표시 */}
|
||||||
{/* A. 프로필 영역 */}
|
{isProfileLoading ? (
|
||||||
<div className={clsx('p-6 text-center transition-opacity duration-200', !isOpen && 'md:opacity-0 md:hidden')}>
|
<div className="w-full h-full bg-gray-200 animate-pulse" />
|
||||||
<div className="w-24 h-24 mx-auto bg-gray-200 rounded-full mb-4 overflow-hidden shadow-inner ring-4 ring-gray-50">
|
) : (
|
||||||
{/* 프로필 이미지 (임시) */}
|
<Image
|
||||||
<img
|
src={displayProfile.imageUrl || defaultProfile.imageUrl!}
|
||||||
src="https://api.dicebear.com/7.x/notionists/svg?seed=Felix"
|
alt="Profile"
|
||||||
alt="Profile"
|
fill
|
||||||
className="w-full h-full object-cover"
|
sizes="96px"
|
||||||
/>
|
className="object-cover"
|
||||||
</div>
|
unoptimized
|
||||||
<h2 className="text-xl font-bold text-gray-800">Dev Park</h2>
|
priority
|
||||||
<p className="text-sm text-gray-500 mt-1">풀스택을 꿈꾸는 개발자</p>
|
/>
|
||||||
<p className="text-xs text-gray-400 mt-3 font-light leading-relaxed">
|
)}
|
||||||
"코드로 세상을 바꾸고 싶은<br />박개발의 기술 블로그입니다."
|
</div>
|
||||||
</p>
|
{isProfileLoading ? (
|
||||||
|
<div className="space-y-2 flex flex-col items-center"><div className="h-6 w-24 bg-gray-200 rounded animate-pulse" /><div className="h-4 w-32 bg-gray-100 rounded animate-pulse" /></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="text-xl font-bold text-gray-800">{displayProfile.name}</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1 whitespace-pre-line leading-relaxed">{displayProfile.bio}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* B. 네비게이션 & 카테고리 */}
|
<nav className="flex-1 px-4 py-2 flex flex-col">
|
||||||
<nav className="flex-1 px-4 py-2">
|
<div className={clsx('flex flex-col items-center gap-4 mt-4', isOpen && 'hidden')}><Folder size={24} className="text-gray-400" /></div>
|
||||||
{/* 닫혔을 때(좁은 모드) 메뉴 아이콘 표시 */}
|
<div className={clsx('space-y-1 flex-1', !isOpen && 'md:hidden')}>
|
||||||
<div className={clsx('flex flex-col items-center gap-4 mt-4', isOpen && 'hidden')}>
|
<div className="flex items-center justify-between px-4 mb-3 mt-4 h-8">
|
||||||
<Folder size={24} className="text-gray-400" />
|
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Categories</p>
|
||||||
</div>
|
{isAdmin && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
{/* 열렸을 때 메뉴 목록 */}
|
{!isCategoryEditMode ? (
|
||||||
<div className={clsx('space-y-1', !isOpen && 'md:hidden')}>
|
<button onClick={() => setIsCategoryEditMode(true)} className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded" title="카테고리 관리"><Settings size={14} /></button>
|
||||||
<p className="px-4 text-xs font-bold text-gray-400 uppercase tracking-wider mb-3 mt-4">
|
) : (
|
||||||
Categories
|
<>
|
||||||
</p>
|
<button onClick={() => handleAddCategory(null)} className="p-1 text-green-600 hover:bg-green-100 rounded" title="최상위 카테고리 추가"><Plus size={16} /></button>
|
||||||
|
<button onClick={() => setIsCategoryEditMode(false)} className="p-1 text-gray-500 hover:bg-gray-100 rounded" title="관리 종료"><X size={16} /></button>
|
||||||
{/* 로딩 중일 때 스켈레톤 UI */}
|
</>
|
||||||
{!categories && (
|
|
||||||
<div className="space-y-2 px-4">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="h-8 bg-gray-100 rounded animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 실제 카테고리 렌더링 */}
|
|
||||||
{categories?.map((cat) => {
|
|
||||||
// 현재 카테고리(또는 자식)가 선택되었는지 확인
|
|
||||||
const isActive = pathname.includes(`/category/${cat.id}`);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={cat.id} className="mb-1">
|
|
||||||
{/* 1차 카테고리 */}
|
|
||||||
<Link
|
|
||||||
href={`/category/${cat.id}`}
|
|
||||||
className={clsx(
|
|
||||||
'flex items-center justify-between px-4 py-2.5 text-sm font-medium rounded-lg transition-all group',
|
|
||||||
isActive
|
|
||||||
? 'bg-blue-50 text-blue-600'
|
|
||||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
{isActive ? <FolderOpen size={16} /> : <Folder size={16} />}
|
|
||||||
<span>{cat.name}</span>
|
|
||||||
</div>
|
|
||||||
{cat.children && cat.children.length > 0 && (
|
|
||||||
<ChevronRight size={14} className={clsx("text-gray-300 transition-transform", isActive && "rotate-90")} />
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* 2차 카테고리 (자식이 있을 경우) */}
|
|
||||||
{cat.children && cat.children.length > 0 && (
|
|
||||||
<div className="ml-5 mt-1 space-y-0.5 border-l-2 border-gray-100 pl-2">
|
|
||||||
{cat.children.map((child) => {
|
|
||||||
const isChildActive = pathname.includes(`/category/${child.id}`);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={child.id}
|
|
||||||
href={`/category/${child.id}`}
|
|
||||||
className={clsx(
|
|
||||||
"block px-3 py-2 text-sm rounded-md transition-colors",
|
|
||||||
isChildActive
|
|
||||||
? "text-blue-600 font-medium bg-blue-50/50"
|
|
||||||
: "text-gray-500 hover:text-gray-900 hover:bg-gray-50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
- {child.name}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
|
|
||||||
|
{!categories && <div className="space-y-2 px-4">{[1, 2, 3].map((i) => <div key={i} className="h-8 bg-gray-100 rounded animate-pulse" />)}</div>}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={clsx("min-h-[100px] pb-10 transition-colors rounded-lg", isCategoryEditMode && "border-2 border-dashed", isRootDragOver ? "border-blue-500 bg-blue-50" : "border-transparent")}
|
||||||
|
onDragOver={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(true); e.dataTransfer.dropEffect = 'move'; } : undefined}
|
||||||
|
onDragLeave={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(false); } : undefined}
|
||||||
|
onDrop={isCategoryEditMode ? handleRootDrop : undefined}
|
||||||
|
>
|
||||||
|
{categories?.map((cat) => (
|
||||||
|
<CategoryItem key={cat.id} category={cat} depth={0} pathname={pathname} isEditMode={isCategoryEditMode} onDrop={handleMoveCategory} onAdd={handleAddCategory} onDelete={handleDeleteCategory} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!isCategoryEditMode && (
|
||||||
|
<div className="mb-1 mt-2">
|
||||||
|
<Link
|
||||||
|
href="/category/uncategorized"
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2.5 px-4 py-2 text-sm rounded-lg transition-all',
|
||||||
|
pathname === '/category/uncategorized'
|
||||||
|
? 'bg-blue-50 text-blue-600 font-medium'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FileQuestion size={16} />
|
||||||
|
<span>미분류</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCategoryEditMode && categories?.length === 0 && <div className="text-center text-xs text-gray-400 py-4">+ 버튼을 눌러 카테고리를 추가하세요.</div>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* C. 광고 영역 (거슬리지 않게 하단 배치) */}
|
|
||||||
<div className={clsx('px-6 pb-6', !isOpen && 'hidden')}>
|
|
||||||
<div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl border border-dashed border-gray-200 text-center relative overflow-hidden group cursor-pointer hover:border-blue-200 transition-colors">
|
|
||||||
<div className="absolute top-0 right-0 p-1">
|
|
||||||
<span className="text-[9px] bg-gray-200 text-gray-500 px-1 rounded">AD</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-blue-500 font-semibold mb-1">AWS Cloud School</p>
|
|
||||||
<p className="text-[11px] text-gray-500">
|
|
||||||
국비지원 과정 모집중<br/>
|
|
||||||
<span className="underline group-hover:text-blue-600">자세히 보기 →</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* D. 소셜 링크 (최하단) */}
|
|
||||||
<div className={clsx('p-6 border-t border-gray-100 bg-white', !isOpen && 'hidden')}>
|
<div className={clsx('p-6 border-t border-gray-100 bg-white', !isOpen && 'hidden')}>
|
||||||
<div className="flex justify-center gap-3">
|
<div className="flex justify-center gap-3">
|
||||||
<a
|
<a href={displayProfile.githubUrl || '#'} target="_blank" rel="noreferrer" className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-gray-800 hover:text-white transition-all shadow-sm"><Github size={18} /></a>
|
||||||
href="https://github.com"
|
<a href={`mailto:${displayProfile.email}`} className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-blue-500 hover:text-white transition-all shadow-sm"><Mail size={18} /></a>
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-gray-800 hover:text-white transition-all shadow-sm hover:-translate-y-1"
|
|
||||||
aria-label="Github"
|
|
||||||
>
|
|
||||||
<Github size={18} />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="mailto:user@example.com"
|
|
||||||
className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-blue-500 hover:text-white transition-all shadow-sm hover:-translate-y-1"
|
|
||||||
aria-label="Email"
|
|
||||||
>
|
|
||||||
<Mail size={18} />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-[10px] text-gray-300 mt-4 font-light">
|
<p className="text-center text-[10px] text-gray-300 mt-4 font-light">© 2024 {displayProfile.name}. All rights reserved.</p>
|
||||||
© 2024 Dev Park. All rights reserved.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{isEditModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
||||||
|
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2"><Edit3 size={18} className="text-blue-600" /> 프로필 수정</h3>
|
||||||
|
<button onClick={() => setIsEditModalOpen(false)} className="text-gray-400 hover:text-gray-600"><XCircle size={24} /></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
<div className="flex flex-col items-center mb-6">
|
||||||
|
<div className="relative w-24 h-24 mb-3 group cursor-pointer" onClick={() => fileInputRef.current?.click()}>
|
||||||
|
{/* 🎨 모달 내부 프리뷰 이미지에도 적용 */}
|
||||||
|
<Image src={editForm.imageUrl || defaultProfile.imageUrl!} alt="Preview" fill className="rounded-full object-cover border-2 border-gray-100 group-hover:border-blue-300 transition-colors" unoptimized />
|
||||||
|
<div className="absolute inset-0 bg-black/30 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"><Camera className="text-white" size={24} /></div>
|
||||||
|
</div>
|
||||||
|
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" onChange={handleImageUpload} />
|
||||||
|
<button type="button" onClick={() => fileInputRef.current?.click()} className="text-xs text-blue-600 font-medium hover:underline" disabled={isUploading}>{isUploading ? '업로드 중...' : '이미지 변경'}</button>
|
||||||
|
</div>
|
||||||
|
<div><label className="block text-xs font-bold text-gray-500 mb-1">이름 (Name)</label><input type="text" value={editForm.name} onChange={(e) => setEditForm({...editForm, name: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" required /></div>
|
||||||
|
<div><label className="block text-xs font-bold text-gray-500 mb-1">소개 (Bio)</label><textarea value={editForm.bio} onChange={(e) => setEditForm({...editForm, bio: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm resize-none h-24" required /></div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><label className="block text-xs font-bold text-gray-500 mb-1">Github URL</label><input type="url" value={editForm.githubUrl || ''} onChange={(e) => setEditForm({...editForm, githubUrl: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" /></div>
|
||||||
|
<div><label className="block text-xs font-bold text-gray-500 mb-1">Email</label><input type="email" value={editForm.email || ''} onChange={(e) => setEditForm({...editForm, email: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" /></div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 flex gap-2">
|
||||||
|
<button type="button" onClick={() => setIsEditModalOpen(false)} className="flex-1 py-2.5 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors">취소</button>
|
||||||
|
<button type="submit" disabled={updateProfileMutation.isPending || isUploading} className="flex-1 py-2.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors shadow-sm disabled:bg-gray-400 flex justify-center items-center gap-2">{updateProfileMutation.isPending ? '저장 중...' : <><Save size={16} /> 저장하기</>}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
// src/components/post/MarkdownRenderer.tsx
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
// 🛠️ 보안 패치: rehype-sanitize 추가 (반드시 npm install rehype-sanitize 실행 필요)
|
||||||
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import { Copy, Check, Terminal, ExternalLink } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
interface MarkdownRendererProps {
|
interface MarkdownRendererProps {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -7,8 +16,187 @@ interface MarkdownRendererProps {
|
|||||||
|
|
||||||
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||||
return (
|
return (
|
||||||
<div className="prose prose-slate max-w-none prose-headings:font-bold prose-a:text-blue-600 hover:prose-a:text-blue-500 prose-img:rounded-xl">
|
<div className="markdown-content">
|
||||||
<ReactMarkdown>{content}</ReactMarkdown>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
// 🛡️ 중요: 여기서 HTML 태그를 소독하여 XSS 공격 방지
|
||||||
|
rehypePlugins={[rehypeSanitize]}
|
||||||
|
components={{
|
||||||
|
// 1. 코드 블록 커스텀 (Mac 스타일 윈도우 + 문법 강조)
|
||||||
|
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) 스타일
|
||||||
|
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) 스타일
|
||||||
|
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 }) {
|
||||||
|
// rehype-sanitize가 적용되면 기본적으로 img 태그가 허용되지만,
|
||||||
|
// onError 핸들링 등을 위해 커스텀 컴포넌트 유지는 좋음.
|
||||||
|
return (
|
||||||
|
<span className="block my-8">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="rounded-xl shadow-lg border border-gray-100 w-full object-cover max-h-[600px] 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">{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 }) {
|
||||||
|
return <ol className="list-decimal pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-500 font-medium">{children}</ol>;
|
||||||
|
},
|
||||||
|
li({ children }) {
|
||||||
|
return <li className="pl-1">{children}</li>;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 7. 헤딩 스타일
|
||||||
|
h1({ children }) {
|
||||||
|
return <h1 className="text-3xl font-extrabold mt-12 mb-6 pb-4 border-b border-gray-100 text-gray-900">{children}</h1>;
|
||||||
|
},
|
||||||
|
h2({ children }) {
|
||||||
|
return <h2 className="text-2xl font-bold mt-10 mb-5 pb-2 text-gray-800">{children}</h2>;
|
||||||
|
},
|
||||||
|
h3({ children }) {
|
||||||
|
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">{children}</h3>;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 코드 블록 컴포넌트 (변경 없음)
|
||||||
|
function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||||
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
setIsCopied(true);
|
||||||
|
setTimeout(() => setIsCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative my-8 group rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl bg-[#1e1e1e]">
|
||||||
|
<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">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-[#FF5F56] border border-[#E0443E]" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-[#FFBD2E] border border-[#DEA123]" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-[#27C93F] border border-[#1AAB29]" />
|
||||||
|
</div>
|
||||||
|
{language && (
|
||||||
|
<div className="ml-4 flex items-center gap-1.5 px-2 py-0.5 rounded text-[10px] font-mono font-medium text-gray-400 bg-gray-700/50 border border-gray-600/50">
|
||||||
|
<Terminal size={10} />
|
||||||
|
<span className="uppercase tracking-wider">{language}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px] font-medium transition-all duration-200 border",
|
||||||
|
isCopied
|
||||||
|
? "bg-green-500/10 text-green-400 border-green-500/20"
|
||||||
|
: "bg-gray-700/50 text-gray-400 border-transparent hover:bg-gray-600 hover:text-white"
|
||||||
|
)}
|
||||||
|
title="코드 복사"
|
||||||
|
>
|
||||||
|
{isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
<span>{isCopied ? 'Copied!' : 'Copy'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative font-mono text-[14px] leading-relaxed">
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={vscDarkPlus}
|
||||||
|
language={language}
|
||||||
|
PreTag="div"
|
||||||
|
showLineNumbers={true}
|
||||||
|
lineNumberStyle={{ minWidth: '2.5em', paddingRight: '1em', color: '#6e7681', textAlign: 'right' }}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: '1.5rem',
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,9 +43,6 @@ export default function PostCard({ post }: { post: Post }) {
|
|||||||
|
|
||||||
{/* 하단 정보 */}
|
{/* 하단 정보 */}
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-gray-50">
|
<div className="flex items-center justify-between pt-4 border-t border-gray-50">
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
|
||||||
<span>By Dev Park</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-medium text-blue-500 flex items-center gap-1">
|
<span className="text-xs font-medium text-blue-500 flex items-center gap-1">
|
||||||
Read more <span className="group-hover:translate-x-1 transition-transform">→</span>
|
Read more <span className="group-hover:translate-x-1 transition-transform">→</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
41
src/components/post/PostListItem.tsx
Normal file
41
src/components/post/PostListItem.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Post } from '@/types';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PostListItemProps {
|
||||||
|
post: Post;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PostListItem({ post }: PostListItemProps) {
|
||||||
|
return (
|
||||||
|
<Link href={`/posts/${post.slug}`} className="block group">
|
||||||
|
<div className="flex items-center justify-between py-4 border-b border-gray-100 hover:bg-gray-50 px-4 -mx-4 rounded-lg transition-colors">
|
||||||
|
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||||
|
{/* 카테고리 라벨 (작은 화면에선 숨김 처리 가능) */}
|
||||||
|
<span className="hidden sm:inline-block px-2.5 py-1 rounded-md bg-slate-100 text-slate-600 text-xs font-medium whitespace-nowrap">
|
||||||
|
{post.categoryName}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<h3 className="text-base font-medium text-gray-800 truncate group-hover:text-blue-600 transition-colors">
|
||||||
|
{post.title}
|
||||||
|
</h3>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,58 @@
|
|||||||
// src/store/authStore.ts
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
// 🛠️ 보안 개선: jwt-decode 라이브러리 사용 (npm install jwt-decode 필요)
|
||||||
|
import { jwtDecode } from 'jwt-decode';
|
||||||
|
|
||||||
|
// 토큰에서 추출할 사용자 정보 타입
|
||||||
|
interface UserInfo {
|
||||||
|
memberId: number;
|
||||||
|
nickname: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT Payload 타입 정의
|
||||||
|
interface JwtPayload {
|
||||||
|
userId?: number;
|
||||||
|
memberId?: number;
|
||||||
|
id?: number;
|
||||||
|
role?: string;
|
||||||
|
roles?: string;
|
||||||
|
auth?: string;
|
||||||
|
nickname?: string;
|
||||||
|
name?: string;
|
||||||
|
sub?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
role: string | null;
|
role: string | null;
|
||||||
_hasHydrated: boolean; // 👈 추가: 데이터 로딩 완료 여부
|
user: UserInfo | null;
|
||||||
|
_hasHydrated: boolean;
|
||||||
login: (token: string) => void;
|
login: (accessToken: string, refreshToken?: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
setHydrated: () => void; // 👈 추가: 로딩 완료 상태 변경 함수
|
setHydrated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... (getRoleFromToken 함수는 기존과 동일하게 유지하거나, 아래에 포함시켰습니다) ...
|
// 🛠️ 개선됨: 라이브러리를 사용한 안전한 파싱
|
||||||
const getRoleFromToken = (token: string): string => {
|
const parseToken = (token: string): { role: string; user: UserInfo | null } => {
|
||||||
try {
|
try {
|
||||||
const base64Url = token.split('.')[1];
|
const decoded = jwtDecode<JwtPayload>(token);
|
||||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
return {
|
||||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
// 권한 정보 매핑 (백엔드 키값에 따라 유동적 대응)
|
||||||
}).join(''));
|
role: decoded.role || decoded.roles || decoded.auth || 'USER',
|
||||||
const decoded = JSON.parse(jsonPayload);
|
user: {
|
||||||
return decoded.role || decoded.roles || decoded.auth || 'USER';
|
memberId: Number(decoded.userId || decoded.memberId || decoded.id || 0),
|
||||||
|
nickname: decoded.nickname || decoded.name || 'User',
|
||||||
|
email: decoded.sub || '',
|
||||||
|
}
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return 'USER';
|
// console.error('Token parsing error:', e);
|
||||||
|
return { role: 'USER', user: null };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,28 +60,42 @@ export const useAuthStore = create(
|
|||||||
persist<AuthState>(
|
persist<AuthState>(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
role: null,
|
role: null,
|
||||||
_hasHydrated: false, // 초기값은 로딩 안됨
|
user: null,
|
||||||
|
_hasHydrated: false,
|
||||||
login: (token: string) => {
|
|
||||||
const role = getRoleFromToken(token);
|
login: (accessToken: string, refreshToken?: string) => {
|
||||||
const finalRole = Array.isArray(role) ? role[0] : role;
|
const { role, user } = parseToken(accessToken);
|
||||||
set({ accessToken: token, isLoggedIn: true, role: finalRole });
|
set({
|
||||||
|
accessToken,
|
||||||
|
refreshToken: refreshToken || null,
|
||||||
|
isLoggedIn: true,
|
||||||
|
role,
|
||||||
|
user
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => set({ accessToken: null, isLoggedIn: false, role: null }),
|
logout: () => set({
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isLoggedIn: false,
|
||||||
|
role: null,
|
||||||
|
user: null
|
||||||
|
}),
|
||||||
|
|
||||||
setHydrated: () => set({ _hasHydrated: true }),
|
setHydrated: () => set({ _hasHydrated: true }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-storage',
|
name: 'auth-storage',
|
||||||
storage: createJSONStorage(() => localStorage), // 명시적 스토리지 설정
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
|
||||||
// 👇 핵심: 데이터를 다 불러오면(rehydrate) 실행되는 함수
|
|
||||||
onRehydrateStorage: () => (state) => {
|
onRehydrateStorage: () => (state) => {
|
||||||
state?.setHydrated();
|
state?.setHydrated();
|
||||||
},
|
},
|
||||||
|
// 💡 추가 팁: 보안상 민감한 refreshToken은 localStorage 저장을 제외하고 싶다면
|
||||||
|
// partial settings를 사용할 수 있습니다. (로그인 유지를 위해선 백엔드 쿠키가 필요)
|
||||||
|
// partialize: (state) => ({ accessToken: state.accessToken, isLoggedIn: state.isLoggedIn, user: state.user, role: state.role }),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
// 1. 공통 응답 구조 (API 명세 0번 참고)
|
// 1. 공통 응답 구조
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 게시글 (Post) 타입 (API 명세 2번 참고)
|
// 2. 게시글 (Post) 타입
|
||||||
export interface Post {
|
export interface Post {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -13,7 +13,7 @@ export interface Post {
|
|||||||
categoryName: string;
|
categoryName: string;
|
||||||
viewCount: number;
|
viewCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
content?: string; // 상세 조회시에만 옴
|
content?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 게시글 목록 페이징 응답
|
// 3. 게시글 목록 페이징 응답
|
||||||
@@ -24,7 +24,7 @@ export interface PostListResponse {
|
|||||||
last: boolean;
|
last: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 로그인 응답 (API 명세 1-3번 참고)
|
// 4. 로그인 응답
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
grantType: string;
|
grantType: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
@@ -32,34 +32,89 @@ export interface AuthResponse {
|
|||||||
accessTokenExpiresIn: number;
|
accessTokenExpiresIn: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 카테고리 (API 명세 4번 참고)
|
// 5. 카테고리
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
children: Category[];
|
children: Category[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 회원가입 요청
|
// 6. 프로필 정보
|
||||||
|
export interface Profile {
|
||||||
|
name: string;
|
||||||
|
bio: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
githubUrl?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 회원가입 요청
|
||||||
export interface SignupRequest {
|
export interface SignupRequest {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 이메일 인증 요청
|
// 8. 이메일 인증 요청
|
||||||
export interface VerifyRequest {
|
export interface VerifyRequest {
|
||||||
email: string;
|
email: string;
|
||||||
code: string;
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 로그인 요청
|
// 9. 로그인 요청
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 로그인 성공 응답 데이터
|
// 10. 로그인 성공 응답 데이터
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken?: string; // 나중을 위해 추가
|
refreshToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. 프로필 수정 요청
|
||||||
|
export interface ProfileUpdateRequest {
|
||||||
|
name: string;
|
||||||
|
bio: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
githubUrl?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. 카테고리 생성 요청
|
||||||
|
export interface CategoryCreateRequest {
|
||||||
|
name: string;
|
||||||
|
parentId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. 카테고리 수정 요청
|
||||||
|
export interface CategoryUpdateRequest {
|
||||||
|
name: string;
|
||||||
|
parentId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. 댓글 타입 (계층형)
|
||||||
|
export interface Comment {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
author: string;
|
||||||
|
isPostAuthor: boolean;
|
||||||
|
memberId?: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
children: Comment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15. 댓글 작성 요청
|
||||||
|
export interface CommentSaveRequest {
|
||||||
|
postSlug: string;
|
||||||
|
content: string;
|
||||||
|
parentId?: number | null;
|
||||||
|
guestNickname?: string; // 비회원일 경우 필수
|
||||||
|
guestPassword?: string; // 비회원일 경우 필수
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16. 댓글 삭제 요청 (비회원 검증용)
|
||||||
|
export interface CommentDeleteRequest {
|
||||||
|
guestPassword?: string;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user