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,6 +101,27 @@ public class KnowledgeController {
.map(ResponseEntity::ok);
}
@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,

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

View File

@@ -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<string, string> = {
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<string, string> = {
TEXT: "Text",
};
function buildTree(categories: Category[]): CategoryTree[] {
const map = new Map<string, CategoryTree>();
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<string>;
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 (
<div>
<div
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer text-sm transition-colors ${
isSelected
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
}`}
style={{ paddingLeft: `${(node.DEPTH - 1) * 16 + 8}px` }}
onClick={() => {
onSelect(node.ID, node.FULL_PATH);
if (hasChildren) onToggle(node.ID);
}}
>
<span className="w-4 text-center text-xs">
{hasChildren ? (isExpanded ? "▼" : "▶") : "·"}
</span>
<span className="flex-1 truncate">{node.NAME}</span>
{totalCount > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-bg-hover)]">
{totalCount}
</span>
)}
</div>
{isExpanded && hasChildren && (
<div>
{node.children.map((child) => (
<CategoryNode
key={child.ID}
node={child}
selectedId={selectedId}
onSelect={onSelect}
expanded={expanded}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
);
}
export default function KnowledgePage() {
const { request } = useApi();
const [items, setItems] = useState<KnowledgeItem[]>([]);
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<CategoryTree[]>([]);
const [uncategorized, setUncategorized] = useState<KnowledgeItem[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>("__all__");
const [selectedCategoryPath, setSelectedCategoryPath] = useState<string>("전체");
const [categoryItems, setCategoryItems] = useState<KnowledgeItem[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(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<KnowledgeItem[]>({
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 (
<AuthGuard>
<NavBar />
<main className="max-w-7xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">Knowledge</h1>
<div className="flex gap-1 bg-[var(--color-bg-card)] rounded-lg p-0.5 border border-[var(--color-border)]">
<button
onClick={() => setViewMode("list")}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
viewMode === "list"
? "bg-[var(--color-primary)] text-white"
: "text-[var(--color-text-muted)] hover:text-white"
}`}
>
</button>
<button
onClick={() => { setViewMode("category"); fetchCategories(); }}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
viewMode === "category"
? "bg-[var(--color-primary)] text-white"
: "text-[var(--color-text-muted)] hover:text-white"
}`}
>
</button>
</div>
</div>
<Link
href="/knowledge/add"
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"
@@ -70,15 +284,113 @@ export default function KnowledgePage() {
{loading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : items.length === 0 ? (
) : viewMode === "list" ? (
/* 목록 뷰 */
items.length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]">No knowledge items yet. Add your first item to get started.</p>
</div>
) : (
<>
<div className="space-y-3">
{items.map((item) => (
{displayItems.map((item) => (
<KnowledgeItemCard key={item.ID} item={item} />
))}
</div>
{totalPages > 1 && <Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />}
</>
)
) : (
/* 카테고리 뷰 — 2단 레이아웃 */
<div className="flex gap-4" style={{ minHeight: "60vh" }}>
{/* 왼쪽: 카테고리 트리 */}
<div className="w-64 shrink-0 bg-[var(--color-bg-card)] rounded-xl border border-[var(--color-border)] p-3 overflow-y-auto" style={{ maxHeight: "75vh" }}>
{/* 전체 보기 */}
<div
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer text-sm transition-colors mb-1 ${
selectedCategoryId === "__all__"
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
}`}
onClick={handleSelectAll}
>
<span className="w-4 text-center text-xs">*</span>
<span className="flex-1"></span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-bg-hover)]">{items.length}</span>
</div>
{/* 카테고리 트리 */}
{categoryTree.map((node) => (
<CategoryNode
key={node.ID}
node={node}
selectedId={selectedCategoryId}
onSelect={handleSelectCategory}
expanded={expanded}
onToggle={handleToggle}
/>
))}
{/* 미분류 */}
{uncategorized.length > 0 && (
<div
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer text-sm transition-colors mt-2 ${
selectedCategoryId === "__uncategorized__"
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
}`}
onClick={handleSelectUncategorized}
>
<span className="w-4 text-center text-xs">?</span>
<span className="flex-1"></span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-bg-hover)]">{uncategorized.length}</span>
</div>
)}
</div>
{/* 오른쪽: 선택된 카테고리의 항목 목록 */}
<div className="flex-1 min-w-0">
{/* 브레드크럼 */}
<div className="text-sm text-[var(--color-text-muted)] mb-3">
{selectedCategoryPath.split("/").map((part, i, arr) => (
<span key={i}>
{i > 0 && <span className="mx-1">{">"}</span>}
<span className={i === arr.length - 1 ? "text-white" : ""}>{part}</span>
</span>
))}
</div>
{catLoading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : (displayItems as KnowledgeItem[]).length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]">
{selectedCategoryId === "__all__"
? "등록된 지식이 없습니다."
: "이 카테고리에 항목이 없습니다."}
</p>
</div>
) : (
<>
<div className="space-y-3">
{(displayItems as KnowledgeItem[]).map((item) => (
<KnowledgeItemCard key={item.ID} item={item} />
))}
</div>
{totalPages > 1 && <Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />}
</>
)}
</div>
</div>
)}
</main>
</AuthGuard>
);
}
function KnowledgeItemCard({ item }: { item: KnowledgeItem }) {
return (
<Link
key={item.ID}
href={`/knowledge/${item.ID}`}
className="block bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
>
@@ -95,8 +407,14 @@ export default function KnowledgePage() {
<h3 className="font-medium truncate">
{item.TITLE || item.SOURCE_URL || "Untitled"}
</h3>
{item.SOURCE_URL && (
<p className="text-sm text-[var(--color-text-muted)] truncate mt-1">{item.SOURCE_URL}</p>
{item.CATEGORIES && (
<div className="flex flex-wrap gap-1 mt-1">
{item.CATEGORIES.split(", ").map((cat, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
{cat}
</span>
))}
</div>
)}
</div>
<span className="text-sm text-[var(--color-text-muted)] ml-4 whitespace-nowrap">
@@ -104,10 +422,54 @@ export default function KnowledgePage() {
</span>
</div>
</Link>
))}
</div>
)}
</main>
</AuthGuard>
);
}
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 (
<div className="flex items-center justify-center gap-1 mt-6">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] disabled:opacity-30 hover:bg-[var(--color-bg-hover)]"
>
</button>
{start > 1 && (
<>
<button onClick={() => onPageChange(1)} className="px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-bg-hover)]">1</button>
{start > 2 && <span className="px-1 text-[var(--color-text-muted)]">...</span>}
</>
)}
{pages.map((p) => (
<button
key={p}
onClick={() => onPageChange(p)}
className={`px-3 py-1.5 text-sm rounded-lg ${
p === currentPage ? "bg-[var(--color-primary)] text-white" : "hover:bg-[var(--color-bg-hover)]"
}`}
>
{p}
</button>
))}
{end < totalPages && (
<>
{end < totalPages - 1 && <span className="px-1 text-[var(--color-text-muted)]">...</span>}
<button onClick={() => onPageChange(totalPages)} className="px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-bg-hover)]">{totalPages}</button>
</>
)}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] disabled:opacity-30 hover:bg-[var(--color-bg-hover)]"
>
</button>
</div>
);
}

View File

@@ -22,21 +22,40 @@ const AuthContext = createContext<AuthContextType>({
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [accessToken, setAccessToken] = useState<string | null>(null);
const [accessToken, setAccessTokenState] = useState<string | null>(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<LoginResponse>("/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(() => {