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:
2026-04-13 04:11:08 +00:00
parent f9f710ec90
commit 6c2129d42e
5 changed files with 515 additions and 49 deletions

View File

@@ -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) {

View File

@@ -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의 카테고리 매핑 전체 삭제
*/

View File

@@ -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());
}