Add category view, pagination, and persist login across deployments
- Add 2-panel category view: sidebar tree + filtered item list - Category counts use DISTINCT with descendant inclusion - Hide empty categories, show category badges on item cards - Add client-side pagination (10 items/page) for both views - Persist access token in localStorage to survive page refresh - Fix token refresh retry on backend restart Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ResponseEntity<Map<String, Object>>> categories(@AuthenticationPrincipal String userId) {
|
||||
return Mono.fromCallable(() -> {
|
||||
var categories = categoryRepository.findAllByUserWithCount(userId);
|
||||
var uncategorized = categoryRepository.findUncategorizedItems(userId);
|
||||
return ResponseEntity.ok(Map.<String, Object>of(
|
||||
"categories", categories,
|
||||
"uncategorized", uncategorized
|
||||
));
|
||||
}).subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
@GetMapping("/categories/{categoryId}/items")
|
||||
public Mono<ResponseEntity<List<Map<String, Object>>>> 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<ResponseEntity<List<Map<String, Object>>>> getChunks(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@PathVariable String id) {
|
||||
|
||||
@@ -110,6 +110,62 @@ public class CategoryRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 트리 + 해당 카테고리 및 하위 카테고리에 속한 고유 항목 수 조회
|
||||
*/
|
||||
public List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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의 카테고리 매핑 전체 삭제
|
||||
*/
|
||||
|
||||
@@ -32,24 +32,28 @@ public class KnowledgeRepository {
|
||||
|
||||
public List<Map<String, Object>> 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<Object> 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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user