diff --git a/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java b/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java index cd610fc..bb88f75 100644 --- a/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java +++ b/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java @@ -1,6 +1,7 @@ package com.sundol.controller; import com.sundol.dto.IngestRequest; +import com.sundol.repository.CategoryRepository; import com.sundol.service.KnowledgeService; import com.sundol.service.YouTubeTranscriptService; import org.slf4j.Logger; @@ -22,11 +23,14 @@ public class KnowledgeController { private final KnowledgeService knowledgeService; private final YouTubeTranscriptService youTubeTranscriptService; + private final CategoryRepository categoryRepository; public KnowledgeController(KnowledgeService knowledgeService, - YouTubeTranscriptService youTubeTranscriptService) { + YouTubeTranscriptService youTubeTranscriptService, + CategoryRepository categoryRepository) { this.knowledgeService = knowledgeService; this.youTubeTranscriptService = youTubeTranscriptService; + this.categoryRepository = categoryRepository; } @GetMapping @@ -97,7 +101,28 @@ public class KnowledgeController { .map(ResponseEntity::ok); } -@GetMapping("/{id}/chunks") + @GetMapping("/categories") + public Mono>> categories(@AuthenticationPrincipal String userId) { + return Mono.fromCallable(() -> { + var categories = categoryRepository.findAllByUserWithCount(userId); + var uncategorized = categoryRepository.findUncategorizedItems(userId); + return ResponseEntity.ok(Map.of( + "categories", categories, + "uncategorized", uncategorized + )); + }).subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic()); + } + + @GetMapping("/categories/{categoryId}/items") + public Mono>>> itemsByCategory( + @AuthenticationPrincipal String userId, + @PathVariable String categoryId) { + return Mono.fromCallable(() -> categoryRepository.findItemsByCategoryId(userId, categoryId)) + .subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic()) + .map(ResponseEntity::ok); + } + + @GetMapping("/{id}/chunks") public Mono>>> getChunks( @AuthenticationPrincipal String userId, @PathVariable String id) { diff --git a/sundol-backend/src/main/java/com/sundol/repository/CategoryRepository.java b/sundol-backend/src/main/java/com/sundol/repository/CategoryRepository.java index ff7cb5a..19c0547 100644 --- a/sundol-backend/src/main/java/com/sundol/repository/CategoryRepository.java +++ b/sundol-backend/src/main/java/com/sundol/repository/CategoryRepository.java @@ -110,6 +110,62 @@ public class CategoryRepository { ); } + /** + * 카테고리 트리 + 해당 카테고리 및 하위 카테고리에 속한 고유 항목 수 조회 + */ + public List> findAllByUserWithCount(String userId) { + return jdbcTemplate.queryForList( + "SELECT RAWTOHEX(c.id) AS id, c.name, RAWTOHEX(c.parent_id) AS parent_id, c.depth, c.full_path, " + + " (SELECT COUNT(DISTINCT kic.knowledge_item_id) " + + " FROM knowledge_item_categories kic " + + " JOIN categories c2 ON c2.id = kic.category_id " + + " WHERE c2.user_id = HEXTORAW(?) AND (c2.full_path = c.full_path OR c2.full_path LIKE c.full_path || '/%')) AS item_count " + + "FROM categories c WHERE c.user_id = HEXTORAW(?) ORDER BY c.full_path", + userId, userId + ); + } + + /** + * 특정 카테고리 및 하위 카테고리에 속한 knowledge_items 조회. + * full_path LIKE 'parent_path%' 로 하위 카테고리를 포함한다. + */ + public List> findItemsByCategoryId(String userId, String categoryId) { + // 먼저 선택된 카테고리의 full_path를 가져옴 + var catResult = jdbcTemplate.queryForList( + "SELECT full_path FROM categories WHERE RAWTOHEX(id) = ?", categoryId + ); + if (catResult.isEmpty()) return List.of(); + String fullPath = (String) catResult.get(0).get("FULL_PATH"); + + return jdbcTemplate.queryForList( + "SELECT DISTINCT RAWTOHEX(ki.id) AS id, ki.type, ki.title, ki.source_url, ki.status, ki.created_at, " + + " (SELECT LISTAGG(c2.full_path, ', ') WITHIN GROUP (ORDER BY c2.full_path) " + + " FROM knowledge_item_categories kic2 JOIN categories c2 ON c2.id = kic2.category_id " + + " WHERE kic2.knowledge_item_id = ki.id) AS categories " + + "FROM knowledge_items ki " + + "JOIN knowledge_item_categories kic ON kic.knowledge_item_id = ki.id " + + "JOIN categories c ON c.id = kic.category_id " + + "WHERE ki.user_id = HEXTORAW(?) AND (c.full_path = ? OR c.full_path LIKE ?) " + + "ORDER BY ki.created_at DESC", + userId, fullPath, fullPath + "/%" + ); + } + + /** + * 카테고리가 없는 knowledge_items 조회 + */ + public List> findUncategorizedItems(String userId) { + return jdbcTemplate.queryForList( + "SELECT RAWTOHEX(ki.id) AS id, ki.type, ki.title, ki.source_url, ki.status, ki.created_at, " + + " NULL AS categories " + + "FROM knowledge_items ki " + + "WHERE ki.user_id = HEXTORAW(?) " + + "AND NOT EXISTS (SELECT 1 FROM knowledge_item_categories kic WHERE kic.knowledge_item_id = ki.id) " + + "ORDER BY ki.created_at DESC", + userId + ); + } + /** * knowledge_item의 카테고리 매핑 전체 삭제 */ diff --git a/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java b/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java index d67282e..9ce925d 100644 --- a/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java +++ b/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java @@ -32,24 +32,28 @@ public class KnowledgeRepository { public List> list(String userId, String type, String status, String search) { StringBuilder sql = new StringBuilder( - "SELECT RAWTOHEX(id) AS id, type, title, source_url, status, created_at, updated_at FROM knowledge_items WHERE user_id = HEXTORAW(?)" + "SELECT RAWTOHEX(ki.id) AS id, ki.type, ki.title, ki.source_url, ki.status, ki.created_at, ki.updated_at, " + + " (SELECT LISTAGG(c.full_path, ', ') WITHIN GROUP (ORDER BY c.full_path) " + + " FROM knowledge_item_categories kic JOIN categories c ON c.id = kic.category_id " + + " WHERE kic.knowledge_item_id = ki.id) AS categories " + + "FROM knowledge_items ki WHERE ki.user_id = HEXTORAW(?)" ); java.util.List params = new java.util.ArrayList<>(); params.add(userId); if (type != null && !type.isEmpty()) { - sql.append(" AND type = ?"); + sql.append(" AND ki.type = ?"); params.add(type); } if (status != null && !status.isEmpty()) { - sql.append(" AND status = ?"); + sql.append(" AND ki.status = ?"); params.add(status); } if (search != null && !search.isEmpty()) { - sql.append(" AND UPPER(title) LIKE UPPER(?)"); + sql.append(" AND UPPER(ki.title) LIKE UPPER(?)"); params.add("%" + search + "%"); } - sql.append(" ORDER BY created_at DESC"); + sql.append(" ORDER BY ki.created_at DESC"); return jdbcTemplate.queryForList(sql.toString(), params.toArray()); } diff --git a/sundol-frontend/src/app/knowledge/page.tsx b/sundol-frontend/src/app/knowledge/page.tsx index 2f0f8ef..dfb5700 100644 --- a/sundol-frontend/src/app/knowledge/page.tsx +++ b/sundol-frontend/src/app/knowledge/page.tsx @@ -13,11 +13,26 @@ interface KnowledgeItem { SOURCE_URL: string; STATUS: string; CREATED_AT: string; + CATEGORIES?: string; +} + +interface Category { + ID: string; + NAME: string; + PARENT_ID: string | null; + DEPTH: number; + FULL_PATH: string; + ITEM_COUNT: number; +} + +interface CategoryTree extends Category { + children: CategoryTree[]; } const statusColors: Record = { PENDING: "bg-yellow-500/20 text-yellow-400", EXTRACTING: "bg-blue-500/20 text-blue-400", + STRUCTURING: "bg-orange-500/20 text-orange-400", CHUNKING: "bg-purple-500/20 text-purple-400", CATEGORIZING: "bg-indigo-500/20 text-indigo-400", EMBEDDING: "bg-cyan-500/20 text-cyan-400", @@ -31,10 +46,111 @@ const typeLabels: Record = { TEXT: "Text", }; +function buildTree(categories: Category[]): CategoryTree[] { + const map = new Map(); + const roots: CategoryTree[] = []; + + for (const cat of categories) { + map.set(cat.ID, { ...cat, children: [] }); + } + + for (const cat of categories) { + const node = map.get(cat.ID)!; + if (cat.PARENT_ID && map.has(cat.PARENT_ID)) { + map.get(cat.PARENT_ID)!.children.push(node); + } else { + roots.push(node); + } + } + + // 항목이 0개인 카테고리 제거 + function prune(nodes: CategoryTree[]): CategoryTree[] { + return nodes + .map((n) => ({ ...n, children: prune(n.children) })) + .filter((n) => n.ITEM_COUNT > 0 || n.children.length > 0); + } + + return prune(roots); +} + +// ITEM_COUNT는 백엔드에서 하위 카테고리 포함 고유 항목 수를 이미 계산함 + +function CategoryNode({ + node, + selectedId, + onSelect, + expanded, + onToggle, +}: { + node: CategoryTree; + selectedId: string | null; + onSelect: (id: string, path: string) => void; + expanded: Set; + onToggle: (id: string) => void; +}) { + const isExpanded = expanded.has(node.ID); + const isSelected = selectedId === node.ID; + const hasChildren = node.children.length > 0; + const totalCount = node.ITEM_COUNT; + + return ( +
+
{ + onSelect(node.ID, node.FULL_PATH); + if (hasChildren) onToggle(node.ID); + }} + > + + {hasChildren ? (isExpanded ? "▼" : "▶") : "·"} + + {node.NAME} + {totalCount > 0 && ( + + {totalCount} + + )} +
+ {isExpanded && hasChildren && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + export default function KnowledgePage() { const { request } = useApi(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); + const [viewMode, setViewMode] = useState<"list" | "category">("category"); + const [currentPage, setCurrentPage] = useState(1); + const PAGE_SIZE = 10; + + // 카테고리 뷰 상태 + const [categoryTree, setCategoryTree] = useState([]); + const [uncategorized, setUncategorized] = useState([]); + const [selectedCategoryId, setSelectedCategoryId] = useState("__all__"); + const [selectedCategoryPath, setSelectedCategoryPath] = useState("전체"); + const [categoryItems, setCategoryItems] = useState([]); + const [expanded, setExpanded] = useState>(new Set()); + const [catLoading, setCatLoading] = useState(false); const fetchItems = async () => { try { @@ -47,19 +163,117 @@ export default function KnowledgePage() { } }; + const fetchCategories = async () => { + try { + const data = await request<{ categories: Category[]; uncategorized: KnowledgeItem[] }>({ + method: "GET", + url: "/api/knowledge/categories", + }); + const tree = buildTree(data.categories); + setCategoryTree(tree); + setUncategorized(data.uncategorized); + // 최상위 카테고리를 펼침 + setExpanded(new Set(tree.map((n) => n.ID))); + } catch (err) { + console.error("Failed to load categories:", err); + } + }; + + const fetchCategoryItems = async (categoryId: string) => { + setCatLoading(true); + try { + const data = await request({ + method: "GET", + url: `/api/knowledge/categories/${categoryId}/items`, + }); + setCategoryItems(data); + } catch (err) { + console.error("Failed to load category items:", err); + } finally { + setCatLoading(false); + } + }; + + const handleSelectCategory = (id: string, path: string) => { + setSelectedCategoryId(id); + setSelectedCategoryPath(path); + setCurrentPage(1); + fetchCategoryItems(id); + }; + + const handleSelectUncategorized = () => { + setSelectedCategoryId("__uncategorized__"); + setSelectedCategoryPath("미분류"); + setCategoryItems(uncategorized as KnowledgeItem[]); + setCurrentPage(1); + }; + + const handleSelectAll = () => { + setSelectedCategoryId("__all__"); + setSelectedCategoryPath("전체"); + setCategoryItems(items); + setCurrentPage(1); + }; + + const handleToggle = (id: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + useEffect(() => { fetchItems(); - // Poll for status updates every 5 seconds + fetchCategories(); const interval = setInterval(fetchItems, 5000); return () => clearInterval(interval); }, []); + // 카테고리 뷰에서 표시할 항목 결정 + const allDisplayItems = + viewMode === "list" + ? items + : selectedCategoryId === "__all__" + ? items + : selectedCategoryId === "__uncategorized__" + ? uncategorized + : categoryItems; + + const totalPages = Math.max(1, Math.ceil(allDisplayItems.length / PAGE_SIZE)); + const displayItems = allDisplayItems.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + return (
-

Knowledge

+
+

Knowledge

+
+ + +
+
Loading...

- ) : items.length === 0 ? ( -
-

No knowledge items yet. Add your first item to get started.

-
+ ) : viewMode === "list" ? ( + /* 목록 뷰 */ + items.length === 0 ? ( +
+

No knowledge items yet. Add your first item to get started.

+
+ ) : ( + <> +
+ {displayItems.map((item) => ( + + ))} +
+ {totalPages > 1 && } + + ) ) : ( -
- {items.map((item) => ( - + {/* 왼쪽: 카테고리 트리 */} +
+ {/* 전체 보기 */} +
-
-
-
- - {typeLabels[item.TYPE] || item.TYPE} - - - {item.STATUS} - -
-

- {item.TITLE || item.SOURCE_URL || "Untitled"} -

- {item.SOURCE_URL && ( -

{item.SOURCE_URL}

- )} -
- - {new Date(item.CREATED_AT).toLocaleDateString()} - + * + 전체 + {items.length} +
+ + {/* 카테고리 트리 */} + {categoryTree.map((node) => ( + + ))} + + {/* 미분류 */} + {uncategorized.length > 0 && ( +
+ ? + 미분류 + {uncategorized.length}
- - ))} + )} +
+ + {/* 오른쪽: 선택된 카테고리의 항목 목록 */} +
+ {/* 브레드크럼 */} +
+ {selectedCategoryPath.split("/").map((part, i, arr) => ( + + {i > 0 && {">"}} + {part} + + ))} +
+ + {catLoading ? ( +

Loading...

+ ) : (displayItems as KnowledgeItem[]).length === 0 ? ( +
+

+ {selectedCategoryId === "__all__" + ? "등록된 지식이 없습니다." + : "이 카테고리에 항목이 없습니다."} +

+
+ ) : ( + <> +
+ {(displayItems as KnowledgeItem[]).map((item) => ( + + ))} +
+ {totalPages > 1 && } + + )} +
)}
); } + +function KnowledgeItemCard({ item }: { item: KnowledgeItem }) { + return ( + +
+
+
+ + {typeLabels[item.TYPE] || item.TYPE} + + + {item.STATUS} + +
+

+ {item.TITLE || item.SOURCE_URL || "Untitled"} +

+ {item.CATEGORIES && ( +
+ {item.CATEGORIES.split(", ").map((cat, i) => ( + + {cat} + + ))} +
+ )} +
+ + {new Date(item.CREATED_AT).toLocaleDateString()} + +
+ + ); +} + +function Pagination({ currentPage, totalPages, onPageChange }: { currentPage: number; totalPages: number; onPageChange: (page: number) => void }) { + const pages: number[] = []; + const start = Math.max(1, currentPage - 2); + const end = Math.min(totalPages, currentPage + 2); + for (let i = start; i <= end; i++) pages.push(i); + + return ( +
+ + {start > 1 && ( + <> + + {start > 2 && ...} + + )} + {pages.map((p) => ( + + ))} + {end < totalPages && ( + <> + {end < totalPages - 1 && ...} + + + )} + +
+ ); +} diff --git a/sundol-frontend/src/lib/auth-context.tsx b/sundol-frontend/src/lib/auth-context.tsx index d92f71c..2f46fe8 100644 --- a/sundol-frontend/src/lib/auth-context.tsx +++ b/sundol-frontend/src/lib/auth-context.tsx @@ -22,21 +22,40 @@ const AuthContext = createContext({ }); export function AuthProvider({ children }: { children: React.ReactNode }) { - const [accessToken, setAccessToken] = useState(null); + const [accessToken, setAccessTokenState] = useState(null); const [isLoading, setIsLoading] = useState(true); const logoutRef = useRef<() => void>(() => {}); + // localStorage와 동기화하는 setter + const setAccessToken = useCallback((token: string | null) => { + setAccessTokenState(token); + if (token) { + localStorage.setItem("accessToken", token); + } else { + localStorage.removeItem("accessToken"); + } + }, []); + // interceptor 콜백 등록 useEffect(() => { setAuthCallbacks( (token: string) => setAccessToken(token), () => logoutRef.current() ); - }, []); + }, [setAccessToken]); useEffect(() => { - // Try to restore session from refresh token cookie - const tryRefresh = async () => { + const restoreSession = async () => { + // 1차: localStorage에서 복원 + const stored = localStorage.getItem("accessToken"); + if (stored) { + setAccessTokenState(stored); + api.defaults.headers.common["Authorization"] = `Bearer ${stored}`; + setIsLoading(false); + return; + } + + // 2차: refresh token cookie로 복원 try { const res = await api.post("/api/auth/refresh"); const token = res.data.accessToken; @@ -48,8 +67,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setIsLoading(false); } }; - tryRefresh(); - }, []); + restoreSession(); + }, [setAccessToken]); useEffect(() => { if (accessToken) { @@ -61,7 +80,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const login = useCallback((response: LoginResponse) => { setAccessToken(response.accessToken); - }, []); + }, [setAccessToken]); const logout = useCallback(async () => { try { @@ -71,7 +90,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } setAccessToken(null); window.location.href = "/login"; - }, []); + }, [setAccessToken]); // ref로 최신 logout 유지 (interceptor에서 사용) useEffect(() => {