.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { http } from './http';
|
||||
import { ApiResponse, PostListResponse, Post } from '@/types';
|
||||
import { ApiResponse, PostListResponse, Post, PostSaveRequest } from '@/types';
|
||||
|
||||
// 1. 게시글 목록 조회 (검색, 카테고리, 태그, 정렬 필터링 지원)
|
||||
export const getPosts = async (params?: {
|
||||
@@ -37,13 +37,13 @@ export const getPost = async (slug: string) => {
|
||||
};
|
||||
|
||||
// 4. 게시글 작성
|
||||
export const createPost = async (data: any) => {
|
||||
export const createPost = async (data: PostSaveRequest) => {
|
||||
const response = await http.post<ApiResponse<Post>>('/api/admin/posts', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 5. 게시글 수정
|
||||
export const updatePost = async (id: number, data: any) => {
|
||||
export const updatePost = async (id: number, data: PostSaveRequest) => {
|
||||
const response = await http.put<ApiResponse<Post>>(`/api/admin/posts/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
@@ -52,4 +52,4 @@ export const updatePost = async (id: number, data: any) => {
|
||||
export const deletePost = async (id: number) => {
|
||||
const response = await http.delete<ApiResponse<null>>(`/api/admin/posts/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
};
|
||||
|
||||
231
src/app/admin/page.tsx
Normal file
231
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
ArrowRight,
|
||||
Eye,
|
||||
FileText,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
PenLine,
|
||||
Settings,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { getCategories } from '@/api/category';
|
||||
import { getPosts } from '@/api/posts';
|
||||
import AdminCategoryPanel from '@/components/admin/AdminCategoryPanel';
|
||||
import AdminProfilePanel from '@/components/admin/AdminProfilePanel';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { Category, Post } from '@/types';
|
||||
|
||||
const countCategories = (categories: Category[] = []): number => {
|
||||
return categories.reduce((count, category) => {
|
||||
return count + 1 + countCategories(category.children || []);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const formatDate = (value?: string) => {
|
||||
if (!value) return '-';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
function PostRow({ post }: { post: Post }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
className="flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-gray-900">{post.title}</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{post.categoryName || '미분류'} · {formatDate(post.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 text-xs text-gray-400">
|
||||
<Eye size={14} />
|
||||
<span>{post.viewCount.toLocaleString()}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
const { isLoggedIn, role, _hasHydrated } = useAuthStore();
|
||||
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated) return;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
toast.error('관리자 권한이 필요합니다.');
|
||||
router.replace('/');
|
||||
}
|
||||
}, [_hasHydrated, isAdmin, isLoggedIn, router]);
|
||||
|
||||
const { data: latestData, isLoading: isLatestLoading } = useQuery({
|
||||
queryKey: ['posts', 'admin', 'latest'],
|
||||
queryFn: () => getPosts({ size: 5, sort: 'createdAt,desc' }),
|
||||
enabled: isAdmin,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const { data: popularData, isLoading: isPopularLoading } = useQuery({
|
||||
queryKey: ['posts', 'admin', 'popular'],
|
||||
queryFn: () => getPosts({ size: 5, sort: 'viewCount,desc' }),
|
||||
enabled: isAdmin,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const { data: categories, isLoading: isCategoriesLoading } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: getCategories,
|
||||
enabled: isAdmin,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
if (!_hasHydrated || !isAdmin) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={36} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isLoading = isLatestLoading || isPopularLoading || isCategoriesLoading;
|
||||
const latestPosts = latestData?.content ?? [];
|
||||
const popularPosts = popularData?.content ?? [];
|
||||
const totalPosts = latestData?.totalElements ?? 0;
|
||||
const totalCategories = countCategories(categories);
|
||||
const lastUpdatedAt = latestPosts[0]?.createdAt;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-8 px-1 py-4 md:px-4">
|
||||
<header className="flex flex-col justify-between gap-5 border-b border-gray-200 pb-8 md:flex-row md:items-end">
|
||||
<div>
|
||||
<p className="mb-3 inline-flex items-center gap-2 rounded-full border border-blue-100 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
|
||||
<Settings size={14} />
|
||||
Admin
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold tracking-normal text-gray-950 md:text-4xl">
|
||||
관리자 설정
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-gray-500">
|
||||
공개 화면과 분리된 운영 공간입니다. 게시글 현황을 확인하고 프로필과 카테고리를 정리할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/write"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-gray-800"
|
||||
>
|
||||
<PenLine size={17} />
|
||||
새 글 작성
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between text-gray-500">
|
||||
<span className="text-sm font-medium">총 게시글</span>
|
||||
<FileText size={18} />
|
||||
</div>
|
||||
<p className="mt-4 text-3xl font-bold text-gray-950">{totalPosts.toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between text-gray-500">
|
||||
<span className="text-sm font-medium">카테고리</span>
|
||||
<FolderTree size={18} />
|
||||
</div>
|
||||
<p className="mt-4 text-3xl font-bold text-gray-950">{totalCategories.toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between text-gray-500">
|
||||
<span className="text-sm font-medium">최근 업데이트</span>
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<p className="mt-4 text-2xl font-bold text-gray-950">{formatDate(lastUpdatedAt)}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.8fr)]">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-950">최근 글</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">발행 흐름을 빠르게 확인합니다.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/archive"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
전체보기
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||
</div>
|
||||
) : latestPosts.length > 0 ? (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{latestPosts.map((post) => (
|
||||
<PostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||
아직 작성된 글이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h2 className="text-lg font-bold text-gray-950">인기 글</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">조회수가 높은 글을 확인합니다.</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||
</div>
|
||||
) : popularPosts.length > 0 ? (
|
||||
<div className="mt-4 divide-y divide-gray-100">
|
||||
{popularPosts.map((post) => (
|
||||
<PostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||
아직 집계된 인기 글이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<AdminProfilePanel />
|
||||
<AdminCategoryPanel />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,4 +6,4 @@ export default function Loading() {
|
||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,9 @@ export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
<AuthInitializer /> {/* 👈 앱 실행 시 토큰 자동 검사 */}
|
||||
{children}
|
||||
<Toaster position="top-right" />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createPost, updatePost, getPost } from '@/api/posts';
|
||||
import { getCategories } from '@/api/category';
|
||||
import { uploadImage } from '@/api/image';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { PostSaveRequest } from '@/types';
|
||||
import { Loader2, Save, Image as ImageIcon, ArrowLeft, Folder, UploadCloud, FileText, Trash2, X, Clock } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import dynamic from 'next/dynamic';
|
||||
@@ -165,7 +166,7 @@ function WritePageContent() {
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: any) => isEditMode ? updatePost(existingPost!.id, data) : createPost(data),
|
||||
mutationFn: (data: PostSaveRequest) => isEditMode ? updatePost(existingPost!.id, data) : createPost(data),
|
||||
onSuccess: async (response: any) => {
|
||||
const savedPost = response?.data;
|
||||
const newSlug = savedPost?.slug || editSlug;
|
||||
@@ -521,4 +522,4 @@ export default function WritePage() {
|
||||
<WritePageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
431
src/components/admin/AdminCategoryPanel.tsx
Normal file
431
src/components/admin/AdminCategoryPanel.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Folder, FolderOpen, FolderPlus, FolderTree, Loader2, Save, Trash2 } from 'lucide-react';
|
||||
import { createCategory, deleteCategory, getCategories, updateCategory } from '@/api/category';
|
||||
import { Category, CategoryUpdateRequest } from '@/types';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
const ROOT_PARENT = 'root';
|
||||
|
||||
type FlatCategory = Category & {
|
||||
depth: number;
|
||||
parentId: number | null;
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (typeof error === 'object' && error !== null && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response;
|
||||
if (response?.data?.message) return `${fallback}: ${response.data.message}`;
|
||||
}
|
||||
|
||||
if (error instanceof Error) return `${fallback}: ${error.message}`;
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const sortCategories = (categories: Category[] = []) => {
|
||||
return [...categories].sort((a, b) => a.id - b.id);
|
||||
};
|
||||
|
||||
const flattenCategories = (
|
||||
categories: Category[] = [],
|
||||
depth = 0,
|
||||
parentId: number | null = null,
|
||||
): FlatCategory[] => {
|
||||
return sortCategories(categories).flatMap((category) => {
|
||||
const resolvedParentId = category.parentId ?? parentId;
|
||||
const current: FlatCategory = {
|
||||
...category,
|
||||
parentId: resolvedParentId,
|
||||
depth,
|
||||
};
|
||||
|
||||
return [
|
||||
current,
|
||||
...flattenCategories(category.children || [], depth + 1, category.id),
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const collectDescendantIds = (category: Category): Set<number> => {
|
||||
const ids = new Set<number>([category.id]);
|
||||
|
||||
for (const child of category.children || []) {
|
||||
for (const id of collectDescendantIds(child)) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
const parentValueToId = (value: string): number | null => {
|
||||
return value === ROOT_PARENT ? null : Number(value);
|
||||
};
|
||||
|
||||
const parentIdToValue = (parentId?: number | null) => {
|
||||
return parentId == null ? ROOT_PARENT : String(parentId);
|
||||
};
|
||||
|
||||
function ParentSelect({
|
||||
value,
|
||||
onChange,
|
||||
categories,
|
||||
disabledIds = new Set<number>(),
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
categories: FlatCategory[];
|
||||
disabledIds?: Set<number>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||
>
|
||||
<option value={ROOT_PARENT}>최상위 카테고리</option>
|
||||
{categories.map((category) => (
|
||||
<option
|
||||
key={category.id}
|
||||
value={category.id}
|
||||
disabled={disabledIds.has(category.id)}
|
||||
>
|
||||
{`${' '.repeat(category.depth)}${category.depth > 0 ? '- ' : ''}${category.name}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryTree({
|
||||
categories,
|
||||
selectedId,
|
||||
onSelect,
|
||||
depth = 0,
|
||||
}: {
|
||||
categories: Category[];
|
||||
selectedId: number | null;
|
||||
onSelect: (category: Category) => void;
|
||||
depth?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{sortCategories(categories).map((category) => {
|
||||
const isSelected = selectedId === category.id;
|
||||
const hasChildren = category.children?.length > 0;
|
||||
|
||||
return (
|
||||
<div key={category.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(category)}
|
||||
className={clsx(
|
||||
'flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm transition',
|
||||
isSelected ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50',
|
||||
)}
|
||||
style={{ paddingLeft: `${12 + depth * 16}px` }}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{hasChildren ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||
<span className="truncate font-medium">{category.name}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-gray-400">#{category.id}</span>
|
||||
</button>
|
||||
|
||||
{hasChildren && (
|
||||
<CategoryTree
|
||||
categories={category.children}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedCategoryEditor({
|
||||
category,
|
||||
flatCategories,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
}: {
|
||||
category: FlatCategory;
|
||||
flatCategories: FlatCategory[];
|
||||
onUpdate: (id: number, data: CategoryUpdateRequest) => void;
|
||||
onDelete: (id: number) => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
}) {
|
||||
const [name, setName] = useState(category.name);
|
||||
const [parentValue, setParentValue] = useState(parentIdToValue(category.parentId));
|
||||
const [deleteArmed, setDeleteArmed] = useState(false);
|
||||
const disabledParentIds = useMemo(() => collectDescendantIds(category), [category]);
|
||||
const isBusy = isSaving || isDeleting;
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
toast.error('카테고리 이름을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate(category.id, {
|
||||
name: trimmedName,
|
||||
parentId: parentValueToId(parentValue),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-900">선택한 카테고리</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">이름 변경과 부모 카테고리 이동을 여기서 처리합니다.</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-gray-500">
|
||||
#{category.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold text-gray-500">카테고리 이름</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
disabled={isBusy}
|
||||
className="mt-1 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold text-gray-500">상위 카테고리</span>
|
||||
<div className="mt-1">
|
||||
<ParentSelect
|
||||
value={parentValue}
|
||||
onChange={setParentValue}
|
||||
categories={flatCategories}
|
||||
disabledIds={disabledParentIds}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{!deleteArmed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteArmed(true)}
|
||||
disabled={isBusy}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full border border-red-100 bg-white px-4 py-2 text-sm font-semibold text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
삭제
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(category.id)}
|
||||
disabled={isBusy}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-red-300"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="animate-spin" size={15} /> : <Trash2 size={15} />}
|
||||
삭제 확인
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteArmed(false)}
|
||||
disabled={isBusy}
|
||||
className="rounded-full px-3 py-2 text-sm font-semibold text-gray-500 transition hover:bg-white"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isBusy}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-2 text-sm font-semibold text-white transition hover:bg-gray-800 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
>
|
||||
{isSaving ? <Loader2 className="animate-spin" size={15} /> : <Save size={15} />}
|
||||
변경 저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminCategoryPanel() {
|
||||
const queryClient = useQueryClient();
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [createParentValue, setCreateParentValue] = useState(ROOT_PARENT);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: getCategories,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const flatCategories = useMemo(() => flattenCategories(categories), [categories]);
|
||||
const selectedCategory = flatCategories.find((category) => category.id === selectedId);
|
||||
|
||||
const invalidateCategoryData = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createCategory,
|
||||
onSuccess: () => {
|
||||
invalidateCategoryData();
|
||||
setCreateName('');
|
||||
setCreateParentValue(ROOT_PARENT);
|
||||
toast.success('카테고리가 생성되었습니다.');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error, '카테고리 생성 실패')),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: CategoryUpdateRequest }) => updateCategory(id, data),
|
||||
onSuccess: () => {
|
||||
invalidateCategoryData();
|
||||
toast.success('카테고리가 수정되었습니다.');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error, '카테고리 수정 실패')),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteCategory,
|
||||
onSuccess: () => {
|
||||
invalidateCategoryData();
|
||||
setSelectedId(null);
|
||||
toast.success('카테고리가 삭제되었습니다.');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error, '카테고리 삭제 실패')),
|
||||
});
|
||||
|
||||
const handleCreate = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const trimmedName = createName.trim();
|
||||
if (!trimmedName) {
|
||||
toast.error('카테고리 이름을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
createMutation.mutate({
|
||||
name: trimmedName,
|
||||
parentId: parentValueToId(createParentValue),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
|
||||
<FolderTree size={19} />
|
||||
카테고리 관리
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">공개 사이드바의 카테고리 구조를 관리자 화면에서 정리합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(260px,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div className="rounded-lg border border-gray-200 p-3">
|
||||
<div className="mb-3 flex items-center justify-between px-1">
|
||||
<h3 className="text-sm font-bold text-gray-900">카테고리 트리</h3>
|
||||
<span className="text-xs text-gray-400">{flatCategories.length.toLocaleString()}개</span>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-64 items-center justify-center rounded-lg bg-gray-50">
|
||||
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||
</div>
|
||||
) : categories.length > 0 ? (
|
||||
<CategoryTree categories={categories} selectedId={selectedId} onSelect={(category) => setSelectedId(category.id)} />
|
||||
) : (
|
||||
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||
아직 카테고리가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={handleCreate} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<FolderPlus size={18} className="text-blue-600" />
|
||||
<h3 className="text-sm font-bold text-gray-900">새 카테고리 추가</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<input
|
||||
value={createName}
|
||||
onChange={(event) => setCreateName(event.target.value)}
|
||||
placeholder="카테고리 이름"
|
||||
disabled={createMutation.isPending}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||
/>
|
||||
<ParentSelect
|
||||
value={createParentValue}
|
||||
onChange={setCreateParentValue}
|
||||
categories={flatCategories}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 className="animate-spin" size={15} /> : <FolderPlus size={15} />}
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{selectedCategory ? (
|
||||
<SelectedCategoryEditor
|
||||
key={`${selectedCategory.id}-${selectedCategory.name}-${selectedCategory.parentId}`}
|
||||
category={selectedCategory}
|
||||
flatCategories={flatCategories}
|
||||
onUpdate={(id, data) => updateMutation.mutate({ id, data })}
|
||||
onDelete={(id) => deleteMutation.mutate(id)}
|
||||
isSaving={updateMutation.isPending}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 px-4 py-12 text-center">
|
||||
<p className="text-sm font-semibold text-gray-700">수정할 카테고리를 선택하세요.</p>
|
||||
<p className="mt-2 text-sm text-gray-400">왼쪽 트리에서 항목을 선택하면 이름과 위치를 변경할 수 있습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
229
src/components/admin/AdminProfilePanel.tsx
Normal file
229
src/components/admin/AdminProfilePanel.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Camera, Loader2, Save, UserRound } from 'lucide-react';
|
||||
import { getProfile, updateProfile } from '@/api/profile';
|
||||
import { uploadImage } from '@/api/image';
|
||||
import { Profile, ProfileUpdateRequest } from '@/types';
|
||||
|
||||
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 getErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (typeof error === 'object' && error !== null && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response;
|
||||
if (response?.data?.message) return `${fallback}: ${response.data.message}`;
|
||||
}
|
||||
|
||||
if (error instanceof Error) return `${fallback}: ${error.message}`;
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
function toProfileForm(profile: Profile): ProfileUpdateRequest {
|
||||
return {
|
||||
name: profile.name,
|
||||
bio: profile.bio,
|
||||
imageUrl: profile.imageUrl || '',
|
||||
githubUrl: profile.githubUrl || '',
|
||||
email: profile.email || '',
|
||||
};
|
||||
}
|
||||
|
||||
function ProfileForm({ profile }: { profile: Profile }) {
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [form, setForm] = useState<ProfileUpdateRequest>(() => toProfileForm(profile));
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: updateProfile,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||
toast.success('프로필이 저장되었습니다.');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error, '프로필 저장 실패')),
|
||||
});
|
||||
|
||||
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
const uploadToast = toast.loading('이미지 업로드 중...');
|
||||
|
||||
try {
|
||||
const response = await uploadImage(file);
|
||||
if (response.code === 'SUCCESS' && response.data) {
|
||||
setForm((previous) => ({ ...previous, imageUrl: response.data }));
|
||||
toast.success('이미지가 업로드되었습니다.', { id: uploadToast });
|
||||
} else {
|
||||
toast.error(response.message || '이미지 업로드 실패', { id: uploadToast });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast });
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
updateMutation.mutate({
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
bio: form.bio.trim(),
|
||||
githubUrl: form.githubUrl?.trim(),
|
||||
email: form.email?.trim(),
|
||||
imageUrl: form.imageUrl?.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const isSaving = updateMutation.isPending || isUploading;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div className="flex flex-col items-center rounded-lg border border-gray-200 bg-gray-50 p-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="group relative h-28 w-28 overflow-hidden rounded-full border border-gray-200 bg-white shadow-sm"
|
||||
aria-label="프로필 이미지 변경"
|
||||
>
|
||||
<Image
|
||||
src={form.imageUrl || defaultProfile.imageUrl!}
|
||||
alt="프로필 미리보기"
|
||||
fill
|
||||
sizes="112px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-black/35 text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{isUploading ? <Loader2 className="animate-spin" size={24} /> : <Camera size={24} />}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isSaving}
|
||||
className="mt-4 rounded-full border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-blue-200 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
이미지 변경
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold text-gray-500">이름</span>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((previous) => ({ ...previous, name: event.target.value }))}
|
||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold text-gray-500">Email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email || ''}
|
||||
onChange={(event) => setForm((previous) => ({ ...previous, email: event.target.value }))}
|
||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold text-gray-500">Github URL</span>
|
||||
<input
|
||||
type="url"
|
||||
value={form.githubUrl || ''}
|
||||
onChange={(event) => setForm((previous) => ({ ...previous, githubUrl: event.target.value }))}
|
||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold text-gray-500">소개</span>
|
||||
<textarea
|
||||
value={form.bio}
|
||||
onChange={(event) => setForm((previous) => ({ ...previous, bio: event.target.value }))}
|
||||
className="mt-1 h-28 w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm leading-6 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-gray-800 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
>
|
||||
{isSaving ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
|
||||
저장하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminProfilePanel() {
|
||||
const { data: profile, isLoading } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: getProfile,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
|
||||
const formKey = [
|
||||
displayProfile.name,
|
||||
displayProfile.bio,
|
||||
displayProfile.imageUrl,
|
||||
displayProfile.githubUrl,
|
||||
displayProfile.email,
|
||||
].join('|');
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
|
||||
<UserRound size={19} />
|
||||
프로필 관리
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">공개 사이드바에 표시되는 블로그 소개를 수정합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-56 items-center justify-center rounded-lg bg-gray-50">
|
||||
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||
</div>
|
||||
) : (
|
||||
<ProfileForm key={formKey} profile={displayProfile} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,189 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useEffect, useMemo, Suspense } from 'react';
|
||||
import { Suspense, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
// 🎨 이미지 최적화를 위해 next/image 사용
|
||||
import Image from 'next/image';
|
||||
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,
|
||||
Archive
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { clsx } from 'clsx';
|
||||
import { Profile, ProfileUpdateRequest, Category } from '@/types';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import toast from 'react-hot-toast';
|
||||
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트 추가
|
||||
|
||||
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;
|
||||
};
|
||||
import {
|
||||
Archive,
|
||||
ChevronRight,
|
||||
FileQuestion,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Github,
|
||||
Mail,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { getCategories } from '@/api/category';
|
||||
import { getProfile } from '@/api/profile';
|
||||
import PostSearch from '@/components/post/PostSearch';
|
||||
import { Category, Profile } from '@/types';
|
||||
|
||||
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 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 sortCategories = (categories: Category[] = []) => {
|
||||
return [...categories].sort((a, b) => a.id - b.id);
|
||||
};
|
||||
|
||||
function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
|
||||
const sortedChildren = useMemo(() => sortCategories(category.children), [category.children]);
|
||||
const hasChildren = sortedChildren.length > 0;
|
||||
const isActive = decodeURIComponent(pathname) === `/category/${category.name}`;
|
||||
|
||||
|
||||
const hasActiveChild = useMemo(() => {
|
||||
const check = (cats: Category[] | undefined): boolean => {
|
||||
if (!cats) return false;
|
||||
return cats.some(c =>
|
||||
decodeURIComponent(pathname) === `/category/${c.name}` || check(c.children)
|
||||
);
|
||||
const check = (categories: Category[] | undefined): boolean => {
|
||||
if (!categories) return false;
|
||||
|
||||
return categories.some((child) => {
|
||||
return decodeURIComponent(pathname) === `/category/${child.name}` || check(child.children);
|
||||
});
|
||||
};
|
||||
|
||||
return check(category.children);
|
||||
}, [category.children, pathname]);
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(isActive || hasActiveChild);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive || hasActiveChild) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [isActive, hasActiveChild]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedChildren = useMemo(() => {
|
||||
if (!category.children) return [];
|
||||
return [...category.children].sort((a, b) => a.id - b.id);
|
||||
}, [category.children]);
|
||||
const isChildrenVisible = isExpanded || isActive || hasActiveChild;
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<div
|
||||
<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'
|
||||
'flex items-center justify-between rounded-lg px-4 py-2 text-sm transition-all',
|
||||
isActive ? 'bg-blue-50 font-medium text-blue-600' : 'text-gray-600 hover:bg-gray-50',
|
||||
)}
|
||||
style={{ marginLeft: `${depth * 12}px` }}
|
||||
draggable={isEditMode}
|
||||
onDragStart={isEditMode ? handleDragStart : undefined}
|
||||
onDragOver={isEditMode ? handleDragOver : undefined}
|
||||
onDragLeave={isEditMode ? handleDragLeave : undefined}
|
||||
onDrop={isEditMode ? handleDrop : undefined}
|
||||
style={{ marginLeft: `${depth * 10}px` }}
|
||||
>
|
||||
{!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>
|
||||
)}
|
||||
<Link href={`/category/${category.name}`} className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
{isActive ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||
<span className="truncate">{category.name}</span>
|
||||
</Link>
|
||||
|
||||
{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 && (
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsExpanded((previous) => !previous);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-200/50 rounded-full transition-colors ml-1"
|
||||
className="ml-1 rounded-full p-1 transition-colors hover:bg-gray-200/50"
|
||||
aria-label={isChildrenVisible ? `${category.name} 접기` : `${category.name} 펼치기`}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={clsx(
|
||||
"text-gray-400 transition-transform duration-200",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={clsx('text-gray-400 transition-transform duration-200', isChildrenVisible && 'rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && sortedChildren.length > 0 && (
|
||||
<div className="border-l-2 border-gray-100 ml-4 animate-in slide-in-from-top-1 duration-200 fade-in">
|
||||
{isChildrenVisible && hasChildren && (
|
||||
<div className="ml-4 border-l-2 border-gray-100">
|
||||
{sortedChildren.map((child) => (
|
||||
<CategoryItem
|
||||
key={child.id}
|
||||
category={child}
|
||||
depth={0}
|
||||
<CategoryItem
|
||||
key={child.id}
|
||||
category={child}
|
||||
depth={depth + 1}
|
||||
pathname={pathname}
|
||||
isEditMode={isEditMode}
|
||||
onDrop={onDrop}
|
||||
onAdd={onAdd}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -194,57 +111,18 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
|
||||
|
||||
function SidebarContent() {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [isMounted, setIsMounted] = useState(false); // 🛠️ [Fix] 하이드레이션 매칭을 위한 상태 추가
|
||||
|
||||
const pathname = usePathname();
|
||||
const { role, _hasHydrated } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 🆕 검색 로직 추가
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const keyword = searchParams.get('keyword') || '';
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true); // 🛠️ [Fix] 클라이언트 마운트 후 true로 설정
|
||||
}, []);
|
||||
|
||||
const handleSearch = (newKeyword: string) => {
|
||||
if (newKeyword.trim()) {
|
||||
router.push(`/?keyword=${encodeURIComponent(newKeyword.trim())}`);
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: getCategories,
|
||||
});
|
||||
|
||||
const sortedCategories = useMemo(() => {
|
||||
if (!categories) return undefined;
|
||||
return [...categories].sort((a, b) => a.id - b.id);
|
||||
}, [categories]);
|
||||
const sortedCategories = useMemo(() => sortCategories(categories), [categories]);
|
||||
|
||||
const { data: profile, isLoading: isProfileLoading } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
@@ -252,147 +130,44 @@ function SidebarContent() {
|
||||
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 displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
|
||||
|
||||
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) => {
|
||||
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('카테고리 정보를 찾을 수 없습니다.');
|
||||
const handleSearch = (newKeyword: string) => {
|
||||
const trimmedKeyword = newKeyword.trim();
|
||||
if (trimmedKeyword) {
|
||||
router.push(`/?keyword=${encodeURIComponent(trimmedKeyword)}`);
|
||||
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);
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
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
|
||||
type="button"
|
||||
onClick={() => setIsOpen((previous) => !previous)}
|
||||
className="fixed left-4 top-4 z-50 rounded-full bg-white p-2 shadow-md transition-colors hover:bg-gray-100 md:hidden"
|
||||
aria-label={isOpen ? '사이드바 닫기' : '사이드바 열기'}
|
||||
>
|
||||
{isOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
|
||||
{/* 🛠️ [수정] overflow-y-auto 제거, overflow-hidden 추가 */}
|
||||
<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 flex flex-col overflow-hidden', isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0')}>
|
||||
|
||||
{/* 🛠️ [수정] 고정 영역: 프로필 (shrink-0 추가) */}
|
||||
<div className={clsx('p-6 text-center transition-opacity duration-200 relative group shrink-0', !isOpen && 'md:opacity-0 md:hidden')}>
|
||||
{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="프로필 수정">
|
||||
<Edit3 size={16} />
|
||||
</button>
|
||||
)}
|
||||
<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">
|
||||
{/* 🛠️ [Fix] isMounted를 체크하여 첫 렌더링 시 무조건 스켈레톤을 보여줍니다. (서버와 일치시킴) */}
|
||||
{!isMounted || isProfileLoading ? (
|
||||
<div className="w-full h-full bg-gray-200 animate-pulse" />
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed left-0 top-0 z-40 flex h-screen flex-col overflow-hidden border-r border-gray-100 bg-white transition-all duration-300 ease-in-out',
|
||||
isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0',
|
||||
)}
|
||||
>
|
||||
<div className={clsx('shrink-0 p-6 text-center transition-opacity duration-200', !isOpen && 'md:hidden md:opacity-0')}>
|
||||
<Link href="/" className="block transition-opacity hover:opacity-80">
|
||||
<div className="relative mx-auto mb-4 h-24 w-24 overflow-hidden rounded-full bg-gray-200 shadow-inner ring-4 ring-gray-50">
|
||||
{isProfileLoading ? (
|
||||
<div className="h-full w-full animate-pulse bg-gray-200" />
|
||||
) : (
|
||||
<Image
|
||||
src={displayProfile.imageUrl || defaultProfile.imageUrl!}
|
||||
alt="Profile"
|
||||
<Image
|
||||
src={displayProfile.imageUrl || defaultProfile.imageUrl!}
|
||||
alt="Profile"
|
||||
fill
|
||||
sizes="96px"
|
||||
className="object-cover"
|
||||
@@ -401,44 +176,44 @@ function SidebarContent() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 🛠️ [Fix] 텍스트 영역도 동일하게 처리 */}
|
||||
{!isMounted || 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>
|
||||
|
||||
{isProfileLoading ? (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="h-6 w-24 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-gray-500">{displayProfile.bio}</p>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 🛠️ [수정] min-h-0 추가하여 내부 스크롤 동작 보장 */}
|
||||
<nav className="flex-1 px-4 py-2 flex flex-col min-h-0">
|
||||
<div className={clsx('flex flex-col items-center gap-4 mt-4 shrink-0', isOpen && 'hidden')}><Folder size={24} className="text-gray-400" /></div>
|
||||
|
||||
<div className={clsx('flex flex-col h-full', !isOpen && 'md:hidden')}>
|
||||
|
||||
{/* 🛠️ [수정] 상단 고정 네비게이션 영역 (shrink-0) */}
|
||||
<nav className="flex min-h-0 flex-1 flex-col px-4 py-2">
|
||||
<div className={clsx('mt-4 flex shrink-0 flex-col items-center gap-4', isOpen && 'hidden')}>
|
||||
<Folder size={24} className="text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className={clsx('flex h-full flex-col', !isOpen && 'md:hidden')}>
|
||||
<div className="shrink-0 space-y-1">
|
||||
{/* 🆕 전체 검색바 (Archives 위) */}
|
||||
<div className="px-1 mb-6 mt-2">
|
||||
<PostSearch
|
||||
onSearch={handleSearch}
|
||||
placeholder="검색..."
|
||||
<div className="mb-6 mt-2 px-1">
|
||||
<PostSearch
|
||||
onSearch={handleSearch}
|
||||
placeholder="검색..."
|
||||
initialKeyword={keyword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 2. 아카이브 링크 */}
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
<Link
|
||||
href="/archive"
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 px-4 py-2 text-sm rounded-lg transition-all group',
|
||||
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all',
|
||||
pathname === '/archive'
|
||||
? 'bg-blue-50 text-blue-600 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
? 'bg-blue-50 font-medium text-blue-600'
|
||||
: 'text-gray-600 hover:bg-gray-50',
|
||||
)}
|
||||
>
|
||||
<Archive size={16} />
|
||||
@@ -446,112 +221,83 @@ function SidebarContent() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-gray-100 mb-4" />
|
||||
<div className="mb-4 border-t border-gray-100" />
|
||||
|
||||
{/* 1. 카테고리 섹션 헤더 (고정) */}
|
||||
<div className="flex items-center justify-between px-4 mb-3 h-8">
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Categories</p>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1">
|
||||
{!isCategoryEditMode ? (
|
||||
<button onClick={() => setIsCategoryEditMode(true)} className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded" title="카테고리 관리"><Settings size={14} /></button>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3 flex h-8 items-center justify-between px-4">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-gray-400">Categories</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🛠️ [수정] 스크롤 가능한 카테고리 리스트 영역 (flex-1, overflow-y-auto) */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide -mx-2 px-2">
|
||||
{!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-[50px] transition-colors rounded-lg mb-6", 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}
|
||||
>
|
||||
{sortedCategories?.map((cat) => (
|
||||
<CategoryItem key={cat.id} category={cat} depth={0} pathname={pathname} isEditMode={isCategoryEditMode} onDrop={handleMoveCategory} onAdd={handleAddCategory} onDelete={handleDeleteCategory} />
|
||||
<div className="-mx-2 flex-1 overflow-y-auto px-2 scrollbar-hide">
|
||||
{!categories && (
|
||||
<div className="space-y-2 px-4">
|
||||
{[1, 2, 3].map((item) => (
|
||||
<div key={item} className="h-8 animate-pulse rounded bg-gray-100" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 min-h-[50px] rounded-lg">
|
||||
{sortedCategories?.map((category) => (
|
||||
<CategoryItem
|
||||
key={category.id}
|
||||
category={category}
|
||||
depth={0}
|
||||
pathname={pathname}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!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 className="mb-1 mt-2">
|
||||
<Link
|
||||
href="/category/uncategorized"
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all',
|
||||
pathname === '/category/uncategorized'
|
||||
? 'bg-blue-50 font-medium text-blue-600'
|
||||
: 'text-gray-600 hover:bg-gray-50',
|
||||
)}
|
||||
>
|
||||
<FileQuestion size={16} />
|
||||
<span>미분류</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 🛠️ [수정] 고정 영역: 푸터 (shrink-0 추가) */}
|
||||
<div className={clsx('p-6 border-t border-gray-100 bg-white shrink-0', !isOpen && 'hidden')}>
|
||||
<div className={clsx('shrink-0 border-t border-gray-100 bg-white p-6', !isOpen && 'hidden')}>
|
||||
<div className="flex justify-center gap-3">
|
||||
<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>
|
||||
<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>
|
||||
<a
|
||||
href={displayProfile.githubUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full bg-gray-50 p-2.5 text-gray-500 shadow-sm transition-all hover:bg-gray-800 hover:text-white"
|
||||
aria-label="Github"
|
||||
>
|
||||
<Github size={18} />
|
||||
</a>
|
||||
<a
|
||||
href={`mailto:${displayProfile.email}`}
|
||||
className="rounded-full bg-gray-50 p-2.5 text-gray-500 shadow-sm transition-all hover:bg-blue-500 hover:text-white"
|
||||
aria-label="Email"
|
||||
>
|
||||
<Mail size={18} />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-center text-[10px] text-gray-300 mt-4 font-light">© {new Date().getFullYear()} {displayProfile.name}. All rights reserved.</p>
|
||||
<p className="mt-4 text-center text-[10px] font-light text-gray-300">
|
||||
© {new Date().getFullYear()} {displayProfile.name}. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<Suspense fallback={<div className="w-72 h-screen bg-white border-r border-gray-100" />}>
|
||||
<Suspense fallback={<div className="h-screen w-72 border-r border-gray-100 bg-white" />}>
|
||||
<SidebarContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,12 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LogOut, PenLine, User, UserPlus } from 'lucide-react';
|
||||
import { LogOut, PenLine, Settings, User, UserPlus } from 'lucide-react';
|
||||
|
||||
export default function TopHeader() {
|
||||
const router = useRouter();
|
||||
const { isLoggedIn, role, logout } = useAuthStore(); // 👈 role 가져오기
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
const { isLoggedIn, role, logout, _hasHydrated } = useAuthStore();
|
||||
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
||||
|
||||
const handleLogout = () => {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
@@ -24,21 +19,30 @@ export default function TopHeader() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
if (!_hasHydrated) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute top-6 right-6 z-30 flex items-center gap-3">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
{/* 👇 관리자(ADMIN)일 때만 글쓰기 버튼 노출 */}
|
||||
{role && role.includes('ADMIN') && (
|
||||
<Link
|
||||
href="/write"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded-full shadow-md hover:bg-blue-700 hover:shadow-lg transition-all transform hover:-translate-y-0.5"
|
||||
>
|
||||
<PenLine size={16} />
|
||||
<span>글쓰기</span>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-gray-700 text-sm font-semibold rounded-full border border-gray-200 shadow-sm hover:bg-gray-50 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span className="hidden sm:inline">관리자 설정</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/write"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded-full shadow-md hover:bg-blue-700 hover:shadow-lg transition-all transform hover:-translate-y-0.5"
|
||||
>
|
||||
<PenLine size={16} />
|
||||
<span>새 글</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -70,4 +74,4 @@ export default function TopHeader() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,13 @@ export interface PostListResponse {
|
||||
last: boolean;
|
||||
}
|
||||
|
||||
export interface PostSaveRequest {
|
||||
title: string;
|
||||
content: string;
|
||||
categoryId: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// 4. 로그인 응답
|
||||
export interface AuthResponse {
|
||||
grantType: string;
|
||||
@@ -128,4 +135,4 @@ export interface CommentSaveRequest {
|
||||
// 16. 댓글 삭제 요청 (비회원 검증용)
|
||||
export interface CommentDeleteRequest {
|
||||
guestPassword?: string;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user