'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 WindowSurface from '@/components/ui/WindowSurface'; 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 => { const ids = new Set([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(), disabled, }: { value: string; onChange: (value: string) => void; categories: FlatCategory[]; disabledIds?: Set; disabled?: boolean; }) { return ( ); } function CategoryTree({ categories, selectedId, onSelect, depth = 0, }: { categories: Category[]; selectedId: number | null; onSelect: (category: Category) => void; depth?: number; }) { return (
{sortCategories(categories).map((category) => { const isSelected = selectedId === category.id; const hasChildren = category.children?.length > 0; return (
{hasChildren && ( )}
); })}
); } 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 (

선택한 카테고리

이름 변경과 부모 카테고리 이동을 여기서 처리합니다.

#{category.id}
{!deleteArmed ? ( ) : ( <> )}
); } export default function AdminCategoryPanel() { const queryClient = useQueryClient(); const [createName, setCreateName] = useState(''); const [createParentValue, setCreateParentValue] = useState(ROOT_PARENT); const [selectedId, setSelectedId] = useState(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 (

카테고리 관리

공개 사이드바의 카테고리 구조를 관리자 화면에서 정리합니다.

카테고리 트리

{flatCategories.length.toLocaleString()}개
{isLoading ? (
) : categories.length > 0 ? ( setSelectedId(category.id)} /> ) : (
아직 카테고리가 없습니다.
)}

새 카테고리 추가

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" />
{selectedCategory ? ( updateMutation.mutate({ id, data })} onDelete={(id) => deleteMutation.mutate(id)} isSaving={updateMutation.isPending} isDeleting={deleteMutation.isPending} /> ) : (

수정할 카테고리를 선택하세요.

왼쪽 트리에서 항목을 선택하면 이름과 위치를 변경할 수 있습니다.

)}
); }