2026-01-13 18:28:11 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect } from "react";
|
|
|
|
|
import { ChevronRight, ChevronDown, Folder, FolderOpen, Tag } from "lucide-react";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { LangCategory, getCategories } from "@/lib/api/multilang";
|
|
|
|
|
|
|
|
|
|
interface CategoryTreeProps {
|
|
|
|
|
selectedCategoryId: number | null;
|
|
|
|
|
onSelectCategory: (category: LangCategory | null) => void;
|
|
|
|
|
onDoubleClickCategory?: (category: LangCategory) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CategoryNodeProps {
|
|
|
|
|
category: LangCategory;
|
|
|
|
|
level: number;
|
|
|
|
|
selectedCategoryId: number | null;
|
|
|
|
|
onSelectCategory: (category: LangCategory) => void;
|
|
|
|
|
onDoubleClickCategory?: (category: LangCategory) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CategoryNode({
|
|
|
|
|
category,
|
|
|
|
|
level,
|
|
|
|
|
selectedCategoryId,
|
|
|
|
|
onSelectCategory,
|
|
|
|
|
onDoubleClickCategory,
|
|
|
|
|
}: CategoryNodeProps) {
|
2026-01-14 11:05:57 +09:00
|
|
|
// 기본값: 접힌 상태로 시작
|
|
|
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
2026-01-13 18:28:11 +09:00
|
|
|
const hasChildren = category.children && category.children.length > 0;
|
|
|
|
|
const isSelected = selectedCategoryId === category.categoryId;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors",
|
|
|
|
|
isSelected
|
|
|
|
|
? "bg-primary text-primary-foreground"
|
|
|
|
|
: "hover:bg-muted"
|
|
|
|
|
)}
|
|
|
|
|
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
|
|
|
|
onClick={() => onSelectCategory(category)}
|
|
|
|
|
onDoubleClick={() => onDoubleClickCategory?.(category)}
|
|
|
|
|
>
|
|
|
|
|
{/* 확장/축소 아이콘 */}
|
|
|
|
|
{hasChildren ? (
|
|
|
|
|
<button
|
|
|
|
|
className="shrink-0"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setIsExpanded(!isExpanded);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{isExpanded ? (
|
|
|
|
|
<ChevronDown className="h-4 w-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="w-4" />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 폴더/태그 아이콘 */}
|
|
|
|
|
{hasChildren || level === 0 ? (
|
|
|
|
|
isExpanded ? (
|
|
|
|
|
<FolderOpen className="h-4 w-4 shrink-0 text-amber-500" />
|
|
|
|
|
) : (
|
|
|
|
|
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
|
|
|
|
|
)
|
|
|
|
|
) : (
|
|
|
|
|
<Tag className="h-4 w-4 shrink-0 text-blue-500" />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 카테고리 이름 */}
|
|
|
|
|
<span className="truncate">{category.categoryName}</span>
|
|
|
|
|
|
|
|
|
|
{/* prefix 표시 */}
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"ml-auto text-xs",
|
|
|
|
|
isSelected ? "text-primary-foreground/70" : "text-muted-foreground"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{category.keyPrefix}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 자식 카테고리 */}
|
|
|
|
|
{hasChildren && isExpanded && (
|
|
|
|
|
<div>
|
|
|
|
|
{category.children!.map((child) => (
|
|
|
|
|
<CategoryNode
|
|
|
|
|
key={child.categoryId}
|
|
|
|
|
category={child}
|
|
|
|
|
level={level + 1}
|
|
|
|
|
selectedCategoryId={selectedCategoryId}
|
|
|
|
|
onSelectCategory={onSelectCategory}
|
|
|
|
|
onDoubleClickCategory={onDoubleClickCategory}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function CategoryTree({
|
|
|
|
|
selectedCategoryId,
|
|
|
|
|
onSelectCategory,
|
|
|
|
|
onDoubleClickCategory,
|
|
|
|
|
}: CategoryTreeProps) {
|
|
|
|
|
const [categories, setCategories] = useState<LangCategory[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadCategories();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const loadCategories = async () => {
|
|
|
|
|
try {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
const response = await getCategories();
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
setCategories(response.data);
|
|
|
|
|
} else {
|
|
|
|
|
setError(response.error?.details || "카테고리 로드 실패");
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError("카테고리 로드 중 오류 발생");
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-32 items-center justify-center">
|
|
|
|
|
<div className="animate-pulse text-sm text-muted-foreground">
|
|
|
|
|
카테고리 로딩 중...
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-32 items-center justify-center">
|
|
|
|
|
<div className="text-sm text-destructive">{error}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (categories.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-32 items-center justify-center">
|
|
|
|
|
<div className="text-sm text-muted-foreground">
|
|
|
|
|
카테고리가 없습니다
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
{/* 전체 선택 옵션 */}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
|
|
|
|
selectedCategoryId === null
|
|
|
|
|
? "bg-primary text-primary-foreground"
|
|
|
|
|
: "hover:bg-muted"
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => onSelectCategory(null)}
|
|
|
|
|
>
|
|
|
|
|
<Folder className="h-4 w-4 shrink-0" />
|
|
|
|
|
<span>전체</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 카테고리 트리 */}
|
|
|
|
|
{categories.map((category) => (
|
|
|
|
|
<CategoryNode
|
|
|
|
|
key={category.categoryId}
|
|
|
|
|
category={category}
|
|
|
|
|
level={0}
|
|
|
|
|
selectedCategoryId={selectedCategoryId}
|
|
|
|
|
onSelectCategory={onSelectCategory}
|
|
|
|
|
onDoubleClickCategory={onDoubleClickCategory}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default CategoryTree;
|
|
|
|
|
|
|
|
|
|
|